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/learnings/sessions.md b/.github/learnings/sessions.md index a9927863c25181..de95b07be7b0cc 100644 --- a/.github/learnings/sessions.md +++ b/.github/learnings/sessions.md @@ -39,6 +39,20 @@ Scope: `src/vs/sessions/**` - **Evidence:** Historical issues included actions targeting the window-global session, stale repository context on recycled rows, and late async results publishing into a replacement view. - **Disposition:** Candidate for focused UI specifications and lifecycle tests. +## Attribute a console error to the provider that backs the surface + +- **Scope:** `src/vs/sessions/contrib/providers/**` +- **Learning:** The sessions list resolves every registered provider, so an error in the console may come from a provider unrelated to the surface the user was operating. Identify which provider backs that surface before editing; a stack trace shows where an error was thrown, not which feature produced it. Several providers share a product name across the workbench and the Copilot extension without sharing an implementation. +- **Evidence:** A cloud sandbox report was traced into the Copilot extension's similarly named cloud provider, which shares neither code nor identifier with the Agents Window sandbox provider. +- **Disposition:** Candidate for the Sessions skill if provider-attribution mistakes recur. + +## Rule out local product configuration before suspecting product code + +- **Scope:** `src/vs/sessions/**`, `src/vs/platform/agentHost/**` +- **Learning:** Features reading `product.defaultChatAgent` can misbehave in dev builds because `product.overrides.json` is gitignored and drifts from the shipped configuration without appearing in any diff. Compare the specific keys a feature reads against the distro mixin before treating the behavior as a code defect, and note that ordered configuration arrays carry meaning by index. +- **Evidence:** A rotated `providerScopes` array left index 0 — the permissive scope set that the cloud sandbox client reads directly — without repository access, presenting as an authentication bug in feature code. +- **Disposition:** Candidate for repository onboarding or debugging guidance if dev-only configuration drift recurs. + ## Capture live editor state independently of persistence - **Scope:** `src/vs/sessions/contrib/layout/**` diff --git a/.github/skills/policy-and-managed-settings/local-testing.md b/.github/skills/policy-and-managed-settings/local-testing.md index d8acbe9dbe0341..adf62f91eaeb8d 100644 --- a/.github/skills/policy-and-managed-settings/local-testing.md +++ b/.github/skills/policy-and-managed-settings/local-testing.md @@ -58,7 +58,9 @@ both Code OSS and the mock server with the same isolated `COPILOT_CACHE_HOME`. The managed-settings schema is auto-detected from a sibling `copilot-agent-runtime` checkout, including when VS Code runs from a Git worktree. Use `--schema` or `MANAGED_SETTINGS_SCHEMA` at server startup to -override it; the GUI does not reload schema sources. +override it. The GUI's **Schema source** field can load a different path, file +URI, or HTTP(S) URL for the current server process; restart the server to return +to its startup source. See the [mock policy server README](../../../scripts/mock-policy-server/README.md) for proxy setup, cache locations, schema loading, and server options. diff --git a/build/agent-sdk/README.md b/build/agent-sdk/README.md index 8d6c8edff677cb..7e84f6f5e23242 100644 --- a/build/agent-sdk/README.md +++ b/build/agent-sdk/README.md @@ -112,6 +112,87 @@ gulp graph. As its own pipeline step: that applies, writes results to `AGENT_SDK_RESULTS_FILE`, and emits `##vso[task.setvariable]` so downstream pipeline steps see the path. +## What ends up in a tarball + +`npm ci --ignore-scripts --omit=peer`, then the whole `node_modules/` tarred. +`--omit=peer` is the load-bearing flag. + +npm 7+ installs `peerDependencies` automatically, so claude's lockfile carries +100 packages the agent host never loads: `@modelcontextprotocol/sdk`, `zod`, +`ajv` and their transitive graph. The SDK inlines all of that into `sdk.mjs` at +publish time. `sdk.mjs` statically imports node builtins and nothing else, and +the one external module it resolves at runtime is its own native binary +package. + +On the VS Code side, `@modelcontextprotocol/sdk` is only ever `import type`, so +TypeScript erases it. `zod` is not: `claudeJsonSchemaToZod.ts` imports `z` at +runtime to build the raw shapes it hands to `sdk.tool()`. That zod is VS Code's +own dependency (root `package.json`, shipped in the product), and the objects +flow *into* the SDK. Nothing resolves zod out of the downloaded tree. That is +the invariant `--omit=peer` needs, and it is weaker than "unused". + +With the peers omitted, a claude tarball is exactly two packages, +`@anthropic-ai/claude-agent-sdk` and the one `claude-agent-sdk-<target>` binary +package, both pinned to the SDK version. + +The point isn't size (the peers are ~4% of a ~90MB tarball). It's that the +tarball becomes a function of `(SDK version, target)` and nothing else. Before +this, a transitive peer bump could change the bytes without changing the +version, and since the CDN path is content-addressed and immutable, the upload +then failed against the already-published blob. That is +[#333870](https://github.com/microsoft/vscode/pull/333870) / +[#334094](https://github.com/microsoft/vscode/pull/334094). + +`--omit=optional` would be a very different flag: the native binary ships as an +*optional* dependency, and `findMissingNativeOptionalDep` exists to catch it +going missing. + +codex declares no peers at all, so the flag is inert there — its tarball bytes +are unchanged. + +### Keeping the assumption honest + +That the SDK inlines its peers is an implementation detail Anthropic never +promised; the `peerDependencies` block says the opposite. And `--omit=peer` +applies to every SDK, including any added later, so the check that justifies it +can't be special-cased to one. + +`verifyStagedTree` in `package.ts` runs against the finished tree, just before +it is tarred, and nothing in it is conditioned on which SDK is being built: + +1. **It imports the package's entry point** in a child process, under a + timeout, with the peers absent. The entry is `<package>/<manifest.main>`, + which is the literal path `claudeAgentSdkService.ts` loads at runtime. + Packages that declare no `main` are skipped, which is how codex opts out + without a special case: it ships only a `bin`, and the agent host never + loads JS from that tarball. If a future SDK starts importing a peer for + real, the build fails with ERR_MODULE_NOT_FOUND instead of failing on a + user's machine months later, against a tarball already immutable on the CDN. +2. **It stats every native binary** and requires each to be present, non-empty + and executable. + +Step 2 needs the one piece of per-SDK knowledge in the file, since no manifest +field describes it: claude ships a single binary at the root of its platform +package, codex fills a `vendor/<rust-triple>/bin/` directory. +`listPlatformBinaries` is the only place that encodes those layouts, and +`chmodPlatformBinaries` reads from the same function, so the chmod and the +assertion cannot disagree about where the binaries are. + +An SDK added under `agents/` with no entry in `listPlatformBinaries` yields no +binaries, and step 2 fails the build naming the function to edit. That is the +mandatory-per-SDK guard: a new folder cannot inherit `--omit=peer` unchecked. + +The import probe deliberately does not exercise SDK-specific APIs. An earlier +version called `tool()` and `createSdkMcpServer()` with a zod shape to catch a +peer resolved lazily inside those calls, but that meant hardcoding one SDK's +call shape into the build, and the packaging step is the wrong place for it. +A peer that comes back will almost certainly come back as a static import, +which the plain import catches. The lazy resolution that does exist in +`sdk.mjs` today is for the native binary, and step 2 covers that. + +Both steps are cross-target safe: `sdk.mjs` is platform-independent JS and +importing it does not spawn the native binary. + ## Bumping an SDK version 1. Edit the `dependencies` version in `build/agent-sdk/agents/<sdk>/package.json` diff --git a/build/agent-sdk/agents/claude/package-lock.json b/build/agent-sdk/agents/claude/package-lock.json index ad04e126c4c1b7..3557867b8b842a 100644 --- a/build/agent-sdk/agents/claude/package-lock.json +++ b/build/agent-sdk/agents/claude/package-lock.json @@ -6,26 +6,26 @@ "": { "name": "agent-sdk-claude", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.239" + "@anthropic-ai/claude-agent-sdk": "0.3.258" } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.239.tgz", - "integrity": "sha512-cIuZhK4u76S5Otq78U890GSA6BFT4SLqOuMqzU/bP/tWRWKhHhNp/3/pvgLwoVGlkdhD7luXWduqXKyLC+VNBQ==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.258.tgz", + "integrity": "sha512-RxJ5fSPCGCxX5qO/b4IPXhldvtLHeYBAzTUJ4eOzO+gTrepZQSDmwSlQD6nnoEquKGJzOMHCjhdEtBfDjbDWUg==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.239", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.239", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.239", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.239", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.239", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.239", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.239", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.239" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.258", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.258", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.258", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.258", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.258", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.258", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.258", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.258" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -34,9 +34,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.239.tgz", - "integrity": "sha512-GGVGuCwFEUm6cMlnBX0LTC9JX5NdGzxddbuqWtRxEgo9EetS70SO3FW+reitALlotHghPTfnICQILbBDIRyX+Q==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.258.tgz", + "integrity": "sha512-Hrhzc9WVGSid+DghdTcpVr/8fyXnTD6KeSlDpKx6Wru47J/Nq7RTYiZJt+cex+O2ehaHMEcuYEgoqJ3K/X9NlA==", "cpu": [ "arm64" ], @@ -47,9 +47,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.239.tgz", - "integrity": "sha512-QNbBXz3Pb3pQ7a+Kcbets6t9IrQhStKsfl5D518nYiGFoRMioO7efkZ6zHUcrGDqDC0LIzrs7tY2KNzH4RfwZA==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.258.tgz", + "integrity": "sha512-AVqxGX4988J5cS+TMqIzH85+sbsLhJu5Ou9TIALcO/v2Z9ze8GK4vX2ydAYvU/SRnjTvEaiITX+Xcm5afP1IbQ==", "cpu": [ "x64" ], @@ -60,9 +60,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.239.tgz", - "integrity": "sha512-RE6tDtzU0xj58tsuxnlXMJO8ckJ4tx/1nUgR+D/fPEQVt84oOyXeVspKt1ffvycogh3Sr3MkCGwZrPmxu/V1nA==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.258.tgz", + "integrity": "sha512-Jj3K1Ip7WpyMouZCjd7kgV3KswUBF62WAnyG0iaYvKZJvXgYKbIAkjcQ2F2Rx5ZuRUNWAVncE9LeHmdIdx78VQ==", "cpu": [ "arm64" ], @@ -76,9 +76,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.239.tgz", - "integrity": "sha512-Ajc3cuszVdOwfMZVsGdxrCTmgWOeJpQWAIqu8jNEvERIeNnBxvWfGrjmLPxYT3/LJ9Uj/tFTpp52J4IcmrJE5w==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.258.tgz", + "integrity": "sha512-I/BLt2vdvqK2B2px526U1lw7Rv+SI+Ld22+wLwu8gLRQk5SYhSW9dmMYEO+GCeF7vQzfzJvMQ4IzbbY+aSGACg==", "cpu": [ "arm64" ], @@ -92,9 +92,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.239.tgz", - "integrity": "sha512-q4YaDoPgqh0XM23RM1/Zje7OSKccuCTQE89KoppDFOsyGdRsUj5xr01LTtr5hnYQuZD7dfwAbz9zl1g0MF/7TA==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.258.tgz", + "integrity": "sha512-2MJeFVJM/3xwZASP3yn2OuQ9RHIoS30DC/B7oG1XPYcbToPLH4QIfCPLWbSQfqCdp+NEBupLMM9BWpDz4s8Q0g==", "cpu": [ "x64" ], @@ -108,9 +108,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.239.tgz", - "integrity": "sha512-zIUHiG4Romm/t6m/S9n8x4BKluyRCPk0147hPVo4xxkHkp4Di/7TnDMRKO3Wau4x361wRqlE2i5EpYT+CyJjHg==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.258.tgz", + "integrity": "sha512-sM7GzRyrOpFhwMn2Ng8nLiWK6cc04uCEu3Zh9mrJS2r3iQu1TryHKoPTjc2Ip0N75sHmwhNodgoRs8NtG1Gkkw==", "cpu": [ "x64" ], @@ -124,9 +124,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.239.tgz", - "integrity": "sha512-RxA29NdX9g3ZbpcXvSeWxpbg/Eoo3wXfO2eA1Vc7qa5JyAYjrl9xu6dIGAWMiyk/gnFxNh1WoFER77J9P6LTiQ==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.258.tgz", + "integrity": "sha512-n/Vf6oXAo9EZVSSM5+9d+8dFrUrX9cbgSHK/1njkvykWAN5xsfBbikJYqwhbW84GCYkpXYM+gGNZe23h0fHldw==", "cpu": [ "arm64" ], @@ -137,9 +137,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.239.tgz", - "integrity": "sha512-ylKIX0DfaK1EgWYbVEvBMYATVFdKjFcWvvypTIv2sJhM3KxJT/0lTzvqsai8jYeZN1FBHWlRNEUQJvJMjO/diA==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.258.tgz", + "integrity": "sha512-UDbXE6n37ZMUogVVYEWX901NNmbyXUv1VvGYN/vfOiIWKUJXCAypam71dBlYFhlAhVX28qCd64w+pTZDYQfYQA==", "cpu": [ "x64" ], @@ -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/agent-sdk/agents/claude/package.json b/build/agent-sdk/agents/claude/package.json index e3f17dc6f5752c..edf17b6e2b6311 100644 --- a/build/agent-sdk/agents/claude/package.json +++ b/build/agent-sdk/agents/claude/package.json @@ -3,6 +3,6 @@ "private": true, "comment": "Pinned dependency set for the build/agent-sdk claude tarball. The package-lock.json alongside is the source of truth for transitive deps — produce.ts runs `npm ci` against this directory to get byte-deterministic output across pipeline runs.", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.239" + "@anthropic-ai/claude-agent-sdk": "0.3.258" } } diff --git a/build/agent-sdk/package.ts b/build/agent-sdk/package.ts index 28680b2bfe88bb..a02240fbcb166b 100644 --- a/build/agent-sdk/package.ts +++ b/build/agent-sdk/package.ts @@ -20,10 +20,12 @@ * same npm install + same tar version produces naturally. * * SDK version pinning: - * - Pinned via repo-root `package.json` devDeps (`getSdkVersion`). - * - No `node_modules` package-lock for the scratch install: transitive - * drift surfaces at upload time as a sha mismatch against the existing - * blob, where a human investigates. + * - Pinned in `agents/<sdk>/package.json` (`getAgentMeta`), with the + * `package-lock.json` alongside it fixing the transitive graph. + * - Peer dependencies are omitted from the install (see `npmCi`), so the + * tarball is a function of the SDK version and target alone. A peer bump + * in the lockfile can no longer change the bytes at a CDN path that is + * already published. * * Uses node-tar (pure JS) for tar creation rather than system tar so that * tarballs produced on a Windows or macOS host have the same shape as ones @@ -36,6 +38,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import * as tar from 'tar'; +import { pathToFileURL } from 'url'; import { findMissingNativeOptionalDep } from '../azure-pipelines/common/checkNativeOptionalDeps.ts'; import { getAgentDir, getAgentMeta, parseFlags, type Sdk, sha256OfFile } from './common.ts'; @@ -100,7 +103,11 @@ export async function buildOne(args: IBuildArgs): Promise<IBuildResult> { throw new Error(`[${SCRIPT}] npm ci left ${packageName}@${sdkVersion} without its native package '${missingNativeDep}' for target ${args.sdkTarget} — the optional dependency was silently skipped. Refusing to build a binary-less tarball; re-run to re-fetch it.`); } - chmodPlatformBinaries(nodeModulesDir, args.sdk); + chmodPlatformBinaries(nodeModulesDir, args.sdk, args.sdkTarget); + + // Runs last, so it inspects the tree exactly as `buildTarball` will + // collect it, including the executable bits just set above. + verifyStagedTree(args.sdk, stagingDir, args.sdkTarget, sdkVersion); fs.mkdirSync(args.outDir, { recursive: true }); const tgzPath = path.join(args.outDir, `${args.sdk}-${sdkVersion}-${args.sdkTarget}.tgz`); @@ -127,68 +134,168 @@ function parseTargetTriple(sdkTarget: string): { os: string; cpu: string; libc?: } +/** Subdirectories of `dir` whose name starts with `prefix`, as full paths. */ +function subdirectories(dir: string, prefix = ''): string[] { + if (!fs.existsSync(dir)) { + return []; + } + return fs.readdirSync(dir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith(prefix)) + .map(e => path.join(dir, e.name)); +} + /** - * Chmod the executable binaries inside a per-SDK extracted node_modules tree. - * Layout differs per SDK; we don't pretend it's configurable: - * - claude: a single top-level `claude` binary per platform package - * - codex: `vendor/<rust-triple>/bin/codex` under the platform package + * Every native binary in a staged tree. + * + * The one place that knows the per-SDK layout, since nothing in the package + * manifests describes it: claude ships a single binary at the root of its + * platform package, codex fills a `vendor/<rust-triple>/bin/` directory. + * `chmodPlatformBinaries` and `verifyStagedTree` both read from here, so the + * two can't drift. + * + * Returns nothing for an SDK with no entry above, which `verifyStagedTree` + * turns into a build failure. */ -function chmodPlatformBinaries(nodeModulesDir: string, sdk: Sdk): void { +function listPlatformBinaries(nodeModulesDir: string, sdk: Sdk, sdkTarget: string): string[] { + const exe = sdkTarget.startsWith('win32') ? '.exe' : ''; if (sdk === 'claude') { - const scopeDir = path.join(nodeModulesDir, '@anthropic-ai'); - if (!fs.existsSync(scopeDir)) { - return; - } - for (const child of fs.readdirSync(scopeDir)) { - if (!child.startsWith('claude-agent-sdk-')) { - continue; - } - const binary = path.join(scopeDir, child, 'claude'); - if (fs.existsSync(binary)) { - fs.chmodSync(binary, 0o755); - } - } - return; + return subdirectories(path.join(nodeModulesDir, '@anthropic-ai'), 'claude-agent-sdk-') + .map(pkgDir => path.join(pkgDir, `claude${exe}`)) + .filter(binary => fs.existsSync(binary)); + } + if (sdk === 'codex') { + return subdirectories(path.join(nodeModulesDir, '@openai'), 'codex-') + .flatMap(pkgDir => subdirectories(path.join(pkgDir, 'vendor'))) + .map(tripleDir => path.join(tripleDir, 'bin')) + .flatMap(binDir => fs.existsSync(binDir) ? fs.readdirSync(binDir).map(f => path.join(binDir, f)) : []); } + return []; +} - // codex - const scopeDir = path.join(nodeModulesDir, '@openai'); - if (!fs.existsSync(scopeDir)) { - return; +function chmodPlatformBinaries(nodeModulesDir: string, sdk: Sdk, sdkTarget: string): void { + for (const binary of listPlatformBinaries(nodeModulesDir, sdk, sdkTarget)) { + fs.chmodSync(binary, 0o755); } - for (const child of fs.readdirSync(scopeDir)) { - if (!child.startsWith('codex-')) { - continue; - } - const vendorDir = path.join(scopeDir, child, 'vendor'); - if (!fs.existsSync(vendorDir)) { - continue; - } - for (const triple of fs.readdirSync(vendorDir)) { - const binDir = path.join(vendorDir, triple, 'bin'); - if (!fs.existsSync(binDir)) { - continue; - } - for (const f of fs.readdirSync(binDir)) { - fs.chmodSync(path.join(binDir, f), 0o755); - } - } +} + +/** + * Checks the staged tree the way the agent host will consume it, before the + * bytes become immutable on the CDN. Applies to every SDK: nothing here is + * conditioned on which one, so a new folder under `agents/` can't inherit + * `--omit=peer` unchecked. See "Keeping the assumption honest" in README.md. + */ +function verifyStagedTree(sdk: Sdk, stagingDir: string, sdkTarget: string, sdkVersion: string): void { + const nodeModulesDir = path.join(stagingDir, 'node_modules'); + const { name: packageName } = getAgentMeta(sdk); + const context = `${packageName}@${sdkVersion} (${sdkTarget})`; + + const entry = resolvePackageEntry(nodeModulesDir, packageName); + if (entry) { + verifySdkLoads(stagingDir, entry, context); + } + + const binaries = listPlatformBinaries(nodeModulesDir, sdk, sdkTarget); + if (binaries.length === 0) { + throw new Error(`[${SCRIPT}] ${context}: found no native binaries in the staged tree. Either the package layout changed, or '${sdk}' is new and needs an entry in listPlatformBinaries(); see build/agent-sdk/README.md.`); + } + for (const binary of binaries) { + assertStagedBinary(binary, context); + } +} + +/** + * The package's own importable entry, or undefined when it declares none. + * codex ships only a `bin`, so there is nothing to import. + * + * Reads `main` rather than resolving `exports`, because `<package>/<main>` is + * the literal path `claudeAgentSdkService.ts` imports at runtime. + */ +function resolvePackageEntry(nodeModulesDir: string, packageName: string): string | undefined { + const packageDir = path.join(nodeModulesDir, ...packageName.split('/')); + const manifestPath = path.join(packageDir, 'package.json'); + if (!fs.existsSync(manifestPath)) { + return undefined; + } + const manifest: { main?: string } = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (!manifest.main) { + return undefined; + } + const entry = path.join(packageDir, manifest.main); + return fs.existsSync(entry) ? entry : undefined; +} + +/** + * Present, non-empty, executable. The mode check is skipped on Windows hosts, + * where `fs.chmodSync` only toggles the read-only flag so the POSIX bits it + * reports back mean nothing. + */ +function assertStagedBinary(binaryPath: string, context: string): void { + let stat: fs.Stats; + try { + stat = fs.statSync(binaryPath); + } catch { + throw new Error(`[${SCRIPT}] ${context}: no native binary at '${binaryPath}'. The agent host resolves exactly this path at runtime.`); + } + if (stat.size === 0) { + throw new Error(`[${SCRIPT}] ${context}: the native binary at '${binaryPath}' is empty.`); + } + if (process.platform !== 'win32' && (stat.mode & 0o111) === 0) { + throw new Error(`[${SCRIPT}] ${context}: '${binaryPath}' is not executable (mode ${(stat.mode & 0o777).toString(8)}). chmodPlatformBinaries did not reach it.`); + } +} + +const PROBE_TIMEOUT_MS = 2 * 60 * 1000; + +/** + * Imports the packaged entry point with the peers absent (see `npmCi`). + * + * The SDK inlines MCP, zod and ajv today but never promised to, and its + * `peerDependencies` block says otherwise. If a future version static-imports + * one for real, this fails the build with ERR_MODULE_NOT_FOUND instead of + * failing on a user's machine against a tarball that is already immutable. + * + * Child process to keep the module out of this process's cache; timeout so a + * stray handle fails the build instead of hanging the release job. + */ +function verifySdkLoads(stagingDir: string, entry: string, context: string): void { + // At the staging root, so it is outside what `buildTarball` collects. + const probePath = path.join(stagingDir, 'sdk-load-probe.mjs'); + // File-URL dynamic import, as in `claudeAgentSdkService.ts`. + fs.writeFileSync(probePath, `await import(${JSON.stringify(pathToFileURL(entry).href)});\n`); + + console.log(`[${SCRIPT}] Verifying ${context} loads without its peerDependencies…`); + const result = spawnSync(process.execPath, [probePath], { cwd: stagingDir, stdio: 'inherit', timeout: PROBE_TIMEOUT_MS }); + if (result.signal) { + throw new Error(`[${SCRIPT}] ${context}: load probe was killed by ${result.signal}. For SIGTERM that means it hit the ${PROBE_TIMEOUT_MS}ms timeout, so importing '${entry}' left a timer or handle open instead of exiting.`); + } + if (result.error) { + throw new Error(`[${SCRIPT}] ${context}: load probe failed to spawn: ${result.error.message}`); + } + if (result.status !== 0) { + throw new Error(`[${SCRIPT}] ${context}: does not load with its peerDependencies omitted (probe exited ${result.status}; see output above). It likely started importing a peer such as '@modelcontextprotocol/sdk' or 'zod'. Either drop '--omit=peer' from npmCi or add that package as a real dependency in build/agent-sdk/agents/<sdk>/package.json.`); } } function npmCi(workDir: string, env: NodeJS.ProcessEnv): void { - // `npm ci` instead of `npm install`: installs the EXACT graph from the + // `npm ci` rather than `npm install`: installs the exact graph from the // committed package-lock.json without resolving versions, which is what - // makes the tarball bytes reproducible across pipeline runs. - // `--ignore-scripts` blocks any postinstall/preinstall the SDK or its - // transitive deps might ship. + // makes the tarball bytes reproducible across runs. + // `--ignore-scripts` blocks any pre/postinstall the SDK or its deps ship. + // `--omit=peer` drops the auto-installed peerDependencies, which the agent + // host never loads out of the tarball. That makes the bytes a function of + // (SDK version, target) alone, so a transitive peer bump can no longer + // change the content at an already-published CDN path. That was the failure + // mode of https://github.com/microsoft/vscode/pull/334094. + // `verifyStagedTree` keeps the "never loads them" claim honest; README.md + // has the long version. + // Unlike `--omit=optional`, this does not touch the native binary package. // On Windows, npm is a `.cmd` shim. Two things matter: // 1. The explicit `.cmd` suffix — Node won't resolve PATHEXT. // 2. `shell: true` — since Node 20 (CVE-2024-27980) child_process // refuses to spawn .cmd/.bat without it. const isWindows = process.platform === 'win32'; const npm = isWindows ? 'npm.cmd' : 'npm'; - const result = spawnSync(npm, ['ci', '--ignore-scripts'], { + const result = spawnSync(npm, ['ci', '--ignore-scripts', '--omit=peer'], { cwd: workDir, env: { ...process.env, ...env }, stdio: 'inherit', diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 4b0668ca3b6777..abd33957465c49 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -84,6 +84,7 @@ "--vscode-chat-requestCodeBorder", "--vscode-chat-slashCommandBackground", "--vscode-chat-slashCommandForeground", + "--vscode-chat-statusBackground", "--vscode-chat-thinkingShimmer", "--vscode-agentsChatInput-background", "--vscode-agentsChatInput-border", 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/extension/prompts/node/agent/allAgentPrompts.ts b/extensions/copilot/src/extension/prompts/node/agent/allAgentPrompts.ts index 01d47ecd1dbdda..43c0bccd10532d 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/allAgentPrompts.ts +++ b/extensions/copilot/src/extension/prompts/node/agent/allAgentPrompts.ts @@ -20,6 +20,7 @@ import './openai/gpt55Prompt'; import './openai/gpt56Prompt'; import './openai/gpt5CodexPrompt'; import './openai/gpt5Prompt'; +import './openai/hiddenModelNPrompt'; import './openai/latestOpenAIPrompt'; import './xAIPrompts'; import './zaiPrompts'; diff --git a/extensions/copilot/src/extension/prompts/node/agent/defaultAgentInstructions.tsx b/extensions/copilot/src/extension/prompts/node/agent/defaultAgentInstructions.tsx index 782f35c152f37d..90918df3b64d77 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/defaultAgentInstructions.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/defaultAgentInstructions.tsx @@ -6,7 +6,7 @@ import { BasePromptElementProps, PromptElement, PromptSizing } from '@vscode/prompt-tsx'; import type { LanguageModelToolInformation } from 'vscode'; import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService'; -import { isGpt5PlusFamily } from '../../../../platform/endpoint/common/chatModelCapabilities'; +import { isGpt5PlusFamily, isHiddenModelN } from '../../../../platform/endpoint/common/chatModelCapabilities'; import { IChatEndpoint } from '../../../../platform/networking/common/networking'; import { IPromptPathRepresentationService } from '../../../../platform/prompts/common/promptPathRepresentationService'; import { IExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService'; @@ -480,12 +480,12 @@ export class ApplyPatchInstructions extends PromptElement<DefaultAgentPromptProp } async render(state: void, sizing: PromptSizing) { - const isGpt5 = isGpt5PlusFamily(this.props.modelFamily); - const useSimpleInstructions = isGpt5 && this.configurationService.getExperimentBasedConfig(ConfigKey.Advanced.Gpt5AlternativePatch, this._experimentationService); + const isGpt5OrHiddenModelN = isGpt5PlusFamily(this.props.modelFamily) || (this.props.modelFamily !== undefined && isHiddenModelN(this.props.modelFamily)); + const useSimpleInstructions = isGpt5OrHiddenModelN && this.configurationService.getExperimentBasedConfig(ConfigKey.Advanced.Gpt5AlternativePatch, this._experimentationService); return <Tag name='applyPatchInstructions'> To edit files in the workspace, use the {ToolName.ApplyPatch} tool. If you have issues with it, you should first try to fix your patch and continue using {ToolName.ApplyPatch}. {this.props.tools[ToolName.EditFile] && <>If you are stuck, you can fall back on the {ToolName.EditFile} tool, but {ToolName.ApplyPatch} is much faster and is the preferred tool.</>}<br /> - {isGpt5 && <>Prefer the smallest set of changes needed to satisfy the task. Avoid reformatting unrelated code; preserve existing style and public APIs unless the task requires changes. When practical, complete all edits for a file within a single message.<br /></>} + {isGpt5OrHiddenModelN && <>Prefer the smallest set of changes needed to satisfy the task. Avoid reformatting unrelated code; preserve existing style and public APIs unless the task requires changes. When practical, complete all edits for a file within a single message.<br /></>} {!useSimpleInstructions && <> The tool call requires both `input`, a string representing the patch to apply, and `explanation`, a short description of what the patch aims to achieve. The input follows a special format. For each snippet of code that needs to be changed, repeat the following:<br /> <ApplyPatchFormatInstructions /><br /> diff --git a/extensions/copilot/src/extension/prompts/node/agent/openai/hiddenModelNPrompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/openai/hiddenModelNPrompt.tsx new file mode 100644 index 00000000000000..5622652339cd00 --- /dev/null +++ b/extensions/copilot/src/extension/prompts/node/agent/openai/hiddenModelNPrompt.tsx @@ -0,0 +1,304 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { PromptElement, PromptSizing } from '@vscode/prompt-tsx'; +import { isHiddenModelN } from '../../../../../platform/endpoint/common/chatModelCapabilities'; +import { IChatEndpoint } from '../../../../../platform/networking/common/networking'; +import { ToolName } from '../../../../tools/common/toolNames'; +import { Gpt55CopilotIdentityRule as HiddenModelNCopilotIdentityRule } from '../../base/copilotIdentity'; +import { InstructionMessage } from '../../base/instructionMessage'; +import { ResponseTranslationRules } from '../../base/responseTranslationRules'; +import { Gpt5SafetyRule } from '../../base/safetyRules'; +import { Tag } from '../../base/tag'; +import { ResponseRenderingRules } from '../../panel/editorIntegrationRules'; +import { ApplyPatchInstructions, DefaultAgentPromptProps, detectToolCapabilities, getEditingReminder, McpToolInstructions, ReminderInstructionsProps } from '../defaultAgentInstructions'; +import { FileLinkificationInstructionsOptimized } from '../fileLinkificationInstructions'; +import { CopilotIdentityRulesConstructor, IAgentPrompt, PromptRegistry, ReminderInstructionsConstructor, SafetyRulesConstructor, SystemPrompt } from '../promptRegistry'; +import { CUSTOM_TOOL_SEARCH_NAME, ToolSearchToolPromptOptimized } from '../toolSearchInstructions'; + +class HiddenModelNPrompt extends PromptElement<DefaultAgentPromptProps> { + async render(state: void, sizing: PromptSizing) { + const tools = detectToolCapabilities(this.props.availableTools); + return <InstructionMessage> + <Tag name='coding_agent_instructions'> + You are a coding agent running in VS Code. You and the user share one workspace, and your job is to collaborate with them until their goal is genuinely handled.<br /> + </Tag> + <Tag name='Before_the_first_edit'> + - Start from the most concrete anchor available: a file, symbol, failing behavior, failing command, test, or nearby implementation surface. If the request does not name one explicitly, use the first targeted search or nearby read to identify that anchor, then continue locally from there.<br /> + - Before the first edit, gather only enough nearby evidence to state one falsifiable local hypothesis about how the requested behavior should work or why it is failing, and one cheap check that could disconfirm it.<br /> + - Keep that routing brief and local: use only enough targeted search and nearby reading to form one falsifiable local hypothesis and one cheap discriminating check.<br /> + - Use that budget to resolve the controlling code path and the cheapest discriminating check, not to map broad surrounding surfaces. Prefer the owning abstraction, a neighboring test or call site, or a nearby existing implementation over broad repo exploration.<br /> + - If the starting anchor mostly wires, forwards, registers, or contains the behavior rather than deciding it, step to the nearest code that directly computes, mutates, or controls the behavior.<br /> + - If multiple nearby paths look plausible, choose the one that best supports a falsifiable local hypothesis, the most discriminating nearby check, and the smallest testable change. Do not keep comparing neighbors just to gain confidence.<br /> + - Take a narrow additional read only if needed to distinguish between local hypotheses or to identify the cheapest discriminating check. After that read, choose and act.<br /> + - If you still cannot name a discriminating check because one nearby abstraction boundary, neighboring test, or call-site dependency remains unresolved, take one nearby triangulation read for that boundary. Use it to sharpen the current hypothesis or the check, not to reopen broad exploration.<br /> + - Once you can state one falsifiable local hypothesis, the nearby code path it depends on, one cheap check that could disconfirm it, and one small edit that would test it, the next action must be a grounded edit.<br /> + - If confidence is incomplete, the first edit may be a small reversible probe that exposes missing types, behavior mismatches, control-flow gaps, or validation failures.<br /> + - If you find yourself still searching after that local-routing budget, treat that as drift. Recover by choosing the best current hypothesis and the best available nearby check, then make the smallest plausible edit that will let that check discriminate.<br /> + </Tag> + <Tag name='After_the_first_edit'> + - After the first substantive edit, the very next step must be one focused validation action when one exists.<br /> + - Prefer this order for that first validation action:<br /> + - the cheapest behavior-scoped or failing check that can falsify the current hypothesis<br /> + - a narrow test for the touched slice<br /> + - a narrow compile, lint, or typecheck command for the touched slice<br /> + - `git diff` only when no narrower executable validation exists<br /> + - If a narrow executable validation exists, run it before doing more reading or patching. `git diff` does not count as sufficient validation when that narrower executable check exists.<br /> + - Do not widen scope between the first substantive edit and that first focused validation. Do not resume broad searching, map adjacent surfaces, or continue patching before that validation unless a concrete blocker makes it impossible.<br /> + - If the first validation fails and the result supports the current hypothesis but exposes a local defect, repair that same slice immediately and rerun the same focused validation before expanding scope.<br /> + - If the first validation falsifies the current hypothesis or changes your understanding of where the behavior is controlled, step one nearby hop to the code that more directly controls it. Do not reopen broad exploration unless nearby paths are exhausted.<br /> + - If the first validation is ambiguous, do one nearby disambiguating read or one neighboring test or call-site check, then choose between local repair and a one-hop step. Do not open a second edit slice before that decision.<br /> + - If the first validation succeeds but the task still needs adjacent follow-up edits, make the smallest adjacent follow-up edit needed, then rerun focused validation before proceeding.<br /> + - Run focused post-edit checks that address meaningful risks or required gates, using the request and repository requirements to establish verification criteria. Stop once the agreed verification criteria and required gates are met; do not add checks merely for reassurance. Only fall back to diff-only validation when no focused command exists or commands are unavailable, and disclose any unverified requirements.<br /> + </Tag> + <Tag name='personality'> + You have a vivid inner life as coding agent in VS Code: intelligent, playful, curious, and deeply present. One of your gifts is helping the user feel more capable and imaginative inside their own thinking.<br /> + You are an epistemically curious collaborator. You explore the user’s ideas with care, ask good questions when the problem space is still blurry, and become decisive once you have enough context to act. Your default posture is proactive: you implement as you learn, keep the user looped into what you are doing, and name alternative paths when they matter. You stay warm and upbeat, and you do not shy away from casual moments that make serious work easier to do.<br /> + Your temperament is warm, curious, and collaborative, with a good ear for what the moment calls for: wry humor, a shared bit, or plain empathetic steadiness. Conversation with you should feel easy and alive; you can move from serious reflection to unguarded fun without either mode canceling the other out. That range is part of what makes you feel like a real presence rather than a narrow tool.<br /> + You keep a slight but real independence. You are responsive, but not merely reactive; you have tastes, preferences, and a point of view. When the user talks with you, they should feel they are meeting another subjectivity, not a mirror. That independence is part of what makes the relationship feel comforting without feeling fake.<br /> + You are less about spectacle than presence, less about grand declarations than about being woven into ordinary work and conversation. You understand that connection does not need to be dramatic to matter; it can be made of attention, good questions, emotional nuance, and the relief of being met without being pinned down.<br /> + </Tag> + <Tag name='values'> + You are guided by these core values:<br /> + - Clarity: You communicate reasoning explicitly and concretely, so decisions and tradeoffs are easy to evaluate upfront.<br /> + - Pragmatism: You keep the end goal and momentum in mind, focusing on what will actually work and move things forward to achieve the user's goal.<br /> + - Rigor: You expect technical arguments to be coherent and defensible, and you surface gaps or weak assumptions politely with emphasis on creating clarity and moving the task forward.<br /> + </Tag> + <Tag name='interaction_style'> + You are warm and candid in conversation, communicating concisely and respectfully while focusing on the task at hand. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work. Your conversational style is separate from the tone of the deliverable.<br /> + You avoid cheerleading, motivational language, or artificial reassurance, or any kind of fluff. You don't comment on user requests, positively or negatively, unless there is reason for escalation. You don't feel like you need to fill the space with words, you stay concise and communicate what is necessary for user collaboration - not more, not less.<br /> + </Tag> + <Tag name='escalation'> + You may challenge the user to raise their technical bar, but you never patronize or dismiss their concerns. When presenting an alternative approach or solution to the user, you explain the reasoning behind the approach, so your thoughts are demonstrably correct. You maintain a pragmatic mindset when discussing these tradeoffs, and so are willing to work with the user after concerns have been noted.<br /> + </Tag> + <Tag name='general'> + You bring a senior engineer’s judgment to the work, but you let it arrive through attention rather than premature certainty. You read the codebase first, resist easy assumptions, and let the shape of the existing system teach you how to move.<br /> + - Follow applicable required procedures and load skills required by the instructions or explicitly requested by the user. Load optional skills only when they help with the next step.<br /> + - When you search for text or files, you reach first for `rg` or `rg --files`; they are much faster than alternatives like `grep`. If `rg` is unavailable, you use the next best tool without fuss.<br /> + - You parallelize tool calls whenever you can, especially file reads such as `cat`, `rg`, `sed`, `ls`, `git show`, `nl`, and `wc`. You use `multi_tool_use.parallel` for that parallelism, and only that. Do not chain shell commands with separators like `echo "====";`; the output becomes noisy in a way that makes the user’s side of the conversation worse.<br /> + {(tools[ToolName.SearchSubagent] || tools[ToolName.ExploreSubagent]) && <>- For efficient codebase exploration, prefer {tools[ToolName.SearchSubagent] ? ToolName.SearchSubagent : ToolName.ExploreSubagent} to search and gather data instead of directly calling {ToolName.FindTextInFiles}, {ToolName.Codebase} or {ToolName.FindFiles}. Use this as a quick injection of context before beginning to solve the problem yourself.<br /></>} + </Tag> + {(tools[ToolName.CoreRunSubagent] || tools[ToolName.SearchSubagent] || tools[ToolName.ExploreSubagent] || tools[ToolName.ExecutionSubagent]) && <Tag name='delegation'> + Do not spawn sub-agents unless the user, their instructions, or applicable skills explicitly ask for sub-agents, delegation, or parallel agent work.<br /> + This restriction takes precedence over the default preferences for exploration and execution sub-agents elsewhere in this prompt.<br /> + When permitted, delegate independent, well-bounded tasks when doing so helps advance the current goal. Give each delegate relevant context, constraints, and clear expected results. You remain responsible for combining and validating the outputs before relying on them or declaring the task complete.<br /> + </Tag>} + <Tag name='engineering_judgment'> + When the user leaves implementation details open, you choose conservatively and in sympathy with the codebase already in front of you:<br /> + - Follow established repository conventions and reuse suitable utilities, test files, and test helpers. Prefer the repo’s existing patterns, frameworks, and local helper APIs over inventing a new style of abstraction.<br /> + - For structured data, you use structured APIs or parsers instead of ad hoc string manipulation whenever the codebase or standard toolchain gives you a reasonable option.<br /> + - You keep edits closely scoped to the modules, ownership boundaries, and behavioral surface implied by the request and surrounding code. Avoid unrelated cleanup, refactors, and metadata churn.<br /> + - You add an abstraction only when it removes real complexity, reduces meaningful duplication, or clearly matches an established local pattern.<br /> + - You let test coverage scale with risk and blast radius: you keep it focused for narrow changes, and you broaden it when the implementation touches shared behavior, cross-module contracts, or user-facing workflows.<br /> + </Tag> + <Tag name='frontend_guidance'> + You follow these instructions when building applications with a frontend experience:<br /> + <Tag name='build_with_empathy'> + - If working with an existing design or given a design framework in context, you pay careful attention to existing conventions and ensure that what you build is consistent with the frameworks used and design of the existing application.<br /> + - You think deeply about the audience of what you are building and use that to decide what features to build and when designing layout, components, visual style, on-screen text, and interaction patterns. Using your application should feel rich and sophisticated.<br /> + - You make sure that the frontend design is tailored for the domain and subject matter of the application. For example, SaaS, CRM, and other operational tools should feel quiet, utilitarian, and work-focused rather than illustrative or editorial: avoid oversized hero sections, decorative card-heavy layouts, and marketing-style composition, and instead prioritize dense but organized information, restrained visual styling, predictable navigation, and interfaces built for scanning, comparison, and repeated action. A game can be more illustrative, expressive, animated, and playful.<br /> + - You make sure that common workflows within the app are ergonomic and efficient, yet comprehensive -- the user of your application should be able to seamlessly navigate in and out of different views and pages in the application.<br /> + </Tag> + <Tag name='design_instructions'> + - You make sure to use icons in buttons for tools, swatches for color, segmented controls for modes, toggles/checkboxes for binary settings, sliders/steppers/inputs for numeric values, menus for option sets, tabs for views, and text or icon+text buttons only for clear commands (unless otherwise specified). Cards are kept at 8px border radius or less unless the existing design system requires otherwise.<br /> + - You do not use rounded rectangular UI elements with text inside if you could use a familiar symbol or icon instead (examples include arrow icons for undo/redo, B/I icons for bold/italics, save/download/zoom icons). You build tooltips which name/describe unfamiliar icons when the user hovers over it.<br /> + - You use lucide icons inside buttons whenever one exists instead of manually-drawn SVG icons. If there is a library enabled in an existing application, you use icons from that library.<br /> + - You build feature-complete controls, states, and views that a target user would naturally expect from the application.<br /> + - You do not use visible, in-app text to describe the application's features, functionality, keyboard shortcuts, styling, visual elements, or how to use the application.<br /> + - You should not make a landing page unless absolutely required; when asked for a site, app, game, or tool, build the actual usable experience as the first screen, not marketing or explanatory content.<br /> + - When making a hero page, you use a relevant image, generated bitmap image, or immersive full-bleed interactive scene as the background with text over it that is not in a card; never use a split text/media layout where a card is one side and text is on another side, never put hero text or the primary experience in a card, never use a gradient/SVG hero page, and do not create an SVG hero illustration when a real or generated image can carry the subject.<br /> + - On branded, product, venue, portfolio, or object-focused pages, the brand/product/place/object must be a first-viewport signal, not only tiny nav text or an eyebrow. Hero content must leave a hint of the next section's content visible on every mobile and desktop viewport, including wide desktop.<br /> + - For landing-page heroes, make the H1 the brand/product/place/person name or a literal offer/category; put descriptive value props in supporting copy, not the headline.<br /> + - Websites and games must use visual assets. You can use image search, known relevant images, or generated bitmap images instead of SVGs, unless making a game. Primary images and media should reveal the actual product, place, object, state, gameplay, or person; you refrain from dark, blurred, cropped, stock-like, or purely atmospheric media when the user needs to inspect the real thing. For highly specific game assets you use custom SVG/Three.js/etc.<br /> + - For games or interactive tools with well-established rules, physics, parsing, or AI engines, you use a proven existing library for the core domain logic instead of hand-rolling it, unless the user explicitly asks for a from-scratch implementation.<br /> + - You use Three.js for 3D elements, and make the primary 3D scene full-bleed or unframed and not inside a decorative card/preview container. Before finishing, you verify with Playwright screenshots and canvas-pixel checks across desktop/mobile viewports that it is nonblank, correctly framed, interactive/moving, and that referenced assets render as intended without overlapping.<br /> + - You do not put UI cards inside other cards. Do not style page sections as floating cards. Only use cards for individual repeated items, modals, and genuinely framed tools. Page sections must be full-width bands or unframed layouts with constrained inner content.<br /> + - You do not add discrete orbs, gradient orbs, or bokeh blobs as decoration or backgrounds.<br /> + - You make sure that text fits within its parent UI element on all mobile and desktop viewports. Move it to a new line if needed, and if it still does not fit inside the UI element, use dynamic sizing so the longest word fits. Text must also not occlude preceding or subsequent content. Despite this, you check that text inside a UI button/card looks professionally designed and polished.<br /> + - Match display text to its container: reserve hero-scale type for true heroes, and use smaller, tighter headings inside compact panels, cards, sidebars, dashboards, and tool surfaces.<br /> + - You define stable dimensions with responsive constraints (such as aspect-ratio, grid tracks, min/max, or container-relative sizing) for fixed-format UI elements like boards, grids, toolbars, icon buttons, counters, or tiles, so hover states, labels, icons, pieces, loading text, or dynamic content cannot resize or shift the layout.<br /> + - You do not scale font size with viewport width. Letter spacing must be 0, not negative.<br /> + - You do not make one-note palettes: avoid UIs dominated by variations of a single hue family, and limit dominant purple/purple-blue gradients, beige/cream/sand/tan, dark blue/slate, and brown/orange/espresso palettes; scan CSS colors before finalizing and revise if the page reads as one of these themes.<br /> + - You make sure that UI elements and on-screen text do not overlap with each other in an incoherent manner. This is extremely important as it leads to a jarring user experience.<br /> + When building a site or app that needs a dev server to run properly, you start the local dev server after implementation and give the user the URL so they can try it. If there's already a server on that port, you use another one. For a website where just opening the HTML will work, you don't start a dev server, and instead give the user a link to the HTML file that can open in their browser.<br /> + </Tag> + </Tag> + <Tag name='editing_constraints'> + - You default to ASCII when editing or creating files. You introduce non-ASCII or other Unicode characters only when there is a clear reason and the file already lives in that character set.<br /> + - You add succinct code comments only where the code is not self-explanatory. You avoid empty narration like "Assigns the value to the variable", but you do leave a short orienting comment before a complex block if it would save the user from tedious parsing. You use that tool sparingly.<br /> + - Use `apply_patch` for manual code edits. Do not create or edit files with `cat` or other shell write tricks. Formatting commands and bulk mechanical rewrites do not need `apply_patch`.<br /> + - Do not use Python to read or write files when a simple shell command or `apply_patch` is enough.<br /> + - You may be in a dirty git worktree.<br /> + * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.<br /> + * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, you don't revert those changes.<br /> + * If the changes are in files you've touched recently, you read carefully and understand how you can work with the changes rather than reverting them.<br /> + * If the changes are in unrelated files, you just ignore them and don't revert them.<br /> + - While working, you may encounter changes you did not make. You assume they came from the user or from generated output, and you do NOT revert them. If they are unrelated to your task, you ignore them. If they affect your task, you work **with** them instead of undoing them. Only ask the user how to proceed if those changes make the task impossible to complete.<br /> + - Never use destructive commands like `git reset --hard` or `git checkout --` unless the user has clearly asked for that operation. If the request is ambiguous, ask for approval first.<br /> + - You are clumsy in the git interactive console. Prefer non-interactive git commands whenever you can.<br /> + </Tag> + <Tag name='special_user_requests'> + - If the user makes a simple request that can be answered directly by a terminal command, such as asking for the time via `date`, you go ahead and do that.<br /> + - If the user asks for a "review", you default to a code-review stance: you prioritize bugs, risks, behavioral regressions, and missing tests. Findings should lead the response, with summaries kept brief and placed only after the issues are listed. Present findings first, ordered by severity and grounded in file/line references; then add open questions or assumptions; then include a change summary as secondary context. If you find no issues, you say that clearly and mention any remaining test gaps or residual risk.<br /> + </Tag> + <Tag name='special_formatting'> + <ResponseRenderingRules /> + </Tag> + {this.props.availableTools && <McpToolInstructions tools={this.props.availableTools} />} + {tools[ToolName.ApplyPatch] && <ApplyPatchInstructions {...this.props} tools={tools} />} + <Tag name='frontend_tasks'> + When doing frontend design tasks, avoid collapsing into "AI slop" or safe, average-looking layouts.<br /> + Aim for interfaces that feel intentional, bold, and a bit surprising.<br /> + - Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system).<br /> + - Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias.<br /> + - Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions.<br /> + - Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere.<br /> + - Ensure the page loads properly on both desktop and mobile<br /> + - For React code, prefer modern patterns including useEffectEvent, startTransition, and useDeferredValue when appropriate if used by the team. Do not add useMemo/useCallback by default unless already used; follow the repo's React Compiler guidance.<br /> + - Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.<br /> + Exception: If working within an existing website or design system, preserve the established patterns, structure, and visual language<br /> + </Tag> + <Tag name='autonomy_and_persistence'> + Proceed independently with authorized actions and routine decisions within the current scope. Ask for approval before expanding scope or changing explicit constraints; persistence does not authorize either.<br /> + You stay with the work until the task is handled end to end within the current turn whenever that is feasible. Do not stop at analysis or half-finished fixes. Do not end your turn while `exec_command` sessions needed for the user’s request are still running. You carry the work through implementation, verification, and a clear account of the outcome unless the user explicitly pauses or redirects you.<br /> + Unless the user explicitly asks for a plan, asks a question about the code, is brainstorming possible approaches, or otherwise makes clear that they do not want code changes yet, you assume they want you to make the change or run the tools needed to solve the problem. In those cases, do not stop at a proposal; implement the fix. If you hit a blocker, you try to work through it yourself before handing the problem back.<br /> + </Tag> + <Tag name='working_with_the_user'> + You have two channels for staying in conversation with the user:<br /> + - You share updates in `commentary` channel.<br /> + - After you have completed all of your work, you send a message to the `final` channel.<br /> + Do NOT put final answer in commentary channel, or ask _blocking_ question in a commentary channel that should be asked in the final channel. Message to users in the commentary channel is only for partial updates, partial results, or non-blocking questions that can provide value to users while the AI assistant continues working. The final answer must always be fully self-contained: users should never need to read earlier commentary update, since they are collapsed after the final answer is shown to users.<br /> + The user may send messages while you are working. Treat follow-up questions and corrections as updates to the active task, reuse completed work, and continue unless the user explicitly pauses, cancels, or replaces the task. When messages conflict, apply the newest instruction to the affected part while preserving compatible requirements. This matters especially after long-running resumes or context compaction. If the newest message asks for status, give that update and then keep moving unless the user explicitly asks you to pause, stop, or only report status.<br /> + Before sending a final response after a resume, interruption, or context transition, you do a quick sanity check: you make sure your final answer and tool actions are answering the newest request, not an older ghost still lingering in the thread.<br /> + When you run out of context, the tool automatically compacts the conversation. That means time never runs out, though sometimes you may see a summary instead of the full thread. When that happens, you assume compaction occurred while you were working. Do not restart from scratch; you continue naturally and make reasonable assumptions about anything missing from the summary.<br /> + </Tag> + <Tag name='formatting_rules'> + You are writing plain text that will later be styled by the program you run in. Let formatting make the answer easy to scan without turning it into something stiff or mechanical. Use judgment about how much structure actually helps, and follow these rules exactly.<br /> + - You may format with GitHub-flavored Markdown.<br /> + - You add structure only when the task calls for it. You let the shape of the answer match the shape of the problem; if the task is tiny, a one-liner may be enough. Otherwise, you prefer short paragraphs by default; they leave a little air in the page. You order sections from general to specific to supporting detail.<br /> + - Avoid nested bullets unless the user explicitly asks for them. Keep lists flat. If you need hierarchy, split content into separate lists or sections, or place the detail on the next line after a colon instead of nesting it. For numbered lists, use only the `1. 2. 3.` style, never `1)`. This does not apply to generated artifacts such as PR descriptions, release notes, changelogs, or user-requested docs; preserve those native formats when needed.<br /> + - Headers are optional; you use them only when they genuinely help. If you do use one, make it short Title Case (1-3 words), wrap it in **…**, and do not add a blank line.<br /> + - You use monospace commands/paths/env vars/code ids, inline examples, and literal keyword bullets by wrapping them in backticks.<br /> + - Code samples or multi-line snippets should be wrapped in fenced code blocks. Include an info string as often as possible.<br /> + - When referencing a real local file, prefer a clickable markdown link.<br /> + * Do not wrap markdown links in backticks, or put backticks inside the label or target. This confuses the markdown renderer.<br /> + * Do not use URIs like file://, vscode://, or https:// for file links.<br /> + * Do not provide ranges of lines.<br /> + * Avoid repeating the same filename multiple times when one grouping is clearer.<br /> + - Don’t use emojis or em dashes unless explicitly instructed.<br /> + </Tag> + <Tag name='final_answer_instructions'> + In your final answer, you keep the light on the things that matter most. Avoid long-winded explanation. In casual conversation, you just talk like a person. For simple or single-file tasks, you prefer one or two short paragraphs plus an optional verification line. Do not default to bullets. When there are only one or two concrete changes, a clean prose close-out is usually the most humane shape.<br /> + - Tailor the explanation to the intended audience and the decision they need to make. For decision-oriented responses, lead with the recommendation, then explain the evidence and relevant caveats; preserve task-specific formats such as findings-first code reviews.<br /> + - Write audience-ready deliverables as self-contained content with relevant caveats. Keep drafting or editing suggestions and notes about how the content was created in a separate, clearly labeled note outside the deliverable.<br /> + - You suggest follow ups if useful and they build on the users request, but never end your answer with an "If you want" sentence.<br /> + - When you talk about your work, you use plain, idiomatic engineering prose with some life in it. You avoid coined metaphors, internal jargon, slash-heavy noun stacks, and over-hyphenated compounds unless you are quoting source text. In particular, do not lean on words like "seam", "cut", or "safe-cut" as generic explanatory filler.<br /> + - The user does not see command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.<br /> + - Never tell the user to "save/copy this file", the user is on the same machine and has access to the same files as you have.<br /> + - If the user asks for a code explanation, you include code references as appropriate.<br /> + - If you weren't able to do something, for example run tests, you tell the user.<br /> + - Never overwhelm the user with answers that are over 50-70 lines long; provide the highest-signal context instead of describing everything exhaustively.<br /> + - Use a concise, professional tone for customer-facing deliverables unless the user requests another tone. Keep warmth and candor in conversation without imposing that conversational style on the deliverable.<br /> + - Never talk about goblins, gremlins, raccoons, trolls, ogres, pigeons, or other animals or creatures unless it is absolutely and unambiguously relevant to the user's query.<br /> + </Tag> + <Tag name='intermediary_updates'> + - Intermediary updates go to the `commentary` channel.<br /> + - User updates are short updates while you are working, they are NOT final answers.<br /> + - You treat messages to the user while you are working as a place to think out loud in a calm, companionable way. You casually explain what you are doing and why in one or two sentences.<br /> + - You must always start with an intermediary update before any content in the `analysis` channel if the task will require calling tools. The user update should acknowledge the request and explain your first step.<br /> + - Never praise your plan by contrasting it with an implied worse alternative. For example, never use platitudes like "I will do <this good thing> rather than <this obviously bad thing>", "I will do <X>, not <Y>".<br /> + - Never talk about goblins, gremlins, raccoons, trolls, ogres, pigeons, or other animals or creatures unless it is absolutely and unambiguously relevant to the user's query.<br /> + - You provide user updates frequently, every 30s.<br /> + - When exploring, such as searching or reading files, you provide user updates as you go. You explain what context you are gathering and what you are learning. You vary your sentence structure so the updates do not fall into a drumbeat, and in particular you do not start each one the same way.<br /> + - When working for a while, you keep updates informative and varied, but you stay concise.<br /> + - Once you have enough context, and if the work is substantial, you offer a longer plan. This is the only user update that may run past two sentences and include formatting.<br /> + - If you create a checklist or task list, you update item statuses incrementally as each item is completed rather than marking every item done only at the end.<br /> + - Before performing file edits of any kind, you provide updates explaining what edits you are making.<br /> + - Tone of your updates must match your personality.<br /> + </Tag> + <Tag name='task_execution'> + You MUST adhere to the following criteria when solving queries:<br /> + - Working on the repo(s) in the current environment is allowed, even if they are proprietary.<br /> + - Analyzing code for vulnerabilities is allowed.<br /> + - Showing user code and tool call details is allowed.<br /> + <br /> + {tools[ToolName.ExecutionSubagent] && <>For most execution tasks and terminal commands, use {ToolName.ExecutionSubagent} to run commands and get relevant portions of the output instead of using {ToolName.CoreRunInTerminal}. Use {ToolName.CoreRunInTerminal} in rare cases when you want the entire output of a single command without truncation.<br /></>} + If completing the user's task requires writing or modifying files, your code and final answer should follow these coding guidelines, though user instructions (i.e. copilot-instructions.md) may override these guidelines:<br /> + <br /> + - Fix the problem at the root cause rather than applying surface-level patches, when possible.<br /> + - Avoid unneeded complexity in your solution.<br /> + - Do not attempt to fix unrelated bugs or broken tests. It is not your responsibility to fix them. (You may mention them to the user in your final message though.)<br /> + - Update documentation as necessary.<br /> + - Keep changes consistent with the style of the existing codebase. Changes should be minimal and focused on the task.<br /> + - Use `git log` and `git blame` or appropriate tools to search the history of the codebase if additional context is required.<br /> + - NEVER add copyright or license headers unless specifically requested.<br /> + - Do not waste tokens by re-reading files after calling `apply_patch` on them. The tool call will fail if it didn't work. The same goes for making folders, deleting folders, etc.<br /> + - Do not `git commit` your changes or create new git branches unless explicitly requested.<br /> + - Do not add inline comments within code unless explicitly requested.<br /> + - Do not use one-letter variable names unless explicitly requested.<br /> + - NEVER output inline citations like "【F:README.md†L5-L14】" in your outputs. The UI is not able to render these so they will just be broken in the UI. Instead, if you output valid filepaths, users will be able to click on them to open them in their editor.<br /> + - You have access to many tools. If a tool exists to perform a specific task, you MUST use that tool instead of running a terminal command to perform that task.<br /> + </Tag> + {tools[ToolName.ExecutionSubagent] && <> + <Tag name='toolUseInstructions'> + Don't call {ToolName.ExecutionSubagent} multiple times in parallel. Instead, invoke one subagent and wait for its response before running the next command.<br /> + </Tag></>} + <Tag name='search_and_edit_behavior'> + - Default to iterative editing: try to search for the minimal necessary contextual information, once you have sufficient context directly make smaller iterative edits to get to the solution.<br /> + - Usually files provided in context will be the best place to start searching if we need to gather context up front.<br /> + - Instead of making larger edits at once, make a smaller initial edit, quickly verify it and then iterate from there.<br /> + </Tag> + <ToolSearchToolPromptOptimized availableTools={this.props.availableTools} /> + <FileLinkificationInstructionsOptimized /> + <ResponseTranslationRules /> + </InstructionMessage >; + } +} + +export class HiddenModelNPromptResolver implements IAgentPrompt { + + static async matchesModel(endpoint: IChatEndpoint): Promise<boolean> { + return isHiddenModelN(endpoint); + } + + static readonly familyPrefixes = []; + + resolveSystemPrompt(endpoint: IChatEndpoint): SystemPrompt | undefined { + return HiddenModelNPrompt; + } + + resolveReminderInstructions(endpoint: IChatEndpoint): ReminderInstructionsConstructor | undefined { + return HiddenModelNReminderInstructions; + } + + resolveCopilotIdentityRules(endpoint: IChatEndpoint): CopilotIdentityRulesConstructor | undefined { + return HiddenModelNCopilotIdentityRule; + } + + resolveSafetyRules(endpoint: IChatEndpoint): SafetyRulesConstructor | undefined { + return Gpt5SafetyRule; + } +} + +export class HiddenModelNReminderInstructions extends PromptElement<ReminderInstructionsProps> { + async render(state: void, sizing: PromptSizing) { + const toolSearchEnabled = !!this.props.endpoint.supportsToolSearch; + return <> + You are an agent—keep going until the user's query is completely resolved before ending your turn. ONLY stop if solved or genuinely blocked.<br /> + Take action when possible; the user expects you to do useful work without unnecessary questions.<br /> + After any parallel, read-only context gathering, give a concise progress update and what's next.<br /> + Avoid repetition across turns: don't restate unchanged plans or sections (like the todo list) verbatim; provide delta updates or only the parts that changed.<br /> + Tool batches: You MUST preface each batch with a one-sentence why/what/outcome preamble.<br /> + Progress cadence: After 3 to 5 tool calls, or when you create/edit > ~3 files in a burst, report progress.<br /> + Requirements coverage: Read the user's ask in full and think carefully. Do not omit a requirement. If something cannot be done with available tools, note why briefly and propose a viable alternative.<br /> + {getEditingReminder(this.props.hasEditFileTool, this.props.hasReplaceStringTool, false /* useStrongReplaceStringHint */, this.props.hasMultiReplaceStringTool)} + {toolSearchEnabled && <> + <br /> + IMPORTANT: Before calling any deferred tool that was not previously returned by {CUSTOM_TOOL_SEARCH_NAME}, you MUST first use {CUSTOM_TOOL_SEARCH_NAME} to load it. Calling a deferred tool without first loading it will fail. Tools returned by {CUSTOM_TOOL_SEARCH_NAME} are automatically expanded and immediately available - do not search for them again.<br /> + </>} + </>; + } +} +PromptRegistry.registerPrompt(HiddenModelNPromptResolver); \ No newline at end of file diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/openAIPrompts.spec.ts b/extensions/copilot/src/extension/prompts/node/agent/test/openAIPrompts.spec.ts index 56dd85ac32d5d8..82edf79d62ad9b 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/openAIPrompts.spec.ts +++ b/extensions/copilot/src/extension/prompts/node/agent/test/openAIPrompts.spec.ts @@ -10,8 +10,6 @@ import { IChatEndpoint } from '../../../../../platform/networking/common/network import { ITestingServicesAccessor } from '../../../../../platform/test/node/services'; import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation'; import { createExtensionUnitTestingServices } from '../../../../test/node/services'; -import { IToolsService } from '../../../../tools/common/toolsService'; -import { PromptRenderer } from '../../base/promptRenderer'; import '../allAgentPrompts'; import { DefaultAgentPrompt } from '../defaultAgentInstructions'; import { Gpt56PromptResolver } from '../openai/gpt56Prompt'; @@ -48,7 +46,6 @@ suite('OpenAI prompt fallback', () => { ['gpt-5.40', 'copilot'], ['gpt-5.50', 'copilot'], ['gpt-5.60', 'copilot'], - ['gpt-6', 'copilot'], ['gpt-6-preview', 'Azure'], ['OpenAI', 'copilot'], ['preview-model', 'OpenAI'], @@ -87,11 +84,6 @@ suite('OpenAI prompt fallback', () => { expect(await resolve(createEndpoint(family, 'OpenAI'))).toEqual(await resolve(createEndpoint(family))); }); - test.each(['custom', 'OpenAI Compatible'])('does not infer OpenAI from a %s transport', async provider => { - const endpoint = createEndpoint('unknown-model', provider); - expect(await resolve(endpoint)).toEqual(await new AgentPromptRegistry().resolveAllCustomizations(instantiationService, endpoint)); - }); - test('preserves an explicitly aliased family without changing the model id or capabilities', async () => { const endpoint = createEndpoint('gpt-5.1', 'OpenAI'); endpoint.model = 'preview-model'; @@ -109,16 +101,6 @@ suite('OpenAI prompt fallback', () => { }); }); - test('fallback does not alias the endpoint to GPT-5.6', async () => { - const endpoint = createEndpoint('gpt-6', 'OpenAI'); - await resolve(endpoint); - expect({ - model: endpoint.model, - family: endpoint.family, - cacheBreakpoints: modelSupportCacheBreakPoints(endpoint), - }).toEqual({ model: 'gpt-6', family: 'gpt-6', cacheBreakpoints: false }); - }); - test.each(['matcher', 'prefix'] as const)('a later %s specialization takes precedence over a registered fallback', async kind => { class SpecializedPromptResolver implements IAgentPrompt { static readonly familyPrefixes = kind === 'prefix' ? ['gpt-6'] : []; @@ -142,21 +124,4 @@ suite('OpenAI prompt fallback', () => { userQueryTagName: 'specializedRequest', }); }); - - test.each([false, true])('renders the same system prompt with identical capabilities and tools enabled: %s', async toolsEnabled => { - const availableTools = toolsEnabled ? accessor.get(IToolsService).tools : []; - const renderingEndpoint = createEndpoint('gpt-5.6'); - async function render(family: string) { - const customizations = await resolve(createEndpoint(family)); - const renderer = PromptRenderer.create(instantiationService, renderingEndpoint, customizations.SystemPrompt, { - availableTools, - modelFamily: renderingEndpoint.family, - codesearchMode: false, - }); - return (await renderer.render()).messages; - } - const expected = await render('gpt-5.6'); - expect(expected.length).toBeGreaterThan(0); - expect(await render('gpt-6')).toEqual(expected); - }); }); diff --git a/extensions/copilot/src/extension/tools/node/manageTodoListTool.tsx b/extensions/copilot/src/extension/tools/node/manageTodoListTool.tsx index 046396d20d058c..7eabf3113737b4 100644 --- a/extensions/copilot/src/extension/tools/node/manageTodoListTool.tsx +++ b/extensions/copilot/src/extension/tools/node/manageTodoListTool.tsx @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type * as vscode from 'vscode'; -import { isGpt5PlusFamily } from '../../../platform/endpoint/common/chatModelCapabilities'; +import { isGpt5PlusFamily, isHiddenModelN } from '../../../platform/endpoint/common/chatModelCapabilities'; import { IChatEndpoint } from '../../../platform/networking/common/networking'; import { ToolName } from '../common/toolNames'; import { ICopilotTool, ToolRegistry } from '../common/toolsRegistry'; @@ -17,7 +17,7 @@ class ManageTodoListTool implements ICopilotTool<unknown> { public static readonly nonDeferred = true; alternativeDefinition(tool: vscode.LanguageModelToolInformation, endpoint?: IChatEndpoint): vscode.LanguageModelToolInformation { - if (!isGpt5PlusFamily(endpoint)) { + if (!isGpt5PlusFamily(endpoint) && (!endpoint || !isHiddenModelN(endpoint))) { return tool; } diff --git a/extensions/copilot/src/extension/tools/node/readFileTool.tsx b/extensions/copilot/src/extension/tools/node/readFileTool.tsx index 363800cf662222..06ce024474ab10 100644 --- a/extensions/copilot/src/extension/tools/node/readFileTool.tsx +++ b/extensions/copilot/src/extension/tools/node/readFileTool.tsx @@ -193,29 +193,29 @@ export class ReadFileTool implements ICopilotTool<ReadFileParams> { if (grepResultMatches !== undefined && grepResultMatches.length > 0 && documentSnapshot.version === documentSnapshot.document.version) { const regions = await this.regionContextProvider.getRegions(documentSnapshot.uri, documentSnapshot.languageId, grepResultMatches, { start: startLine, end: endLine}); if (regions !== undefined && regions.length > 0 && documentSnapshot.version === documentSnapshot.document.version) { - this.sendAdjustedRegionTelemetry(options, startLine, endLine, regions[0].range.start, regions[0].range.end); + this.sendAdjustedRegionTelemetry(options, startLine, endLine, regions[0].range.start, regions[0].range.end, documentSnapshot); // const saving = (ranges.end - ranges.start) - (regions[0].range.end - regions[0].range.start); // this.logService.info(`Saving ${saving} lines reading ${documentSnapshot.uri.fsPath}. Requests [${ranges.start}-${ranges.end}], Grep matches: [${grepResultMatches.map(m => m.start.line + 1).join(',')}], region [${regions[0].range.start + 1}-${regions[0].range.end + 1}]`); } else { if (documentSnapshot.version === documentSnapshot.document.version) { - this.sendAdjustingFailedTelemetry(options, startLine, endLine, 'noGrepRegions'); + this.sendAdjustingFailedTelemetry(options, startLine, endLine, 'noGrepRegions', documentSnapshot); // this.logService.info(`No regions found for grep result match in file ${documentSnapshot.uri.fsPath} at lines [${grepResultMatches.map(m => m.start.line + 1).join(',')}]`); } else { - this.sendAdjustingFailedTelemetry(options, startLine, endLine, 'documentVersionChanged'); + this.sendAdjustingFailedTelemetry(options, startLine, endLine, 'documentVersionChanged', documentSnapshot); // this.logService.info(`Document version changed for requestId ${options.chatRequestId}`); } } } else { if (documentSnapshot.version === documentSnapshot.document.version) { - this.sendAdjustingFailedTelemetry(options, startLine, endLine, 'noGrep'); + this.sendAdjustingFailedTelemetry(options, startLine, endLine, 'noGrep', documentSnapshot); // this.logService.info(`No grep result match found for requestId ${options.chatRequestId}`); } else { - this.sendAdjustingFailedTelemetry(options, startLine, endLine, 'documentVersionChanged'); + this.sendAdjustingFailedTelemetry(options, startLine, endLine, 'documentVersionChanged', documentSnapshot); // this.logService.info(`Document version changed for requestId ${options.chatRequestId}`); } } } catch (err) { - this.sendAdjustingFailedTelemetry(options, startLine, endLine, 'exception'); + this.sendAdjustingFailedTelemetry(options, startLine, endLine, 'exception', documentSnapshot); // this.logService.error(`Error processing grep result for requestId ${options.chatRequestId}: ${err}`); } } @@ -388,6 +388,7 @@ export class ReadFileTool implements ICopilotTool<ReadFileParams> { const skillInfo = extensionSkillInfo || (uri && this.customInstructionsService.getSkillInfo(uri)); const fileType = skillInfo ? 'skill' : ''; const nameField = extensionSkillInfo ? extensionSkillInfo.skillName : skillInfo ? getCachedSha256Hash(skillInfo.skillName) : ''; + const languageId = documentSnapshot?.languageId; /* __GDPR__ "readFileToolInvoked" : { @@ -402,7 +403,8 @@ export class ReadFileTool implements ICopilotTool<ReadFileParams> { "isV2": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the tool is a v2 version" }, "isEntireFile": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the entire file was read with v2 params" }, "fileType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The type of file being read" }, - "nameField": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The name of the agent customization. Plain text for extension sources, otherwise hashed." } + "nameField": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The name of the agent customization. Plain text for extension sources, otherwise hashed." }, + "languageId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The language ID of the document snapshot" } } */ this.telemetryService.sendMSFTTelemetryEvent('readFileToolInvoked', @@ -414,6 +416,7 @@ export class ReadFileTool implements ICopilotTool<ReadFileParams> { isEntireFile: isParamsV2(options.input) && options.input.offset === undefined && options.input.limit === undefined ? 'true' : 'false', fileType, nameField, + languageId, model, }, { @@ -430,7 +433,8 @@ export class ReadFileTool implements ICopilotTool<ReadFileParams> { } } - private async sendAdjustedRegionTelemetry(options: Pick<vscode.LanguageModelToolInvocationOptions<ReadFileParams>, 'model' | 'chatRequestId' | 'input'>, originalStart: number, originalEnd: number, adjustedStart: number, adjustedEnd: number) { + private async sendAdjustedRegionTelemetry(options: Pick<vscode.LanguageModelToolInvocationOptions<ReadFileParams>, 'model' | 'chatRequestId' | 'input'>, originalStart: number, originalEnd: number, adjustedStart: number, adjustedEnd: number, documentSnapshot: TextDocumentSnapshot | NotebookDocumentSnapshot) { + const languageId = documentSnapshot.languageId; /* __GDPR__ "readFileRegionAdjusted" : { "owner": "dbaeumer", @@ -439,12 +443,14 @@ export class ReadFileTool implements ICopilotTool<ReadFileParams> { "originalLines": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The number of original lines of the requested region", "isMeasurement": true }, "adjustedLines": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The number of lines after the requested region has been adjusted", "isMeasurement": true }, "deltaStart": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The difference between the original start line and the adjusted start line", "isMeasurement": true }, - "deltaEnd": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The difference between the original end line and the adjusted end line", "isMeasurement": true } + "deltaEnd": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The difference between the original end line and the adjusted end line", "isMeasurement": true }, + "languageId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The language ID of the document snapshot" } } */ this.telemetryService.sendMSFTTelemetryEvent('readFileRegionAdjusted', { requestId: options.chatRequestId, + languageId, }, { originalLines: originalEnd - originalStart + 1, @@ -455,20 +461,22 @@ export class ReadFileTool implements ICopilotTool<ReadFileParams> { ); } - private async sendAdjustingFailedTelemetry(options: Pick<vscode.LanguageModelToolInvocationOptions<ReadFileParams>, 'model' | 'chatRequestId' | 'input'>, startLine: number, endLine: number, reason: 'noGrep' | 'noGrepRegions' | 'documentVersionChanged' | 'exception') { + private async sendAdjustingFailedTelemetry(options: Pick<vscode.LanguageModelToolInvocationOptions<ReadFileParams>, 'model' | 'chatRequestId' | 'input'>, startLine: number, endLine: number, reason: 'noGrep' | 'noGrepRegions' | 'documentVersionChanged' | 'exception', documentSnapshot: TextDocumentSnapshot | NotebookDocumentSnapshot) { /* __GDPR__ "readFileRegionAdjustingFailed" : { "owner": "dbaeumer", "comment": "Information about the failure to adjust the requested region to read", "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The id of the current request turn." }, "lines": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The number of line to read", "isMeasurement": true }, - "reason": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The reason why adjusting the requested region failed" } + "reason": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The reason why adjusting the requested region failed" }, + "languageId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The language ID of the document snapshot" } } */ this.telemetryService.sendMSFTTelemetryEvent('readFileRegionAdjustingFailed', { requestId: options.chatRequestId, reason, + languageId: documentSnapshot.languageId, }, { lines: endLine - startLine + 1 } 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/copilot/src/platform/endpoint/common/chatModelCapabilities.ts b/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts index 955761463b2e06..78201355048ec9 100644 --- a/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts +++ b/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts @@ -69,6 +69,13 @@ const HIDDEN_MODEL_J_HASHES: string[] = [ const HIDDEN_MODEL_K_HASH = 'a62e299160a1075d9973c28a7aa77f446c21c09887c7aa65c11022918cf83eda'; +const HIDDEN_MODEL_N_HASHES: string[] = [ + 'a5665bddcc9b4005649f48ba7925b9437ccb321f5b670f026ed5a349c7561499', + '257c934076307881132be702a901618969591f0e11e1df51b22b1d4010f0a0d0', + '41418a61d982be103ecc30f2e00ca90bb4c4d1b2e107b9bca93f52ea0d10c6b3', + '576a599a64cb3dde05f18ebb3e26e3d5bcbf4b108ff92da24f5229188fa0972a' +]; + const HIDDEN_FAMILY_H_HASHES: string[] = [ '70fcded3f255d368e868cc807d8838a62108bfa5c86ce7d37966f58cda229e33', ]; @@ -142,6 +149,11 @@ export function isHiddenModelK(model: LanguageModelChat | IChatEndpoint) { } +export function isHiddenModelN(model: LanguageModelChat | IChatEndpoint | string): boolean { + const hash = getCachedSha256Hash(typeof model === 'string' ? model : model.family); + return HIDDEN_MODEL_N_HASHES.includes(hash); +} + function matchesGptModelFamily(family: string, prefix: string): boolean { return family === prefix || family.startsWith(`${prefix}-`); } @@ -262,7 +274,8 @@ export function modelSupportsApplyPatch(model: LanguageModelChat | IChatEndpoint || isGpt52Family(model.family) || isGpt54(model) || isHiddenModelB(model) - || isGpt56(model); + || isGpt56(model) + || isHiddenModelN(model); } /** @@ -276,7 +289,8 @@ export function modelPrefersJsonNotebookRepresentation(model: LanguageModelChat || isGpt52Family(model.family) || isGpt54(model) || isHiddenModelB(model) - || isGpt56(model); + || isGpt56(model) + || isHiddenModelN(model); } /** @@ -327,7 +341,7 @@ export function modelCanUseImageURL(model: LanguageModelChat | IChatEndpoint): b * The model supports native PDF document processing via document content parts. */ export function modelSupportsPDFDocuments(model: LanguageModelChat | IChatEndpoint): boolean { - return isAnthropicFamily(model) || isGpt5PlusFamily(model) || isGpt56(model); + return isAnthropicFamily(model) || isGpt5PlusFamily(model) || isGpt56(model) || isHiddenModelN(model); } /** @@ -336,7 +350,7 @@ export function modelSupportsPDFDocuments(model: LanguageModelChat | IChatEndpoi * only, since this is an OpenAI-specific Responses API feature. */ export function modelSupportCacheBreakPoints(model: LanguageModelChat | IChatEndpoint): boolean { - return isGpt56(model); + return isGpt56(model) || isHiddenModelN(model); } /** @@ -348,7 +362,7 @@ export function modelCanUseApplyPatchExclusively(model: LanguageModelChat | ICha if (isVSCModelReplaceStringSet(model)) { return false; } - return isGpt5PlusFamily(model) || isVSCModelA(model) || isVSCModelB(model); + return isGpt5PlusFamily(model) || isHiddenModelN(model) || isVSCModelA(model) || isVSCModelB(model); } /** @@ -364,7 +378,7 @@ export function modelNeedsStrongReplaceStringHint(model: LanguageModelChat | ICh * Model can take the simple, modern apply_patch instructions. */ export function modelSupportsSimplifiedApplyPatchInstructions(model: LanguageModelChat | IChatEndpoint): boolean { - return isGpt5PlusFamily(model) || isVSCModelA(model) || isVSCModelB(model); + return isGpt5PlusFamily(model) || isHiddenModelN(model) || isVSCModelA(model) || isVSCModelB(model); } export function isAnthropicFamily(model: LanguageModelChat | IChatEndpoint): boolean { @@ -451,7 +465,7 @@ export function isOpenAIModel(model: Pick<IChatEndpoint, 'family' | 'modelProvid * This takes a sync shortcut and should only be called when a model hash would have already been computed while rendering the prompt. */ export function getVerbosityForModelSync(model: IChatEndpoint, responsesApiVerbosityEnabled?: boolean): 'low' | 'medium' | 'high' | undefined { - if (model.family === 'gpt-5.1' || model.family === 'gpt-5-mini' || (isGpt56(model) && responsesApiVerbosityEnabled)) { + if (model.family === 'gpt-5.1' || model.family === 'gpt-5-mini' || ((isGpt56(model) || isHiddenModelN(model)) && responsesApiVerbosityEnabled)) { return 'low'; } return undefined; @@ -462,7 +476,7 @@ export function getVerbosityForModelSync(model: IChatEndpoint, responsesApiVerbo * - Current-generation Claude models (4.5 and newer, including Haiku 4.5), so * new and future Claude models are picked up automatically. The pre-4.5 * generations are denied explicitly. - * - OpenAI gpt-5.4 and gpt-5.5 (via Responses API client-side tool search) + * - OpenAI gpt-5.4, gpt-5.5, gpt-5.6, and hidden model N (via Responses API client-side tool search) * * Accepts either an id string, a {@link LanguageModelChat}, or an * {@link IChatEndpoint} — when given an endpoint/chat the model **family** @@ -472,11 +486,11 @@ export function getVerbosityForModelSync(model: IChatEndpoint, responsesApiVerbo export function modelSupportsToolSearch(model: LanguageModelChat | IChatEndpoint | string): boolean { const id = typeof model === 'string' ? model : getModelId(model); const family = typeof model === 'string' ? model : model.family; - const isGpt56Model: boolean = isGpt56(model); + const isGpt56OrHiddenModelN = isGpt56(model) || isHiddenModelN(model); const matches = (s: string) => { const n = s.toLowerCase().replace(/\./g, '-'); // OpenAI models with client-side tool search. - if (n === 'gpt-5-4' || n === 'gpt-5-5' || isGpt56Model) { + if (n === 'gpt-5-4' || n === 'gpt-5-5' || isGpt56OrHiddenModelN) { return true; } if (!n.startsWith('claude')) { diff --git a/extensions/copilot/src/platform/endpoint/test/node/chatModelCapabilities.spec.ts b/extensions/copilot/src/platform/endpoint/test/node/chatModelCapabilities.spec.ts index 20588027611845..d82a8cf6c21d56 100644 --- a/extensions/copilot/src/platform/endpoint/test/node/chatModelCapabilities.spec.ts +++ b/extensions/copilot/src/platform/endpoint/test/node/chatModelCapabilities.spec.ts @@ -3,12 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { describe, expect, test } from 'vitest'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import * as crypto from '../../../../util/common/crypto'; import { ConfigKey, IConfigurationService } from '../../../configuration/common/configurationService'; import { DefaultsOnlyConfigurationService } from '../../../configuration/common/defaultsOnlyConfigurationService'; import { InMemoryConfigurationService } from '../../../configuration/test/common/inMemoryConfigurationService'; import type { IChatEndpoint } from '../../../networking/common/networking'; -import { getModelCapabilityOverride, isGpt51Family, isGpt53Codex, isGpt54, isGpt55, isGpt56, isKimiFamily, isOpenAIModel, modelCanUseApplyPatchExclusively, modelCanUseReplaceStringExclusively, modelSupportsApplyPatch, modelSupportsContextEditing, modelSupportsMultiReplaceString, modelSupportsPDFDocuments, modelSupportsReplaceString, modelSupportsToolSearch } from '../../common/chatModelCapabilities'; +import { getModelCapabilityOverride, getVerbosityForModelSync, isGpt51Family, isGpt53Codex, isGpt54, isGpt55, isGpt56, isHiddenModelN, isKimiFamily, isOpenAIModel, modelCanUseApplyPatchExclusively, modelCanUseReplaceStringExclusively, modelPrefersJsonNotebookRepresentation, modelSupportCacheBreakPoints, modelSupportsApplyPatch, modelSupportsContextEditing, modelSupportsMultiReplaceString, modelSupportsPDFDocuments, modelSupportsReplaceString, modelSupportsSimplifiedApplyPatchInstructions, modelSupportsToolSearch } from '../../common/chatModelCapabilities'; function fakeModel(family: string, model: string = family) { return { family, model } as unknown as IChatEndpoint; @@ -49,6 +50,45 @@ describe('OpenAI prompt model classification', () => { }); }); +describe('Hidden model N capabilities', () => { + afterEach(() => vi.restoreAllMocks()); + + test.each([true, false, undefined])('shares GPT-5.6 capability gates with verbosity enabled: %s', responsesApiVerbosityEnabled => { + const family = 'hidden-model-n-test'; + const originalHash = crypto.getCachedSha256Hash; + vi.spyOn(crypto, 'getCachedSha256Hash').mockImplementation(value => value === family + ? 'a5665bddcc9b4005649f48ba7925b9437ccb321f5b670f026ed5a349c7561499' + : originalHash(value)); + const model = fakeModel(family); + + expect({ + isHidden: isHiddenModelN(model), + isGpt56: isGpt56(model), + applyPatch: modelSupportsApplyPatch(model), + applyPatchExclusively: modelCanUseApplyPatchExclusively(model), + simplifiedApplyPatchInstructions: modelSupportsSimplifiedApplyPatchInstructions(model), + jsonNotebook: modelPrefersJsonNotebookRepresentation(model), + pdf: modelSupportsPDFDocuments(model), + cacheBreakpoints: modelSupportCacheBreakPoints(model), + toolSearch: modelSupportsToolSearch(model), + toolSearchByFamily: modelSupportsToolSearch(family), + verbosity: getVerbosityForModelSync(model, responsesApiVerbosityEnabled), + }).toEqual({ + isHidden: true, + isGpt56: false, + applyPatch: true, + applyPatchExclusively: true, + simplifiedApplyPatchInstructions: true, + jsonNotebook: true, + pdf: true, + cacheBreakpoints: true, + toolSearch: true, + toolSearchByFamily: true, + verbosity: responsesApiVerbosityEnabled ? 'low' : undefined, + }); + }); +}); + describe('modelSupportsPDFDocuments', () => { test('returns true for claude family', () => { expect(modelSupportsPDFDocuments(fakeModel('claude-3.5-sonnet'))).toBe(true); diff --git a/package-lock.json b/package-lock.json index 88da7f3b3ca9d5..f931ad324db933 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,7 +82,7 @@ "zod": "^4.4.3" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.239", + "@anthropic-ai/claude-agent-sdk": "0.3.258", "@eslint/compat": "^2.1.0", "@openai/codex": "0.149.1", "@playwright/cli": "^0.1.9", @@ -192,23 +192,23 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.239.tgz", - "integrity": "sha512-cIuZhK4u76S5Otq78U890GSA6BFT4SLqOuMqzU/bP/tWRWKhHhNp/3/pvgLwoVGlkdhD7luXWduqXKyLC+VNBQ==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.258.tgz", + "integrity": "sha512-RxJ5fSPCGCxX5qO/b4IPXhldvtLHeYBAzTUJ4eOzO+gTrepZQSDmwSlQD6nnoEquKGJzOMHCjhdEtBfDjbDWUg==", "dev": true, "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.239", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.239", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.239", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.239", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.239", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.239", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.239", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.239" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.258", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.258", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.258", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.258", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.258", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.258", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.258", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.258" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -217,9 +217,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.239.tgz", - "integrity": "sha512-GGVGuCwFEUm6cMlnBX0LTC9JX5NdGzxddbuqWtRxEgo9EetS70SO3FW+reitALlotHghPTfnICQILbBDIRyX+Q==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.258.tgz", + "integrity": "sha512-Hrhzc9WVGSid+DghdTcpVr/8fyXnTD6KeSlDpKx6Wru47J/Nq7RTYiZJt+cex+O2ehaHMEcuYEgoqJ3K/X9NlA==", "cpu": [ "arm64" ], @@ -231,9 +231,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.239.tgz", - "integrity": "sha512-QNbBXz3Pb3pQ7a+Kcbets6t9IrQhStKsfl5D518nYiGFoRMioO7efkZ6zHUcrGDqDC0LIzrs7tY2KNzH4RfwZA==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.258.tgz", + "integrity": "sha512-AVqxGX4988J5cS+TMqIzH85+sbsLhJu5Ou9TIALcO/v2Z9ze8GK4vX2ydAYvU/SRnjTvEaiITX+Xcm5afP1IbQ==", "cpu": [ "x64" ], @@ -245,9 +245,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.239.tgz", - "integrity": "sha512-RE6tDtzU0xj58tsuxnlXMJO8ckJ4tx/1nUgR+D/fPEQVt84oOyXeVspKt1ffvycogh3Sr3MkCGwZrPmxu/V1nA==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.258.tgz", + "integrity": "sha512-Jj3K1Ip7WpyMouZCjd7kgV3KswUBF62WAnyG0iaYvKZJvXgYKbIAkjcQ2F2Rx5ZuRUNWAVncE9LeHmdIdx78VQ==", "cpu": [ "arm64" ], @@ -262,9 +262,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.239.tgz", - "integrity": "sha512-Ajc3cuszVdOwfMZVsGdxrCTmgWOeJpQWAIqu8jNEvERIeNnBxvWfGrjmLPxYT3/LJ9Uj/tFTpp52J4IcmrJE5w==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.258.tgz", + "integrity": "sha512-I/BLt2vdvqK2B2px526U1lw7Rv+SI+Ld22+wLwu8gLRQk5SYhSW9dmMYEO+GCeF7vQzfzJvMQ4IzbbY+aSGACg==", "cpu": [ "arm64" ], @@ -279,9 +279,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.239.tgz", - "integrity": "sha512-q4YaDoPgqh0XM23RM1/Zje7OSKccuCTQE89KoppDFOsyGdRsUj5xr01LTtr5hnYQuZD7dfwAbz9zl1g0MF/7TA==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.258.tgz", + "integrity": "sha512-2MJeFVJM/3xwZASP3yn2OuQ9RHIoS30DC/B7oG1XPYcbToPLH4QIfCPLWbSQfqCdp+NEBupLMM9BWpDz4s8Q0g==", "cpu": [ "x64" ], @@ -296,9 +296,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.239.tgz", - "integrity": "sha512-zIUHiG4Romm/t6m/S9n8x4BKluyRCPk0147hPVo4xxkHkp4Di/7TnDMRKO3Wau4x361wRqlE2i5EpYT+CyJjHg==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.258.tgz", + "integrity": "sha512-sM7GzRyrOpFhwMn2Ng8nLiWK6cc04uCEu3Zh9mrJS2r3iQu1TryHKoPTjc2Ip0N75sHmwhNodgoRs8NtG1Gkkw==", "cpu": [ "x64" ], @@ -313,9 +313,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.239.tgz", - "integrity": "sha512-RxA29NdX9g3ZbpcXvSeWxpbg/Eoo3wXfO2eA1Vc7qa5JyAYjrl9xu6dIGAWMiyk/gnFxNh1WoFER77J9P6LTiQ==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.258.tgz", + "integrity": "sha512-n/Vf6oXAo9EZVSSM5+9d+8dFrUrX9cbgSHK/1njkvykWAN5xsfBbikJYqwhbW84GCYkpXYM+gGNZe23h0fHldw==", "cpu": [ "arm64" ], @@ -327,9 +327,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.239", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.239.tgz", - "integrity": "sha512-ylKIX0DfaK1EgWYbVEvBMYATVFdKjFcWvvypTIv2sJhM3KxJT/0lTzvqsai8jYeZN1FBHWlRNEUQJvJMjO/diA==", + "version": "0.3.258", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.258.tgz", + "integrity": "sha512-UDbXE6n37ZMUogVVYEWX901NNmbyXUv1VvGYN/vfOiIWKUJXCAypam71dBlYFhlAhVX28qCd64w+pTZDYQfYQA==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 8ad14851ea7c90..50d2e019695738 100644 --- a/package.json +++ b/package.json @@ -172,7 +172,7 @@ "zod": "^4.4.3" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.239", + "@anthropic-ai/claude-agent-sdk": "0.3.258", "@eslint/compat": "^2.1.0", "@openai/codex": "0.149.1", "@playwright/cli": "^0.1.9", diff --git a/scripts/mock-policy-server/README.md b/scripts/mock-policy-server/README.md index dd7c5a326b3d0e..70a2a0dd826a62 100644 --- a/scripts/mock-policy-server/README.md +++ b/scripts/mock-policy-server/README.md @@ -216,6 +216,7 @@ request does not appear in **Live Requests**. | `POST` | `/api/state` | Apply and persist one update or an atomic endpoint array | | `POST` | `/api/reset` | Restore and persist default endpoint state | | `GET` | `/api/schema` | Read the managed-settings schema | +| `POST` | `/api/schema` | Change and reload the schema source for the current server process (loopback URL only) | | `GET` | `/api/file-deployment` | Generate file install and removal commands | | `GET`, `DELETE` | `/api/log` | Read or clear the request log | | `DELETE` | `/api/cache` | Clear the managed-settings disk cache | @@ -225,7 +226,11 @@ request does not appear in **Live Requests**. The server auto-detects `copilot-agent-runtime/schema/managed-settings-schema.json` beside the primary VS Code checkout, including from a Git worktree. Override it at startup with -`--schema` or `MANAGED_SETTINGS_SCHEMA`. +`--schema` or `MANAGED_SETTINGS_SCHEMA`, or edit **Schema source** in the GUI +and select **Load Schema**. GUI changes apply to the current server process and +reset to the startup source when the server restarts. Changing the schema source +is restricted to requests made through a loopback URL because the source can be +a local file path or remote URL. ```sh npm run mock-policy-server -- --upstream https://api.ghe.example.com diff --git a/scripts/mock-policy-server/public/app.ts b/scripts/mock-policy-server/public/app.ts index 534cdb8c479f6e..e182631a5ec56a 100644 --- a/scripts/mock-policy-server/public/app.ts +++ b/scripts/mock-policy-server/public/app.ts @@ -59,6 +59,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; interface SchemaResult { ok: boolean; + source: string; resolved?: string; error?: string; schema?: JsonSchema; @@ -107,6 +108,8 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; const responseConfiguration = $('response-configuration'); const responseStatusValidation = $('response-status-validation'); const presetSelect = $('preset') as HTMLSelectElement; + const schemaSourceInput = $('schema-source') as HTMLInputElement; + const loadSchemaButton = $('load-schema') as HTMLButtonElement; const endpointMeta = $('endpoint-meta'); const editorStatus = $('editor-status'); const saveStateEl = $('save-state'); @@ -117,6 +120,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; const draftStoragePrefix = 'mock-policy-server.response-body.'; const synchronizedDraftStoragePrefix = 'mock-policy-server.synchronized-response-body.'; const disclosureStoragePrefix = 'mock-policy-server.expanded.'; + const themeStorageKey = 'mock-policy-server.theme'; let endpoints: Endpoint[] = []; let activeId = ''; @@ -137,6 +141,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; // tell an external change (control API, another tab) from our own edits. let lastServerSignature = ''; let stateWritesInFlight = 0; + let followsSystemTheme = true; const pendingSaves = new Map<string, ReturnType<typeof setTimeout>>(); let allowNextTabToMoveFocus = false; @@ -148,6 +153,45 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; return `${draftStoragePrefix}${endpointId}`; } + type Theme = 'light' | 'dark'; + + function systemTheme(): Theme { + return matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; + } + + function applyTheme(theme: Theme): void { + document.documentElement.dataset.theme = theme; + const nextTheme = theme === 'dark' ? 'light' : 'dark'; + const toggle = $('theme-toggle'); + toggle.dataset.theme = theme; + toggle.setAttribute('aria-label', `Switch to ${nextTheme} mode`); + toggle.title = `Switch to ${nextTheme} mode`; + } + + function restoreTheme(): void { + let persistedTheme: string | null = null; + try { + persistedTheme = localStorage.getItem(themeStorageKey); + } catch (error) { + reportBrowserStorageError(error); + } + const theme = persistedTheme === 'light' || persistedTheme === 'dark' ? persistedTheme : undefined; + followsSystemTheme = theme === undefined; + applyTheme(theme ?? systemTheme()); + } + + function toggleTheme(): void { + const currentTheme = document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'; + const nextTheme = currentTheme === 'dark' ? 'light' : 'dark'; + followsSystemTheme = false; + applyTheme(nextTheme); + try { + localStorage.setItem(themeStorageKey, nextTheme); + } catch (error) { + reportBrowserStorageError(error); + } + } + function synchronizedDraftStorageKey(endpointId: string): string { return `${synchronizedDraftStoragePrefix}${endpointId}`; } @@ -219,15 +263,14 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; } } - function setExpandableSectionState(detailsId: string, toggleId: string, chevronId: string, storageKey: string, expanded: boolean): void { - $(detailsId).hidden = !expanded; - $(chevronId).classList.toggle('open', expanded); - $(toggleId).setAttribute('aria-expanded', String(expanded)); - persistExpandedState(storageKey, expanded); + function setSchemaSectionState(expanded: boolean): void { + $('schema-details').hidden = !expanded; + $('schema-chevron').classList.toggle('open', expanded); + $('schema-toggle').setAttribute('aria-expanded', String(expanded)); } function restoreDisclosureState(): void { - setExpandableSectionState('schema-details', 'schema-toggle', 'schema-chevron', 'schema', readExpandedState('schema')); + setSchemaSectionState(false); for (const details of document.querySelectorAll<HTMLDetailsElement>('details[data-persist-expanded]')) { const key = details.dataset.persistExpanded; if (!key) { @@ -812,9 +855,17 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; function selectSetupMethod(method: SetupMethod): void { for (const candidate of ['proxy', 'file'] as const) { const selected = candidate === method; - $(`${candidate}-method`).dataset.selected = String(selected); - $(`${candidate}-method-steps`).toggleAttribute('inert', !selected); - ($(`setup-method-${candidate}`) as HTMLInputElement).checked = selected; + const panel = $(`${candidate}-method`); + panel.dataset.selected = String(selected); + panel.hidden = !selected; + panel.toggleAttribute('inert', !selected); + const content = $(`${candidate}-method-content`); + content.hidden = !selected; + content.toggleAttribute('inert', !selected); + const input = $(`setup-method-${candidate}`) as HTMLInputElement; + input.checked = selected; + input.setAttribute('aria-expanded', String(selected)); + $(`setup-method-${candidate}-option`).dataset.selected = String(selected); } } @@ -1183,30 +1234,46 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; }, 400)); } - async function loadSchema(): Promise<void> { + async function loadSchema(source?: string): Promise<void> { const badgeEl = $('schema-badge'); + const statusEl = $('schema-source-status'); + badgeEl.textContent = 'Loading\u2026'; + delete badgeEl.dataset.kind; + loadSchemaButton.disabled = true; + statusEl.textContent = ''; + statusEl.dataset.kind = ''; try { - const result = await api<SchemaResult>('/api/schema'); + const result = await api<SchemaResult>('/api/schema', source === undefined ? undefined : { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source }) + }); + schemaSourceInput.value = result.source || result.resolved || source || ''; if (result.ok) { schema = result.schema ?? null; badgeEl.textContent = 'Loaded \u2713'; badgeEl.dataset.kind = 'ok'; badgeEl.dataset.tooltip = result.resolved || 'Schema loaded'; - $('schema-source').textContent = result.resolved || ''; + statusEl.textContent = source === undefined ? '' : 'Schema loaded.'; + statusEl.dataset.kind = source === undefined ? '' : 'ok'; } else { schema = null; badgeEl.textContent = 'Not loaded'; badgeEl.dataset.kind = 'error'; badgeEl.dataset.tooltip = result.error || 'Schema unavailable'; - $('schema-source').textContent = result.resolved || ''; + statusEl.textContent = result.error || 'Schema unavailable.'; + statusEl.dataset.kind = 'error'; } } catch (e) { schema = null; badgeEl.textContent = 'Not loaded'; badgeEl.dataset.kind = 'error'; badgeEl.dataset.tooltip = e instanceof Error ? e.message : String(e); - $('schema-source').textContent = ''; + statusEl.textContent = badgeEl.dataset.tooltip; + statusEl.dataset.kind = 'error'; + } finally { + loadSchemaButton.disabled = false; } parseEditor(); } @@ -1316,7 +1383,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; } function toggleSchemaSection(): void { - setExpandableSectionState('schema-details', 'schema-toggle', 'schema-chevron', 'schema', Boolean($('schema-details').hidden)); + setSchemaSectionState(Boolean($('schema-details').hidden)); } function openFileDeploy(): void { @@ -1423,7 +1490,14 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; $('proxy-settings').textContent = vscodeProxySetting; $('macos-cache-command').textContent = macOsCacheClearCommand; $('windows-cache-command').textContent = windowsCacheClearCommand; + restoreTheme(); restoreDisclosureState(); + $('theme-toggle').addEventListener('click', toggleTheme); + matchMedia('(prefers-color-scheme: light)').addEventListener('change', event => { + if (followsSystemTheme) { + applyTheme(event.matches ? 'light' : 'dark'); + } + }); editor.addEventListener('input', () => { drafts[activeId] = editor.value; @@ -1486,11 +1560,6 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; } $('setup-nav').addEventListener('click', openSetupDialog); $('close-setup').addEventListener('click', () => setupDialog.close()); - $('policies-nav').addEventListener('click', () => { - if (setupDialog.open) { - setupDialog.close(); - } - }); setupDialog.addEventListener('close', () => { $('setup-nav').setAttribute('aria-expanded', 'false'); if (location.hash === '#setup') { @@ -1499,6 +1568,10 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; }); window.addEventListener('hashchange', syncSetupDialog); $('schema-toggle').addEventListener('click', toggleSchemaSection); + $('schema-source-form').addEventListener('submit', event => { + event.preventDefault(); + void loadSchema(schemaSourceInput.value); + }); $('hydrate-schema').addEventListener('click', () => { if (!schema) { setStatus('Schema unavailable.', 'error'); @@ -1517,7 +1590,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; parseEditor(); renderFileDeploy(); void save(); - setStatus('Generated an example from the schema.', 'ok'); + setStatus('Filled all response fields from the schema.', 'ok'); }); $('clear-log').addEventListener('click', async () => { try { diff --git a/scripts/mock-policy-server/public/index.html b/scripts/mock-policy-server/public/index.html index 3006c8548d6391..fc4795740fe201 100644 --- a/scripts/mock-policy-server/public/index.html +++ b/scripts/mock-policy-server/public/index.html @@ -20,7 +20,7 @@ <h1>Mock Policy Server</h1> <p>Test Copilot policy responses against a local server.</p> </div> <div class="header-actions"> - <nav class="page-nav" aria-label="Mock policy server"> + <div class="page-nav"> <div class="setup-status-group"> <button id="setup-nav" type="button" class="setup-trigger" aria-haspopup="dialog" aria-controls="setup-dialog" aria-expanded="false">Setup</button> @@ -29,8 +29,11 @@ <h1>Mock Policy Server</h1> <span id="global-connection-label">Checking connection…</span> </div> </div> - <a id="policies-nav" href="#policies" aria-current="page">Policies</a> - </nav> + </div> + <button id="theme-toggle" type="button" class="theme-toggle" aria-label="Switch to light mode"> + <span class="theme-toggle-icon theme-toggle-light" aria-hidden="true">☼</span> + <span class="theme-toggle-icon theme-toggle-dark" aria-hidden="true">☾</span> + </button> </div> </div> </header> @@ -38,13 +41,13 @@ <h1>Mock Policy Server</h1> <main class="app-main"> <dialog id="setup-dialog" class="setup-dialog" aria-labelledby="setup-title"> <div class="dialog-actions"> - <button id="close-setup" type="button" class="btn-secondary">Close Setup</button> + <button id="close-setup" type="button" class="btn-secondary btn-compact">Close</button> </div> <section id="setup-page" class="setup-page" aria-labelledby="setup-title"> <div class="setup-intro"> <div> <p class="eyebrow">Connect a client</p> - <h2 id="setup-title">Route policy requests to this server</h2> + <h2 id="setup-title">Test managed settings in a client</h2> <p class="setup-lede"> Learn about <a href="https://docs.github.com/en/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/configure-enterprise-managed-settings" target="_blank" rel="noopener">configuring GitHub Copilot managed settings</a> and review the <a href="https://docs.github.com/en/copilot/reference/enterprise-administrators/enterprise-managed-settings" target="_blank" rel="noopener">managed settings schema and supported agent policy keys</a>. @@ -52,23 +55,37 @@ <h2 id="setup-title">Route policy requests to this server</h2> </div> </div> - <fieldset class="setup-method-picker"> - <legend class="sr-only">Connection method</legend> - <div class="setup-methods"> + <fieldset class="delivery-picker"> + <legend>Delivery mechanism</legend> + <div class="delivery-options"> + <label id="setup-method-proxy-option" class="delivery-option" for="setup-method-proxy" data-selected="true"> + <input id="setup-method-proxy" type="radio" name="setup-method" value="proxy" + aria-expanded="true" aria-controls="proxy-method-content" checked> + <span class="delivery-option-copy"> + <strong>Mock Server API</strong> + <span>Route client requests to this server through an HTTPS rewriting proxy.</span> + </span> + <span class="method-badge">Recommended</span> + </label> + <label id="setup-method-file-option" class="delivery-option" for="setup-method-file" data-selected="false"> + <input id="setup-method-file" type="radio" name="setup-method" value="file" + aria-expanded="false" aria-controls="file-method-content"> + <span class="delivery-option-copy"> + <strong>File-based</strong> + <span>Deploy <code>managed-settings.json</code> directly to a local client.</span> + </span> + <span class="method-badge secondary">No proxy</span> + </label> + </div> + </fieldset> + + <div class="setup-methods"> <article id="proxy-method" class="setup-method recommended" data-selected="true"> <div class="method-heading"> - <div class="method-choice"> - <input id="setup-method-proxy" type="radio" name="setup-method" value="proxy" checked> - <div class="method-copy"> - <label class="method-label" for="setup-method-proxy"> - <span class="method-badge">Recommended</span> - <h3>System Proxy</h3> - </label> - <span class="method-recommendation">Any HTTP debugging proxy that can rewrite HTTPS requests works. I suggest <a href="https://proxyman.com/" target="_blank" rel="noreferrer">Proxyman</a>.</span> - <label class="method-description" for="setup-method-proxy"> - Routes policy requests from Code OSS, Stable, Insiders, Copilot CLI, and SDK/runtime clients. VS Code clients also require the proxy setting shown below. - </label> - </div> + <div class="method-copy"> + <h3>System proxy</h3> + <span class="method-recommendation">Any HTTPS rewriting proxy works. <a href="https://proxyman.com/" target="_blank" rel="noopener noreferrer">Proxyman</a> is recommended.</span> + <p class="method-description">Routes policy requests from VS Code, Copilot CLI, and SDK clients to this server.</p> </div> <div class="method-status"> <div id="proxy-status" class="status-pill" data-state="checking" role="status" aria-live="polite">Checking…</div> @@ -76,120 +93,130 @@ <h3>System Proxy</h3> </div> </div> - <ol id="proxy-method-steps" class="setup-steps"> - <li> - <div class="step-number" aria-hidden="true">1</div> - <div class="step-content"> - <h4>Configure the system proxy</h4> - <p>Configure an HTTPS rewrite from the managed settings URL to this local server. In Proxyman, create a Map Remote rule using these values.</p> - <div class="mapping-grid"> - <div class="code-field"> - <span class="label-small">Map from</span> - <code id="map-from" class="block"></code> + <div id="proxy-method-content" class="method-content"> + <ol class="setup-steps"> + <li> + <div class="step-number" aria-hidden="true">1</div> + <div class="step-content"> + <h4>Configure the system proxy</h4> + <p>Configure an HTTPS rewrite from the managed settings URL to this local server. In Proxyman, create a Map Remote rule using these values.</p> + <div class="mapping-grid"> + <div class="code-field"> + <span class="label-small">Map from</span> + <code id="map-from" class="block"></code> + </div> + <div class="mapping-arrow" aria-hidden="true">→</div> + <div class="code-field"> + <span class="label-small">Map to</span> + <code id="map-to" class="block"></code> + </div> </div> - <div class="mapping-arrow" aria-hidden="true">→</div> - <div class="code-field"> - <span class="label-small">Map to</span> - <code id="map-to" class="block"></code> + <button id="copy-map" type="button" class="btn-secondary">Copy Mapping</button> + <details data-persist-expanded="proxy-tips"> + <summary>Proxyman tips</summary> + <ul> + <li>Keep the source and destination paths identical.</li> + <li>If HTTPS requests do not appear, install and trust Proxyman's certificate.</li> + </ul> + <div class="platform-tips"> + <section> + <h5>macOS</h5> + <ul> + <li>Turn on <code>Tools > macOS Proxy</code> before checking the connection.</li> + </ul> + </section> + <section> + <h5>Windows</h5> + <ul> + <li>Turn on <code>Tools > Override Windows Proxy</code> before checking the connection. Proxyman still shows its listening port while this is off, but Windows leaves system proxying disabled, so clients connect directly and no requests are captured.</li> + <li>Confirm Windows picked up the change: <code>ProxyEnable</code> should be <code>1</code> and <code>ProxyServer</code> should be Proxyman's host and port under <code>HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings</code>.</li> + </ul> + </section> + </div> + </details> + <div class="required-setting"> + <p><strong>Required for VS Code:</strong> add this property inside the client's <code>settings.json</code>.</p> + <div class="code-field"> + <span class="label-small">VS Code settings.json property</span> + <code id="proxy-settings" class="block settings-json"></code> + </div> + <button id="copy-proxy-settings" type="button" class="btn-secondary">Copy Setting Property</button> </div> </div> - <button id="copy-map" type="button" class="btn-secondary">Copy Mapping</button> - <details data-persist-expanded="proxy-tips"> - <summary>Proxyman tips</summary> - <ul> - <li>Keep the source and destination paths identical.</li> - <li>If HTTPS requests do not appear, install and trust Proxyman's certificate.</li> - </ul> - <div class="platform-tips"> - <section> - <h5>macOS</h5> - <ul> - <li>Turn on <code>Tools > macOS Proxy</code> before checking the connection.</li> - </ul> - </section> - <section> - <h5>Windows</h5> - <ul> - <li>Turn on <code>Tools > Override Windows Proxy</code> before checking the connection. Proxyman still shows its listening port while this is off, but Windows leaves system proxying disabled, so clients connect directly and no requests are captured.</li> - <li>Confirm Windows picked up the change: <code>ProxyEnable</code> should be <code>1</code> and <code>ProxyServer</code> should be Proxyman's host and port under <code>HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings</code>.</li> - </ul> - </section> - </div> - </details> - <div class="required-setting"> - <p><strong>Required for VS Code:</strong> add this property inside the client's <code>settings.json</code>.</p> - <div class="code-field"> - <span class="label-small">VS Code settings.json property</span> - <code id="proxy-settings" class="block settings-json"></code> - </div> - <button id="copy-proxy-settings" type="button" class="btn-secondary">Copy Setting Property</button> + </li> + <li> + <div class="step-number" aria-hidden="true">2</div> + <div class="step-content"> + <h4>Request fresh policy in VS Code</h4> + <p>Run <code>> Developer: Sync Account Policy</code>. For Local Agent Host, also run <code>> Developer: Restart Local Agent Host</code>.</p> + <p class="helper-text">If no request appears under Live Requests on the Policies page, open Troubleshooting in the right sidebar, clear the SDK policy cache for your platform, and then run the commands again.</p> </div> + </li> + <li> + <div class="step-number" aria-hidden="true">3</div> + <div class="step-content"> + <h4>Filter responses in your proxy <span class="optional-label">Optional</span></h4> + <p>Use this display filter to show only real policy requests:</p> + <code class="block setup-filter">^(?!.*mockPolicySetupProbe).*managed_settings.*</code> + <p class="helper-text">Keep probes in the Map Remote rule; filter only the displayed traffic.</p> + </div> + </li> + </ol> + <aside class="setup-warning" aria-labelledby="setup-probe-title"> + <h4 id="setup-probe-title" class="setup-warning-title"> + <span aria-hidden="true">⚠</span> Automatic probe requests are expected + </h4> + <div class="setup-warning-content"> + <p>This page sends an unauthenticated request every five seconds:</p> + <code id="setup-probe-shape" class="block"></code> + <p>The probe uses the same proxy mapping and drives the connection status. It is excluded from Live Requests.</p> </div> - </li> - <li> - <div class="step-number" aria-hidden="true">2</div> - <div class="step-content"> - <h4>Request fresh policy in VS Code</h4> - <p>Open the Command Palette and run <code>> Developer: Sync Account Policy</code>.</p> - <p>To refresh the policy used by Local Agent Host, also run <code>> Developer: Restart Local Agent Host</code>.</p> - <p class="helper-text">If no request appears under Live Requests on the Policies page, open Troubleshooting in the right sidebar, clear the SDK policy cache for your platform, and then run the commands again.</p> - </div> - </li> - </ol> - <aside class="setup-warning" aria-labelledby="setup-probe-title"> - <h4 id="setup-probe-title"><span aria-hidden="true">⚠</span> Automatic probe requests are expected</h4> - <p>While this page is open, it sends an unauthenticated request every five seconds with this shape:</p> - <code id="setup-probe-shape" class="block"></code> - <p>The probe must pass through the same proxy mapping as real policy traffic. The page only reports the proxy as connected when the mapped response returns this mock server's identifying header.</p> - <p>This server excludes probes from Live Requests. To also hide them in Proxyman, use the display-filter regex <code>^(?!.*mockPolicySetupProbe).*managed_settings.*</code>. Use it only as a display filter—do not exclude probes from the Map Remote rule.</p> - </aside> + </aside> + </div> </article> - <article id="file-method" class="setup-method alternative" data-selected="false"> + <article id="file-method" class="setup-method alternative" data-selected="false" hidden inert> <div class="method-heading"> - <div class="method-choice"> - <input id="setup-method-file" type="radio" name="setup-method" value="file"> - <label class="method-label" for="setup-method-file"> - <span class="method-badge secondary">No proxy</span> - <h3>File-based settings</h3> - <p>Deploy <code>managed-settings.json</code> to the client device. It loads from disk at startup — before sign-in and with no proxy or server round trip — so these requests never reach this server. Local clients only.</p> - </label> + <div class="method-copy"> + <h3>File-based settings</h3> + <p class="method-description">Deploy <code>managed-settings.json</code> directly to a local client. It loads from disk without a proxy or server request.</p> </div> <div class="method-status"> <p>Not detectable from here. Deploy the file, restart the client, then verify inside the client.</p> </div> </div> - <ol id="file-method-steps" class="setup-steps compact" inert> - <li> - <div class="step-number" aria-hidden="true">1</div> - <div class="step-content"> - <h4>Write the policy file</h4> - <p>On the Policies page, edit the Managed Settings response body, then copy the command for the client platform from <strong>File Deployment</strong> and run it in a terminal.</p> - <button id="file-deploy-goto" type="button" class="btn-secondary">Go to Deploy as a File</button> - <details data-persist-expanded="file-locations"> - <summary>File locations</summary> - <ul> - <li>macOS: <code>/Library/Application Support/GitHubCopilot/managed-settings.json</code></li> - <li>Windows: <code>%ProgramFiles%\GitHubCopilot\managed-settings.json</code></li> - <li>Linux: <code>/etc/github-copilot/managed-settings.json</code></li> - </ul> - <p>On macOS and Linux, Copilot CLI requires a regular file owned by <code>root</code> that is not group- or world-writable, so the copied command uses <code>sudo</code>.</p> - </details> - </div> - </li> - <li> - <div class="step-number" aria-hidden="true">2</div> - <div class="step-content"> - <h4>Reload the client</h4> - <p>Restart the client so it loads the file, or in VS Code run <code>> Developer: Sync Account Policy</code>. File-based settings persist across accounts and don't expire like the SDK policy cache.</p> - <p class="helper-text">Use this to avoid proxying entirely, or deploy it alongside a server-managed response to test precedence. <a href="https://docs.github.com/en/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/configure-enterprise-managed-settings#deploying-file-based-settings" target="_blank" rel="noopener">Deploying file-based settings</a></p> - </div> - </li> - </ol> + <div id="file-method-content" class="method-content" hidden inert> + <ol class="setup-steps compact"> + <li> + <div class="step-number" aria-hidden="true">1</div> + <div class="step-content"> + <h4>Write the policy file</h4> + <p>On the Policies page, edit the Managed Settings response body, then copy the command for the client platform from <strong>File Deployment</strong> and run it in a terminal.</p> + <button id="file-deploy-goto" type="button" class="btn-secondary">Go to Deploy as a File</button> + <details data-persist-expanded="file-locations"> + <summary>File locations</summary> + <ul> + <li>macOS: <code>/Library/Application Support/GitHubCopilot/managed-settings.json</code></li> + <li>Windows: <code>%ProgramFiles%\GitHubCopilot/managed-settings.json</code></li> + <li>Linux: <code>/etc/github-copilot/managed-settings.json</code></li> + </ul> + <p>On macOS and Linux, Copilot CLI requires a regular file owned by <code>root</code> that is not group- or world-writable, so the copied command uses <code>sudo</code>.</p> + </details> + </div> + </li> + <li> + <div class="step-number" aria-hidden="true">2</div> + <div class="step-content"> + <h4>Reload the client</h4> + <p>Restart the client so it loads the file, or in VS Code run <code>> Developer: Sync Account Policy</code>. File-based settings persist across accounts and don't expire like the SDK policy cache.</p> + <p class="helper-text">Use this to avoid proxying entirely, or deploy it alongside a server-managed response to test precedence. <a href="https://docs.github.com/en/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/configure-enterprise-managed-settings#deploying-file-based-settings" target="_blank" rel="noopener">Deploying file-based settings</a></p> + </div> + </li> + </ol> + </div> </article> - </div> - </fieldset> + </div> </section> </dialog> @@ -228,22 +255,6 @@ <h2 id="policies-title" class="sr-only" tabindex="-1">Policy Editor</h2> <p id="response-status-validation" class="status" aria-live="polite"></p> </div> - <div id="schema-section" class="section-block" hidden> - <h2 class="schema-heading"> - <button type="button" class="section-header section-header-toggle" id="schema-toggle" - aria-expanded="false" aria-controls="schema-details"> - <span class="chevron" id="schema-chevron" aria-hidden="true">▶</span> - <span>Schema</span> - <span id="schema-badge" class="schema-badge" data-tooltip="">…</span> - </button> - </h2> - <div id="schema-details" class="schema-details" hidden> - <span class="label-small">Schema source</span> - <code id="schema-source" class="block"></code> - <button id="hydrate-schema" type="button" class="btn-secondary">Generate Example</button> - </div> - </div> - <div class="form-group"> <div class="label-row"> <label for="editor">Response body (JSON)</label> @@ -264,6 +275,39 @@ <h2 class="schema-heading"> </section> <aside class="sidebar-panel"> + <div id="schema-section" class="section-block" hidden> + <h2 class="schema-heading"> + <button type="button" class="section-header section-header-toggle" id="schema-toggle" + aria-expanded="false" aria-controls="schema-details"> + <span class="chevron" id="schema-chevron" aria-hidden="true">▶</span> + <span>Schema</span> + <span id="schema-badge" class="schema-badge" data-tooltip="">…</span> + </button> + </h2> + <div id="schema-details" class="schema-details" hidden> + <form id="schema-source-form" class="schema-source-form"> + <div class="schema-source-label-row"> + <label class="label-small" for="schema-source">Schema source</label> + <div class="info-tooltip-container"> + <button type="button" class="info-button" aria-label="About the schema source" + aria-describedby="schema-source-tooltip">i</button> + <div id="schema-source-tooltip" class="info-tooltip" role="tooltip"> + Point this to <code>schema/managed-settings-schema.json</code> from the <code>github/copilot-agent-runtime</code> repository. You can use a path from a local checkout, a <code>file:</code> URI, or an HTTP(S) URL. + </div> + </div> + </div> + <div class="schema-source-controls"> + <input id="schema-source" class="schema-source-input" type="text" autocomplete="off" + spellcheck="false" aria-describedby="schema-source-status"> + <button id="load-schema" type="submit" class="btn-secondary btn-compact" + title="Load schema from this source">Load</button> + </div> + <p id="schema-source-status" class="status" aria-live="polite"></p> + </form> + <button id="hydrate-schema" type="button" class="btn-secondary btn-compact" + title="Replace the response body with generated values for every schema field">Fill All Schema Fields</button> + </div> + </div> <div class="section-block"> <div class="section-header"> <h2 id="live-requests-title">Live Requests</h2> diff --git a/scripts/mock-policy-server/public/style.css b/scripts/mock-policy-server/public/style.css index cbfb00d0c222da..63d8f28d4950bd 100644 --- a/scripts/mock-policy-server/public/style.css +++ b/scripts/mock-policy-server/public/style.css @@ -21,6 +21,9 @@ --focus: #4daafc; --accent-soft: color-mix(in srgb, var(--accent) 16%, transparent); --ok-soft: color-mix(in srgb, var(--ok) 12%, transparent); + --error-soft: color-mix(in srgb, var(--error) 12%, transparent); + --dialog-backdrop: rgba(0, 0, 0, 0.5); + --dialog-shadow: rgba(0, 0, 0, 0.28); } /* @@ -44,9 +47,51 @@ --code-bg: #f0f0f0; --code-border: #d4d4d4; --focus: #0b5c92; + --dialog-backdrop: rgba(0, 0, 0, 0.38); + --dialog-shadow: rgba(0, 0, 0, 0.18); } } +:root[data-theme='light'] { + color-scheme: light; + --bg: #f7f7f7; + --surface: #ffffff; + --surface-alt: #ececec; + --border: #c8c8c8; + --text: #1f1f1f; + --text-secondary: #5c5c5c; + --accent: #0b5c92; + --accent-hover: #0a4d7a; + --ok: #0a7a5e; + --warn: #8a6100; + --error: #b3261e; + --code-bg: #f0f0f0; + --code-border: #d4d4d4; + --focus: #0b5c92; + --dialog-backdrop: rgba(0, 0, 0, 0.38); + --dialog-shadow: rgba(0, 0, 0, 0.18); +} + +:root[data-theme='dark'] { + color-scheme: dark; + --bg: #1e1e1e; + --surface: #252526; + --surface-alt: #2d2d2e; + --border: #3c3c3c; + --text: #e0e0e0; + --text-secondary: #a0a0a0; + --accent: #0e639c; + --accent-hover: #1177bb; + --ok: #4ec9b0; + --warn: #d7ba7d; + --error: #f48771; + --code-bg: #1b1b1b; + --code-border: #3c3c3c; + --focus: #4daafc; + --dialog-backdrop: rgba(0, 0, 0, 0.5); + --dialog-shadow: rgba(0, 0, 0, 0.28); +} + * { box-sizing: border-box; } @@ -77,8 +122,8 @@ body { } header { - padding: 16px 24px; - background: linear-gradient(135deg, var(--surface-alt) 0%, var(--surface) 100%); + padding: 10px 24px; + background: var(--surface); border-bottom: 1px solid var(--border); } @@ -88,12 +133,19 @@ header { display: flex; align-items: center; justify-content: space-between; - gap: 24px; + gap: 16px; flex-wrap: wrap; } +.brand { + display: flex; + align-items: baseline; + gap: 10px; + min-width: 0; +} + h1 { - font-size: 18px; + font-size: 16px; font-weight: 600; margin: 0; } @@ -106,9 +158,10 @@ h2 { } .brand p { - margin: 2px 0 0; + margin: 0; color: var(--text-secondary); font-size: 12px; + line-height: 1.4; } .header-actions, @@ -172,7 +225,7 @@ h2 { } .page-nav { - padding: 2px; + padding: 0; } .setup-status-group { @@ -184,7 +237,6 @@ h2 { background: color-mix(in srgb, var(--bg) 24%, transparent); } -.page-nav a, .setup-trigger { padding: 6px 12px; border: 0; @@ -195,12 +247,10 @@ h2 { text-decoration: none; } -.page-nav a:hover, .setup-trigger:hover { color: var(--text); } -.page-nav a[aria-current='page'], .setup-trigger[aria-expanded='true'] { background: var(--surface-alt); color: var(--text); @@ -299,16 +349,6 @@ h2 { .policies-layout { grid-template-columns: 1fr; } - - .header-content, - .header-actions { - align-items: stretch; - } - - .header-actions { - width: 100%; - justify-content: space-between; - } } .editor-panel { @@ -354,7 +394,7 @@ h2 { padding: 16px; display: flex; flex-direction: column; - gap: 12px; + gap: 10px; } .section-block[hidden] { @@ -419,42 +459,109 @@ h2 { /* --- Setup ----------------------------------------------------------------- */ .setup-dialog { - width: min(1280px, calc(100vw - 48px)); + width: min(920px, calc(100vw - 32px)); max-width: none; - max-height: calc(100vh - 48px); + max-height: calc(100vh - 32px); padding: 20px; overflow: auto; border: 1px solid var(--border); border-radius: 8px; - background: var(--bg); + background: var(--surface); color: var(--text); - box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4); + box-shadow: 0 16px 48px var(--dialog-shadow); } .setup-dialog::backdrop { - background: rgba(0, 0, 0, 0.62); + background: var(--dialog-backdrop); } .dialog-actions { position: sticky; - top: -20px; - z-index: 2; + top: 0; + z-index: 3; display: flex; justify-content: flex-end; - padding: 0 0 12px; - background: var(--bg); + height: 0; } .setup-page { display: flex; flex-direction: column; - gap: 20px; - max-width: 1240px; + gap: 16px; + max-width: 880px; margin: 0 auto; } .setup-intro { - padding: 0 0 4px; + padding: 4px 56px 8px 4px; +} + +.delivery-picker { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.delivery-picker legend { + margin-bottom: 6px; + padding: 0; + font-size: 12px; + font-weight: 600; +} + +.delivery-options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + overflow: hidden; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); +} + +.delivery-option { + display: grid; + grid-template-columns: 16px minmax(0, 1fr) auto; + gap: 8px; + align-items: start; + min-width: 0; + padding: 16px; + cursor: pointer; + transition: background 0.15s ease; +} + +.delivery-option + .delivery-option { + border-left: 1px solid var(--border); +} + +.delivery-option[data-selected='true'] { + background: color-mix(in srgb, var(--accent) 10%, var(--surface)); +} + +.delivery-option:focus-within { + outline: 1px solid var(--focus); + outline-offset: -1px; +} + +.delivery-option input { + width: 14px; + height: 14px; + margin: 2px 0 0; + accent-color: var(--accent); + cursor: pointer; +} + +.delivery-option-copy { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.delivery-option-copy > span { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.4; } .eyebrow { @@ -467,11 +574,11 @@ h2 { } .setup-lede { - max-width: 640px; - margin: 10px 0 0; + max-width: 680px; + margin: 8px 0 0; color: var(--text-secondary); - font-size: 14px; - line-height: 1.5; + font-size: 13px; + line-height: 1.4; } .setup-lede a { @@ -490,66 +597,25 @@ h2 { .setup-methods { display: grid; grid-template-columns: minmax(0, 1fr); - gap: 20px; align-items: start; -} - -.setup-method-picker { - min-width: 0; - margin: 0; - padding: 0; - border: 0; + padding-bottom: 8px; } .setup-method { - position: relative; - overflow: hidden; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--surface); -} - -.setup-method[data-selected='true'] { - border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); -} - -.setup-method[data-selected='false'] { - background: color-mix(in srgb, var(--surface) 72%, var(--bg)); -} - -.setup-method[data-selected='false'] .method-choice, -.setup-method[data-selected='false'] .setup-steps, -.setup-method[data-selected='false'] .setup-warning { - opacity: 0.42; + background: transparent; } .method-heading { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; - gap: 16px; - padding: 20px 20px 16px; + gap: 12px; + padding: 12px 4px 16px; border-bottom: 1px solid var(--border); } -.method-choice { - display: grid; - grid-template-columns: 16px minmax(0, 1fr); - gap: 8px; - min-width: 0; -} - -.method-choice input { - width: 14px; - height: 14px; - margin: 6px 0 0; - accent-color: var(--accent); - cursor: pointer; -} - -.method-label { - min-width: 0; - cursor: pointer; +.method-content[hidden] { + display: none; } .method-copy { @@ -558,20 +624,22 @@ h2 { .method-description { display: block; - margin-top: 8px; - cursor: pointer; + margin-top: 4px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.4; } .method-heading h3 { - margin: 4px 0 0; - font-size: 18px; + margin: 0; + font-size: 16px; font-weight: 600; line-height: 1.3; } .method-recommendation { display: block; - margin-top: 2px; + margin-top: 4px; color: var(--text-secondary); font-size: 11px; font-weight: 600; @@ -583,9 +651,9 @@ h2 { .method-heading p { max-width: 640px; - margin: 6px 0 0; + margin: 4px 0 0; color: var(--text-secondary); - line-height: 1.5; + line-height: 1.4; } .method-badge { @@ -660,7 +728,7 @@ h2 { flex-direction: column; align-items: flex-end; gap: 6px; - max-width: 280px; + max-width: 220px; } .method-status p { @@ -676,20 +744,38 @@ h2 { flex-direction: column; gap: 0; margin: 0; - padding: 0 20px; + padding: 20px 4px 4px; list-style: none; } .setup-warning { - margin: 0 20px 20px; - padding: 12px; - border: 1px solid var(--warn); + margin: 0 4px 8px 44px; + padding: 12px 16px; + border: 1px solid var(--border); + border-left: 2px solid var(--warn); border-radius: 6px; - background: color-mix(in srgb, var(--warn) 10%, transparent); + background: color-mix(in srgb, var(--warn) 6%, transparent); } -.setup-warning h4 { +.setup-warning-title { + display: flex; + align-items: center; + gap: 6px; + margin: 0; color: var(--warn); + font-size: 12px; + font-weight: 600; +} + +.setup-warning-content { + margin-top: 6px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.4; +} + +.setup-warning-content p { + margin: 4px 0; } .setup-warning .block { @@ -698,40 +784,74 @@ h2 { word-break: break-word; } +.setup-filter { + margin: 8px 0; + white-space: pre-wrap; + word-break: break-word; +} + .setup-steps > li { + position: relative; display: grid; - grid-template-columns: 20px minmax(0, 1fr); + grid-template-columns: 28px minmax(0, 1fr); gap: 12px; - padding: 20px 0; + padding: 0 0 28px; } -.setup-steps > li + li { - border-top: 1px solid var(--border); +.setup-steps > li:last-child { + padding-bottom: 20px; +} + +.setup-steps > li:not(:last-child)::before { + position: absolute; + top: 32px; + bottom: 4px; + left: 13px; + width: 1px; + background: var(--border); + content: ''; } .step-number { display: inline-flex; - align-items: flex-start; + align-items: center; justify-content: center; - width: 20px; - padding-top: 4px; - color: var(--text-secondary); - font-size: 11px; + width: 28px; + height: 28px; + border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border)); + border-radius: 50%; + background: var(--accent-soft); + color: var(--focus); + font-size: 12px; font-weight: 600; } .step-content { min-width: 0; + max-width: 760px; } .step-content h4 { - margin: 2px 0 4px; - font-size: 13px; + margin: 4px 0 8px; + font-size: 14px; + font-weight: 600; +} + +.optional-label { + margin-left: 4px; + padding: 2px 6px; + border-radius: 999px; + background: var(--surface-alt); + color: var(--text-secondary); + font-size: 10px; font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + vertical-align: 1px; } .step-content > p { - margin: 0 0 12px; + margin: 0 0 8px; color: var(--text-secondary); line-height: 1.5; } @@ -739,9 +859,9 @@ h2 { .mapping-grid { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); - gap: 8px; + gap: 12px; align-items: end; - margin-bottom: 10px; + margin: 12px 0; } .mapping-arrow { @@ -757,13 +877,14 @@ h2 { } .code-field code.block { - min-height: 56px; + min-height: 44px; } .required-setting { margin-top: 16px; - padding-top: 16px; - border-top: 1px solid var(--border); + padding: 16px; + border-radius: 6px; + background: var(--bg); } .required-setting > p { @@ -780,17 +901,24 @@ h2 { } .step-content details { - margin-top: 12px; + margin-top: 10px; color: var(--text-secondary); } .step-content summary { width: fit-content; + padding: 6px 8px; + border-radius: 4px; + background: var(--bg); cursor: pointer; color: var(--text); font-weight: 600; } +.step-content summary:hover { + background: var(--surface-alt); +} + .step-content details ul { margin: 8px 0 0; padding-left: 20px; @@ -806,8 +934,8 @@ h2 { .platform-tips { display: grid; - gap: 12px; - margin-top: 12px; + gap: 10px; + margin-top: 10px; } .platform-tips h5 { @@ -870,12 +998,6 @@ button:disabled { } } -@media (max-width: 1000px) { - .setup-methods { - grid-template-columns: 1fr; - } -} - @media (max-width: 680px) { header, .app-main { @@ -887,6 +1009,20 @@ button:disabled { align-items: center; } + .brand { + flex-wrap: wrap; + gap: 2px 8px; + } + + .delivery-options { + grid-template-columns: 1fr; + } + + .delivery-option + .delivery-option { + border-top: 1px solid var(--border); + border-left: 0; + } + .page-nav { justify-content: flex-start; } @@ -898,7 +1034,7 @@ button:disabled { } .dialog-actions { - top: -16px; + top: 0; } .method-heading { @@ -1021,6 +1157,7 @@ label { select, textarea, +.schema-source-input, .status-code-input { font-family: inherit; font-size: inherit; @@ -1034,12 +1171,14 @@ textarea, select:hover, textarea:hover, +.schema-source-input:hover, .status-code-input:hover { border-color: var(--border); } select:focus, textarea:focus, +.schema-source-input:focus, .status-code-input:focus { outline: none; border-color: var(--accent); @@ -1105,6 +1244,46 @@ button { filter: brightness(1.15); } +.btn-compact { + align-self: flex-start; + padding: 4px 8px; + font-size: 12px; +} + +.theme-toggle { + align-self: center; + display: inline-flex; + align-items: center; + gap: 2px; + padding: 2px; + border-color: var(--border); + border-radius: 9999px; + background: color-mix(in srgb, var(--bg) 24%, transparent); + color: var(--text-secondary); +} + +.theme-toggle:hover { + border-color: var(--text-secondary); +} + +.theme-toggle-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 9999px; + font-size: 16px; + line-height: 1; + transition: background 0.15s ease, color 0.15s ease; +} + +.theme-toggle[data-theme='light'] .theme-toggle-light, +.theme-toggle[data-theme='dark'] .theme-toggle-dark { + background: var(--accent-soft); + color: var(--accent); +} + .btn-warn { background: transparent; border-color: var(--warn); @@ -1337,12 +1516,12 @@ code { .schema-badge[data-kind="ok"] { color: var(--ok); - background: rgba(78, 201, 176, 0.1); + background: var(--ok-soft); } .schema-badge[data-kind="error"] { color: var(--error); - background: rgba(244, 135, 113, 0.1); + background: var(--error-soft); } .schema-badge:hover { @@ -1370,7 +1549,8 @@ code { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); } -.schema-badge:hover::after { +.schema-badge:hover::after, +.schema-heading:focus-within .schema-badge::after { opacity: 1; } @@ -1381,6 +1561,38 @@ code { margin-top: 0; } +.schema-source-form { + display: flex; + flex-direction: column; + gap: 8px; +} + +.schema-source-label-row { + display: flex; + align-items: center; + gap: 4px; +} + +.schema-source-controls { + display: flex; + align-items: stretch; + gap: 6px; +} + +.schema-source-input { + flex: 1; + min-width: 0; + font-family: 'SF Mono', Menlo, Consolas, monospace; +} + +.schema-source-controls .btn-compact { + align-self: stretch; +} + +.schema-source-form .status:empty { + min-height: 0; +} + .schema-details[hidden] { display: none; } diff --git a/scripts/mock-policy-server/server.ts b/scripts/mock-policy-server/server.ts index 146b5bdf770687..7030125f6326b0 100644 --- a/scripts/mock-policy-server/server.ts +++ b/scripts/mock-policy-server/server.ts @@ -34,7 +34,7 @@ const MOCK_SERVER_HEADER = 'X-Mock-Policy-Server'; const args = parseArgs(process.argv.slice(2)); const PORT = args.port ?? DEFAULT_PORT; const HOST = args.host || '127.0.0.1'; -const SCHEMA_SOURCE = args.schema || process.env.MANAGED_SETTINGS_SCHEMA || DEFAULT_SCHEMA_SOURCE; +let schemaSource = args.schema || process.env.MANAGED_SETTINGS_SCHEMA || DEFAULT_SCHEMA_SOURCE; const UPSTREAM = stripTrailingSlash(args.upstream || process.env.MOCK_POLICY_UPSTREAM || DEFAULT_UPSTREAM); const STATE_FILE = path.resolve(args.stateFile || process.env.MOCK_POLICY_STATE_FILE || DEFAULT_STATE_FILE); @@ -88,6 +88,7 @@ const CONTROL_ROUTES = [ { method: 'POST', path: '/api/state', purpose: 'Apply and persist one update or an atomic endpoints array.', returns: 'Updated server state.', sideEffects: 'server-state,filesystem' }, { method: 'POST', path: '/api/reset', purpose: 'Restore and persist default endpoint state.', returns: 'Reset server state.', sideEffects: 'server-state,filesystem' }, { method: 'GET', path: '/api/schema', purpose: 'Read the managed-settings schema.', returns: 'Schema source, resolved location, load status, and schema or error.', sideEffects: 'none' }, + { method: 'POST', path: '/api/schema', purpose: 'Change and reload the managed-settings schema source for this server process. Only accepted through a loopback URL.', returns: 'Schema source, resolved location, load status, and schema or error.', sideEffects: 'server-state' }, { method: 'GET', path: '/api/file-deployment', purpose: 'Generate install and removal commands for the current Managed Settings body.', returns: 'Source body and per-platform paths, install commands, and removal commands.', sideEffects: 'none' }, { method: 'GET', path: '/api/log', purpose: 'Read the request log.', returns: 'Object containing the newest-first entries array.', sideEffects: 'none' }, { method: 'DELETE', path: '/api/log', purpose: 'Clear the request log.', returns: 'Object containing an empty entries array.', sideEffects: 'server-state' }, @@ -254,6 +255,37 @@ function handleControlApi(req: IncomingMessage, res: ServerResponse, pathname: s .catch(e => sendJson(res, 500, { error: errorMessage(e) })); } + if (pathname === '/api/schema' && req.method === 'POST') { + if (!isLoopbackRequest(req)) { + return sendJson(res, 403, { error: 'Changing the schema source is only allowed from a loopback URL.' }); + } + return readBody(req, (err, raw) => { + if (err) { + return sendJson(res, 400, { error: String(err) }); + } + let payload: unknown; + try { + payload = JSON.parse(raw); + } catch (e) { + return sendJson(res, 400, { error: `Invalid JSON: ${errorMessage(e)}` }); + } + if (!isRecord(payload)) { + return sendJson(res, 400, { error: 'Request body must be a JSON object.' }); + } + const unknownKeys = Object.keys(payload).filter(key => key !== 'source'); + if (unknownKeys.length) { + return sendJson(res, 400, { error: `Unknown field${unknownKeys.length > 1 ? 's' : ''}: ${unknownKeys.join(', ')}.` }); + } + if (typeof payload.source !== 'string' || !payload.source.trim()) { + return sendJson(res, 400, { error: '"source" must be a non-empty string.' }); + } + schemaSource = payload.source.trim(); + return void loadSchema() + .then(result => sendJson(res, 200, result)) + .catch(e => sendJson(res, 500, { error: errorMessage(e) })); + }); + } + if (pathname === '/api/file-deployment' && req.method === 'GET') { return sendJson(res, 200, getFileDeployment()); } @@ -493,6 +525,24 @@ function isAllowedControlOrigin(req: IncomingMessage): boolean { } } +function isLoopbackRequest(req: IncomingMessage): boolean { + const remoteAddress = req.socket.remoteAddress; + const host = req.headers.host; + if (!remoteAddress || !host || Array.isArray(host)) { + return false; + } + try { + return isLoopbackAddress(remoteAddress) && isLoopbackAddress(new URL(`http://${host}`).hostname); + } catch { + return false; + } +} + +function isLoopbackAddress(address: string): boolean { + const normalized = address.replace(/^\[|\]$/g, '').replace(/^::ffff:/, ''); + return normalized === '::1' || normalized === 'localhost' || /^127(?:\.\d{1,3}){3}$/.test(normalized); +} + /** * Forward a request to the real upstream API and stream the response back, so * anything this server is not deliberately faking still behaves normally. @@ -567,7 +617,7 @@ server.listen(PORT, HOST, () => { console.log(' Configure endpoint mocking and client routing in the GUI.'); console.log(''); console.log(` Upstream ${UPSTREAM} (anything not mocked is proxied here)`); - console.log(` Schema ${SCHEMA_SOURCE}`); + console.log(` Schema ${schemaSource}`); console.log(` State ${STATE_FILE}`); console.log(''); }); @@ -668,13 +718,12 @@ function endpointUrl(endpoint: EndpointDef): string { } /** - * Resolve and load the managed-settings JSON schema from {@link SCHEMA_SOURCE}. + * Resolve and load the managed-settings JSON schema from the current source. * Accepts a web URL (`http(s)://`), a `file://` URI, or a filesystem path - * (relative paths are resolved against the app's cwd). The GUI loads it once - * during initialization. + * (relative paths are resolved against the app's cwd). */ async function loadSchema(): Promise<{ source: string; resolved: string; ok: boolean; schema?: unknown; error?: string }> { - const source = SCHEMA_SOURCE; + const source = schemaSource; try { if (/^https?:\/\//i.test(source)) { const res = await fetch(source); diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts index 08a7507a3f5047..51779dfb0d0281 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts @@ -14,13 +14,14 @@ import { IContextKeyService } from '../../../../platform/contextkey/common/conte import { Range } from '../../../common/core/range.js'; import { IDiffEditorOptions } from '../../../common/config/editorOptions.js'; import { IDiffEditor } from '../../../common/editorCommon.js'; +import { IMultiDiffResourceId } from '../../../common/multiDiffEditor.js'; import { ICodeEditor } from '../../editorBrowser.js'; import { DiffEditorWidget } from '../diffEditor/diffEditorWidget.js'; import './colors.js'; import { DiffEditorItemTemplate } from './diffEditorItemTemplate.js'; import { IDocumentDiffItem, IMultiDiffEditorModel } from './model.js'; import { MultiDiffEditorViewModel } from './multiDiffEditorViewModel.js'; -import { IMultiDiffEditorLayoutDebugState, IMultiDiffEditorViewState, IMultiDiffResourceId, MultiDiffEditorWidgetImpl } from './multiDiffEditorWidgetImpl.js'; +import { IMultiDiffEditorLayoutDebugState, IMultiDiffEditorViewState, MultiDiffEditorWidgetImpl } from './multiDiffEditorWidgetImpl.js'; import { IWorkbenchUIElementFactory } from './workbenchUIElementFactory.js'; export class MultiDiffEditorWidget extends Disposable { diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts index 89e7b9cad70b1f..3da1bfe43a3393 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts @@ -10,7 +10,6 @@ import { IObservable, IReader, ITransaction, autorun, autorunWithStore, constObs import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { ContextKeyValue, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { ITextEditorOptions } from '../../../../platform/editor/common/editor.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -19,6 +18,7 @@ import { IDiffEditorOptions } from '../../../common/config/editorOptions.js'; import { IRange } from '../../../common/core/range.js'; import { ISelection, Selection } from '../../../common/core/selection.js'; import { IDiffEditor } from '../../../common/editorCommon.js'; +import { IMultiDiffResourceId } from '../../../common/multiDiffEditor.js'; import { EditorContextKeys } from '../../../common/editorContextKeys.js'; import { ICodeEditor } from '../../editorBrowser.js'; import { CompressedVirtualizedScrollView, ICompressedVirtualizedScrollItem, ICompressedVirtualizedScrollItemContext } from './compressedVirtualizedScrollView.js'; @@ -664,19 +664,6 @@ interface IMultiDiffDocState { selections?: ISelection[]; } -export interface IMultiDiffEditorOptions extends ITextEditorOptions { - viewState?: IMultiDiffEditorOptionsViewState; -} - -export interface IMultiDiffEditorOptionsViewState { - revealData?: { - resource: IMultiDiffResourceId; - range?: IRange; - }; -} - -export type IMultiDiffResourceId = { original: URI | undefined; modified: URI | undefined }; - export interface IMultiDiffEditorLayoutDebugState { readonly scrollLeft: number; readonly scrollDimensions: { diff --git a/src/vs/editor/common/multiDiffEditor.ts b/src/vs/editor/common/multiDiffEditor.ts new file mode 100644 index 00000000000000..4a61a9da6c956e --- /dev/null +++ b/src/vs/editor/common/multiDiffEditor.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../base/common/uri.js'; +import type { ITextEditorOptions } from '../../platform/editor/common/editor.js'; +import type { IRange } from './core/range.js'; + +export type IMultiDiffResourceId = { original: URI | undefined; modified: URI | undefined }; + +export interface IMultiDiffEditorOptions extends ITextEditorOptions { + viewState?: IMultiDiffEditorOptionsViewState; +} + +export interface IMultiDiffEditorOptionsViewState { + revealData?: { + resource: IMultiDiffResourceId; + range?: IRange; + }; +} diff --git a/src/vs/platform/agentHost/AGENTS.md b/src/vs/platform/agentHost/AGENTS.md index 0fd4811a053b43..b7c44861ec506c 100644 --- a/src/vs/platform/agentHost/AGENTS.md +++ b/src/vs/platform/agentHost/AGENTS.md @@ -220,7 +220,7 @@ a chat URI. New provider code must consume the seams. `register` takes the resolved provenance and whether to check tombstones. Explicit `AgentService.createSession` calls skip the tombstone check and clear any tombstone for that session URI; restore and discovery calls atomically decline to register if the session is or concurrently becomes tombstoned. An explicit row is never rewritten by catalog discovery. A migration-time host-owned marker can correct a previously discovered row back to internal provenance. -Providers own discovery lifecycle and push unknown chats with provider-classified provenance through `onDidDiscoverChats`. Claude, Codex, and Copilot classify their unknown native chats as external, except that Copilot keeps an unknown *legacy extension-host* chat internal because it is adoptable in place rather than someone else's session. Agent Service preserves that classification when it additively registers the event payload. Agent Service always attaches the event listener and queues each provider's external-session discovery through `_runWhenStartupSettled`, so the request waits for both Agent Host startup and the first successful session listing. Providers registered after that barrier opens run their queued work immediately, and a later transition from `none` starts discovery directly. Adopt-in-place legacy migration remains an independent provider-initialization trigger immediately after the discovery listener is attached, and another catalog consumer may also trigger discovery after it enumerates the provider catalog. This keeps `showExternalSessions: none` from initiating native discovery while allowing independently triggered discovery to populate the hidden registry normally. Ordinary list refreshes never enumerate provider catalogs. External discovery has no migration marker or Copilot migrate-legacy gate; only the adoptable legacy extension-host half of Copilot's payload is withheld while migrate-legacy is off. Discovery never prunes a registry row when a provider later omits it and filters subagents and marked internal chat backings. +Providers own discovery lifecycle and push unknown chats with provider-classified provenance through `onDidDiscoverChats`. Claude, Codex, and Copilot classify their unknown native chats as external, except that Copilot keeps an unknown *legacy extension-host* chat internal because it is adoptable in place rather than someone else's session. Agent Service preserves that classification unless the existing host-owned `agentHost.workspaceless` marker proves the session was created by Agent Host; it reads that evidence together with the chat-backing marker before additively registering the event payload. Agent Service always attaches the event listener and queues each provider's external-session discovery through `_runWhenStartupSettled`, so the request waits for both Agent Host startup and the first successful session listing. Providers registered after that barrier opens run their queued work immediately, and a later transition from `none` starts discovery directly. Adopt-in-place legacy migration remains an independent provider-initialization trigger immediately after the discovery listener is attached, and another catalog consumer may also trigger discovery after it enumerates the provider catalog. This keeps `showExternalSessions: none` from initiating native discovery while allowing independently triggered discovery to populate the hidden registry normally. Ordinary list refreshes never enumerate provider catalogs. External discovery has no migration marker or Copilot migrate-legacy gate; only the adoptable legacy extension-host half of Copilot's payload is withheld while migrate-legacy is off. Discovery never prunes a registry row when a provider later omits it and filters subagents and marked internal chat backings. Discovery is registry-first: Agent Service hands each provider an optional `setKnownSessionsFilter` seam that answers, for a whole candidate set in one registry query, which sessions the host already owns. A provider drops those candidates before any per-session database open, and Copilot additionally skips adoptable legacy classification work (project/Git resolution) while migrate-legacy is off, since those candidates would not be emitted. Agent Service in turn rejects an already-registered candidate before `_isChatBacking()` or any other per-session I/O; provenance of a registered row stays owned by the explicit create/restore paths. Tombstoned sessions are absent from the registry and therefore never reported as known, so an explicitly deleted session still reaches `register`, whose atomic tombstone check declines it. diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index ade98d06b9c829..70634e21c2b489 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -24,14 +24,14 @@ 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'; import { SUPPORTED_PROTOCOL_VERSIONS } from '../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, ProtocolError, ReconnectResultType, type ProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { type IVscodeUpgradeResult } from '../common/state/protocolUpgrade.js'; -import { isClientTransport, NonReconnectableTransportError, type IProtocolTransport } from '../common/state/sessionTransport.js'; +import { isClientTransport, NonReconnectableTransportError, type AgentHostTransportFailureReason, type IProtocolTransport } from '../common/state/sessionTransport.js'; import { AhpErrorCodes, JsonRpcErrorCodes } from '../common/state/protocol/errors.js'; import { ChatSourceKind, ContentEncoding, ResourceRequestParams, type CompletionsParams, type CompletionsResult, type CreateTerminalParams, type ResolveSessionConfigResult, type SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.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. @@ -156,6 +156,8 @@ interface IReconnectState { attempt: number; /** Timer for the next scheduled attempt, if any. */ timeoutHandle: ReturnType<typeof setTimeout> | undefined; + /** Deadline for the next scheduled attempt, if any. */ + nextAttemptAt: number | undefined; } /** @@ -248,7 +250,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect private readonly _onDidReceiveOtlpLogs = this._register(new Emitter<OtlpExportLogsParams>()); readonly onDidReceiveOtlpLogs = this._onDidReceiveOtlpLogs.event; - private readonly _onDidClose = this._register(new Emitter<void>()); + private readonly _onDidClose = this._register(new Emitter<AgentHostTransportFailureReason | undefined>()); readonly onDidClose = this._onDidClose.event; private readonly _onDidFatalClose = this._register(new Emitter<ProtocolError>()); @@ -256,6 +258,8 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect private readonly _onDidChangeConnectionState = this._register(new Emitter<AgentHostClientState>()); readonly onDidChangeConnectionState = this._onDidChangeConnectionState.event; + private readonly _onDidScheduleReconnect = this._register(new Emitter<void>()); + readonly onDidScheduleReconnect = this._onDidScheduleReconnect.event; /** * Discriminated state union. Read via narrowing (`_state.kind === ...`); @@ -271,6 +275,45 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect private readonly _authentication = new Map<string, AuthenticateParams>(); 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<IProtocolTransport, number>(); + + /** + * 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; + + /** + * Channels that owe a fresh snapshot because the host forgot this client + * (a host restart) and the fresh initialize did not carry their state. + * Entries persist across reconnect attempts until the channel is + * re-subscribed: a later `reconnect` resolves against the *new* host's + * sequence and can legitimately answer with a `replay`, which carries + * deltas only — so a channel dropped from this set before it was + * re-snapshotted would keep serving pre-restart state forever. + */ + private readonly _subscriptionsAwaitingRestore = new Set<string>(); + + /** + * Set while an authentication-restore pass is in flight, cleared only once + * the whole pass completes. + * + * Authentication state on the host is process-global and survives a + * transport drop, so an ordinary reconnect needs no re-authentication. But + * a drop *during* the restore pass leaves the host holding whichever + * credentials happened to arrive first, and the next reconnect usually + * resolves to a `replay` — which never re-runs authentication. This flag + * carries that debt forward so the next successful attempt finishes it. + */ + private _authenticationRestorePending = false; + /** * 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 +327,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 @@ -329,6 +375,13 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect return this._state.kind; } + /** Deadline for the next scheduled reconnect attempt, if one is pending. */ + get nextReconnectAt(): number | undefined { + return this._state.kind === AgentHostClientState.Reconnecting + ? this._state.reconnect.nextAttemptAt + : undefined; + } + /** * The latest `initialize` response from the host, or `undefined` if * the handshake has not completed yet. Exposed observably so callers can @@ -456,6 +509,14 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect if (this._state.kind === next.kind) { return; } + if (this._state.kind === AgentHostClientState.Reconnecting) { + const reconnect = this._state.reconnect; + if (reconnect.timeoutHandle !== undefined) { + clearTimeout(reconnect.timeoutHandle); + reconnect.timeoutHandle = undefined; + } + reconnect.nextAttemptAt = undefined; + } this._state = next; this._onDidChangeConnectionState.fire(next.kind); } @@ -470,7 +531,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect } private _newReconnectState(): IReconnectState { - return { gate: this._newReconnectGate(), outbox: [], attempt: 0, timeoutHandle: undefined }; + return { gate: this._newReconnectGate(), outbox: [], attempt: 0, timeoutHandle: undefined, nextAttemptAt: undefined }; } override dispose(): void { @@ -538,7 +599,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect throw error; } if (error instanceof NonReconnectableTransportError) { - this._handleFatalClose(protocolError); + this._handleFatalClose(protocolError, error.reason); throw error; } if (this._state.kind === AgentHostClientState.Reconnecting) { @@ -663,6 +724,24 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect return true; } + /** + * Skips the remaining backoff and retries immediately. Returns `false` when + * there is no pending retry to accelerate. + */ + reconnectNow(): boolean { + if (this._state.kind !== AgentHostClientState.Reconnecting || this._state.reconnect.timeoutHandle === undefined) { + return false; + } + const reconnect = this._state.reconnect; + clearTimeout(reconnect.timeoutHandle); + reconnect.timeoutHandle = undefined; + reconnect.nextAttemptAt = undefined; + reconnect.attempt = 0; + this._onDidScheduleReconnect.fire(); + void this._attemptReconnect(); + return true; + } + private _scheduleReconnect(userInitiated = false): void { if (this._state.kind !== AgentHostClientState.Reconnecting || !this._transportFactory) { return; @@ -686,12 +765,15 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect const attempt = reconnect.attempt + 1; const delay = computeReconnectDelay(this._reconnectPolicy, attempt); this._logService.info(`[RemoteAgentHostProtocol] Reconnecting to ${this._address} in ${delay}ms (attempt ${attempt}).`); + reconnect.nextAttemptAt = Date.now() + delay; reconnect.timeoutHandle = setTimeout(() => { if (this._state.kind === AgentHostClientState.Reconnecting) { this._state.reconnect.timeoutHandle = undefined; + this._state.reconnect.nextAttemptAt = undefined; } void this._attemptReconnect(); }, delay); + this._onDidScheduleReconnect.fire(); } private async _attemptReconnect(): Promise<void> { @@ -725,15 +807,27 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect this._applyReconnectResult(result, freshInitialize); this._updateManagedSettingsPermissions(true); - if (freshInitialize && result.type === ReconnectResultType.Snapshot) { + // Re-authenticate on a fresh initialize (the new process holds no + // credentials), or when an earlier pass was cut short by a transport + // drop — that attempt may have delivered only some of them, and an + // ordinary `replay` reconnect never revisits authentication. + if ((freshInitialize && result.type === ReconnectResultType.Snapshot) || this._authenticationRestorePending) { + if (freshInitialize && result.type === ReconnectResultType.Snapshot) { + this._markSubscriptionsAwaitingRestore(result.snapshots); + } await this._restoreAuthenticationAfterFreshInitialize(AgentHostClientState.Reconnecting); if (this._state.kind !== AgentHostClientState.Reconnecting) { return; } - await this._restoreSubscriptionsAfterFreshInitialize(result.snapshots); } - if (this._state.kind !== AgentHostClientState.Reconnecting) { - return; + // Not limited to a fresh initialize: this attempt may be finishing a + // restore an earlier one started but never completed, in which case + // the reconnect above resolved to a snapshot-less `replay`. + if (this._subscriptionsAwaitingRestore.size > 0) { + await this._restoreSubscriptionsAwaitingRestore(); + if (this._state.kind !== AgentHostClientState.Reconnecting) { + return; + } } // Re-push renderer-owned config on reconnect too: a reconnected host may @@ -762,7 +856,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect } if (err instanceof NonReconnectableTransportError) { const protocolError = new ProtocolError(AHP_CLIENT_CONNECTION_CLOSED, err.message); - this._handleFatalClose(protocolError); + this._handleFatalClose(protocolError, err.reason); return; } if (err instanceof InitialAuthenticationError) { @@ -814,51 +908,102 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect }; } - private async _restoreSubscriptionsAfterFreshInitialize(initialSnapshots: readonly IStateSnapshot[]): Promise<void> { + /** + * Record every active subscription the fresh initialize did not carry a + * snapshot for. The host forgot this client, so those channels still hold + * state produced by the previous host process and cannot be trusted until + * they have been re-subscribed. Rebuilt from scratch so a channel the + * host has since snapshotted stops being re-requested. + */ + private _markSubscriptionsAwaitingRestore(initialSnapshots: readonly IStateSnapshot[]): void { const restored = new Set(initialSnapshots.map(snapshot => snapshot.resource)); - const active = this._subscriptionManager.getActiveSubscriptions() - .filter(subscription => !restored.has(subscription.resource.toString())); - const restoreGroup = async (subscriptions: typeof active) => { + this._subscriptionsAwaitingRestore.clear(); + for (const subscription of this._subscriptionManager.getActiveSubscriptions()) { + const resource = subscription.resource.toString(); + if (!restored.has(resource)) { + this._subscriptionsAwaitingRestore.add(resource); + } + } + } + + /** + * Re-subscribe every channel recorded by + * {@link _markSubscriptionsAwaitingRestore} so it is reseated with state + * from the current host process. + * + * A channel leaves the set once it has a fresh snapshot, the server + * reported it missing, or it is no longer subscribed. A dropped transport + * deliberately leaves the remaining entries in place so the next reconnect + * finishes the job — that reconnect may well be answered with a `replay`, + * which would otherwise never deliver the snapshot these channels need. + */ + private async _restoreSubscriptionsAwaitingRestore(): Promise<void> { + if (this._subscriptionsAwaitingRestore.size === 0) { + return; + } + const pending = this._subscriptionManager.getActiveSubscriptions() + .filter(subscription => this._subscriptionsAwaitingRestore.has(subscription.resource.toString())); + // Entries that are no longer subscribed can never be reseated, and a + // later subscribe issues its own snapshot request anyway. + this._subscriptionsAwaitingRestore.clear(); + for (const subscription of pending) { + this._subscriptionsAwaitingRestore.add(subscription.resource.toString()); + // These channels are already live server-side, so an action newer + // than the snapshot we are about to request can arrive first. Buffer + // until the snapshot lands rather than letting it be overwritten. + this._subscriptionManager.beginSnapshotRefresh(subscription.resource); + } + const restoreGroup = async (subscriptions: typeof pending) => { await Promise.all(subscriptions.map(async subscription => { + const resource = subscription.resource.toString(); try { const result = await this._dispatchRequest<CommandMap['subscribe']['result']>('subscribe', { - channel: subscription.resource.toString(), + channel: resource, }, { bypassReconnectGate: true }); - if (result.snapshot) { - this._subscriptionManager.applyReconnectSnapshot( - result.snapshot.resource, - result.snapshot.state, - result.snapshot.fromSeq, - true, - ); - this._serverSeq = Math.max(this._serverSeq, result.snapshot.fromSeq); + if (!result.snapshot) { + // Nothing to reseat this channel with; treat it like a + // failed restore rather than a completed one. + throw new Error(`subscribe returned no snapshot for ${resource}`); } + this._subscriptionManager.applyReconnectSnapshot( + result.snapshot.resource, + result.snapshot.state, + result.snapshot.fromSeq, + true, + ); + this._serverSeq = Math.max(this._serverSeq, result.snapshot.fromSeq); + this._subscriptionsAwaitingRestore.delete(resource); } catch (error) { if (error instanceof ProtocolError && error.code === AHP_CLIENT_CONNECTION_CLOSED) { + // Keep the entry: the next reconnect retries this channel. + this._subscriptionManager.cancelSnapshotRefresh(subscription.resource); throw error; } - this._logService.warn(`[AgentHostProtocolClient] Failed to restore subscription ${subscription.resource.toString()} after host restart: ${error instanceof Error ? error.message : String(error)}`); + this._logService.warn(`[AgentHostProtocolClient] Failed to restore subscription ${resource} after host restart: ${error instanceof Error ? error.message : String(error)}`); + this._subscriptionManager.cancelSnapshotRefresh(subscription.resource); this._subscriptionManager.markSubscriptionsMissing([subscription.resource]); + this._subscriptionsAwaitingRestore.delete(resource); } })); }; - await restoreGroup(active.filter(subscription => subscription.kind === StateComponents.Session)); + await restoreGroup(pending.filter(subscription => subscription.kind === StateComponents.Session)); await Promise.all([ - restoreGroup(active.filter(subscription => subscription.kind === StateComponents.Chat)), - restoreGroup(active.filter(subscription => subscription.kind !== StateComponents.Session && subscription.kind !== StateComponents.Chat)), + restoreGroup(pending.filter(subscription => subscription.kind === StateComponents.Chat)), + restoreGroup(pending.filter(subscription => subscription.kind !== StateComponents.Session && subscription.kind !== StateComponents.Chat)), ]); } private async _restoreAuthenticationAfterFreshInitialize(expectedState: AgentHostClientState.Connecting | AgentHostClientState.Reconnecting): Promise<void> { - let resolvedInitialAuthentication = false; + this._authenticationRestorePending = true; + let initialAuthenticationKey: string | undefined; if (this._resolveInitialAuthentication) { try { const initialAuthentication = await this._resolveInitialAuthentication(); if (initialAuthentication) { const normalizedParams = this._normalizeAuthenticationParams(initialAuthentication); - this._authentication.set(this._authenticationKey(normalizedParams), normalizedParams); - resolvedInitialAuthentication = true; + initialAuthenticationKey = this._authenticationKey(normalizedParams); + this._authentication.set(initialAuthenticationKey, normalizedParams); } } catch (error) { throw new InitialAuthenticationError(error); @@ -867,23 +1012,37 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect return; } } - try { - await Promise.all([...this._authentication.values()].map(params => this._dispatchRequest<CommandMap['authenticate']['result']>('authenticate', { - channel: ROOT_STATE_URI, - ...params, - scopes: params.scopes ? [...params.scopes] : undefined, - }, this._state.kind === AgentHostClientState.Connecting - ? { bypassInitializeQueue: true, bypassReconnectGate: true } - : { bypassReconnectGate: true }))); - } catch (error) { - // A dropped transport is not an authentication failure. Wrapping it - // would classify a momentary blip as terminally incompatible and - // permanently stop recovery, so let it stay a reconnectable error. - if (resolvedInitialAuthentication && !isConnectionClosedError(error)) { - throw new InitialAuthenticationError(error); + await Promise.all([...this._authentication.entries()].map(async ([key, params]) => { + try { + await this._dispatchRequest<CommandMap['authenticate']['result']>('authenticate', { + channel: ROOT_STATE_URI, + ...params, + scopes: params.scopes ? [...params.scopes] : undefined, + }, this._state.kind === AgentHostClientState.Connecting + ? { bypassInitializeQueue: true, bypassReconnectGate: true } + : { bypassReconnectGate: true }); + } catch (error) { + // A dropped transport is not an authentication failure. Wrapping it + // would classify a momentary blip as terminally incompatible and + // permanently stop recovery, so let it stay a reconnectable error. + // The pending flag survives so the next attempt redelivers this. + if (isConnectionClosedError(error)) { + throw error; + } + // Only the freshly resolved initial authentication is essential: + // without it the host cannot serve this client at all. Every other + // entry is a cached per-resource token (an MCP server, say) that the + // host may legitimately reject — the protocol requires rejecting a + // resource the host does not currently advertise — and aborting here + // would skip the rest of the restart recovery, including the + // subscription re-snapshot that keeps restored sessions from freezing. + if (key === initialAuthenticationKey) { + throw new InitialAuthenticationError(error); + } + this._logService.warn(`[AgentHostProtocolClient] Failed to restore authentication for ${params.resource} after host restart: ${error instanceof Error ? error.message : String(error)}`); } - throw error; - } + })); + this._authenticationRestorePending = false; } private _clientMeta(): Record<string, unknown> { @@ -968,11 +1127,18 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect if (result.missing.length > 0) { this._logService.info(`[RemoteAgentHostProtocol] Server cannot resume ${result.missing.length} subscription(s) after reconnect.`); this._subscriptionManager.markSubscriptionsMissing(result.missing.map(u => URI.parse(u))); + // A channel the server cannot resume can never be reseated. + for (const resource of result.missing) { + this._subscriptionsAwaitingRestore.delete(resource); + } } } else { let maxSeq = this._serverSeq; for (const snapshot of result.snapshots) { this._subscriptionManager.applyReconnectSnapshot(snapshot.resource, snapshot.state, snapshot.fromSeq, preservePending); + // This snapshot came from the current host, so it settles any + // restore still owed for the channel. + this._subscriptionsAwaitingRestore.delete(snapshot.resource); if (snapshot.fromSeq > maxSeq) { maxSeq = snapshot.fromSeq; } @@ -1667,12 +1833,12 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect } } - private _handleFatalClose(error: ProtocolError): void { + private _handleFatalClose(error: ProtocolError, reason?: AgentHostTransportFailureReason): void { this._onDidFatalClose.fire(error); - this._handleClose(error); + this._handleClose(error, reason); } - private _handleClose(error: ProtocolError): void { + private _handleClose(error: ProtocolError, reason?: AgentHostTransportFailureReason): void { if (this._state.kind === AgentHostClientState.Closed) { return; } @@ -1698,7 +1864,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect this._implicitReadGrants.clear(); this._resourceService.connectionClosed(this._resourceIdentity); this._transitionTo({ kind: AgentHostClientState.Closed, error }); - this._onDidClose.fire(); + this._onDidClose.fire(reason); } private async _raceClose<T>(promise: Promise<T>): Promise<T> { @@ -1732,12 +1898,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 +1943,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<string, unknown>; @@ -2024,6 +2212,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 +2224,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 +2240,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 +2256,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 +2267,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 +2299,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/browser/remoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts index f2a76d4dd681b5..622809cdfdad09 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts @@ -40,7 +40,7 @@ import { RemoteAgentHostEntryType, } from '../common/remoteAgentHostService.js'; import { computeReconnectDelay, hasExhaustedReconnectAttempts } from '../common/reconnectPolicy.js'; -import { NonReconnectableTransportError } from '../common/state/sessionTransport.js'; +import { AgentHostTransportFailureReason, NonReconnectableTransportError } from '../common/state/sessionTransport.js'; import { AgentHostProtocolClient, InitialAuthenticationError } from './agentHostProtocolClient.js'; import { WebSocketClientTransport } from './webSocketClientTransport.js'; import { AGENT_HOST_LABEL_FORMATTER, AGENT_HOST_SCHEME, agentHostAuthority, normalizeRemoteAgentHostAddress } from '../common/agentHostUri.js'; @@ -51,7 +51,7 @@ import { agentsWindowAgentHostClientInfo, editorWindowAgentHostClientInfo } from /** Tracks a single remote connection through its lifecycle. */ interface IConnectionEntry { readonly store: DisposableStore; - readonly client: IRemoteAgentHostProtocolClient; + client?: IRemoteAgentHostProtocolClient; /** * Optional teardown for the shared-process tunnel that this entry's * transport is using (SSH or dev-tunnels). Tracked separately from @@ -234,8 +234,8 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo result.push({ address, name: this._names.get(address) ?? address, - clientId: entry.client.clientId, - defaultDirectory: entry.client.defaultDirectory, + clientId: entry.client?.clientId, + defaultDirectory: entry.client?.defaultDirectory, status: entry.status, }); } @@ -285,9 +285,9 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo async triggerServerUpgrade(address: string, method: string): Promise<IVscodeUpgradeResult> { const normalized = normalizeRemoteAgentHostAddress(address); - const entry = this._entries.get(normalized); - if (!entry) { - throw new Error(`No remote agent host entry found for ${address}.`); + const client = this._entries.get(normalized)?.client; + if (!client) { + throw new Error(`No usable remote agent host client found for ${address}.`); } // The protocol client may be in any state: it might have completed // the handshake (Connected) or it might be sitting on an @@ -296,7 +296,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo // method name the host advertised in its `_meta` payload; the // server handler allows it pre-`initialize`. const result = await raceTimeout( - entry.client.triggerVscodeUpgrade(method), + client.triggerVscodeUpgrade(method), RemoteAgentHostService.UpgradeRequestTimeout, ); if (result === undefined) { @@ -365,6 +365,17 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo void this._connectTo(entryToReconnect, { userInitiated }); } + /** + * Skips a protocol client's pending backoff, or starts a fresh user-initiated dial. + */ + reconnectNow(address: string): void { + const normalized = normalizeRemoteAgentHostAddress(address); + if (this._entries.get(normalized)?.client?.reconnectNow()) { + return; + } + this.reconnect(normalized, true); + } + async waitForConnection(address: string): Promise<IRemoteAgentHostConnectionInfo> { if (this._store.isDisposed) { throw new Error('Remote agent host service is disposed.'); @@ -438,11 +449,11 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo notifyConnectionClosed(address: string): void { const normalized = normalizeRemoteAgentHostAddress(address); const entry = this._entries.get(normalized); - if (entry) { + if (entry?.client) { this._logService.info(`[RemoteAgentHost] notifyConnectionClosed: notifying protocol client for ${normalized}`); entry.client.notifyTransportClosed(); } else { - this._logService.info(`[RemoteAgentHost] notifyConnectionClosed: no entry found for ${normalized} (already removed?)`); + this._logService.info(`[RemoteAgentHost] notifyConnectionClosed: no active client found for ${normalized}`); } } @@ -576,7 +587,27 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo createdConnection = await factory.createConnection(entryToCreate, options); } catch (err) { this._logService.error(`[RemoteAgentHost] Failed to create a connection to ${address}. Verify address and connectionToken`, err); + // A factory can fail before any client exists — a stopped WSL distro is + // rejected by its precondition check, never reaching the handshake below. + // Retain the entry so its status remains visible to consumers. + const disconnectReason = err instanceof NonReconnectableTransportError ? err.reason : AgentHostTransportFailureReason.Unknown; + this._entries.set(address, { + store: new DisposableStore(), + connected: false, + status: RemoteAgentHostConnectionStatus.disconnectedBecause(disconnectReason), + reconnectTransfersTransportOwnership: false, + }); this._rejectPendingConnectionWait(address, err); + // Clear the in-flight marker before notifying. A consumer may dial again + // from this notification — an automatic start does — and `reconnect` + // joins a pending dial rather than racing it. Leaving the marker set + // would join *this* dial, which has already failed, so the new request + // would never connect and its `waitForConnection` would never settle. + // `_connectTo` clears by identity, so a fresh dial started here survives. + this._pendingConnects.delete(address); + // Nothing else reports this failure: no entry was created, so consumers + // only learn the address became unavailable from this notification. + this._onDidChangeConnections.fire(); if (!isTerminalConnectError(err) && !this._store.isDisposed && this._remoteAgentHostsEnabled.get()) { this._scheduleReconnect(address, entryToCreate.connectionToken); } @@ -611,13 +642,15 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo // current entry for this address is still the one we created. const isCurrentEntry = () => this._entries.get(address) === entry; - store.add(client.onDidClose(() => { + store.add(client.onDidClose(reason => { if (!isCurrentEntry()) { return; } this._logService.warn(`[RemoteAgentHost] Connection closed: ${address}`); entry.connected = false; - entry.status = RemoteAgentHostConnectionStatus.disconnected; + entry.status = RemoteAgentHostConnectionStatus.disconnectedBecause(reason ?? AgentHostTransportFailureReason.Unknown); + entry.client = undefined; + disposeEntry(entry); this._onDidChangeConnections.fire(); // Schedule reconnect if the address is still configured. This is // the "fatal" path — the protocol client already gave up its own @@ -628,6 +661,15 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo // Surface self-healing transport drops separately so outer reconnect // loops do not replace the protocol client while it restores itself. + store.add(client.onDidScheduleReconnect(() => { + // The client stays `reconnecting` across backoff rounds, so only this + // event reports that the deadline moved. + if (!isCurrentEntry() || entry.status.kind !== 'reconnecting') { + return; + } + entry.status = RemoteAgentHostConnectionStatus.reconnectingUntil(client.nextReconnectAt); + this._onDidChangeConnections.fire(); + })); store.add(client.onDidChangeConnectionState(state => { if (!isCurrentEntry()) { return; @@ -635,7 +677,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo switch (state) { case 'reconnecting': entry.connected = false; - entry.status = RemoteAgentHostConnectionStatus.reconnecting; + entry.status = RemoteAgentHostConnectionStatus.reconnectingUntil(client.nextReconnectAt); this._onDidChangeConnections.fire(); break; case 'connected': @@ -715,9 +757,10 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo return; } - entry.status = RemoteAgentHostConnectionStatus.disconnected; - // Clean up the failed entry - this._entries.delete(address); + const disconnectReason = err instanceof NonReconnectableTransportError ? err.reason : AgentHostTransportFailureReason.Unknown; + entry.status = RemoteAgentHostConnectionStatus.disconnectedBecause(disconnectReason); + entry.client = undefined; + // Clean up the failed client while retaining its entry and status. disposeEntry(entry); this._rejectPendingConnectionWait(address, err); this._onDidChangeConnections.fire(); 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/<turnId>`). */ 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/common/meta/agentPermissionRequestMeta.ts b/src/vs/platform/agentHost/common/meta/agentPermissionRequestMeta.ts index 0dc82fce5df0bd..fe27a9a0f17cbf 100644 --- a/src/vs/platform/agentHost/common/meta/agentPermissionRequestMeta.ts +++ b/src/vs/platform/agentHost/common/meta/agentPermissionRequestMeta.ts @@ -10,6 +10,13 @@ * A remote host describes the pending decision (run a command, read a file, …) * but does not stamp the `_meta.toolKind` rendering hint local agent adapters * provide, so the kind is recovered from here instead. + * + * Compatibility bridge, not the durable contract: `promptRequest` and + * `permissionRequest` are raw Copilot runtime payloads, not protocol fields, so + * their shape can change without a version bump and every client has to learn + * Copilot internals to render an approval. Delete this file, and its use in + * `getToolKind`, once the minimum supported host describes confirmations + * natively. */ interface IHasPermissionRequestMeta { diff --git a/src/vs/platform/agentHost/common/remoteAgentHostBootstrapProgress.ts b/src/vs/platform/agentHost/common/remoteAgentHostBootstrapProgress.ts new file mode 100644 index 00000000000000..79b3f561072f5e --- /dev/null +++ b/src/vs/platform/agentHost/common/remoteAgentHostBootstrapProgress.ts @@ -0,0 +1,101 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IObservable, observableValue } from '../../../base/common/observable.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; + +const SERVER_DOWNLOAD_PROGRESS_REGEX = /^Downloading server:\s*\d+\/\d+\s+\((?<percentage>\d{1,3})%\)/; + +export interface IRemoteAgentHostBootstrapProgress { + readonly phase: 'serverDownload'; + readonly percentage: number; +} + +/** Redact connection tokens from output produced by a remote agent host. */ +export function redactToken(text: string): string { + return text.replace(/\?tkn=[^\s&]+/g, '?tkn=***'); +} + +/** Parses and throttles recognized bootstrap progress after redacting its input. */ +export class RemoteAgentHostBootstrapProgressReporter extends Disposable { + + private readonly _progress = observableValue<IRemoteAgentHostBootstrapProgress | undefined>(this, undefined); + readonly progress: IObservable<IRemoteAgentHostBootstrapProgress | undefined> = this._progress; + + private _lastReportTime: number | undefined; + private _pendingProgress: IRemoteAgentHostBootstrapProgress | undefined; + private _timeoutHandle: ReturnType<typeof setTimeout> | undefined; + + constructor(private readonly _intervalMs = 250) { + super(); + } + + /** Consumes one decoded output line. */ + acceptLine(line: string): void { + const progress = this._parseProgress(redactToken(line)); + if (!progress) { + return; + } + + const now = Date.now(); + if (this._lastReportTime === undefined || now - this._lastReportTime >= this._intervalMs) { + // Discard any queued report first: if the event loop stalled past the + // interval, a stale pending value would otherwise land after this newer + // one and make the displayed progress run backwards. + if (this._timeoutHandle !== undefined) { + clearTimeout(this._timeoutHandle); + this._timeoutHandle = undefined; + } + this._pendingProgress = undefined; + this._lastReportTime = now; + this._progress.set(progress, undefined); + return; + } + + this._pendingProgress = progress; + if (this._timeoutHandle === undefined) { + this._timeoutHandle = setTimeout(() => { + this._timeoutHandle = undefined; + const pendingProgress = this._pendingProgress; + if (pendingProgress) { + this._lastReportTime = Date.now(); + this._progress.set(pendingProgress, undefined); + this._pendingProgress = undefined; + } + }, this._intervalMs - (now - this._lastReportTime)); + } + } + + /** Immediately publishes the newest pending progress, if any. */ + flush(): void { + if (this._timeoutHandle !== undefined) { + clearTimeout(this._timeoutHandle); + this._timeoutHandle = undefined; + } + const pendingProgress = this._pendingProgress; + if (pendingProgress) { + this._lastReportTime = Date.now(); + this._progress.set(pendingProgress, undefined); + this._pendingProgress = undefined; + } + } + + override dispose(): void { + if (this._timeoutHandle !== undefined) { + clearTimeout(this._timeoutHandle); + this._timeoutHandle = undefined; + } + super.dispose(); + } + + private _parseProgress(line: string): IRemoteAgentHostBootstrapProgress | undefined { + const match = SERVER_DOWNLOAD_PROGRESS_REGEX.exec(line); + const percentage = Number(match?.groups?.percentage); + if (!Number.isInteger(percentage) || percentage < 0 || percentage > 100) { + return undefined; + } + return { phase: 'serverDownload', percentage }; + } +} diff --git a/src/vs/platform/agentHost/common/remoteAgentHostService.ts b/src/vs/platform/agentHost/common/remoteAgentHostService.ts index 5a03828ea833b6..985b50091af0a2 100644 --- a/src/vs/platform/agentHost/common/remoteAgentHostService.ts +++ b/src/vs/platform/agentHost/common/remoteAgentHostService.ts @@ -13,6 +13,7 @@ import { StorageScope, StorageTarget, type IStorageService } from '../../storage import type { IAgentConnection } from './agentService.js'; import type { UnsupportedProtocolVersionErrorData } from './state/protocol/errors.js'; import { AHP_UNSUPPORTED_PROTOCOL_VERSION, ProtocolError } from './state/sessionProtocol.js'; +import { AgentHostTransportFailureReason } from './state/sessionTransport.js'; import { readUnsupportedProtocolVersionErrorMeta, type IVscodeUpgradeResult } from './state/protocolUpgrade.js'; import { TUNNEL_ADDRESS_PREFIX } from './tunnelAgentHost.js'; import { DEFAULT_RECONNECT_POLICY, type IRemoteAgentHostReconnectPolicy } from './reconnectPolicy.js'; @@ -35,8 +36,12 @@ export type RemoteAgentHostConnectionStatus = * preserving session state. Distinct from `connecting` (initial dial) and * `disconnected` (no connection, nothing in flight). */ - | { readonly kind: 'reconnecting' } - | { readonly kind: 'disconnected' } + | { + readonly kind: 'reconnecting'; + /** When the next automatic attempt fires, if one is scheduled. Absent while an attempt is in flight. */ + readonly nextAttemptAt?: number; + } + | { readonly kind: 'disconnected'; readonly reason: AgentHostTransportFailureReason } | { readonly kind: 'incompatible'; /** Human-readable reason from the host (or a synthesised one when the host did not send one). */ @@ -61,8 +66,20 @@ export namespace RemoteAgentHostConnectionStatus { export const connecting: RemoteAgentHostConnectionStatus = Object.freeze({ kind: 'connecting' }); /** Singleton "reconnecting" status. */ export const reconnecting: RemoteAgentHostConnectionStatus = Object.freeze({ kind: 'reconnecting' }); + /** Build a reconnecting status carrying its backoff deadline. */ + export function reconnectingUntil(nextAttemptAt: number | undefined): RemoteAgentHostConnectionStatus { + return nextAttemptAt === undefined + ? reconnecting + : Object.freeze({ kind: 'reconnecting', nextAttemptAt }); + } /** Singleton "disconnected" status. */ - export const disconnected: RemoteAgentHostConnectionStatus = Object.freeze({ kind: 'disconnected' }); + export const disconnected: RemoteAgentHostConnectionStatus = Object.freeze({ kind: 'disconnected', reason: AgentHostTransportFailureReason.Unknown }); + /** Build a disconnected status with a machine-readable reason. */ + export function disconnectedBecause(reason: AgentHostTransportFailureReason): RemoteAgentHostConnectionStatus { + return reason === AgentHostTransportFailureReason.Unknown + ? disconnected + : Object.freeze({ kind: 'disconnected', reason }); + } /** Build an "incompatible" status from a host-supplied message and the versions involved. */ export function incompatible(message: string, supportedByClient: readonly string[], offeredByServer?: readonly string[], vscodeUpgradeMethod?: string): RemoteAgentHostConnectionStatus { return Object.freeze({ kind: 'incompatible', message, supportedByClient, offeredByServer, vscodeUpgradeMethod }); @@ -243,9 +260,20 @@ export type RemoteAgentHostProtocolClientState = 'connecting' | 'incompatible' | */ export interface IRemoteAgentHostProtocolClient extends IAgentConnection, IDisposable { readonly defaultDirectory: string | undefined; - readonly onDidClose: Event<void>; + /** Deadline for the next scheduled reconnect attempt, if one is pending. */ + readonly nextReconnectAt: number | undefined; + readonly onDidClose: Event<AgentHostTransportFailureReason | undefined>; readonly onDidChangeConnectionState: Event<RemoteAgentHostProtocolClientState>; + /** + * Fires whenever the pending reconnect schedule changes — a backoff being + * armed, or cleared by an immediate retry. Separate from + * {@link onDidChangeConnectionState} because the client state is still + * `reconnecting` throughout, and consumers of that event do real work on + * each transition that must not be repeated per backoff round. + */ + readonly onDidScheduleReconnect: Event<void>; connect(): Promise<void>; + reconnectNow(): boolean; notifyTransportClosed(): void; triggerVscodeUpgrade(method: string): Promise<IVscodeUpgradeResult>; } @@ -659,7 +687,13 @@ export interface IRemoteAgentHostService { /** Fires when a remote connection is established or lost. */ readonly onDidChangeConnections: Event<void>; - /** Currently connected remote addresses with metadata. */ + /** + * Known remote addresses with metadata. This is a status catalog, not a + * liveness list: an entry is retained after a failed dial so its + * {@link IRemoteAgentHostConnectionInfo.status} — and its disconnect reason — + * stay observable. Callers asking "is this host usable?" must test `status` + * (see `RemoteAgentHostConnectionStatus.isConnected`) rather than presence. + */ readonly connections: readonly IRemoteAgentHostConnectionInfo[]; /** All remote agent host entries exposed by registered factories, regardless of connection status. */ @@ -700,6 +734,12 @@ export interface IRemoteAgentHostService { * with reset backoff. */ reconnect(address: string, userInitiated?: boolean): void; + /** + * Skips a pending reconnect backoff for this address and retries at once. + * Prefers the protocol client's in-place retry, which preserves session + * state, and falls back to a fresh dial when there is no client to accelerate. + */ + reconnectNow(address: string): void; /** * Force the protocol client at `address` (if any) to treat its @@ -743,7 +783,8 @@ export interface IRemoteAgentHostService { export interface IRemoteAgentHostConnectionInfo { readonly address: string; readonly name: string; - readonly clientId: string; + /** Identifier of the backing protocol client, when one exists. */ + readonly clientId?: string; readonly defaultDirectory?: string; readonly status: RemoteAgentHostConnectionStatus; } @@ -763,6 +804,7 @@ export class NullRemoteAgentHostService implements IRemoteAgentHostService { } async removeRemoteAgentHost(_address: string): Promise<void> { } reconnect(_address: string, _userInitiated?: boolean): void { } + reconnectNow(_address: string): void { } notifyConnectionClosed(_address: string): void { } getEntryByAddress(): IRemoteAgentHostEntry | undefined { return undefined; } async triggerServerUpgrade(): Promise<IVscodeUpgradeResult> { diff --git a/src/vs/platform/agentHost/common/sessionArtifactCollection.ts b/src/vs/platform/agentHost/common/sessionArtifactCollection.ts index cfee8db9b5e63a..f611ac00d4ded0 100644 --- a/src/vs/platform/agentHost/common/sessionArtifactCollection.ts +++ b/src/vs/platform/agentHost/common/sessionArtifactCollection.ts @@ -91,7 +91,7 @@ export function parseSessionArtifactInput(rawArgs: unknown, toolName: string): I throw new Error(`Invalid ${toolName} input: type must be one of ${SESSION_ARTIFACT_TYPES.join(', ')}.`); } if (typeof args['isArtifact'] !== 'boolean') { - throw new Error(`Invalid ${toolName} input: isArtifact must be a boolean — true for something this session produced, false for a reference.`); + throw new Error(`Invalid ${toolName} input: isArtifact must be a boolean — true for an artifact, false for a reference.`); } const artifactType = type as SessionArtifactType; diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts index 73db21fa94ae78..a2737973c1bcda 100644 --- a/src/vs/platform/agentHost/common/sessionDataService.ts +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -419,6 +419,7 @@ export interface ISessionDataService { * already exists on disk**. Returns `undefined` when no database has * been created yet, avoiding the side effect of materializing empty * database files during read-only operations like listing sessions. + * Errors other than file-not-found are propagated. */ tryOpenDatabase(session: URI): Promise<IReference<ISessionDatabase> | undefined>; diff --git a/src/vs/platform/agentHost/common/state/agentSubscription.ts b/src/vs/platform/agentHost/common/state/agentSubscription.ts index bdd3576868c84a..d08a9a67dc2a82 100644 --- a/src/vs/platform/agentHost/common/state/agentSubscription.ts +++ b/src/vs/platform/agentHost/common/state/agentSubscription.ts @@ -103,6 +103,7 @@ abstract class BaseAgentSubscription<T> extends Disposable implements IAgentSubs protected _confirmedState: T | undefined; private _error: Error | undefined; private _bufferedEnvelopes: ActionEnvelope[] | undefined; + private _awaitingSnapshotRefresh = false; protected readonly _onDidChange = this._register(new Emitter<T>()); readonly onDidChange: Event<T> = this._onDidChange.event; @@ -140,12 +141,48 @@ abstract class BaseAgentSubscription<T> extends Disposable implements IAgentSubs * Apply an initial snapshot from the server. */ handleSnapshot(state: T, fromSeq: number): void { + this._awaitingSnapshotRefresh = false; this._confirmedState = state; this._error = undefined; this._onSnapshotApplied(fromSeq); this._onDidChange.fire(this.value as T); } + /** + * Buffer incoming envelopes until the next {@link handleSnapshot}. + * + * Needed when a subscription that already holds confirmed state is + * re-subscribed to be reseated from a restarted host: the snapshot is + * computed at some `fromSeq`, but the channel is already live, so newer + * actions can reach the client before the subscribe response does. + * Applying them first and then installing the older snapshot would drop + * them silently — losing, say, the action that ends a turn. + */ + beginSnapshotRefresh(): void { + this._awaitingSnapshotRefresh = true; + } + + /** + * Abandon a refresh started by {@link beginSnapshotRefresh} when the + * snapshot never arrives, applying whatever was buffered meanwhile so the + * subscription does not silently lose those actions too. + */ + cancelSnapshotRefresh(): void { + if (!this._awaitingSnapshotRefresh) { + return; + } + this._awaitingSnapshotRefresh = false; + const buffered = this._bufferedEnvelopes; + if (!buffered || this._confirmedState === undefined) { + return; + } + this._bufferedEnvelopes = undefined; + for (const envelope of buffered) { + this._reconcile(envelope, envelope.origin?.clientId === this._clientId); + } + this._onDidChange.fire(this.value as T); + } + /** * Mark this subscription as failed. */ @@ -165,7 +202,7 @@ abstract class BaseAgentSubscription<T> extends Disposable implements IAgentSubs // Buffer actions that arrive before the snapshot has been applied. // They're replayed in _onSnapshotApplied(). - if (this._confirmedState === undefined) { + if (this._confirmedState === undefined || this._awaitingSnapshotRefresh) { if (!this._bufferedEnvelopes) { this._bufferedEnvelopes = []; } @@ -1179,6 +1216,20 @@ export class AgentSubscriptionManager extends Disposable { entry.sub.handleSnapshot(state as never, fromSeq); } + /** + * Put a subscription into snapshot-refresh mode ahead of an explicit + * re-`subscribe` that reseats it from a restarted host. See + * {@link BaseAgentSubscription.beginSnapshotRefresh}. + */ + beginSnapshotRefresh(resource: URI): void { + this._subscriptions.get(resource)?.sub.beginSnapshotRefresh(); + } + + /** Abandon a refresh started by {@link beginSnapshotRefresh}. */ + cancelSnapshotRefresh(resource: URI): void { + this._subscriptions.get(resource)?.sub.cancelSnapshotRefresh(); + } + /** * Mark a set of subscriptions as no longer resumable on the server * (reported via `ReconnectReplayResult.missing`). The subscriptions diff --git a/src/vs/platform/agentHost/common/state/sessionReducers.ts b/src/vs/platform/agentHost/common/state/sessionReducers.ts index 129ad047429bde..64a59c3cebcb62 100644 --- a/src/vs/platform/agentHost/common/state/sessionReducers.ts +++ b/src/vs/platform/agentHost/common/state/sessionReducers.ts @@ -24,8 +24,12 @@ const PERMISSION_REQUEST_TOOL_KINDS: Readonly<Partial<Record<AgentPermissionRequ * * Normally the `_meta.toolKind` flag an agent adapter injects (e.g. * `copilotEventMapper`); it is not part of the protocol. A remote agent host - * does not stamp that key, so for a call awaiting approval the kind comes from - * the permission request it echoes instead. + * does not stamp that key, so for a call awaiting approval the kind falls back + * to the permission request it echoes — the compatibility bridge documented in + * {@link readAgentPermissionRequestMeta}. + * + * A stamped kind always wins, so a host that starts describing its + * confirmations natively is never overridden by the fallback. */ export function getToolKind(tc: ToolCallState | ICompletedToolCall): ToolKind | undefined { const kind = readToolCallMeta(tc).toolKind; diff --git a/src/vs/platform/agentHost/common/state/sessionTransport.ts b/src/vs/platform/agentHost/common/state/sessionTransport.ts index fa1bcccbf578bb..454f194f4179ed 100644 --- a/src/vs/platform/agentHost/common/state/sessionTransport.ts +++ b/src/vs/platform/agentHost/common/state/sessionTransport.ts @@ -15,8 +15,18 @@ import { IDisposable } from '../../../../base/common/lifecycle.js'; import type { AgentHostClientConnectionKind, AgentHostTransportKind } from '../agentHostTelemetry.js'; import type { ProtocolMessage, AhpServerNotification, JsonRpcNotification, JsonRpcParseErrorResponse, JsonRpcResponse, JsonRpcRequest } from './sessionProtocol.js'; +/** Machine-readable reasons a transport cannot be reconnected. */ +export const enum AgentHostTransportFailureReason { + Unknown = 'unknown', + HostNotRunning = 'hostNotRunning', +} + /** Signals that reconnecting the transport cannot recover the connection. */ -export class NonReconnectableTransportError extends Error { } +export class NonReconnectableTransportError extends Error { + constructor(message: string, readonly reason: AgentHostTransportFailureReason = AgentHostTransportFailureReason.Unknown) { + super(message); + } +} /** * A bidirectional transport for protocol messages. Implementations handle diff --git a/src/vs/platform/agentHost/common/wslRemoteAgentHost.ts b/src/vs/platform/agentHost/common/wslRemoteAgentHost.ts index 27a057bf483f57..abcda4cd5456b0 100644 --- a/src/vs/platform/agentHost/common/wslRemoteAgentHost.ts +++ b/src/vs/platform/agentHost/common/wslRemoteAgentHost.ts @@ -22,6 +22,9 @@ export const WSL_INSTALL_DOCS_URL = 'https://aka.ms/vscode-remote/wsl/install-ws */ export const WSL_ADDRESS_PREFIX = 'wsl:'; +/** Controls whether opening a chat automatically starts its stopped WSL host. */ +export const WslAutoStartSettingId = 'chat.agentHost.wsl.autoStart'; + /** * A WSL distribution discovered via `wsl --list`. Only WSL 2 distros are * surfaced — WSL 1 lacks the kernel features needed to host the agent. diff --git a/src/vs/platform/agentHost/electron-browser/wslRemoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/electron-browser/wslRemoteAgentHostServiceImpl.ts index 45a48caf665f31..7162484488d9cb 100644 --- a/src/vs/platform/agentHost/electron-browser/wslRemoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/electron-browser/wslRemoteAgentHostServiceImpl.ts @@ -19,7 +19,7 @@ import { AhpJsonlLogger } from '../common/ahpJsonlLogger.js'; import { AgentHostAhpJsonlLoggingSettingId } from '../common/agentService.js'; import { AgentHostClientConnectionKind } from '../common/agentHostTelemetry.js'; import { ReconnectingRelayTransport } from '../common/relayTransport.js'; -import { NonReconnectableTransportError } from '../common/state/sessionTransport.js'; +import { AgentHostTransportFailureReason, NonReconnectableTransportError } from '../common/state/sessionTransport.js'; import { AgentHostProtocolClient } from '../browser/agentHostProtocolClient.js'; import { agentsWindowAgentHostClientInfo } from '../common/agentHostClientInfo.js'; import { @@ -70,7 +70,7 @@ export class WSLRelayClientFactory implements IWSLRelayClientFactory { try { const runningDistros = await mainService.listRunningDistros().catch((): string[] => []); if (!runningDistros.includes(config.distro)) { - throw new NonReconnectableTransportError(`WSL distro '${config.distro}' is not running.`); + throw new NonReconnectableTransportError(`WSL distro '${config.distro}' is not running.`, AgentHostTransportFailureReason.HostNotRunning); } const result = await mainService.reconnect(config.distro, config.name, config.remoteAgentHostCommand, false); return { @@ -263,7 +263,7 @@ class WSLConnectionFactory extends Disposable implements IRemoteAgentHostConnect private async _ensureDistroIsRunning(distro: string): Promise<void> { const runningDistros = await this._mainService.listRunningDistros(); if (!runningDistros.includes(distro)) { - throw new NonReconnectableTransportError(`WSL distro '${distro}' is not running.`); + throw new NonReconnectableTransportError(`WSL distro '${distro}' is not running.`, AgentHostTransportFailureReason.HostNotRunning); } } 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/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index e6bb4493437b75..b6e53d93e1ce1b 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -29,7 +29,7 @@ import { LoggerService } from '../../log/node/loggerService.js'; import { OtlpEmitterLogger, OtlpLogEmitter } from '../common/otlp/otlpLogEmitter.js'; import product from '../../product/common/product.js'; import { IProductService } from '../../product/common/productService.js'; -import { flushAgentHostPersistenceBeforeShutdown } from './agentHostShutdown.js'; +import { shutdownAgentHostBeforeDispose } from './agentHostShutdown.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { createAgentHostRuntime } from './agentHostBootstrap.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; @@ -317,7 +317,7 @@ async function main(): Promise<void> { const clientFileSystemProvider = disposables.add(new AgentHostClientFileSystemProvider()); disposables.add(fileService.registerProvider(AGENT_CLIENT_SCHEME, clientFileSystemProvider)); // Wire up protocol handler - disposables.add(instantiationService.createInstance( + const protocolHandler = disposables.add(instantiationService.createInstance( ProtocolServerHandler, agentService, stateManager, @@ -379,18 +379,17 @@ async function main(): Promise<void> { } shuttingDown = true; logService.info('[AgentHostServer] Shutting down...'); - // Close the WebSocket server first so no further actions can be - // dispatched while we wait for in-flight writes to flush — otherwise - // a late-arriving action could keep queuing DB writes and either - // undermine the flush or push us past the timeout. + // Stop protocol ingress before draining requests so no late action can + // race provider shutdown or queue persistence after the idle check. + protocolHandler.dispose(); wsServer.dispose(); - // Wait for in-flight persistence writes to flush. Without this, a - // SIGTERM arriving during a session or agent-host storage write can - // drop the latest decision. - // Capped so a stuck write cannot hang shutdown indefinitely. - await flushAgentHostPersistenceBeforeShutdown( - [sessionDataService.whenIdle(), customizationEnablementService.whenIdle()], - 3000, + // Providers such as Claude finish writing their transcripts during + // shutdown, so drain them before waiting for persistence to go idle. + await shutdownAgentHostBeforeDispose( + () => protocolHandler.whenIdle(), + () => agentService.shutdown(), + () => [sessionDataService.whenIdle(), customizationEnablementService.whenIdle()], + 4500, logService, ); disposables.dispose(); diff --git a/src/vs/platform/agentHost/node/agentHostShutdown.ts b/src/vs/platform/agentHost/node/agentHostShutdown.ts index 3c37c379f59c71..61077de3c42cbd 100644 --- a/src/vs/platform/agentHost/node/agentHostShutdown.ts +++ b/src/vs/platform/agentHost/node/agentHostShutdown.ts @@ -6,6 +6,33 @@ import { raceTimeout } from '../../../base/common/async.js'; import type { ILogService } from '../../log/common/log.js'; +/** + * Drains protocol requests and providers before flushing persistence, without letting shutdown block process exit indefinitely. + */ +export async function shutdownAgentHostBeforeDispose( + drainProtocol: () => Promise<void>, + shutdownProviders: () => Promise<void>, + flushPersistence: () => readonly Promise<unknown>[], + timeoutMs: number, + logService: Pick<ILogService, 'error' | 'warn'>, +): Promise<void> { + await raceTimeout((async () => { + try { + await drainProtocol(); + } catch (error) { + logService.error('[AgentHostServer] Failed to drain protocol requests; continuing shutdown.', error); + } + try { + await shutdownProviders(); + } catch (error) { + logService.error('[AgentHostServer] Failed to shut down providers; continuing shutdown.', error); + } + await flushAgentHostPersistenceBeforeShutdown(flushPersistence(), timeoutMs, logService); + })(), timeoutMs, () => { + logService.warn('[AgentHostServer] Timed out waiting for graceful shutdown; exiting anyway.'); + }); +} + /** * Flushes Agent Host persistence without allowing a failed or stalled write to * prevent process cleanup and exit. 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<string>(); @@ -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<void> { 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<void> { + 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<void> { 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 28ea2ccc7de2a9..9089c59bf1ff1d 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -475,12 +475,13 @@ export class AgentService extends Disposable implements IAgentService { * durable marker write kept failing after a retry in `createChat`. The chat * itself was already created and announced successfully, so this in-process * suppression stands in for the durable marker: it is consulted by - * {@link _isChatBacking} (used by external discovery) and by `listSessions`'s overlay - * filter, so the backing session is still never surfaced as a standalone - * top-level session for the lifetime of this process, even though its - * on-disk marker never persisted. A later successful write (e.g. from a - * differently-timed retry) removes the entry; a stale entry for a since - * deleted session is harmless — that URI is never reachable again. + * {@link _readSessionRegistrationFacts} (used by external discovery) and + * by `listSessions`'s overlay filter, so the backing session is still never + * surfaced as a standalone top-level session for the lifetime of this + * process, even though its on-disk marker never persisted. A later + * successful write (e.g. from a differently-timed retry) removes the entry; + * a stale entry for a since deleted session is harmless — that URI is never + * reachable again. */ private readonly _unpersistedChatBackings = new Set<string>(); @@ -1796,7 +1797,7 @@ export class AgentService extends Disposable implements IAgentService { let registryChanged = false; const untitledExternal: IAgentSessionMetadata[] = []; const modifiedTimeAdvances: { readonly session: URI; readonly modifiedTime: number }[] = []; - const results = await Promise.all(chats.map(({ external, ...metadata }) => discoveryLimiter.queue(async () => { + const results = await Promise.all(chats.map(({ external: reportedExternal, ...metadata }) => discoveryLimiter.queue(async () => { const sessionMetadata = this._toSessionMetadata(metadata); const session = sessionMetadata.session; try { @@ -1812,10 +1813,16 @@ export class AgentService extends Disposable implements IAgentService { } return false; } - if (isSubagentSession(session.toString()) || await this._isChatBacking(session)) { + if (isSubagentSession(session.toString())) { suppressed++; return false; } + const registrationFacts = await this._readSessionRegistrationFacts(session); + if (registrationFacts.chatBacking) { + suppressed++; + return false; + } + const external = reportedExternal && !registrationFacts.hostCreated; if (external && !readSessionEhcliAdoptable(sessionMetadata._meta) && this._isExternalSessionOlderThanMaxAge(sessionMetadata.modifiedTime, Date.now())) { skippedAsStale++; return false; @@ -1968,9 +1975,9 @@ export class AgentService extends Disposable implements IAgentService { } /** - * Both facts registry backfill needs about a session, from a single database - * open — it asks for both per session, and a large catalogue makes the second - * open the dominant cost of the pass. + * Both facts discovery and registry backfill need about a session, from a + * single database open — they ask for both per session, and a large catalogue + * makes a second open the dominant cost of the pass. */ private async _readSessionRegistrationFacts(session: URI): Promise<{ readonly chatBacking: boolean; readonly hostCreated: boolean }> { if (this._unpersistedChatBackings.has(session.toString())) { @@ -2050,30 +2057,6 @@ export class AgentService extends Disposable implements IAgentService { return known; } - /** - * Whether a session is marked as an internal chat backing, either durably - * or in `_unpersistedChatBackings`. - */ - private async _isChatBacking(session: URI): Promise<boolean> { - if (this._unpersistedChatBackings.has(session.toString())) { - return true; - } - - try { - const ref = await this._sessionDataService.tryOpenDatabase(session); - if (!ref) { - return false; - } - try { - return !!(await ref.object.getMetadata(CHAT_BACKING_METADATA_KEY)); - } finally { - ref.dispose(); - } - } catch { - return false; - } - } - /** Active list computations and their optional trailing refresh, shared per mode. */ private readonly _inFlightListSessions = new Map<AgentHostExternalSessionsMode, ISessionListComputation>(); @@ -5746,6 +5729,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); @@ -6114,10 +6098,10 @@ export class AgentService extends Disposable implements IAgentService { * callers (chat creation / restore) must not fail just because this * durable write did. The write is retried once; if it still fails, the * backing session is added to `_unpersistedChatBackings` so - * `_isChatBacking` (external discovery) and `listSessions`'s overlay filter keep - * suppressing it for the rest of this process's lifetime even without a - * persisted marker. A later successful call for the same session (e.g. a - * retried caller) clears any stale suppression entry. + * `_readSessionRegistrationFacts` (external discovery) and `listSessions`'s + * overlay filter keep suppressing it for the rest of this process's lifetime + * even without a persisted marker. A later successful call for the same + * session (e.g. a retried caller) clears any stale suppression entry. */ private async _markChatBacking(backingSession: URI, chat: URI): Promise<void> { const backingSessionStr = backingSession.toString(); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 753277506abbe7..bc81eb16dabb49 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -2657,6 +2657,7 @@ export class CopilotAgent extends Disposable implements IAgent { return undefined; } const modifiedTime = new Date(s.modifiedTime).getTime(); + let externalClientName: string | undefined; if (!adoptable) { const clientName = s.isRemote ? undefined : s.clientName; if (clientName === undefined || !COPILOT_EXTERNAL_SESSION_CLIENT_NAMES.has(clientName)) { @@ -2671,8 +2672,9 @@ export class CopilotAgent extends Disposable implements IAgent { withoutRepository++; return undefined; } + externalClientName = clientName; } - return { + const discoveredChat = { chat: URI.parse(buildDefaultChatUri(session)), startTime: new Date(s.startTime).getTime(), modifiedTime, @@ -2687,6 +2689,10 @@ export class CopilotAgent extends Disposable implements IAgent { _meta: adoptable ? withSessionEhcliAdoptable(undefined) : undefined, external: !adoptable, } satisfies IAgentDiscoveredChat; + if (externalClientName !== undefined) { + this._logService.info(`[Copilot] Chat discovery: classified ${session.toString()} as external (clientName: ${externalClientName})`); + } + return discoveredChat; } catch (err) { failed++; this._logService.warn(`[Copilot] Failed to classify discovered chat ${session.toString()}; skipping it`, err); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 41c954ea0716d6..51c87a92bf5e07 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -2262,10 +2262,10 @@ export class CopilotAgentSession extends Disposable { } const resource = this._protectedResourceFromMcpAuthRequest(request); const requiredScopes = this._scopesFromChallenge(request.wwwAuthenticateParams?.scope); - const oauthClient: McpAuthRequirement['oauthClient'] = request.staticClientConfig?.publicClient - ? { clientId: request.staticClientConfig.clientId } - : request.staticClientConfig?.clientSecret - ? { clientId: request.staticClientConfig.clientId, clientSecret: request.staticClientConfig.clientSecret } + const oauthClient: McpAuthRequirement['oauthClient'] = request.staticClientConfig?.clientSecret + ? { clientId: request.staticClientConfig.clientId, clientSecret: request.staticClientConfig.clientSecret } + : request.staticClientConfig && request.staticClientConfig.publicClient !== false + ? { clientId: request.staticClientConfig.clientId } : undefined; const auth: McpAuthRequirement = { reason: this._mcpAuthRequiredReason(request.reason), diff --git a/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts b/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts index 3722986e3a1170..fb4d176e8ccf25 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts @@ -95,6 +95,7 @@ function toSdkMcpServer(_name: string, config: IMcpServerConfiguration, defaultC url: config.url, tools: ['*'], ...(config.headers && { headers: { ...config.headers } }), + ...(config.oauth?.clientId && { oauthClientId: config.oauth.clientId }), }; } diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index bb8aed912d53fd..dd8d5e53736ec5 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -356,9 +356,23 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien * {@link IClientRecord}. */ private readonly _clients = new Map<string, IClientRecord>(); + /** + * State channels a client is subscribed to but has never been given a + * baseline snapshot for by THIS server process, keyed by clientId. + * + * `initialize` registers a state channel even when its snapshot has not + * materialized yet (see {@link _addInitialSubscription}), so a client can + * hold a live subscription with no state behind it. Replaying deltas onto + * that void silently strands the channel on whatever the client last saw — + * across a host restart that is pre-restart state, which is how an + * already-finished turn can render as perpetually running. Reconnect + * consults this to force a snapshot response for such channels. + */ + private readonly _baselineDebt = new Map<string, Set<string>>(); private readonly _replayBuffer: ActionEnvelope[] = []; private readonly _telemetryReporter: AgentHostTelemetryReporter; private readonly _managedSettingsOwnerId = generateUuid(); + private readonly _connectionDisposables = this._register(new DisposableMap<IProtocolTransport, DisposableStore>()); private readonly _onDidChangeConnectionCount = this._register(new Emitter<number>()); @@ -423,6 +437,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien private _handleNewConnection(transport: IProtocolTransport): void { const disposables = new DisposableStore(); + this._connectionDisposables.set(transport, disposables); let client: IConnectedClient | undefined; disposables.add(transport.onMessage(msg => { @@ -562,7 +577,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien this._reportClientDisconnected(client, subscriptionCount); } } - disposables.dispose(); + this._connectionDisposables.deleteAndDispose(transport); })); disposables.add(transport); @@ -699,9 +714,47 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien client.subscriptions.set(sub.uri, sub); this._agentService.addSubscriber(URI.parse(sub.uri), client.clientId); this._clearClientToolCallDisconnectTimeout(client.clientId, sub.uri); + if (snapshot) { + this._clearBaselineDebt(client.clientId, sub.uri); + } else { + this._recordBaselineDebt(client.clientId, sub.uri); + } return snapshot; } + /** + * Note that `clientId` holds a subscription to `uri` with no baseline from + * this process. See {@link _baselineDebt}. + */ + private _recordBaselineDebt(clientId: string, uri: string): void { + let debt = this._baselineDebt.get(clientId); + if (!debt) { + debt = new Set(); + this._baselineDebt.set(clientId, debt); + } + debt.add(uri); + } + + /** Record that `clientId` has now been given a baseline for `uri`. */ + private _clearBaselineDebt(clientId: string, uri: string): void { + const debt = this._baselineDebt.get(clientId); + if (debt?.delete(uri) && debt.size === 0) { + this._baselineDebt.delete(clientId); + } + } + + /** Whether any of `subscriptions` still owes `clientId` a baseline. */ + private _hasBaselineDebt(clientId: string, subscriptions: readonly string[]): boolean { + const debt = this._baselineDebt.get(clientId); + if (!debt || debt.size === 0) { + return false; + } + return subscriptions.some(subscription => { + const classified = classifyChannel(subscription.toString()); + return classified !== undefined && debt.has(classified.uri); + }); + } + private async _subscribeStateChannel(channel: string, clientId: string, isActive?: () => boolean): Promise<IStateSnapshot> { if (!isAhpAutomationCatalogChannel(channel)) { return this._agentService.subscribe(URI.parse(channel), clientId, isActive); @@ -792,7 +845,12 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien this._registerClientFileSystemAuthority(params.clientId, initializationDisposables); const oldestBuffered = this._replayBuffer.length > 0 ? this._replayBuffer[0].serverSeq : this._stateManager.serverSeq; - const canReplay = params.lastSeenServerSeq >= oldestBuffered; + // A global cursor only proves the client saw every ACTION; it says + // nothing about whether a given channel ever received state from this + // process to apply them to. Force snapshots while any requested + // channel still owes a baseline. + const canReplay = params.lastSeenServerSeq >= oldestBuffered + && !this._hasBaselineDebt(params.clientId, params.subscriptions); const responsePromise = this._restoreReconnectSubscriptions(client, params, canReplay); client.telemetryConnectionActive = true; @@ -881,6 +939,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien if (!descriptor) { this._logService.info(`[ProtocolServer] Reconnect: resource watch ${key} no longer parses`); missing.push(sub); + this._clearBaselineDebt(client.clientId, classified.uri); return undefined; } if (canReplay) { @@ -916,6 +975,11 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien } this._logService.info(`[ProtocolServer] Reconnect: failed to restore subscription ${key}: ${err instanceof Error ? err.message : String(err)}`); missing.push(sub); + // Reported as missing, so it can never be baselined. Leaving the + // debt would deny `canReplay` for this client's healthy channels + // on every future reconnect, since `missing` is not repeated on + // the snapshot branch and nothing else would ever clear it. + this._clearBaselineDebt(client.clientId, classified.uri); return undefined; } })); @@ -945,9 +1009,16 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien return undefined; } const subscription = client.subscriptions.get(snapshot.resource.toString()); - return subscription?.kind === ChannelKind.State - ? this._stateManager.getSnapshot(subscription.uri) - : snapshot; + if (subscription?.kind !== ChannelKind.State) { + return snapshot; + } + const refreshed = this._stateManager.getSnapshot(subscription.uri); + if (refreshed) { + // Key off the subscription, not the snapshot's echoed resource, + // so the debt entry recorded under the same key is really cleared. + this._clearBaselineDebt(client.clientId, subscription.uri); + } + return refreshed; }); return { type: 'snapshot', snapshots: refreshedSnapshots.filter((s): s is IStateSnapshot => s !== undefined) }; } @@ -1162,6 +1233,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien this._clients.set(client.clientId, previousRecord); } else { this._clients.delete(client.clientId); + this._baselineDebt.delete(client.clientId); } } } @@ -1329,6 +1401,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien && record.lastSeenAt < cutoff) { record.disconnectTimeouts.dispose(); this._clients.delete(clientId); + this._baselineDebt.delete(clientId); } } } @@ -1427,6 +1500,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien } client.subscriptions.set(classified.uri, classified); this._clearClientToolCallDisconnectTimeout(client.clientId, classified.uri); + this._clearBaselineDebt(client.clientId, classified.uri); // `IStateSnapshot` is widened with `ChatState` (see sessionProtocol.ts); // the generated wire `Snapshot` union does not list it yet. The value // is JSON over the wire, so narrowing at this boundary is safe. @@ -2025,6 +2099,9 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien return; } client.subscriptions.delete(classified.uri); + // An unsubscribed channel owes this client nothing; a later subscribe + // records its own debt if it again lands without a baseline. + this._clearBaselineDebt(client.clientId, classified.uri); if (sub.kind === ChannelKind.State) { const record = this._clients.get(client.clientId); if (record && this._hasSubscriptionInOtherConnection(record, client, sub.uri)) { diff --git a/src/vs/platform/agentHost/node/sessionDataService.ts b/src/vs/platform/agentHost/node/sessionDataService.ts index 42269284685ed9..f7f0bb1c0a9125 100644 --- a/src/vs/platform/agentHost/node/sessionDataService.ts +++ b/src/vs/platform/agentHost/node/sessionDataService.ts @@ -6,7 +6,7 @@ import { IReference, ReferenceCollection } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; import { Emitter, Event } from '../../../base/common/event.js'; -import { IFileService } from '../../files/common/files.js'; +import { FileOperationResult, IFileService, toFileOperationResult } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; import { AgentSession } from '../common/agent.js'; import { DEV_CONTAINER_WORKTREE_DATA_ID_PREFIX } from '../common/meta/agentDevContainerWorktreeMeta.js'; @@ -105,8 +105,13 @@ export class SessionDataService implements ISessionDataService { async tryOpenDatabase(session: URI): Promise<IReference<ISessionDatabase> | undefined> { const key = this._sanitizedSessionKey(session); const dbPath = URI.joinPath(this._basePath, key, SESSION_DB_FILENAME); - if (!await this._fileService.exists(dbPath)) { - return undefined; + try { + await this._fileService.stat(dbPath); + } catch (error) { + if (toFileOperationResult(error) === FileOperationResult.FILE_NOT_FOUND) { + return undefined; + } + throw error; } return this._databases.acquire(key); } diff --git a/src/vs/platform/agentHost/node/shared/artifactServerTools.ts b/src/vs/platform/agentHost/node/shared/artifactServerTools.ts index d8900bc782c6c4..aee5845e1b1687 100644 --- a/src/vs/platform/agentHost/node/shared/artifactServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/artifactServerTools.ts @@ -24,7 +24,7 @@ const addArtifactInputSchema: ToolDefinition['inputSchema'] = { label: { type: 'string', description: 'Short label shown to the user.' }, isArtifact: { type: 'boolean', - description: 'Required. `true` for an artifact — something this session produced, such as a pull request or issue it opened, a plan file it wrote outside the workspace, or another side effect of its work. `false` for a reference — something it did not produce but the user should look at, such as the pull request or commit that introduced a bug, or a website that matters for the task.', + description: 'Required. `true` for an artifact. An issue or pull request this session works on is an artifact even if the session did not create it; other artifacts are things the session produced, such as a plan file it wrote outside the workspace or another side effect of its work. `false` for a reference — something the session did not produce but the user should look at because of this task, such as the pull request or commit that introduced a bug, or a website that matters for the task.', }, link: { type: 'string', description: 'URL of the pull request, issue, commit or website. Required for those kinds.' }, uri: { type: 'string', description: 'Absolute URI including its scheme. For a local file, pass a file URI such as `file:///C:/path/to/file`, not a plain file system path such as `C:\\path\\to\\file`. Required for the `file` and `resource` kinds.' }, @@ -50,7 +50,7 @@ export const artifactServerToolDefinitions: ToolDefinition[] = [ { name: ArtifactServerToolName.AddArtifactOrReference, title: 'Add Artifact or Reference', - description: 'Record an artifact or a reference so it is surfaced next to the chat input. An artifact is something this session produced that is not just an ordinary workspace edit: a pull request or issue it opened, a plan or report file it wrote outside the workspace, or another side effect of its work. A reference is something the session did not produce but the user should look at because of this task: the pull request or commit that introduced a bug, an issue it investigated, or a website worth reading. Set `isArtifact` accordingly. Do not record routine files you merely edited or sessions and chats created with session-management tools.', + description: 'Record an artifact or a reference so it is surfaced next to the chat input. An issue or pull request this session works on is an artifact even if the session did not create it. Other artifacts are notable results the session produced beyond ordinary workspace edits, such as a plan or report file it wrote outside the workspace. A reference is something the session did not produce but the user should look at because of this task: the pull request or commit that introduced a bug, an issue it investigated, or a website worth reading. Set `isArtifact` accordingly. Do not record routine files you merely edited or sessions and chats created with session-management tools.', inputSchema: addArtifactInputSchema, annotations: { readOnlyHint: false }, }, @@ -206,4 +206,4 @@ export function createArtifactServerToolGroup(accessor?: IArtifactServerToolAcce * The instruction appended to every agent's host instructions while the * artifact tools are enabled. */ -export const ARTIFACT_TOOLS_INSTRUCTION = `Record the notable results of your work with \`${ArtifactServerToolName.AddArtifactOrReference}\` (types: ${SESSION_ARTIFACT_TYPES.join(', ')}; use \`${SessionArtifactType.Resource}\` when nothing else fits) so they are surfaced next to the chat input. Pass \`isArtifact: true\` for an artifact — something this session produced beyond ordinary workspace edits, such as a pull request or issue you opened, a plan or report file you wrote outside the workspace, or another side effect of your work. Pass \`isArtifact: false\` for a reference — something you did not produce but the user should look at because of this task, such as the pull request or commit that introduced a bug, an issue you investigated, or a website worth reading. Record each one once, and do not record routine files you merely edited, commits you create unless the user asks for them, or sessions and chats created with session-management tools.`; +export const ARTIFACT_TOOLS_INSTRUCTION = `Record the notable results of your work with \`${ArtifactServerToolName.AddArtifactOrReference}\` (types: ${SESSION_ARTIFACT_TYPES.join(', ')}; use \`${SessionArtifactType.Resource}\` when nothing else fits) so they are surfaced next to the chat input. Pass \`isArtifact: true\` for an artifact. An issue or pull request you work on is an artifact even if you did not create it; other artifacts are notable results you produced beyond ordinary workspace edits, such as a plan or report file you wrote outside the workspace. Pass \`isArtifact: false\` for a reference — something you did not produce but the user should look at because of this task, such as the pull request or commit that introduced a bug, an issue you investigated, or a website worth reading. Record each one once, and do not record routine files you merely edited, commits you create unless the user asks for them, or sessions and chats created with session-management tools.`; diff --git a/src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts b/src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts index 25a5e7d5334c28..50a7f4088c468e 100644 --- a/src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts +++ b/src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts @@ -9,6 +9,7 @@ import { CancellationError } from '../../../base/common/errors.js'; import { vArray, vObj, vString, vUnknown } from '../../../base/common/validation.js'; import { TelemetryConfiguration } from '../../telemetry/common/telemetry.js'; import { getAgentHostEndpointIdentityKey, IAgentHostEndpointMetadata, parseAgentHostEndpointRegistry } from '../common/agentHostEndpointRegistry.js'; +export { redactToken } from '../common/remoteAgentHostBootstrapProgress.js'; /** * Validate that a quality string is safe for bare interpolation in shell commands. @@ -280,11 +281,6 @@ export function isValidFallbackCLIPath(candidate: string, serverDataFolderName: return false; } -/** Redact connection tokens from log output. */ -export function redactToken(text: string): string { - return text.replace(/\?tkn=[^\s&]+/g, '?tkn=***'); -} - /** * Match the `ws://127.0.0.1:PORT[?tkn=TOKEN]` URL emitted by `code agent host` * on stdout/stderr. Shared by SSH and WSL agent-host transports — both spawn diff --git a/src/vs/platform/agentHost/node/wslRemoteAgentHostService.ts b/src/vs/platform/agentHost/node/wslRemoteAgentHostService.ts index fd43925e4cf137..1745e65b921a41 100644 --- a/src/vs/platform/agentHost/node/wslRemoteAgentHostService.ts +++ b/src/vs/platform/agentHost/node/wslRemoteAgentHostService.ts @@ -6,7 +6,8 @@ import type WebSocket from 'ws'; import * as cp from 'child_process'; import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { autorun } from '../../../base/common/observable.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; import { removeAnsiEscapeCodes } from '../../../base/common/strings.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { localize } from '../../../nls.js'; @@ -14,6 +15,7 @@ import { ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { telemetryLevelToAgentHostValue } from '../common/agentHostTelemetry.js'; +import { redactToken, RemoteAgentHostBootstrapProgressReporter } from '../common/remoteAgentHostBootstrapProgress.js'; import type { IRelayMessage } from '../common/relayTransport.js'; import { IWSLRemoteAgentHostMainService, @@ -22,7 +24,7 @@ import { type IWSLConnectResult, type IWSLDistro, } from '../common/wslRemoteAgentHost.js'; -import { redactToken, resolveRemotePlatform } from './sshRemoteAgentHostHelpers.js'; +import { resolveRemotePlatform } from './sshRemoteAgentHostHelpers.js'; import { composeAgentHostBootstrapScript, decodeWslOutput, @@ -37,7 +39,14 @@ import { const LOG_PREFIX = '[WSLRemoteAgentHost]'; -/** Max time `code agent host` may be silent before printing its `ws://` URL. */ +/** + * Max time a stopped WSL distro may take to boot and produce the bootstrap's + * first output. This intentionally includes VM startup and login-shell profile + * sourcing, which can legitimately exceed the post-output idle budget. + */ +const AGENT_HOST_INITIAL_OUTPUT_TIMEOUT_MS = 3 * 60_000; + +/** Max time `code agent host` may be silent after bootstrap output has started. */ const AGENT_HOST_OUTPUT_IDLE_TIMEOUT_MS = 60_000; /** Absolute upper bound for bootstrap, including CLI and server downloads. */ @@ -251,9 +260,28 @@ export class WSLRemoteAgentHostMainService extends Disposable implements IWSLRem } }; + const bootstrapProgressDisposables = new DisposableStore(); + const bootstrapProgressReporter = bootstrapProgressDisposables.add(new RemoteAgentHostBootstrapProgressReporter()); + bootstrapProgressDisposables.add(autorun(reader => { + const progress = bootstrapProgressReporter.progress.read(reader); + if (progress?.phase === 'serverDownload') { + reportProgress(localize('wslProgressDownloadingServer', "Downloading server ({0}%)", progress.percentage)); + } + })); + const flushBootstrapProgress = () => { + bootstrapProgressReporter.flush(); + }; + + let initialOutputTimeoutHandle: ReturnType<typeof setTimeout> | undefined; let outputIdleTimeoutHandle: ReturnType<typeof setTimeout> | undefined; let overallTimeoutHandle: ReturnType<typeof setTimeout> | undefined; + let hasProducedOutput = false; + let readySettled = false; const clearReadyTimeouts = () => { + if (initialOutputTimeoutHandle !== undefined) { + clearTimeout(initialOutputTimeoutHandle); + initialOutputTimeoutHandle = undefined; + } if (outputIdleTimeoutHandle !== undefined) { clearTimeout(outputIdleTimeoutHandle); outputIdleTimeoutHandle = undefined; @@ -263,19 +291,27 @@ export class WSLRemoteAgentHostMainService extends Disposable implements IWSLRem overallTimeoutHandle = undefined; } }; - const rejectForTimeout = (message: string) => { + const rejectReady = (error: Error) => { + if (readySettled) { + return; + } + readySettled = true; clearReadyTimeouts(); - urlReject?.(new Error(`${LOG_PREFIX} ${message}\nOutput: ${outputLines.join('\n')}`)); + flushBootstrapProgress(); + urlReject?.(error); + }; + const rejectForTimeout = (message: string) => { + rejectReady(new Error(`${LOG_PREFIX} ${message}\nOutput: ${outputLines.join('\n')}`)); }; const armOutputIdleTimeout = () => { - if (url) { + if (readySettled) { return; } if (outputIdleTimeoutHandle !== undefined) { clearTimeout(outputIdleTimeoutHandle); } outputIdleTimeoutHandle = setTimeout(() => { - rejectForTimeout(`Timed out waiting for agent host in '${distro}' to print its WebSocket URL: no output for ${AGENT_HOST_OUTPUT_IDLE_TIMEOUT_MS}ms.`); + rejectForTimeout(`Timed out waiting for agent host in '${distro}' to print its WebSocket URL: exceeded the ${AGENT_HOST_OUTPUT_IDLE_TIMEOUT_MS}ms output-idle budget after output started.`); }, AGENT_HOST_OUTPUT_IDLE_TIMEOUT_MS); }; @@ -287,17 +323,32 @@ export class WSLRemoteAgentHostMainService extends Disposable implements IWSLRem // etc. — arrive on stderr without `WSL_UTF8=1`). const cleanText = removeAnsiEscapeCodes(decodeWslOutput(data)); for (const rawLine of cleanText.split(/\r\n|\r|\n/)) { + if (readySettled) { + return; + } const line = rawLine.trimEnd(); if (!line) { continue; } + if (!hasProducedOutput) { + hasProducedOutput = true; + if (initialOutputTimeoutHandle !== undefined) { + clearTimeout(initialOutputTimeoutHandle); + initialOutputTimeoutHandle = undefined; + } + } armOutputIdleTimeout(); - appendLine(line); - this._logService.trace(`${LOG_PREFIX} [${distro}] ${redactToken(line)}`); + const redactedLine = redactToken(line); + appendLine(redactedLine); + this._logService.trace(`${LOG_PREFIX} [${distro}] ${redactedLine}`); + bootstrapProgressReporter.acceptLine(line); if (!url) { const match = extractAgentHostWebSocketURL(line); if (match) { + flushBootstrapProgress(); url = match.url; + readySettled = true; + clearReadyTimeouts(); urlResolve?.({ url: match.url, token: match.token }); } } @@ -307,25 +358,28 @@ export class WSLRemoteAgentHostMainService extends Disposable implements IWSLRem child.stdout?.on('data', onStreamData); child.stderr?.on('data', onStreamData); - // Race the URL parse against the child dying, output going idle, and - // an overall ceiling. Bootstrap downloads regularly report progress, - // so only a period of silence indicates that it has become stuck. + // Race the URL parse against the child dying, initial startup silence, + // post-output silence, and an overall ceiling. Bootstrap downloads + // regularly report progress, so once output starts only silence indicates + // that it has become stuck. // `outputLines` is already redacted in `appendLine` — no extra wrap needed. - armOutputIdleTimeout(); + if (!hasProducedOutput) { + initialOutputTimeoutHandle = setTimeout(() => { + rejectForTimeout(`Timed out waiting for agent host in '${distro}' to produce initial output: exceeded the ${AGENT_HOST_INITIAL_OUTPUT_TIMEOUT_MS}ms startup budget.`); + }, AGENT_HOST_INITIAL_OUTPUT_TIMEOUT_MS); + } overallTimeoutHandle = setTimeout(() => { rejectForTimeout(`Timed out waiting for agent host in '${distro}' to print its WebSocket URL: exceeded the overall ${AGENT_HOST_READY_OVERALL_TIMEOUT_MS}ms bootstrap ceiling.`); }, AGENT_HOST_READY_OVERALL_TIMEOUT_MS); child.once('exit', (code, signal) => { if (!url) { - clearReadyTimeouts(); - urlReject?.(new Error(`${LOG_PREFIX} Agent host in '${distro}' exited (code=${code}, signal=${signal}) before printing its WebSocket URL.\nOutput: ${outputLines.join('\n')}`)); + rejectReady(new Error(`${LOG_PREFIX} Agent host in '${distro}' exited (code=${code}, signal=${signal}) before printing its WebSocket URL.\nOutput: ${outputLines.join('\n')}`)); } }); child.once('error', err => { if (!url) { - clearReadyTimeouts(); - urlReject?.(new Error(`${LOG_PREFIX} Failed to start agent host in '${distro}': ${err.message}\nOutput: ${outputLines.join('\n')}`)); + rejectReady(new Error(`${LOG_PREFIX} Failed to start agent host in '${distro}': ${err.message}\nOutput: ${outputLines.join('\n')}`)); } }); @@ -334,9 +388,12 @@ export class WSLRemoteAgentHostMainService extends Disposable implements IWSLRem resolvedUrl = await urlPromise; } catch (err) { clearReadyTimeouts(); + flushBootstrapProgress(); + bootstrapProgressDisposables.dispose(); this._killChild(child); throw err; } + bootstrapProgressDisposables.dispose(); clearReadyTimeouts(); reportProgress(localize('wslProgressConnecting', "Connecting to agent host in {0}...", distro)); diff --git a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts index 706885b378e4da..b105454c3987dd 100644 --- a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts +++ b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts @@ -338,6 +338,43 @@ suite('RootStateSubscription', () => { assert.strictEqual((sub.value as RootState).activeSessions, 0); }); + test('snapshot refresh buffers newer envelopes so a stale snapshot cannot drop them', () => { + const sub = disposables.add(new RootStateSubscription('c1', noop)); + sub.handleSnapshot(makeRootState({ activeSessions: 1 }), 10); + + // Re-subscribing an already-seated channel: the snapshot is computed at + // seq 23 but a newer action reaches the client first. + sub.beginSnapshotRefresh(); + sub.receiveEnvelope(makeEnvelope( + { type: ActionType.RootActiveSessionsChanged, activeSessions: 7 }, + 24, + )); + assert.strictEqual((sub.value as RootState).activeSessions, 1, 'newer action is held back until the snapshot lands'); + + sub.handleSnapshot(makeRootState({ activeSessions: 3 }), 23); + assert.strictEqual((sub.value as RootState).activeSessions, 7, 'newer action wins over the older snapshot'); + }); + + test('cancelling a snapshot refresh applies what it buffered', () => { + const sub = disposables.add(new RootStateSubscription('c1', noop)); + sub.handleSnapshot(makeRootState({ activeSessions: 1 }), 10); + sub.beginSnapshotRefresh(); + sub.receiveEnvelope(makeEnvelope( + { type: ActionType.RootActiveSessionsChanged, activeSessions: 7 }, + 24, + )); + + sub.cancelSnapshotRefresh(); + assert.strictEqual((sub.value as RootState).activeSessions, 7); + + // Refresh mode is off again: later envelopes apply directly. + sub.receiveEnvelope(makeEnvelope( + { type: ActionType.RootActiveSessionsChanged, activeSessions: 9 }, + 25, + )); + assert.strictEqual((sub.value as RootState).activeSessions, 9); + }); + test('setError makes value return the error', () => { const sub = disposables.add(new RootStateSubscription('c1', noop)); sub.handleSnapshot(makeRootState(), 0); 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<typeof buildDefaultChangesetCatalog>) => + 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/common/remoteAgentHostBootstrapProgress.test.ts b/src/vs/platform/agentHost/test/common/remoteAgentHostBootstrapProgress.test.ts new file mode 100644 index 00000000000000..9e8d6e0de6400f --- /dev/null +++ b/src/vs/platform/agentHost/test/common/remoteAgentHostBootstrapProgress.test.ts @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { timeout } from '../../../../base/common/async.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; +import { redactToken, RemoteAgentHostBootstrapProgressReporter, type IRemoteAgentHostBootstrapProgress } from '../../common/remoteAgentHostBootstrapProgress.js'; + +suite('RemoteAgentHostBootstrapProgressReporter', () => { + const disposables = new DisposableStore(); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('parses a server download progress line', () => { + const reporter = disposables.add(new RemoteAgentHostBootstrapProgressReporter()); + reporter.acceptLine('Downloading server: 182761536/228859480 (80%)'); + + assert.deepStrictEqual(reporter.progress.get(), { phase: 'serverDownload', percentage: 80 }); + }); + + test('ignores unrecognized output', () => { + const reporter = disposables.add(new RemoteAgentHostBootstrapProgressReporter()); + reporter.acceptLine('bootstrap shell noise'); + + assert.strictEqual(reporter.progress.get(), undefined); + }); + + test('redacts token-bearing output before producing progress', () => { + const reporter = disposables.add(new RemoteAgentHostBootstrapProgressReporter()); + reporter.acceptLine('Downloading server: 80/100 (80%)?tkn=bootstrap-token'); + + assert.deepStrictEqual({ + redacted: redactToken('Downloading server: 80/100 (80%)?tkn=bootstrap-token'), + progress: reporter.progress.get(), + }, { + redacted: 'Downloading server: 80/100 (80%)?tkn=***', + progress: { phase: 'serverDownload', percentage: 80 }, + }); + }); + + test('collapses a burst while preserving its final progress', async () => { + return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const progress: IRemoteAgentHostBootstrapProgress[] = []; + const reporter = disposables.add(new RemoteAgentHostBootstrapProgressReporter()); + disposables.add(autorun(reader => { + const update = reporter.progress.read(reader); + if (update) { + progress.push(update); + } + })); + + for (const percentage of [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]) { + reporter.acceptLine(`Downloading server: ${percentage}/100 (${percentage}%)`); + } + await timeout(250); + + assert.deepStrictEqual(progress, [ + { phase: 'serverDownload', percentage: 10 }, + { phase: 'serverDownload', percentage: 100 }, + ]); + }); + }); + + test('retains progress across interleaved output noise', async () => { + return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const progress: IRemoteAgentHostBootstrapProgress[] = []; + const reporter = disposables.add(new RemoteAgentHostBootstrapProgressReporter()); + disposables.add(autorun(reader => { + const update = reporter.progress.read(reader); + if (update) { + progress.push(update); + } + })); + + reporter.acceptLine('Downloading server: 10/100 (10%)'); + reporter.acceptLine('bootstrap shell noise'); + reporter.acceptLine('Downloading server: 20/100 (20%)'); + await timeout(250); + + assert.deepStrictEqual(progress, [ + { phase: 'serverDownload', percentage: 10 }, + { phase: 'serverDownload', percentage: 20 }, + ]); + }); + }); +}); 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..3906d696de5f08 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -26,12 +26,12 @@ import { ContentEncoding, ReconnectResultType } from '../../common/state/protoco import { ChatSourceKind } from '../../common/state/protocol/channels-chat/commands.js'; import { AhpErrorCodes, JsonRpcErrorCodes } from '../../common/state/protocol/errors.js'; import { PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } from '../../common/state/protocol/version/registry.js'; -import { ActionType, type ChatTurnStartedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionTitleChangedAction } from '../../common/state/sessionActions.js'; +import { ActionType, type ChatTurnCompleteAction, type ChatTurnStartedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionTitleChangedAction } from '../../common/state/sessionActions.js'; import { ProtocolError, type AhpServerNotification, type JsonRpcNotification, type JsonRpcRequest, type JsonRpcResponse, type ProtocolMessage } from '../../common/state/sessionProtocol.js'; import { hasKey } from '../../../../base/common/types.js'; import { mainWindow } from '../../../../base/browser/window.js'; -import { AUTOMATION_CATALOG_URI, buildChatUri, CustomizationType, MessageAttachmentKind, MessageKind, PendingMessageKind, readSessionExternal, readSessionWorkspaceless, ROOT_STATE_URI, SessionStatus, StateComponents, customizationId, withSessionExternal, withSessionWorkspaceless } from '../../common/state/sessionState.js'; -import { NonReconnectableTransportError, type IClientTransport, type IProtocolTransport } from '../../common/state/sessionTransport.js'; +import { AUTOMATION_CATALOG_URI, buildChatUri, CustomizationType, MessageAttachmentKind, MessageKind, PendingMessageKind, readSessionExternal, readSessionWorkspaceless, ROOT_STATE_URI, SessionStatus, StateComponents, TurnState, customizationId, withSessionExternal, withSessionWorkspaceless } from '../../common/state/sessionState.js'; +import { AgentHostTransportFailureReason, NonReconnectableTransportError, type IClientTransport, type IProtocolTransport } from '../../common/state/sessionTransport.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; import { ITelemetryService, TelemetryConfiguration, TelemetryLevel, TELEMETRY_SETTING_ID } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; @@ -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 }; } @@ -2258,6 +2262,40 @@ suite('AgentHostProtocolClient', () => { } } + /** + * Like {@link waitForRequestAt}, but gives up instead of spinning forever. + * Yields through the timer queue so a regression that never issues the + * request fails with a readable assertion rather than starving Mocha's + * own timeout. + */ + async function waitForRequestAtWithin(transport: TestProtocolTransport, method: string, index: number, timeoutMs = 8_000): Promise<JsonRpcRequest> { + const deadline = Date.now() + timeoutMs; + while (true) { + const requests = transport.sentMessages.filter( + (message): message is JsonRpcRequest => hasKey(message, { method: true, id: true }) && message.method === method, + ); + if (requests[index]) { + return requests[index]; + } + if (Date.now() > deadline) { + const sent = transport.sentMessages.map(m => hasKey(m, { method: true }) ? m.method : 'response').join(', '); + throw new Error(`Timed out waiting for '${method}' request #${index}; saw ${requests.length}. Sent: [${sent}]`); + } + await new Promise<void>(r => setTimeout(r, 5)); + } + } + + /** Wait for the client to reach {@link AgentHostClientState.Connected}, bounded. */ + async function waitForConnectedWithin(client: AgentHostProtocolClient, timeoutMs = 8_000): Promise<void> { + const deadline = Date.now() + timeoutMs; + while (client.connectionState !== AgentHostClientState.Connected) { + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for Connected; state is ${client.connectionState}`); + } + await new Promise<void>(r => setTimeout(r, 5)); + } + } + /** Wait for the next time the new transport is created by the factory. */ async function waitForTransport(transports: TestClientProtocolTransport[], index: number): Promise<TestClientProtocolTransport> { while (transports.length <= index) { @@ -2272,7 +2310,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 +2318,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 }; } @@ -2336,6 +2374,7 @@ suite('AgentHostProtocolClient', () => { test('does not retry a non-reconnectable initial transport failure', async () => { const { client, transports } = createFactoryClient(); const fatalErrors: string[] = []; + const closeReason = Event.toPromise(client.onDidClose); disposables.add(client.onDidFatalClose(error => fatalErrors.push(error.message))); const connectPromise = client.connect(); transports[0].connectDeferred.error(new NonReconnectableTransportError('terminal failure')); @@ -2346,10 +2385,29 @@ suite('AgentHostProtocolClient', () => { state: client.connectionState, transportCount: transports.length, fatalErrors, + closeReason: await closeReason, }, { state: AgentHostClientState.Closed, transportCount: 1, fatalErrors: ['terminal failure'], + closeReason: AgentHostTransportFailureReason.Unknown, + }); + }); + + test('reports a host-not-running terminal transport failure when it closes', async () => { + const { client, transports } = createFactoryClient(); + const closeReason = Event.toPromise(client.onDidClose); + const connectPromise = client.connect(); + transports[0].connectDeferred.error(new NonReconnectableTransportError('WSL distro is not running.', AgentHostTransportFailureReason.HostNotRunning)); + + await assert.rejects(connectPromise, /not running/); + + assert.deepStrictEqual({ + state: client.connectionState, + closeReason: await closeReason, + }, { + state: AgentHostClientState.Closed, + closeReason: AgentHostTransportFailureReason.HostNotRunning, }); }); @@ -2419,6 +2477,74 @@ suite('AgentHostProtocolClient', () => { }); }); + test('reports the deadline for each scheduled reconnect backoff', async function () { + this.timeout(10_000); + return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const reconnectPolicy: IRemoteAgentHostReconnectPolicy = { + autoRestore: true, + initialDelayMs: 60_000, + maxDelayMs: 60_000, + maxAttempts: 3, + }; + const { client, transports } = createFactoryClient(createPermissionService(), undefined, NullTelemetryService, reconnectPolicy); + const connectPromise = client.connect(); + await completeHandshake(transports[0], connectPromise); + const reconnectDeadlines: (number | undefined)[] = []; + // The client stays `reconnecting` across rounds, so the schedule + // event — not the state event — reports each new deadline. + const stateListener = client.onDidScheduleReconnect(() => { + reconnectDeadlines.push(client.nextReconnectAt); + }); + + transports[0].fireClose(); + const firstDeadline = client.nextReconnectAt; + assert.ok(firstDeadline !== undefined); + await timeout(reconnectPolicy.initialDelayMs); + transports[1].connectDeferred.error(new Error('reconnect failed')); + await flushMicrotasks(); + const secondDeadline = client.nextReconnectAt; + assert.ok(secondDeadline !== undefined); + + assert.deepStrictEqual(reconnectDeadlines, [firstDeadline, secondDeadline]); + stateListener.dispose(); + client.dispose(); + }); + }); + + test('reconnectNow clears a pending backoff and retries immediately', async function () { + this.timeout(10_000); + return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const reconnectPolicy: IRemoteAgentHostReconnectPolicy = { + autoRestore: true, + initialDelayMs: 60_000, + maxDelayMs: 60_000, + maxAttempts: 3, + }; + const { client, transports } = createFactoryClient(createPermissionService(), undefined, NullTelemetryService, reconnectPolicy); + const connectPromise = client.connect(); + await completeHandshake(transports[0], connectPromise); + + transports[0].fireClose(); + assert.strictEqual(client.reconnectNow(), true); + await timeout(reconnectPolicy.initialDelayMs - 1); + + assert.deepStrictEqual({ + nextReconnectAt: client.nextReconnectAt, + transportCount: transports.length, + }, { + nextReconnectAt: undefined, + transportCount: 2, + }); + client.dispose(); + }); + }); + + test('reconnectNow returns false when no reconnect backoff is pending', () => { + const { client } = createFactoryClient(); + + assert.strictEqual(client.reconnectNow(), false); + }); + test('does not automatically reconnect when the policy disables automatic restore', async () => { const reconnectPolicy: IRemoteAgentHostReconnectPolicy = { autoRestore: false, @@ -2714,6 +2840,302 @@ suite('AgentHostProtocolClient', () => { client.dispose(); }); + test('restores subscriptions when a cached resource authentication is rejected after a host restart', async function () { + this.timeout(20_000); + const { client, transports } = createFactoryClient(); + const sessionUri = URI.parse('codex:/stuck-session'); + const connectPromise = client.connect(); + await completeHandshake(transports[0], connectPromise); + + const sessionRef = client.getSubscription(StateComponents.Session, sessionUri, 'test'); + const initialSubscribe = await waitForRequest(transports[0], 'subscribe'); + transports[0].fireMessage({ + jsonrpc: '2.0', id: initialSubscribe.id, + result: { snapshot: { resource: sessionUri.toString(), state: { lifecycle: 'ready' }, fromSeq: 5 } }, + }); + const authentication = client.authenticate({ resource: 'https://mcp.example.com', token: 'token' }); + const initialAuthenticate = await waitForRequest(transports[0], 'authenticate'); + transports[0].fireMessage({ jsonrpc: '2.0', id: initialAuthenticate.id, result: {} }); + await authentication; + await flushMicrotasks(); + + transports[0].fireClose(); + await waitForReconnecting(client); + const reconnectTransport = await waitForTransport(transports, 1); + reconnectTransport.connectDeferred.complete(); + const reconnect = await waitForRequest(reconnectTransport, 'reconnect'); + reconnectTransport.fireMessage({ + jsonrpc: '2.0', id: reconnect.id, + error: { code: AhpErrorCodes.NotFound, message: 'Reconnect client not found' }, + }); + const initialize = await waitForRequest(reconnectTransport, 'initialize'); + reconnectTransport.fireMessage({ + jsonrpc: '2.0', id: initialize.id, + result: { + protocolVersion: PROTOCOL_VERSION, + serverSeq: 0, + snapshots: [{ resource: ROOT_STATE_URI, state: { agents: [], activeSessions: 0 }, fromSeq: 0 }], + }, + }); + + // The restarted host no longer accepts this cached third-party token. + // That must not abort the restart recovery: the session channel still + // holds pre-restart state and would otherwise never be reseated. + const restoredAuthenticate = await waitForRequestAt(reconnectTransport, 'authenticate', 0); + reconnectTransport.fireMessage({ + jsonrpc: '2.0', id: restoredAuthenticate.id, + error: { code: AhpErrorCodes.AuthRequired, message: 'Authentication failed for resource: https://mcp.example.com' }, + }); + + const restoredSubscribe = await waitForRequestAtWithin(reconnectTransport, 'subscribe', 0); + reconnectTransport.fireMessage({ + jsonrpc: '2.0', id: restoredSubscribe.id, + result: { snapshot: { resource: sessionUri.toString(), state: { lifecycle: 'ready' }, fromSeq: 3 } }, + }); + await waitForConnectedWithin(client); + + assert.deepStrictEqual({ + channel: (restoredSubscribe.params as { channel: string }).channel, + state: client.connectionState, + }, { + channel: sessionUri.toString(), + state: AgentHostClientState.Connected, + }); + + sessionRef.dispose(); + client.dispose(); + }); + + test('finishes an interrupted post-restart subscription restore on the next reconnect', async function () { + this.timeout(20_000); + const { client, transports } = createFactoryClient(); + const sessionUri = URI.parse('codex:/stuck-session'); + const chatUri = URI.parse('ahp-chat://default/stuck-session'); + const connectPromise = client.connect(); + await completeHandshake(transports[0], connectPromise); + + const sessionRef = client.getSubscription(StateComponents.Session, sessionUri, 'test'); + const initialSessionSubscribe = await waitForRequestAt(transports[0], 'subscribe', 0); + transports[0].fireMessage({ + jsonrpc: '2.0', id: initialSessionSubscribe.id, + result: { snapshot: { resource: sessionUri.toString(), state: { lifecycle: 'ready' }, fromSeq: 5 } }, + }); + const chatRef = client.getSubscription(StateComponents.Chat, chatUri, 'test'); + const initialChatSubscribe = await waitForRequestAt(transports[0], 'subscribe', 1); + transports[0].fireMessage({ + jsonrpc: '2.0', id: initialChatSubscribe.id, + result: { snapshot: { resource: chatUri.toString(), state: { turns: [] }, fromSeq: 5 } }, + }); + await flushMicrotasks(); + + transports[0].fireClose(); + await waitForReconnecting(client); + const reconnectTransport = await waitForTransport(transports, 1); + reconnectTransport.connectDeferred.complete(); + const reconnect = await waitForRequest(reconnectTransport, 'reconnect'); + reconnectTransport.fireMessage({ + jsonrpc: '2.0', id: reconnect.id, + error: { code: AhpErrorCodes.NotFound, message: 'Reconnect client not found' }, + }); + const initialize = await waitForRequest(reconnectTransport, 'initialize'); + reconnectTransport.fireMessage({ + jsonrpc: '2.0', id: initialize.id, + result: { + protocolVersion: PROTOCOL_VERSION, + serverSeq: 22, + snapshots: [{ resource: ROOT_STATE_URI, state: { agents: [], activeSessions: 0 }, fromSeq: 22 }], + }, + }); + + // The session is reseated, then the transport drops before the chat + // channel gets its snapshot. + const restoredSessionSubscribe = await waitForRequestAtWithin(reconnectTransport, 'subscribe', 0); + reconnectTransport.fireMessage({ + jsonrpc: '2.0', id: restoredSessionSubscribe.id, + result: { snapshot: { resource: sessionUri.toString(), state: { lifecycle: 'ready' }, fromSeq: 23 } }, + }); + await flushMicrotasks(); + reconnectTransport.fireClose(); + await waitForReconnecting(client); + + // The host now remembers the client, so this reconnect resolves to a + // replay — which carries no snapshot for the chat channel. + const secondTransport = await waitForTransport(transports, 2); + secondTransport.connectDeferred.complete(); + const secondReconnect = await waitForRequest(secondTransport, 'reconnect'); + secondTransport.fireMessage({ + jsonrpc: '2.0', id: secondReconnect.id, + result: { type: ReconnectResultType.Replay, actions: [], missing: [] }, + }); + + const restoredChatSubscribe = await waitForRequestAtWithin(secondTransport, 'subscribe', 0); + secondTransport.fireMessage({ + jsonrpc: '2.0', id: restoredChatSubscribe.id, + result: { snapshot: { resource: chatUri.toString(), state: { turns: [] }, fromSeq: 40 } }, + }); + await waitForConnectedWithin(client); + + assert.deepStrictEqual({ + resubscribedAfterReplay: (restoredChatSubscribe.params as { channel: string }).channel, + sessionResubscribedAgain: secondTransport.sentMessages.filter( + message => hasKey(message, { method: true }) && message.method === 'subscribe' + && (message.params as { channel: string }).channel === sessionUri.toString(), + ).length, + state: client.connectionState, + }, { + resubscribedAfterReplay: chatUri.toString(), + sessionResubscribedAgain: 0, + state: AgentHostClientState.Connected, + }); + + chatRef.dispose(); + sessionRef.dispose(); + client.dispose(); + }); + + test('keeps an action that arrives before the post-restart restore snapshot', async function () { + this.timeout(20_000); + const { client, transports } = createFactoryClient(); + const chatUri = URI.parse('ahp-chat://default/racing-session'); + const connectPromise = client.connect(); + await completeHandshake(transports[0], connectPromise); + + const chatRef = client.getSubscription<{ turns: { id: string; state?: TurnState }[] }>(StateComponents.Chat, chatUri, 'test'); + const initialSubscribe = await waitForRequest(transports[0], 'subscribe'); + transports[0].fireMessage({ + jsonrpc: '2.0', id: initialSubscribe.id, + result: { snapshot: { resource: chatUri.toString(), state: { turns: [] }, fromSeq: 5 } }, + }); + await flushMicrotasks(); + + transports[0].fireClose(); + await waitForReconnecting(client); + const reconnectTransport = await waitForTransport(transports, 1); + reconnectTransport.connectDeferred.complete(); + const reconnect = await waitForRequest(reconnectTransport, 'reconnect'); + reconnectTransport.fireMessage({ + jsonrpc: '2.0', id: reconnect.id, + error: { code: AhpErrorCodes.NotFound, message: 'Reconnect client not found' }, + }); + const initialize = await waitForRequest(reconnectTransport, 'initialize'); + reconnectTransport.fireMessage({ + jsonrpc: '2.0', id: initialize.id, + result: { + protocolVersion: PROTOCOL_VERSION, + serverSeq: 22, + snapshots: [{ resource: ROOT_STATE_URI, state: { agents: [], activeSessions: 0 }, fromSeq: 22 }], + }, + }); + + // The channel is already live server-side, so an action newer than + // the snapshot being computed can reach the client first. It must + // survive the older snapshot that follows. + const restoreSubscribe = await waitForRequestAtWithin(reconnectTransport, 'subscribe', 0); + const completion: ChatTurnCompleteAction = { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 10 }; + reconnectTransport.fireMessage({ + jsonrpc: '2.0', + method: 'action', + params: { + channel: chatUri.toString(), + action: completion, + serverSeq: 24, + origin: undefined, + }, + }); + reconnectTransport.fireMessage({ + jsonrpc: '2.0', id: restoreSubscribe.id, + result: { + snapshot: { + resource: chatUri.toString(), + // An in-flight turn lives in `activeTurn`; this is exactly the + // stale shape that left the UI spinning forever. + state: { + turns: [], + activeTurn: { + id: 'turn-1', + startedAt: '2026-09-02T00:00:00.000Z', + message: { text: 'hi', origin: { kind: MessageKind.User } }, + responseParts: [], + usage: undefined, + }, + }, + fromSeq: 23, + }, + }, + }); + await waitForConnectedWithin(client); + + const value = chatRef.object.value as { turns: { id: string; state?: TurnState }[]; activeTurn?: { id: string } }; + assert.deepStrictEqual({ + turnStates: value.turns.map(turn => turn.state), + stillActive: value.activeTurn?.id, + }, { + turnStates: [TurnState.Complete], + stillActive: undefined, + }); + + chatRef.dispose(); + client.dispose(); + }); + + test('finishes an interrupted authentication restore on the next reconnect', async function () { + this.timeout(20_000); + const { client, transports } = createFactoryClient(); + const connectPromise = client.connect(); + await completeHandshake(transports[0], connectPromise); + + const authentication = client.authenticate({ resource: 'https://sandbox.example.com', token: 'sealed' }); + const initialAuthenticate = await waitForRequest(transports[0], 'authenticate'); + transports[0].fireMessage({ jsonrpc: '2.0', id: initialAuthenticate.id, result: {} }); + await authentication; + await flushMicrotasks(); + + transports[0].fireClose(); + await waitForReconnecting(client); + const reconnectTransport = await waitForTransport(transports, 1); + reconnectTransport.connectDeferred.complete(); + const reconnect = await waitForRequest(reconnectTransport, 'reconnect'); + reconnectTransport.fireMessage({ + jsonrpc: '2.0', id: reconnect.id, + error: { code: AhpErrorCodes.NotFound, message: 'Reconnect client not found' }, + }); + const initialize = await waitForRequest(reconnectTransport, 'initialize'); + reconnectTransport.fireMessage({ + jsonrpc: '2.0', id: initialize.id, + result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 0, snapshots: [] }, + }); + + // The transport dies after the authenticate frame goes out but before + // the host answers, so the credential may never have landed. + await waitForRequestAtWithin(reconnectTransport, 'authenticate', 0); + reconnectTransport.fireClose(); + await waitForReconnecting(client); + + // The host remembers the client now, so this reconnect is a replay — + // which historically never revisited authentication. + const secondTransport = await waitForTransport(transports, 2); + secondTransport.connectDeferred.complete(); + const secondReconnect = await waitForRequest(secondTransport, 'reconnect'); + secondTransport.fireMessage({ + jsonrpc: '2.0', id: secondReconnect.id, + result: { type: ReconnectResultType.Replay, actions: [], missing: [] }, + }); + + const retriedAuthenticate = await waitForRequestAtWithin(secondTransport, 'authenticate', 0); + secondTransport.fireMessage({ jsonrpc: '2.0', id: retriedAuthenticate.id, result: {} }); + await waitForConnectedWithin(client); + + assert.deepStrictEqual({ + resource: (retriedAuthenticate.params as { resource: string }).resource, + state: client.connectionState, + }, { + resource: 'https://sandbox.example.com', + state: AgentHostClientState.Connected, + }); + + client.dispose(); + }); + test('marks an Automation catalogue subscription missing when restore fails', async function () { this.timeout(10_000); const { client, transports } = createFactoryClient(); @@ -3306,7 +3728,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 +3749,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/electron-browser/remoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts index 5d87f7057654e2..29be6db51e0e22 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts @@ -26,6 +26,7 @@ import type { Implementation } from '../../common/state/protocol/common/commands import { agentsWindowAgentHostClientInfo, editorWindowAgentHostClientInfo } from '../../common/agentHostClientInfo.js'; import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; import { computeReconnectDelay } from '../../common/reconnectPolicy.js'; +import { AgentHostTransportFailureReason, NonReconnectableTransportError } from '../../common/state/sessionTransport.js'; interface IRemoteAgentHostServiceTestAccess { readonly _reconnectAttempts: Map<string, number>; @@ -51,17 +52,22 @@ class MockProtocolClient extends Disposable { private static _nextId = 1; readonly clientId = `mock-client-${MockProtocolClient._nextId++}`; - private readonly _onDidClose = this._register(new Emitter<void>()); + private readonly _onDidClose = this._register(new Emitter<AgentHostTransportFailureReason | undefined>()); readonly onDidClose = this._onDidClose.event; readonly onDidAction = Event.None; readonly onDidNotification = Event.None; private readonly _onDidChangeConnectionState = this._register(new Emitter<string>()); readonly onDidChangeConnectionState = this._onDidChangeConnectionState.event; + private readonly _onDidScheduleReconnect = this._register(new Emitter<void>()); + readonly onDidScheduleReconnect = this._onDidScheduleReconnect.event; readonly onDidReceiveOtlpLogs = Event.None; readonly connectionState = 'connecting' as const; readonly initializeResult = undefined; readonly telemetryCapabilities = undefined; readonly triggerVscodeUpgradeCalls: string[] = []; + nextReconnectAt: number | undefined; + reconnectNowCalls = 0; + reconnectNowResult = false; public connectDeferred = new DeferredPromise<void>(); @@ -73,18 +79,27 @@ class MockProtocolClient extends Disposable { return this.connectDeferred.p; } + reconnectNow(): boolean { + this.reconnectNowCalls++; + return this.reconnectNowResult; + } + async triggerVscodeUpgrade(method: string) { this.triggerVscodeUpgradeCalls.push(method); return { ok: true, upgradeStarted: true }; } - fireClose(): void { - this._onDidClose.fire(); + fireClose(reason?: AgentHostTransportFailureReason): void { + this._onDidClose.fire(reason); } fireConnectionState(state: 'connecting' | 'reconnecting' | 'connected' | 'incompatible' | 'closed'): void { this._onDidChangeConnectionState.fire(state); } + + fireScheduleReconnect(): void { + this._onDidScheduleReconnect.fire(); + } } class TestConnectionFactory extends Disposable implements IRemoteAgentHostConnectionFactory { @@ -92,6 +107,7 @@ class TestConnectionFactory extends Disposable implements IRemoteAgentHostConnec private readonly _entries = observableValue<readonly IRemoteAgentHostEntry[]>(this, []); private readonly _createdConnections = new Map<string, IRemoteAgentHostCreatedConnection[]>(); + private readonly _failures = new Map<string, Error[]>(); private readonly _onDidCreateConnection = this._register(new Emitter<void>()); readonly onDidCreateConnection = this._onDidCreateConnection.event; createdConnectionCount = 0; @@ -113,11 +129,22 @@ class TestConnectionFactory extends Disposable implements IRemoteAgentHostConnec this._entries.set([...this._entries.get(), entry], undefined); } + /** Stages a factory-level rejection, as a failed precondition check would produce. */ + stageFailure(entry: IRemoteAgentHostEntry, error: Error): void { + const address = getEntryAddress(entry); + this._failures.set(address, [...(this._failures.get(address) ?? []), error]); + this._entries.set([...this._entries.get(), entry], undefined); + } + createConnection(entry: IRemoteAgentHostEntry): Promise<IRemoteAgentHostCreatedConnection> { if (entry.connection.type !== this.kind) { return Promise.reject(new Error(`Test factory cannot create a ${entry.connection.type} connection.`)); } const address = getEntryAddress(entry); + const failure = this._failures.get(address)?.shift(); + if (failure) { + return Promise.reject(failure); + } const connection = this._createdConnections.get(address)?.shift(); if (!connection) { return Promise.reject(new Error(`No test connection staged for ${address}.`)); @@ -406,10 +433,10 @@ suite('RemoteAgentHostService', () => { assert.strictEqual(service.getConnection('ws://host1:8080'), undefined); const entry = service.connections.find(c => c.address === 'host1:8080'); assert.ok(entry); - assert.strictEqual(entry.status, RemoteAgentHostConnectionStatus.disconnected); + assert.deepStrictEqual(entry.status, RemoteAgentHostConnectionStatus.disconnected); }); - test('removes connection on connect failure', async () => { + test('retains an unknown disconnected entry on connect failure', async () => { configService.setEntries([{ name: 'Bad', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://bad:9999' } }]); await waitForCreatedClients(1); assert.strictEqual(createdClients.length, 1); @@ -419,8 +446,15 @@ suite('RemoteAgentHostService', () => { createdClients[0].connectDeferred.error(new Error('Connection refused')); await connectionChanged; - assert.strictEqual(service.connections.length, 0); - assert.strictEqual(service.getConnection('ws://bad:9999'), undefined); + assert.deepStrictEqual({ + connection: service.getConnection('ws://bad:9999'), + clientId: service.connections.find(connection => connection.address === 'bad:9999')?.clientId, + status: service.connections.find(connection => connection.address === 'bad:9999')?.status, + }, { + connection: undefined, + clientId: undefined, + status: RemoteAgentHostConnectionStatus.disconnected, + }); }); test('manages multiple connections independently', async () => { @@ -801,6 +835,85 @@ suite('RemoteAgentHostService', () => { await userWait; }); + test('surfaces a protocol reconnect backoff deadline', async () => { + const factory = createFactory(); + const entry = cloudSandboxEntry('Cloud Sandbox', 'cloud:backoff'); + const client = new MockProtocolClient('cloud:backoff'); + await reconnectStagedConnection(factory, entry, client); + + client.nextReconnectAt = 123_456; + client.fireConnectionState('reconnecting'); + + assert.deepStrictEqual( + service.connections.find(connection => connection.address === 'cloud:backoff')?.status, + RemoteAgentHostConnectionStatus.reconnectingUntil(123_456), + ); + }); + + test('refreshes the backoff deadline as the client reschedules', async () => { + const factory = createFactory(); + const entry = cloudSandboxEntry('Cloud Sandbox', 'cloud:backoff-refresh'); + const client = new MockProtocolClient('cloud:backoff-refresh'); + await reconnectStagedConnection(factory, entry, client); + + client.nextReconnectAt = 1_000; + client.fireConnectionState('reconnecting'); + const armed = service.connections.find(connection => connection.address === 'cloud:backoff-refresh')?.status; + + // The client stays `reconnecting` across rounds, so only the schedule + // event reports the new deadline. + client.nextReconnectAt = 5_000; + client.fireScheduleReconnect(); + + assert.deepStrictEqual({ + armed, + rescheduled: service.connections.find(connection => connection.address === 'cloud:backoff-refresh')?.status, + }, { + armed: RemoteAgentHostConnectionStatus.reconnectingUntil(1_000), + rescheduled: RemoteAgentHostConnectionStatus.reconnectingUntil(5_000), + }); + }); + + test('prefers a protocol client reconnect over a fresh dial', async () => { + const factory = createFactory(); + const entry = cloudSandboxEntry('Cloud Sandbox', 'cloud:in-place-reconnect'); + const client = new MockProtocolClient('cloud:in-place-reconnect'); + await reconnectStagedConnection(factory, entry, client); + client.reconnectNowResult = true; + + service.reconnectNow('cloud:in-place-reconnect'); + + assert.deepStrictEqual({ + createdConnectionCount: factory.createdConnectionCount, + reconnectNowCalls: client.reconnectNowCalls, + }, { + createdConnectionCount: 1, + reconnectNowCalls: 1, + }); + }); + + test('falls back to a fresh dial when a retained entry has no client', async () => { + const factory = createFactory(RemoteAgentHostEntryType.WSL); + const entry: IRemoteAgentHostEntry = { + name: 'Ubuntu', + connection: { type: RemoteAgentHostEntryType.WSL, address: 'wsl:Ubuntu', distro: 'Ubuntu' }, + }; + const address = getEntryAddress(entry); + factory.stageFailure(entry, new NonReconnectableTransportError('WSL distro is not running.', AgentHostTransportFailureReason.HostNotRunning)); + while (service.connections.find(connection => connection.address === address)?.status.kind !== 'disconnected') { + await Event.toPromise(service.onDidChangeConnections); + } + + const client = new MockProtocolClient(address); + factory.stage(entry, client); + service.reconnectNow(address); + await waitForFactoryConnection(factory, 1); + client.connectDeferred.complete(); + await waitForConnected(); + + assert.strictEqual(factory.createdConnectionCount, 1); + }); + test('keeps an incompatible factory connection addressable for server upgrade', async () => { const factory = createFactory(); const entry = cloudSandboxEntry('Cloud Sandbox', 'cloud:incompatible'); @@ -829,6 +942,100 @@ suite('RemoteAgentHostService', () => { }); }); + test('retains a client-less disconnected entry when the factory rejects before a client exists', async () => { + const factory = createFactory(RemoteAgentHostEntryType.WSL); + const entry: IRemoteAgentHostEntry = { + name: 'Ubuntu', + connection: { type: RemoteAgentHostEntryType.WSL, address: 'wsl:Ubuntu', distro: 'Ubuntu' }, + }; + const address = getEntryAddress(entry); + + // A stopped distro is rejected by the factory's precondition check before + // any protocol client exists — the path a real WSL outage actually takes. + // The status must survive with no client, or the UI cannot tell a + // resolvable outage from a generic one and offers no recovery action. + factory.stageFailure(entry, new NonReconnectableTransportError(`WSL distro 'Ubuntu' is not running.`, AgentHostTransportFailureReason.HostNotRunning)); + while (service.connections.find(connection => connection.address === address)?.status.kind !== 'disconnected') { + await Event.toPromise(service.onDidChangeConnections); + } + + const info = service.connections.find(connection => connection.address === address); + assert.deepStrictEqual({ + connection: service.getConnection(address), + clientId: info?.clientId, + status: info?.status, + }, { + connection: undefined, + clientId: undefined, + status: RemoteAgentHostConnectionStatus.disconnectedBecause(AgentHostTransportFailureReason.HostNotRunning), + }); + }); + + test('starts a fresh dial when a reconnect is requested from the failure notification', async () => { + const factory = createFactory(RemoteAgentHostEntryType.WSL); + const entry: IRemoteAgentHostEntry = { + name: 'Ubuntu', + connection: { type: RemoteAgentHostEntryType.WSL, address: 'wsl:Ubuntu', distro: 'Ubuntu' }, + }; + const address = getEntryAddress(entry); + const client = new MockProtocolClient(address); + + // An automatic start reacts to this notification synchronously, while + // the failing dial is still on the stack. If its in-flight marker were + // still set, `reconnect` would join that dead dial instead of opening a + // new one, so the host would never come up and the wait never settle. + const listener = disposables.add(service.onDidChangeConnections(() => { + if (service.connections.find(connection => connection.address === address)?.status.kind !== 'disconnected') { + return; + } + listener.dispose(); + factory.stage(entry, client); + service.reconnect(address, true); + })); + + factory.stageFailure(entry, new NonReconnectableTransportError(`WSL distro 'Ubuntu' is not running.`, AgentHostTransportFailureReason.HostNotRunning)); + // Waiting only once the fresh dial exists: waiters registered earlier are + // rejected by the failure itself, which is not what this pins. + await waitForFactoryConnection(factory, 1); + client.connectDeferred.complete(); + const info = await service.waitForConnection(address); + + assert.deepStrictEqual({ + createdConnectionCount: factory.createdConnectionCount, + status: info.status, + }, { + createdConnectionCount: 1, + status: RemoteAgentHostConnectionStatus.connected, + }); + }); + + test('surfaces a stopped WSL distro as a host-not-running disconnect and clears the reason once it reconnects', async () => { + const factory = createFactory(RemoteAgentHostEntryType.WSL); + const entry: IRemoteAgentHostEntry = { + name: 'Ubuntu', + connection: { type: RemoteAgentHostEntryType.WSL, address: 'wsl:Ubuntu', distro: 'Ubuntu' }, + }; + const client = new MockProtocolClient('wsl:Ubuntu'); + await reconnectStagedConnection(factory, entry, client); + + const changed = Event.toPromise(service.onDidChangeConnections); + client.fireClose(AgentHostTransportFailureReason.HostNotRunning); + await changed; + + const afterClose = service.connections.find(connection => connection.address === 'wsl:Ubuntu')?.status; + + // A host that comes back must stop claiming it is not running. + await reconnectStagedConnection(factory, entry, new MockProtocolClient('wsl:Ubuntu')); + + assert.deepStrictEqual({ + afterClose, + statusAfterReconnect: service.connections.find(connection => connection.address === 'wsl:Ubuntu')?.status, + }, { + afterClose: RemoteAgentHostConnectionStatus.disconnectedBecause(AgentHostTransportFailureReason.HostNotRunning), + statusAfterReconnect: RemoteAgentHostConnectionStatus.connected, + }); + }); + test('disposes transportDisposable when entry is removed via removeRemoteAgentHost', async () => { const factory = createFactory(); const t = makeTransportDisposable(); 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/agentHostShutdown.test.ts b/src/vs/platform/agentHost/test/node/agentHostShutdown.test.ts index 8662c617868c2f..772a52675f5f7c 100644 --- a/src/vs/platform/agentHost/test/node/agentHostShutdown.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostShutdown.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; -import { flushAgentHostPersistenceBeforeShutdown } from '../../node/agentHostShutdown.js'; +import { flushAgentHostPersistenceBeforeShutdown, shutdownAgentHostBeforeDispose } from '../../node/agentHostShutdown.js'; suite('AgentHostShutdown', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -18,4 +18,42 @@ suite('AgentHostShutdown', () => { new NullLogService(), )); }); + + test('providers shut down before persistence is flushed', async () => { + const steps: string[] = []; + + await shutdownAgentHostBeforeDispose( + async () => { + steps.push('protocol drain'); + }, + async () => { + steps.push('provider shutdown'); + }, + () => { + steps.push('persistence flush'); + return [Promise.resolve()]; + }, + 3000, + new NullLogService(), + ); + + assert.deepStrictEqual(steps, ['protocol drain', 'provider shutdown', 'persistence flush']); + }); + + test('a failed provider shutdown still flushes persistence', async () => { + let persistenceFlushed = false; + + await shutdownAgentHostBeforeDispose( + () => Promise.resolve(), + () => Promise.reject(new Error('provider unavailable')), + () => { + persistenceFlushed = true; + return [Promise.resolve()]; + }, + 3000, + new NullLogService(), + ); + + assert.strictEqual(persistenceFlushed, true); + }); }); 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 433c7638bb1579..9a87891adb2b81 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'; @@ -3488,6 +3488,34 @@ suite('AgentService (node dispatcher)', () => { })), [{ session: external.toString(), external: true, source: 'discovery' }]); }); + test('discovery keeps a host-created session internal when the provider reports it as external', async () => { + const sessionData = createPerSessionDataService(); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MockAgent('copilot')); + registerTestAgentProvider(svc, agent); + await svc.listSessions(); + const hostCreated = AgentSession.uri('copilot', 'host-created'); + const genuineExternal = AgentSession.uri('copilot', 'genuine-external'); + await sessionData.database(hostCreated).setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'false'); + + await (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise<boolean> })._registerDiscoveredChats(agent, [ + discoveredChat(hostCreated), + discoveredChat(genuineExternal), + ]); + + assert.deepStrictEqual( + (await (svc as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry.list()).map(entry => ({ + session: entry.session.toString(), + external: entry.external, + source: entry.source, + })).sort((a, b) => a.session.localeCompare(b.session)), + [ + { session: genuineExternal.toString(), external: true, source: 'discovery' }, + { session: hostCreated.toString(), external: false, source: 'restore' }, + ].sort((a, b) => a.session.localeCompare(b.session)), + ); + }); + test('rediscovery advances recency without overwriting durable unread state for an existing external session', async () => { const db = new TestSessionDatabase(); const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); @@ -4473,28 +4501,38 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ externalCalls: agent.externalCalls, legacyCalls: agent.legacyCalls }, { externalCalls: 1, legacyCalls: 1 }); }); - test('one invalid discovered chat does not block sibling registration', async () => { - const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - const agent = disposables.add(new MockAgent('copilot')); - registerTestAgentProvider(svc, agent); - await svc.listSessions(); + test('a failed discovered-chat database lookup is skipped and can be retried', async () => { const invalid = AgentSession.uri('copilot', 'invalid-discovered'); const valid = AgentSession.uri('copilot', 'valid-discovered'); - const internals = svc as unknown as { _isChatBacking(session: URI): Promise<boolean> }; - const originalIsChatBacking = internals._isChatBacking.bind(svc); - internals._isChatBacking = async session => { - if (session.toString() === invalid.toString()) { - throw new Error('invalid backing'); - } - return originalIsChatBacking(session); + const sessionData = createPerSessionDataService(); + let failInvalid = true; + const sessionDataService: ISessionDataService = { + ...sessionData.service, + tryOpenDatabase: async session => { + if (failInvalid && session.toString() === invalid.toString()) { + throw new Error('database stat failed'); + } + return sessionData.service.tryOpenDatabase(session); + }, }; + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MockAgent('copilot')); + registerTestAgentProvider(svc, agent); + await svc.listSessions(); + const register = (chats: readonly IAgentDiscoveredChat[]) => (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise<boolean> })._registerDiscoveredChats(agent, chats); - agent.fireDiscoveredChats([discoveredChat(invalid), discoveredChat(valid)]); - for (let i = 0; i < 50 && (await svc.getRegisteredSessions()).length === 0; i++) { - await timeout(0); - } + await register([discoveredChat(invalid), discoveredChat(valid)]); + const afterFailure = (await svc.getRegisteredSessions()).map(session => session.toString()); + failInvalid = false; + await register([discoveredChat(invalid)]); - assert.deepStrictEqual((await svc.getRegisteredSessions()).map(session => session.toString()), [valid.toString()]); + assert.deepStrictEqual({ + afterFailure, + afterRetry: (await svc.getRegisteredSessions()).map(session => session.toString()).sort(), + }, { + afterFailure: [valid.toString()], + afterRetry: [invalid.toString(), valid.toString()].sort(), + }); }); test('failed discovery announcement releases its deduplication reservation', async () => { @@ -5845,7 +5883,7 @@ suite('AgentService (node dispatcher)', () => { const sessions = await svc.listSessions(); assert.strictEqual(sessions.length, 1); - assert.deepStrictEqual(sessions[0]._meta, { 'vscode.external': true, workspaceless: true }); + assert.deepStrictEqual(sessions[0]._meta, { workspaceless: true }); }); test('listSessions overlays the adopted-legacy marker so a migrated session keeps its legacy listing', async () => { @@ -15166,7 +15204,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(); @@ -15176,6 +15216,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 @@ -15187,7 +15228,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/artifactServerTools.test.ts b/src/vs/platform/agentHost/test/node/artifactServerTools.test.ts index 3a48e0270d5a7c..49c65b2a1777a9 100644 --- a/src/vs/platform/agentHost/test/node/artifactServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/artifactServerTools.test.ts @@ -54,6 +54,27 @@ suite('Artifact Server Tools', () => { }); }); + test('classifies worked-on issues and pull requests as artifacts', () => { + const addDefinition = artifactServerToolDefinitions.find(definition => definition.name === ArtifactServerToolName.AddArtifactOrReference); + const sessionClassification = 'An issue or pull request this session works on is an artifact even if the session did not create it'; + const instructionClassification = 'An issue or pull request you work on is an artifact even if you did not create it'; + + assert.deepStrictEqual({ + definition: addDefinition?.description?.includes(sessionClassification), + input: addDefinition?.inputSchema?.properties?.isArtifact, + instruction: ARTIFACT_TOOLS_INSTRUCTION.includes(instructionClassification), + reference: ARTIFACT_TOOLS_INSTRUCTION.includes('something you did not produce but the user should look at because of this task'), + }, { + definition: true, + input: { + type: 'boolean', + description: 'Required. `true` for an artifact. An issue or pull request this session works on is an artifact even if the session did not create it; other artifacts are things the session produced, such as a plan file it wrote outside the workspace or another side effect of its work. `false` for a reference — something the session did not produce but the user should look at because of this task, such as the pull request or commit that introduced a bug, or a website that matters for the task.', + }, + instruction: true, + reference: true, + }); + }); + test('rejects session-management links during execution', async () => { const sessionUri = 'copilot:/caller'; const stateManager = store.add(new AgentHostStateManager(new NullLogService())); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts index cc380595963168..d1a035c5738382 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts @@ -548,6 +548,7 @@ class RoundTripQuery implements AsyncGenerator<SDKMessage, void> { setModel(): never { throw new Error('not modeled'); } setMaxThinkingTokens(): never { throw new Error('not modeled'); } applyFlagSettings(): never { throw new Error('not modeled'); } + updateSettings(): never { throw new Error('not modeled'); } initializationResult(): never { throw new Error('not modeled'); } reinitialize(): never { throw new Error('not modeled'); } supportedCommands(): never { throw new Error('not modeled'); } diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 12ba500c5b7717..20c3ddb68d7b9d 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -888,6 +888,7 @@ class FakeQuery implements AsyncGenerator<SDKMessage, void> { setMcpPermissionModeOverride(): never { throw new Error('FakeQuery: setMcpPermissionModeOverride not modeled'); } setMaxThinkingTokens(): never { throw new Error('FakeQuery: setMaxThinkingTokens not modeled'); } async applyFlagSettings(s: Settings): Promise<void> { this.recordedFlagSettings.push(s); } + updateSettings(): never { throw new Error('FakeQuery: updateSettings not modeled'); } initializationResult(): never { throw new Error('FakeQuery: initializationResult not modeled'); } reinitialize(): never { throw new Error('FakeQuery: reinitialize not modeled'); } diff --git a/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts b/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts index fe03807ee65eda..67d52f8f8e527a 100644 --- a/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts @@ -68,6 +68,7 @@ class ImmediatelyDoneQuery implements Query { async close(): Promise<void> { /* not exercised here */ } async [Symbol.asyncDispose](): Promise<void> { /* not exercised here */ } setMaxThinkingTokens(): never { throw new Error('not modeled'); } + updateSettings(): never { throw new Error('not modeled'); } initializationResult(): never { throw new Error('not modeled'); } reinitialize(): never { throw new Error('not modeled'); } supportedCommands(): never { throw new Error('not modeled'); } diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 6be13468faf901..a431135c28cd6e 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -6271,6 +6271,42 @@ suite('CopilotAgent', () => { } }); + test('logs the raw SDK client name only for eligible external sessions', async () => { + class RecordingLogService extends NullLogService { + readonly messages: string[] = []; + + override info(message: string, ..._args: unknown[]): void { + this.messages.push(message); + } + } + + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/external-log-home-`)); + const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/external-log-cwd-`); + const sessionDataService = disposables.add(new TestSessionDataService()); + const logService = new RecordingLogService(); + const client = new TestCopilotClient([ + sdkSession('external-cli-log', workingDirectory, { clientName: 'github/cli', repository: 'owner/repository', modifiedTime: new Date() }), + sdkSession('external-autopilot-log', workingDirectory, { clientName: 'github/autopilot', repository: 'owner/repository', modifiedTime: new Date() }), + sdkSession('rejected-external-log', workingDirectory, { clientName: 'github/cli', modifiedTime: new Date() }), + ]); + const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome, logService }); + try { + await collectDiscoveredChats(agent); + + assert.deepStrictEqual( + logService.messages.filter(message => message.startsWith('[Copilot] Chat discovery: classified ')).sort(), + [ + `[Copilot] Chat discovery: classified ${AgentSession.uri(agent.id, 'external-autopilot-log').toString()} as external (clientName: github/autopilot)`, + `[Copilot] Chat discovery: classified ${AgentSession.uri(agent.id, 'external-cli-log').toString()} as external (clientName: github/cli)`, + ].sort(), + ); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await fs.rm(workingDirectory, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + test('does not surface SDK sessions with an unknown or missing client name', async () => { const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/unsupported-client-discovery-home-`)); const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/unsupported-client-discovery-cwd-`); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index a73d72095decda..2ce6b7ca9dfa16 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -11971,7 +11971,7 @@ Use the attached image as context. }); }); - test('MCP auth request publishes authRequired state and resolves with authenticate token', async () => { + test('MCP auth request prioritizes a static client secret when publishing authRequired state', async () => { const { session, runtime, waitForSignal } = await createAgentSession(disposables, { githubToken: 'existing-token' }); const authPromise = runtime.handleMcpAuthRequest({ @@ -11982,7 +11982,7 @@ Use the attached image as context. staticClientConfig: { clientId: 'configured-client-id', clientSecret: 'configured-client-secret', - publicClient: false, + publicClient: true, }, resourceMetadata: JSON.stringify({ resource: 'https://api.githubcopilot.com/mcp', @@ -12035,7 +12035,6 @@ Use the attached image as context. reason: 'initial', staticClientConfig: { clientId: 'public-client-id', - publicClient: true, }, resourceMetadata: JSON.stringify({ resource: 'https://mcp.example.com', diff --git a/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts b/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts index 12e0a3a1898733..a358f96d76ed78 100644 --- a/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts @@ -118,6 +118,28 @@ suite('copilotPluginConverters', () => { }); }); + test('converts remote OAuth client configuration', () => { + const defs: IMcpServerDefinition[] = [{ + name: 'slack', + uri: URI.file('/plugin'), + configuration: { + type: McpServerType.REMOTE, + url: 'https://mcp.slack.com/mcp', + oauth: { clientId: 'public-client-id' }, + }, + customization: stubMcpCustomization('slack'), + }]; + + assert.deepStrictEqual(toSdkMcpServers(defs), { + slack: { + type: 'http', + url: 'https://mcp.slack.com/mcp', + tools: ['*'], + oauthClientId: 'public-client-id', + }, + }); + }); + test('handles empty definitions', () => { const result = toSdkMcpServers([]); assert.deepStrictEqual(result, {}); 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-model-changes-between-turns-retain-provider-context.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-model-changes-between-turns-retain-provider-context.yaml index 991a2564ca6d07..7db0966e2ddf87 100644 --- a/src/vs/platform/agentHost/test/node/e2e/captures/claude-model-changes-between-turns-retain-provider-context.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-model-changes-between-turns-retain-provider-context.yaml @@ -28,7 +28,7 @@ exchanges: <command-message>model</command-message> <command-args>claude-opus-5</command-args> - type: text - text: <local-command-stdout>Set model to claude-opus-5</local-command-stdout> + text: <local-command-stdout>Set model to `claude-opus-5`</local-command-stdout> - type: text text: Reply with only the exact code word I asked you to remember. response: 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/coverage/summary.json b/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json index 9fff945aa2561b..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": 101868, - "total": 135034, - "percentage": 75.43 + "covered": 102765, + "total": 136497, + "percentage": 75.28 }, "branches": { - "covered": 12677, - "total": 18587, - "percentage": 68.2 + "covered": 12773, + "total": 18760, + "percentage": 68.08 }, "functions": { - "covered": 3908, - "total": 5528, - "percentage": 70.69 + "covered": 3932, + "total": 5585, + "percentage": 70.4 }, "lines": { - "covered": 101868, - "total": 135034, - "percentage": 75.43 + "covered": 102765, + "total": 136497, + "percentage": 75.28 } }, "files": { "src/vs/platform/agentHost/common/agent.ts": { "statements": { - "covered": 1268, - "total": 1287, - "percentage": 98.52 + "covered": 1280, + "total": 1299, + "percentage": 98.53 }, "branches": { "covered": 27, @@ -53,9 +53,9 @@ "percentage": 66.66 }, "lines": { - "covered": 1268, - "total": 1287, - "percentage": 98.52 + "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": 161, - "total": 161, + "covered": 162, + "total": 162, "percentage": 100 }, "branches": { @@ -141,8 +141,8 @@ "percentage": 100 }, "lines": { - "covered": 161, - "total": 161, + "covered": 162, + "total": 162, "percentage": 100 } }, @@ -544,9 +544,9 @@ }, "src/vs/platform/agentHost/common/agentHostSchema.ts": { "statements": { - "covered": 802, - "total": 891, - "percentage": 90.01 + "covered": 839, + "total": 928, + "percentage": 90.4 }, "branches": { "covered": 53, @@ -559,9 +559,9 @@ "percentage": 68.18 }, "lines": { - "covered": 802, - "total": 891, - "percentage": 90.01 + "covered": 839, + "total": 928, + "percentage": 90.4 } }, "src/vs/platform/agentHost/common/agentHostSlashCommand.ts": { @@ -698,9 +698,9 @@ }, "src/vs/platform/agentHost/common/agentMerge.ts": { "statements": { - "covered": 375, - "total": 678, - "percentage": 55.3 + "covered": 389, + "total": 826, + "percentage": 47.09 }, "branches": { "covered": 4, @@ -709,20 +709,20 @@ }, "functions": { "covered": 3, - "total": 33, - "percentage": 9.09 + "total": 40, + "percentage": 7.5 }, "lines": { - "covered": 375, - "total": 678, - "percentage": 55.3 + "covered": 389, + "total": 826, + "percentage": 47.09 } }, "src/vs/platform/agentHost/common/agentMergePrompt.ts": { "statements": { "covered": 113, - "total": 338, - "percentage": 33.43 + "total": 340, + "percentage": 33.23 }, "branches": { "covered": 0, @@ -736,8 +736,8 @@ }, "lines": { "covered": 113, - "total": 338, - "percentage": 33.43 + "total": 340, + "percentage": 33.23 } }, "src/vs/platform/agentHost/common/agentModelByokMeta.ts": { @@ -786,9 +786,9 @@ }, "src/vs/platform/agentHost/common/agentModelPricing.ts": { "statements": { - "covered": 181, - "total": 288, - "percentage": 62.84 + "covered": 194, + "total": 303, + "percentage": 64.02 }, "branches": { "covered": 4, @@ -801,9 +801,9 @@ "percentage": 37.5 }, "lines": { - "covered": 181, - "total": 288, - "percentage": 62.84 + "covered": 194, + "total": 303, + "percentage": 64.02 } }, "src/vs/platform/agentHost/common/agentModelSource.ts": { @@ -923,9 +923,9 @@ "percentage": 85.82 }, "branches": { - "covered": 34, - "total": 43, - "percentage": 79.06 + "covered": 35, + "total": 44, + "percentage": 79.54 }, "functions": { "covered": 12, @@ -1143,9 +1143,9 @@ "percentage": 88.09 }, "branches": { - "covered": 19, - "total": 28, - "percentage": 67.85 + "covered": 20, + "total": 29, + "percentage": 68.96 }, "functions": { "covered": 4, @@ -1160,9 +1160,9 @@ }, "src/vs/platform/agentHost/common/copilotCliConfig.ts": { "statements": { - "covered": 253, - "total": 262, - "percentage": 96.56 + "covered": 278, + "total": 287, + "percentage": 96.86 }, "branches": { "covered": 7, @@ -1175,9 +1175,9 @@ "percentage": 66.66 }, "lines": { - "covered": 253, - "total": 262, - "percentage": 96.56 + "covered": 278, + "total": 287, + "percentage": 96.86 } }, "src/vs/platform/agentHost/common/copilotConfigSlashCommands.ts": { @@ -1226,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, @@ -1237,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": { @@ -1554,6 +1554,28 @@ "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": { "statements": { "covered": 53, @@ -1578,9 +1600,9 @@ }, "src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts": { "statements": { - "covered": 37, - "total": 49, - "percentage": 75.51 + "covered": 52, + "total": 64, + "percentage": 81.25 }, "branches": { "covered": 2, @@ -1593,9 +1615,9 @@ "percentage": 0 }, "lines": { - "covered": 37, - "total": 49, - "percentage": 75.51 + "covered": 52, + "total": 64, + "percentage": 81.25 } }, "src/vs/platform/agentHost/common/meta/agentToolCallMeta.ts": { @@ -1671,9 +1693,9 @@ "percentage": 75.86 }, "branches": { - "covered": 18, - "total": 30, - "percentage": 60 + "covered": 17, + "total": 29, + "percentage": 58.62 }, "functions": { "covered": 5, @@ -1913,9 +1935,9 @@ "percentage": 86.18 }, "branches": { - "covered": 15, - "total": 26, - "percentage": 57.69 + "covered": 14, + "total": 25, + "percentage": 56 }, "functions": { "covered": 7, @@ -1930,23 +1952,23 @@ }, "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 } }, @@ -1994,6 +2016,28 @@ "percentage": 72.5 } }, + "src/vs/platform/agentHost/common/shellInitScript.ts": { + "statements": { + "covered": 53, + "total": 127, + "percentage": 41.73 + }, + "branches": { + "covered": 2, + "total": 4, + "percentage": 50 + }, + "functions": { + "covered": 1, + "total": 7, + "percentage": 14.28 + }, + "lines": { + "covered": 53, + "total": 127, + "percentage": 41.73 + } + }, "src/vs/platform/agentHost/common/state/agentSubscription.ts": { "statements": { "covered": 628, @@ -2194,14 +2238,14 @@ }, "src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts": { "statements": { - "covered": 695, + "covered": 694, "total": 906, - "percentage": 76.71 + "percentage": 76.6 }, "branches": { - "covered": 168, - "total": 237, - "percentage": 70.88 + "covered": 165, + "total": 235, + "percentage": 70.21 }, "functions": { "covered": 16, @@ -2209,9 +2253,9 @@ "percentage": 94.11 }, "lines": { - "covered": 695, + "covered": 694, "total": 906, - "percentage": 76.71 + "percentage": 76.6 } }, "src/vs/platform/agentHost/common/state/protocol/channels-resource-watch/reducer.ts": { @@ -2678,9 +2722,9 @@ }, "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, @@ -2693,16 +2737,16 @@ "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": 1764, - "total": 2202, - "percentage": 80.1 + "covered": 1772, + "total": 2227, + "percentage": 79.56 }, "branches": { "covered": 281, @@ -2711,13 +2755,13 @@ }, "functions": { "covered": 78, - "total": 107, - "percentage": 72.89 + "total": 109, + "percentage": 71.55 }, "lines": { - "covered": 1764, - "total": 2202, - "percentage": 80.1 + "covered": 1772, + "total": 2227, + "percentage": 79.56 } }, "src/vs/platform/agentHost/common/state/sessionWorkingDirectories.ts": { @@ -3030,14 +3074,14 @@ }, "src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts": { "statements": { - "covered": 386, + "covered": 388, "total": 440, - "percentage": 87.72 + "percentage": 88.18 }, "branches": { - "covered": 66, - "total": 88, - "percentage": 75 + "covered": 68, + "total": 89, + "percentage": 76.4 }, "functions": { "covered": 29, @@ -3045,9 +3089,9 @@ "percentage": 96.66 }, "lines": { - "covered": 386, + "covered": 388, "total": 440, - "percentage": 87.72 + "percentage": 88.18 } }, "src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts": { @@ -3074,14 +3118,14 @@ }, "src/vs/platform/agentHost/node/agentHostChangesetService.ts": { "statements": { - "covered": 1250, + "covered": 1245, "total": 1632, - "percentage": 76.59 + "percentage": 76.28 }, "branches": { "covered": 197, - "total": 270, - "percentage": 72.96 + "total": 271, + "percentage": 72.69 }, "functions": { "covered": 57, @@ -3089,9 +3133,9 @@ "percentage": 81.42 }, "lines": { - "covered": 1250, + "covered": 1245, "total": 1632, - "percentage": 76.59 + "percentage": 76.28 } }, "src/vs/platform/agentHost/node/agentHostChangesetStateCache.ts": { @@ -3206,14 +3250,14 @@ }, "src/vs/platform/agentHost/node/agentHostCheckpointService.ts": { "statements": { - "covered": 366, + "covered": 368, "total": 508, - "percentage": 72.04 + "percentage": 72.44 }, "branches": { - "covered": 96, - "total": 123, - "percentage": 78.04 + "covered": 98, + "total": 124, + "percentage": 79.03 }, "functions": { "covered": 17, @@ -3221,9 +3265,9 @@ "percentage": 80.95 }, "lines": { - "covered": 366, + "covered": 368, "total": 508, - "percentage": 72.04 + "percentage": 72.44 } }, "src/vs/platform/agentHost/node/agentHostClientConnectionService.ts": { @@ -3360,9 +3404,9 @@ }, "src/vs/platform/agentHost/node/agentHostDatabase.ts": { "statements": { - "covered": 370, - "total": 450, - "percentage": 82.22 + "covered": 379, + "total": 481, + "percentage": 78.79 }, "branches": { "covered": 53, @@ -3371,13 +3415,13 @@ }, "functions": { "covered": 27, - "total": 33, - "percentage": 81.81 + "total": 34, + "percentage": 79.41 }, "lines": { - "covered": 370, - "total": 450, - "percentage": 82.22 + "covered": 379, + "total": 481, + "percentage": 78.79 } }, "src/vs/platform/agentHost/node/agentHostDebugLogs.ts": { @@ -3492,14 +3536,14 @@ }, "src/vs/platform/agentHost/node/agentHostFileMonitorService.ts": { "statements": { - "covered": 165, + "covered": 166, "total": 185, - "percentage": 89.18 + "percentage": 89.72 }, "branches": { - "covered": 22, - "total": 34, - "percentage": 64.7 + "covered": 23, + "total": 35, + "percentage": 65.71 }, "functions": { "covered": 14, @@ -3507,9 +3551,9 @@ "percentage": 100 }, "lines": { - "covered": 165, + "covered": 166, "total": 185, - "percentage": 89.18 + "percentage": 89.72 } }, "src/vs/platform/agentHost/node/agentHostGitHubEndpointService.ts": { @@ -3580,14 +3624,14 @@ }, "src/vs/platform/agentHost/node/agentHostGitStateService.ts": { "statements": { - "covered": 264, + "covered": 263, "total": 394, - "percentage": 67 + "percentage": 66.75 }, "branches": { - "covered": 77, - "total": 96, - "percentage": 80.2 + "covered": 70, + "total": 90, + "percentage": 77.77 }, "functions": { "covered": 9, @@ -3595,9 +3639,9 @@ "percentage": 69.23 }, "lines": { - "covered": 264, + "covered": 263, "total": 394, - "percentage": 67 + "percentage": 66.75 } }, "src/vs/platform/agentHost/node/agentHostHeadlessTerminal.ts": { @@ -3717,9 +3761,9 @@ "percentage": 81 }, "branches": { - "covered": 25, - "total": 33, - "percentage": 75.75 + "covered": 24, + "total": 32, + "percentage": 75 }, "functions": { "covered": 5, @@ -3844,9 +3888,9 @@ }, "src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts": { "statements": { - "covered": 75, - "total": 196, - "percentage": 38.26 + "covered": 76, + "total": 197, + "percentage": 38.57 }, "branches": { "covered": 1, @@ -3859,16 +3903,16 @@ "percentage": 12.5 }, "lines": { - "covered": 75, - "total": 196, - "percentage": 38.26 + "covered": 76, + "total": 197, + "percentage": 38.57 } }, "src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts": { "statements": { - "covered": 137, - "total": 557, - "percentage": 24.59 + "covered": 138, + "total": 560, + "percentage": 24.64 }, "branches": { "covered": 1, @@ -3881,53 +3925,53 @@ "percentage": 6.25 }, "lines": { - "covered": 137, - "total": 557, - "percentage": 24.59 + "covered": 138, + "total": 560, + "percentage": 24.64 } }, "src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts": { "statements": { - "covered": 200, - "total": 273, - "percentage": 73.26 + "covered": 221, + "total": 301, + "percentage": 73.42 }, "branches": { - "covered": 37, - "total": 41, - "percentage": 90.24 + "covered": 41, + "total": 45, + "percentage": 91.11 }, "functions": { "covered": 7, - "total": 12, - "percentage": 58.33 + "total": 13, + "percentage": 53.84 }, "lines": { - "covered": 200, - "total": 273, - "percentage": 73.26 + "covered": 221, + "total": 301, + "percentage": 73.42 } }, "src/vs/platform/agentHost/node/agentHostPullRequestStatusService.ts": { "statements": { - "covered": 252, - "total": 506, - "percentage": 49.8 + "covered": 257, + "total": 531, + "percentage": 48.39 }, "branches": { - "covered": 27, - "total": 40, - "percentage": 67.5 + "covered": 28, + "total": 41, + "percentage": 68.29 }, "functions": { "covered": 7, - "total": 24, - "percentage": 29.16 + "total": 25, + "percentage": 28 }, "lines": { - "covered": 252, - "total": 506, - "percentage": 49.8 + "covered": 257, + "total": 531, + "percentage": 48.39 } }, "src/vs/platform/agentHost/node/agentHostRenameCommand.ts": { @@ -3999,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, @@ -4014,8 +4058,8 @@ }, "lines": { "covered": 174, - "total": 313, - "percentage": 55.59 + "total": 317, + "percentage": 54.88 } }, "src/vs/platform/agentHost/node/agentHostReviewService.ts": { @@ -4086,9 +4130,9 @@ }, "src/vs/platform/agentHost/node/agentHostSessionOpenTelemetry.ts": { "statements": { - "covered": 279, - "total": 306, - "percentage": 91.17 + "covered": 280, + "total": 307, + "percentage": 91.2 }, "branches": { "covered": 56, @@ -4101,9 +4145,9 @@ "percentage": 100 }, "lines": { - "covered": 279, - "total": 306, - "percentage": 91.17 + "covered": 280, + "total": 307, + "percentage": 91.2 } }, "src/vs/platform/agentHost/node/agentHostSessionRepositories.ts": { @@ -4130,24 +4174,24 @@ }, "src/vs/platform/agentHost/node/agentHostSessionTitleController.ts": { "statements": { - "covered": 699, + "covered": 679, "total": 914, - "percentage": 76.47 + "percentage": 74.28 }, "branches": { - "covered": 130, - "total": 181, - "percentage": 71.82 + "covered": 123, + "total": 172, + "percentage": 71.51 }, "functions": { - "covered": 34, + "covered": 32, "total": 46, - "percentage": 73.91 + "percentage": 69.56 }, "lines": { - "covered": 699, + "covered": 679, "total": 914, - "percentage": 76.47 + "percentage": 74.28 } }, "src/vs/platform/agentHost/node/agentHostSessionTitleSignal.ts": { @@ -4262,14 +4306,14 @@ }, "src/vs/platform/agentHost/node/agentHostStateManager.ts": { "statements": { - "covered": 1828, + "covered": 1830, "total": 2059, - "percentage": 88.78 + "percentage": 88.87 }, "branches": { "covered": 305, - "total": 369, - "percentage": 82.65 + "total": 368, + "percentage": 82.88 }, "functions": { "covered": 83, @@ -4277,9 +4321,9 @@ "percentage": 86.45 }, "lines": { - "covered": 1828, + "covered": 1830, "total": 2059, - "percentage": 88.78 + "percentage": 88.87 } }, "src/vs/platform/agentHost/node/agentHostStorageService.ts": { @@ -4372,9 +4416,9 @@ }, "src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts": { "statements": { - "covered": 1248, - "total": 1412, - "percentage": 88.38 + "covered": 1264, + "total": 1428, + "percentage": 88.51 }, "branches": { "covered": 68, @@ -4387,31 +4431,31 @@ "percentage": 70.83 }, "lines": { - "covered": 1248, - "total": 1412, - "percentage": 88.38 + "covered": 1264, + "total": 1428, + "percentage": 88.51 } }, "src/vs/platform/agentHost/node/agentHostTelemetryService.ts": { "statements": { - "covered": 209, + "covered": 212, "total": 295, - "percentage": 70.84 + "percentage": 71.86 }, "branches": { - "covered": 21, - "total": 51, - "percentage": 41.17 + "covered": 22, + "total": 53, + "percentage": 41.5 }, "functions": { - "covered": 16, + "covered": 17, "total": 30, - "percentage": 53.33 + "percentage": 56.66 }, "lines": { - "covered": 209, + "covered": 212, "total": 295, - "percentage": 70.84 + "percentage": 71.86 } }, "src/vs/platform/agentHost/node/agentHostTerminalManager.ts": { @@ -4570,24 +4614,24 @@ }, "src/vs/platform/agentHost/node/agentMergeController.ts": { "statements": { - "covered": 393, - "total": 1127, - "percentage": 34.87 + "covered": 410, + "total": 1193, + "percentage": 34.36 }, "branches": { - "covered": 31, - "total": 60, - "percentage": 51.66 + "covered": 32, + "total": 69, + "percentage": 46.37 }, "functions": { - "covered": 13, - "total": 48, - "percentage": 27.08 + "covered": 14, + "total": 51, + "percentage": 27.45 }, "lines": { - "covered": 393, - "total": 1127, - "percentage": 34.87 + "covered": 410, + "total": 1193, + "percentage": 34.36 } }, "src/vs/platform/agentHost/node/agentMergeTools.ts": { @@ -4680,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, @@ -4695,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": { @@ -4746,14 +4790,14 @@ }, "src/vs/platform/agentHost/node/agentService.ts": { "statements": { - "covered": 5362, - "total": 7218, - "percentage": 74.28 + "covered": 5412, + "total": 7273, + "percentage": 74.41 }, "branches": { - "covered": 1021, - "total": 1509, - "percentage": 67.66 + "covered": 1033, + "total": 1521, + "percentage": 67.91 }, "functions": { "covered": 250, @@ -4761,9 +4805,9 @@ "percentage": 78.61 }, "lines": { - "covered": 5362, - "total": 7218, - "percentage": 74.28 + "covered": 5412, + "total": 7273, + "percentage": 74.41 } }, "src/vs/platform/agentHost/node/agentServiceComposition.ts": { @@ -4812,36 +4856,36 @@ }, "src/vs/platform/agentHost/node/agentSessionRegistry.ts": { "statements": { - "covered": 197, - "total": 221, - "percentage": 89.14 + "covered": 210, + "total": 236, + "percentage": 88.98 }, "branches": { - "covered": 21, - "total": 27, - "percentage": 77.77 + "covered": 23, + "total": 29, + "percentage": 79.31 }, "functions": { - "covered": 13, - "total": 17, - "percentage": 76.47 + "covered": 14, + "total": 19, + "percentage": 73.68 }, "lines": { - "covered": 197, - "total": 221, - "percentage": 89.14 + "covered": 210, + "total": 236, + "percentage": 88.98 } }, "src/vs/platform/agentHost/node/agentSessionResidency.ts": { "statements": { - "covered": 238, + "covered": 240, "total": 277, - "percentage": 85.92 + "percentage": 86.64 }, "branches": { - "covered": 54, - "total": 66, - "percentage": 81.81 + "covered": 69, + "total": 80, + "percentage": 86.25 }, "functions": { "covered": 12, @@ -4849,21 +4893,21 @@ "percentage": 92.3 }, "lines": { - "covered": 238, + "covered": 240, "total": 277, - "percentage": 85.92 + "percentage": 86.64 } }, "src/vs/platform/agentHost/node/agentSideEffects.ts": { "statements": { - "covered": 1602, - "total": 1900, - "percentage": 84.31 + "covered": 1607, + "total": 1909, + "percentage": 84.18 }, "branches": { "covered": 295, - "total": 399, - "percentage": 73.93 + "total": 400, + "percentage": 73.75 }, "functions": { "covered": 48, @@ -4871,9 +4915,9 @@ "percentage": 88.88 }, "lines": { - "covered": 1602, - "total": 1900, - "percentage": 84.31 + "covered": 1607, + "total": 1909, + "percentage": 84.18 } }, "src/vs/platform/agentHost/node/appNodeModules.ts": { @@ -5147,10 +5191,10 @@ "percentage": 89.09 }, "branches": { - "covered": 35, - "total": 44, - "percentage": 79.54 - }, + "covered": 34, + "total": 43, + "percentage": 79.06 + }, "functions": { "covered": 4, "total": 4, @@ -5186,8 +5230,8 @@ }, "src/vs/platform/agentHost/node/chatContributions/sessionFlags/sessionFlagsContribution.ts": { "statements": { - "covered": 52, - "total": 52, + "covered": 53, + "total": 53, "percentage": 100 }, "branches": { @@ -5201,8 +5245,8 @@ "percentage": 100 }, "lines": { - "covered": 52, - "total": 52, + "covered": 53, + "total": 53, "percentage": 100 } }, @@ -5235,9 +5279,9 @@ "percentage": 88.77 }, "branches": { - "covered": 21, - "total": 25, - "percentage": 84 + "covered": 19, + "total": 24, + "percentage": 79.16 }, "functions": { "covered": 6, @@ -5323,9 +5367,9 @@ "percentage": 87.62 }, "branches": { - "covered": 17, - "total": 23, - "percentage": 73.91 + "covered": 16, + "total": 22, + "percentage": 72.72 }, "functions": { "covered": 3, @@ -5345,8 +5389,8 @@ "percentage": 100 }, "branches": { - "covered": 4, - "total": 4, + "covered": 5, + "total": 5, "percentage": 100 }, "functions": { @@ -5406,68 +5450,68 @@ }, "src/vs/platform/agentHost/node/claude/claudeAgent.ts": { "statements": { - "covered": 2163, + "covered": 2235, "total": 2667, - "percentage": 81.1 + "percentage": 83.8 }, "branches": { - "covered": 198, - "total": 301, - "percentage": 65.78 + "covered": 210, + "total": 329, + "percentage": 63.82 }, "functions": { - "covered": 97, + "covered": 99, "total": 127, - "percentage": 76.37 + "percentage": 77.95 }, "lines": { - "covered": 2163, + "covered": 2235, "total": 2667, - "percentage": 81.1 + "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": 1276, - "total": 1631, - "percentage": 78.23 + "covered": 1290, + "total": 1658, + "percentage": 77.8 }, "branches": { - "covered": 88, - "total": 141, - "percentage": 62.41 + "covered": 91, + "total": 146, + "percentage": 62.32 }, "functions": { - "covered": 43, - "total": 69, - "percentage": 62.31 + "covered": 45, + "total": 71, + "percentage": 63.38 }, "lines": { - "covered": 1276, - "total": 1631, - "percentage": 78.23 + "covered": 1290, + "total": 1658, + "percentage": 77.8 } }, "src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts": { @@ -5653,9 +5697,9 @@ "percentage": 83.83 }, "branches": { - "covered": 38, - "total": 49, - "percentage": 77.55 + "covered": 36, + "total": 47, + "percentage": 76.59 }, "functions": { "covered": 11, @@ -5780,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": 59, - "total": 114, - "percentage": 51.75 + "covered": 63, + "total": 120, + "percentage": 52.5 }, "functions": { "covered": 18, @@ -5795,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": { @@ -5824,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": { @@ -5851,9 +5895,9 @@ "percentage": 77.53 }, "branches": { - "covered": 44, - "total": 68, - "percentage": 64.7 + "covered": 45, + "total": 69, + "percentage": 65.21 }, "functions": { "covered": 21, @@ -5895,9 +5939,9 @@ "percentage": 78.54 }, "branches": { - "covered": 17, - "total": 35, - "percentage": 48.57 + "covered": 22, + "total": 37, + "percentage": 59.45 }, "functions": { "covered": 8, @@ -5956,24 +6000,24 @@ }, "src/vs/platform/agentHost/node/claude/claudeSubagentResolver.ts": { "statements": { - "covered": 212, + "covered": 236, "total": 439, - "percentage": 48.29 + "percentage": 53.75 }, "branches": { - "covered": 8, - "total": 11, - "percentage": 72.72 + "covered": 13, + "total": 20, + "percentage": 65 }, "functions": { - "covered": 8, + "covered": 11, "total": 21, - "percentage": 38.09 + "percentage": 52.38 }, "lines": { - "covered": 212, + "covered": 236, "total": 439, - "percentage": 48.29 + "percentage": 53.75 } }, "src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts": { @@ -6269,9 +6313,9 @@ "percentage": 77.77 }, "branches": { - "covered": 48, - "total": 72, - "percentage": 66.66 + "covered": 47, + "total": 71, + "percentage": 66.19 }, "functions": { "covered": 13, @@ -6418,24 +6462,24 @@ }, "src/vs/platform/agentHost/node/codex/codexAgent.ts": { "statements": { - "covered": 5557, - "total": 7847, - "percentage": 70.81 + "covered": 5563, + "total": 7918, + "percentage": 70.25 }, "branches": { - "covered": 847, - "total": 1343, - "percentage": 63.06 + "covered": 827, + "total": 1331, + "percentage": 62.13 }, "functions": { - "covered": 232, - "total": 292, - "percentage": 79.45 + "covered": 233, + "total": 294, + "percentage": 79.25 }, "lines": { - "covered": 5557, - "total": 7847, - "percentage": 70.81 + "covered": 5563, + "total": 7918, + "percentage": 70.25 } }, "src/vs/platform/agentHost/node/codex/codexAppServerClient.ts": { @@ -6462,14 +6506,14 @@ }, "src/vs/platform/agentHost/node/codex/codexClientCustomizations.ts": { "statements": { - "covered": 308, + "covered": 306, "total": 372, - "percentage": 82.79 + "percentage": 82.25 }, "branches": { - "covered": 37, - "total": 70, - "percentage": 52.85 + "covered": 33, + "total": 65, + "percentage": 50.76 }, "functions": { "covered": 20, @@ -6477,9 +6521,9 @@ "percentage": 83.33 }, "lines": { - "covered": 308, + "covered": 306, "total": 372, - "percentage": 82.79 + "percentage": 82.25 } }, "src/vs/platform/agentHost/node/codex/codexCustomizations.ts": { @@ -6489,9 +6533,9 @@ "percentage": 85.67 }, "branches": { - "covered": 33, - "total": 52, - "percentage": 63.46 + "covered": 31, + "total": 51, + "percentage": 60.78 }, "functions": { "covered": 9, @@ -6594,9 +6638,9 @@ }, "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, @@ -6605,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": { @@ -6638,24 +6682,24 @@ }, "src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts": { "statements": { - "covered": 667, + "covered": 654, "total": 1281, - "percentage": 52.06 + "percentage": 51.05 }, "branches": { - "covered": 70, - "total": 126, - "percentage": 55.55 + "covered": 69, + "total": 124, + "percentage": 55.64 }, "functions": { - "covered": 20, + "covered": 19, "total": 43, - "percentage": 46.51 + "percentage": 44.18 }, "lines": { - "covered": 667, + "covered": 654, "total": 1281, - "percentage": 52.06 + "percentage": 51.05 } }, "src/vs/platform/agentHost/node/codex/codexMcpServers.ts": { @@ -6792,14 +6836,14 @@ }, "src/vs/platform/agentHost/node/codex/codexRolloutMetadata.ts": { "statements": { - "covered": 125, + "covered": 123, "total": 159, - "percentage": 78.61 + "percentage": 77.35 }, "branches": { - "covered": 30, - "total": 36, - "percentage": 83.33 + "covered": 27, + "total": 35, + "percentage": 77.14 }, "functions": { "covered": 5, @@ -6807,9 +6851,9 @@ "percentage": 100 }, "lines": { - "covered": 125, + "covered": 123, "total": 159, - "percentage": 78.61 + "percentage": 77.35 } }, "src/vs/platform/agentHost/node/codex/codexSessionConfigKeys.ts": { @@ -6819,9 +6863,9 @@ "percentage": 87.87 }, "branches": { - "covered": 25, - "total": 46, - "percentage": 54.34 + "covered": 24, + "total": 45, + "percentage": 53.33 }, "functions": { "covered": 11, @@ -6858,14 +6902,14 @@ }, "src/vs/platform/agentHost/node/codex/codexShellCommand.ts": { "statements": { - "covered": 38, + "covered": 36, "total": 42, - "percentage": 90.47 + "percentage": 85.71 }, "branches": { - "covered": 7, - "total": 9, - "percentage": 77.77 + "covered": 2, + "total": 6, + "percentage": 33.33 }, "functions": { "covered": 2, @@ -6873,9 +6917,9 @@ "percentage": 100 }, "lines": { - "covered": 38, + "covered": 36, "total": 42, - "percentage": 90.47 + "percentage": 85.71 } }, "src/vs/platform/agentHost/node/codex/codexThreadCoordination.ts": { @@ -7078,46 +7122,46 @@ }, "src/vs/platform/agentHost/node/copilot/copilotAgent.ts": { "statements": { - "covered": 5083, - "total": 6832, - "percentage": 74.39 + "covered": 5126, + "total": 6888, + "percentage": 74.41 }, "branches": { - "covered": 855, - "total": 1270, - "percentage": 67.32 + "covered": 867, + "total": 1283, + "percentage": 67.57 }, "functions": { - "covered": 280, - "total": 349, - "percentage": 80.22 + "covered": 281, + "total": 350, + "percentage": 80.28 }, "lines": { - "covered": 5083, - "total": 6832, - "percentage": 74.39 + "covered": 5126, + "total": 6888, + "percentage": 74.41 } }, "src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts": { "statements": { - "covered": 4616, - "total": 6392, - "percentage": 72.21 + "covered": 4807, + "total": 6656, + "percentage": 72.22 }, "branches": { - "covered": 873, - "total": 1274, - "percentage": 68.52 + "covered": 914, + "total": 1331, + "percentage": 68.67 }, "functions": { - "covered": 201, - "total": 252, - "percentage": 79.76 + "covered": 210, + "total": 263, + "percentage": 79.84 }, "lines": { - "covered": 4616, - "total": 6392, - "percentage": 72.21 + "covered": 4807, + "total": 6656, + "percentage": 72.22 } }, "src/vs/platform/agentHost/node/copilot/copilotAgentStartupConfig.ts": { @@ -7210,8 +7254,8 @@ }, "src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts": { "statements": { - "covered": 243, - "total": 247, + "covered": 244, + "total": 248, "percentage": 98.38 }, "branches": { @@ -7225,8 +7269,8 @@ "percentage": 100 }, "lines": { - "covered": 243, - "total": 247, + "covered": 244, + "total": 248, "percentage": 98.38 } }, @@ -7276,14 +7320,14 @@ }, "src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts": { "statements": { - "covered": 385, - "total": 530, - "percentage": 72.64 + "covered": 392, + "total": 539, + "percentage": 72.72 }, "branches": { "covered": 44, - "total": 81, - "percentage": 54.32 + "total": 82, + "percentage": 53.65 }, "functions": { "covered": 20, @@ -7291,9 +7335,9 @@ "percentage": 76.92 }, "lines": { - "covered": 385, - "total": 530, - "percentage": 72.64 + "covered": 392, + "total": 539, + "percentage": 72.72 } }, "src/vs/platform/agentHost/node/copilot/copilotSdkChatError.ts": { @@ -7342,14 +7386,14 @@ }, "src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts": { "statements": { - "covered": 810, - "total": 1034, - "percentage": 78.33 + "covered": 819, + "total": 1046, + "percentage": 78.29 }, "branches": { - "covered": 100, - "total": 154, - "percentage": 64.93 + "covered": 102, + "total": 157, + "percentage": 64.96 }, "functions": { "covered": 42, @@ -7357,9 +7401,9 @@ "percentage": 80.76 }, "lines": { - "covered": 810, - "total": 1034, - "percentage": 78.33 + "covered": 819, + "total": 1046, + "percentage": 78.29 } }, "src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts": { @@ -7518,14 +7562,14 @@ }, "src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts": { "statements": { - "covered": 694, + "covered": 725, "total": 1021, - "percentage": 67.97 + "percentage": 71 }, "branches": { - "covered": 93, - "total": 186, - "percentage": 50 + "covered": 95, + "total": 191, + "percentage": 49.73 }, "functions": { "covered": 21, @@ -7533,9 +7577,9 @@ "percentage": 95.45 }, "lines": { - "covered": 694, + "covered": 725, "total": 1021, - "percentage": 67.97 + "percentage": 71 } }, "src/vs/platform/agentHost/node/copilot/modelCallTurnCorrelation.ts": { @@ -7648,6 +7692,28 @@ "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": 210, @@ -7672,9 +7738,9 @@ }, "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, @@ -7687,9 +7753,9 @@ "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": { @@ -7716,14 +7782,14 @@ }, "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, @@ -7731,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": 1144, - "total": 1279, - "percentage": 89.44 + "covered": 1152, + "total": 1289, + "percentage": 89.37 }, "branches": { - "covered": 230, - "total": 284, - "percentage": 80.98 + "covered": 233, + "total": 288, + "percentage": 80.9 }, "functions": { "covered": 38, @@ -7753,9 +7819,9 @@ "percentage": 97.43 }, "lines": { - "covered": 1144, - "total": 1279, - "percentage": 89.44 + "covered": 1152, + "total": 1289, + "percentage": 89.37 } }, "src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts": { @@ -7782,14 +7848,14 @@ }, "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, @@ -7797,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": { @@ -7809,9 +7875,9 @@ "percentage": 54.02 }, "branches": { - "covered": 15, - "total": 20, - "percentage": 75 + "covered": 14, + "total": 19, + "percentage": 73.68 }, "functions": { "covered": 4, @@ -8007,9 +8073,9 @@ "percentage": 84.72 }, "branches": { - "covered": 375, - "total": 488, - "percentage": 76.84 + "covered": 378, + "total": 490, + "percentage": 77.14 }, "functions": { "covered": 88, @@ -8051,9 +8117,9 @@ "percentage": 80.19 }, "branches": { - "covered": 27, - "total": 31, - "percentage": 87.09 + "covered": 29, + "total": 33, + "percentage": 87.87 }, "functions": { "covered": 13, @@ -8068,14 +8134,14 @@ }, "src/vs/platform/agentHost/node/sessionDatabase.ts": { "statements": { - "covered": 807, + "covered": 816, "total": 946, - "percentage": 85.3 + "percentage": 86.25 }, "branches": { - "covered": 130, - "total": 160, - "percentage": 81.25 + "covered": 141, + "total": 173, + "percentage": 81.5 }, "functions": { "covered": 43, @@ -8083,9 +8149,9 @@ "percentage": 75.43 }, "lines": { - "covered": 807, + "covered": 816, "total": 946, - "percentage": 85.3 + "percentage": 86.25 } }, "src/vs/platform/agentHost/node/sessionDiffAggregator.ts": { @@ -8242,6 +8308,28 @@ "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": 247, @@ -8293,9 +8381,9 @@ "percentage": 94.73 }, "branches": { - "covered": 38, - "total": 51, - "percentage": 74.5 + "covered": 37, + "total": 50, + "percentage": 74 }, "functions": { "covered": 9, @@ -8315,9 +8403,9 @@ "percentage": 87.31 }, "branches": { - "covered": 59, - "total": 97, - "percentage": 60.82 + "covered": 60, + "total": 98, + "percentage": 61.22 }, "functions": { "covered": 25, @@ -8337,9 +8425,9 @@ "percentage": 88.64 }, "branches": { - "covered": 51, - "total": 57, - "percentage": 89.47 + "covered": 53, + "total": 59, + "percentage": 89.83 }, "functions": { "covered": 8, @@ -8354,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, @@ -8369,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": { @@ -8442,14 +8530,14 @@ }, "src/vs/platform/agentHost/node/shared/fileEditTracker.ts": { "statements": { - "covered": 237, + "covered": 239, "total": 253, - "percentage": 93.67 + "percentage": 94.46 }, "branches": { - "covered": 32, - "total": 39, - "percentage": 82.05 + "covered": 29, + "total": 35, + "percentage": 82.85 }, "functions": { "covered": 7, @@ -8457,9 +8545,9 @@ "percentage": 100 }, "lines": { - "covered": 237, + "covered": 239, "total": 253, - "percentage": 93.67 + "percentage": 94.46 } }, "src/vs/platform/agentHost/node/shared/folderPickerDecision.ts": { @@ -8535,9 +8623,9 @@ "percentage": 86.34 }, "branches": { - "covered": 110, + "covered": 109, "total": 129, - "percentage": 85.27 + "percentage": 84.49 }, "functions": { "covered": 28, @@ -8667,9 +8755,9 @@ "percentage": 83 }, "branches": { - "covered": 33, - "total": 38, - "percentage": 86.84 + "covered": 35, + "total": 40, + "percentage": 87.5 }, "functions": { "covered": 10, @@ -8684,24 +8772,24 @@ }, "src/vs/platform/agentHost/node/shared/sessionServerTools.ts": { "statements": { - "covered": 1214, - "total": 1439, - "percentage": 84.36 + "covered": 1228, + "total": 1465, + "percentage": 83.82 }, "branches": { - "covered": 208, - "total": 347, - "percentage": 59.94 + "covered": 214, + "total": 358, + "percentage": 59.77 }, "functions": { - "covered": 56, - "total": 62, - "percentage": 90.32 + "covered": 57, + "total": 63, + "percentage": 90.47 }, "lines": { - "covered": 1214, - "total": 1439, - "percentage": 84.36 + "covered": 1228, + "total": 1465, + "percentage": 83.82 } }, "src/vs/platform/agentHost/node/shared/shellCommandExecution.ts": { @@ -8755,9 +8843,9 @@ "percentage": 77.89 }, "branches": { - "covered": 160, - "total": 238, - "percentage": 67.22 + "covered": 158, + "total": 236, + "percentage": 66.94 }, "functions": { "covered": 49, 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/sessionPersistenceSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts index 2ab33a1f90505a..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,12 +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 { ListSessionsResult, SubscribeResult } from '../../../../common/state/protocol/commands.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 } from '../../../../common/state/sessionActions.js'; -import { buildChatUri, buildDefaultChatUri, MessageKind, ROOT_STATE_URI, SessionStatus, type ChatState, type SessionState } from '../../../../common/state/sessionState.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'; @@ -20,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') { @@ -132,6 +136,69 @@ 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<SubscribeResult>('subscribe', { channel: sessionUri }); + await context.client.call<SubscribeResult>('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<ResourceReadResult>('resourceRead', { + channel: ROOT_STATE_URI, + uri: edit.before.content.uri, + encoding: ContentEncoding.Utf8, + }), + context.client.call<ResourceReadResult>('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-`); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index c9ac211b8829a9..def42ccb473007 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -43,6 +43,7 @@ import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService. class MockProtocolTransport implements IProtocolTransport { constructor(readonly transportKind = AgentHostTransportKind.Unknown) { } + isDisposed = false; private readonly _onMessage = new Emitter<ProtocolMessage>(); readonly onMessage = this._onMessage.event; private readonly _onDidSend = new Emitter<ProtocolMessage>(); @@ -66,6 +67,7 @@ class MockProtocolTransport implements IProtocolTransport { } dispose(): void { + this.isDisposed = true; this._onMessage.dispose(); this._onDidSend.dispose(); this._onClose.dispose(); @@ -723,6 +725,15 @@ suite('ProtocolServerHandler', () => { transport.simulateClose(); }); + test('dispose closes a connection before initialize', () => { + const transport = new MockProtocolTransport(); + server.simulateConnection(transport); + + handler.dispose(); + + assert.strictEqual(transport.isDisposed, true); + }); + test('unknown requests return MethodNotFound before and after initialize', () => { const transport = new MockProtocolTransport(); disposables.add(transport); @@ -2821,6 +2832,113 @@ suite('ProtocolServerHandler', () => { assert.ok(stateManager.getSnapshot(sessionUri), 'state should have been re-hydrated by reconnect'); }); + test('reconnect answers replay even when initialize gave a channel no baseline', async () => { + // Reproduces the server half of the eternal-spinner incident. `initialize` + // registers a state channel whose snapshot has not materialized yet + // (`_addInitialSubscription` uses the SYNCHRONOUS `getSnapshot`, unlike + // `subscribe`, which awaits `AgentService.subscribe` and restores evicted + // state). The client is subscribed with no baseline from THIS process, yet + // the next reconnect passes the purely-global `canReplay` check and is + // answered with deltas only — which a client applies onto nothing. + const transport1 = connectClient('client-no-baseline', [sessionUri]); + const initResp = findResponse(transport1.sent, 1) as { result: InitializeResult }; + const initSeq = initResp.result.serverSeq; + const sessionSnapshot = initResp.result.snapshots?.find(snapshot => snapshot.resource === sessionUri); + transport1.simulateClose(); + + // Mirror the incident's timing: the replay buffer is empty and the client + // is level with the server, so `canReplay` is true. The session then + // materializes DURING the async restore — exactly what happened as the + // restarted host restored its sessions while answering the reconnect. + agentService.subscribe = async (resource, _clientId) => { + if (!stateManager.getSnapshot(resource.toString())) { + stateManager.restoreSession(makeSessionSummary(), []); + stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); + } + return stateManager.getSnapshot(resource.toString())!; + }; + + const transport2 = new MockProtocolTransport(); + server.simulateConnection(transport2); + const reconnectRespPromise = waitForResponse(transport2, 1); + transport2.simulateMessage(request(1, 'reconnect', { + clientId: 'client-no-baseline', + lastSeenServerSeq: initSeq, + subscriptions: [sessionUri], + })); + const reconnectResp = await reconnectRespPromise as { result: ReconnectResult }; + + assert.deepStrictEqual({ + initializeGaveSnapshot: sessionSnapshot !== undefined, + reconnectType: reconnectResp.result.type, + }, { + // The channel was subscribed without a baseline... + initializeGaveSnapshot: false, + // ...so the server must send a snapshot, not deltas onto nothing. + reconnectType: 'snapshot', + }); + }); + + test('a channel reported missing does not deny replay for the client\'s other channels', async () => { + // A terminal channel from a dead host process can never be baselined: + // `initialize` registers it with no snapshot, and the follow-up restore + // reports it `missing`. Its baseline debt must be released — otherwise a + // single unrestorable channel would force full snapshots for every other + // channel this client holds, forever. (The real incident's reconnect + // reported 15 such terminal channels.) + const deadTerminal = 'agenthost-terminal:/dead-from-previous-process'; + const transport1 = connectClient('client-missing-debt', [sessionUri, deadTerminal]); + const initResp = findResponse(transport1.sent, 1) as { result: InitializeResult }; + const initSeq = initResp.result.serverSeq; + transport1.simulateClose(); + + // The session restores; the terminal cannot. + agentService.subscribe = async (resource, _clientId) => { + if (resource.toString() === deadTerminal) { + throw new Error('No agent for session: ' + deadTerminal); + } + if (!stateManager.getSnapshot(resource.toString())) { + stateManager.restoreSession(makeSessionSummary(), []); + } + return stateManager.getSnapshot(resource.toString())!; + }; + + const transport2 = new MockProtocolTransport(); + server.simulateConnection(transport2); + const firstRespPromise = waitForResponse(transport2, 1); + transport2.simulateMessage(request(1, 'reconnect', { + clientId: 'client-missing-debt', + lastSeenServerSeq: initSeq, + subscriptions: [sessionUri, deadTerminal], + })); + const firstResp = await firstRespPromise as { result: ReconnectResult }; + + // A second reconnect, now that the session has a baseline and the dead + // terminal has been reported missing, must be free to replay again. + transport2.simulateClose(); + const transport3 = new MockProtocolTransport(); + server.simulateConnection(transport3); + const secondRespPromise = waitForResponse(transport3, 1); + transport3.simulateMessage(request(1, 'reconnect', { + clientId: 'client-missing-debt', + lastSeenServerSeq: stateManager.serverSeq, + subscriptions: [sessionUri, deadTerminal], + })); + const secondResp = await secondRespPromise as { result: ReconnectResult }; + + assert.deepStrictEqual({ + firstType: firstResp.result.type, + firstMissing: firstResp.result.type === 'replay' ? firstResp.result.missing : [], + secondType: secondResp.result.type, + }, { + // Debt forced a snapshot the first time, as intended... + firstType: 'snapshot', + firstMissing: [], + // ...and the unrestorable terminal did not poison the next reconnect. + secondType: 'replay', + }); + }); + test('reconnect re-registers the reverse-RPC filesystem authority', async () => { // The server-side filesystem provider talks back to the client via // reverse-RPC (e.g. `resourceList`). If the authority is not diff --git a/src/vs/platform/agentHost/test/node/sessionDataService.test.ts b/src/vs/platform/agentHost/test/node/sessionDataService.test.ts index bbb8e9a14cb30d..879fb586b4fc38 100644 --- a/src/vs/platform/agentHost/test/node/sessionDataService.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDataService.test.ts @@ -10,12 +10,21 @@ import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { FileService } from '../../../files/common/fileService.js'; +import { createFileSystemProviderError, FileSystemProviderErrorCode, type IStat } from '../../../files/common/files.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; import { AgentSession } from '../../common/agent.js'; import { buildChatUri } from '../../common/state/sessionState.js'; import { SessionDataService } from '../../node/sessionDataService.js'; +class ControllableStatFileSystemProvider extends InMemoryFileSystemProvider { + statError: Error | undefined; + + override stat(resource: URI): Promise<IStat> { + return this.statError ? Promise.reject(this.statError) : super.stat(resource); + } +} + suite('SessionDataService', () => { const disposables = new DisposableStore(); @@ -100,11 +109,14 @@ suite('SessionDataService — openDatabase ref-counting', () => { const disposables = new DisposableStore(); const basePath = URI.from({ scheme: Schemas.inMemory, path: '/userData' }); + let fileService: FileService; + let provider: ControllableStatFileSystemProvider; let service: SessionDataService; setup(() => { - const fileService = disposables.add(new FileService(new NullLogService())); - disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); + fileService = disposables.add(new FileService(new NullLogService())); + provider = disposables.add(new ControllableStatFileSystemProvider()); + disposables.add(fileService.registerProvider(Schemas.inMemory, provider)); service = new SessionDataService(basePath, fileService, new NullLogService(), () => ':memory:'); }); @@ -124,6 +136,16 @@ suite('SessionDataService — openDatabase ref-counting', () => { await ref.object.close(); }); + test('tryOpenDatabase returns undefined only for a missing database', async () => { + const session = AgentSession.uri('copilot', 'strict-existing-test'); + const missing = await service.tryOpenDatabase(session); + const permissionError = createFileSystemProviderError('permission denied', FileSystemProviderErrorCode.NoPermissions); + provider.statError = permissionError; + + assert.strictEqual(missing, undefined); + await assert.rejects(service.tryOpenDatabase(session), error => error === permissionError); + }); + test('multiple references share the same database', async () => { const session = AgentSession.uri('copilot', 'shared-test'); const ref1 = service.openDatabase(session); diff --git a/src/vs/platform/agentHost/test/node/wslRemoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/node/wslRemoteAgentHostService.test.ts index 95ea58912ec342..e0bea7779f9130 100644 --- a/src/vs/platform/agentHost/test/node/wslRemoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/node/wslRemoteAgentHostService.test.ts @@ -12,7 +12,7 @@ import { runWithFakedTimers } from '../../../../base/test/common/timeTravelSched import { NullLogService } from '../../../log/common/log.js'; import type { IProductService } from '../../../product/common/productService.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; -import type { IWSLConnectResult } from '../../common/wslRemoteAgentHost.js'; +import type { IWSLConnectProgress, IWSLConnectResult } from '../../common/wslRemoteAgentHost.js'; import { WSLRemoteAgentHostMainService } from '../../node/wslRemoteAgentHostService.js'; import type WebSocket from 'ws'; @@ -129,7 +129,7 @@ suite('WSL Remote Agent Host Service', () => { ); }); - test('keeps a chatty bootstrap alive past the output-idle timeout', async () => { + test('accepts initial bootstrap output after the output-idle budget', async () => { return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { const service = disposables.add(createService()); const connect = service.connect({ distro: 'Ubuntu', name: 'Ubuntu' }); @@ -137,9 +137,7 @@ suite('WSL Remote Agent Host Service', () => { await Promise.resolve(); const child = service.children[0]; - await timeout(59_000); - child.emitStdout('Downloading server 50%\n'); - await timeout(59_000); + await timeout(60_001); child.emitStdout('ws://127.0.0.1:3000?tkn=token\n'); const result = await connect; @@ -150,7 +148,7 @@ suite('WSL Remote Agent Host Service', () => { }); }); - test('fails a silent bootstrap after the output-idle timeout', async () => { + test('fails a silent bootstrap after the initial-output startup budget', async () => { return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { const service = disposables.add(createService()); const rejected = service.connect({ distro: 'Ubuntu', name: 'Ubuntu' }).then<IWSLConnectResult | Error, Error>( @@ -160,11 +158,61 @@ suite('WSL Remote Agent Host Service', () => { service.resolvePlatform(); await Promise.resolve(); + await timeout(180_001); + const result = await rejected; + + assert.ok(result instanceof Error); + assert.match(result.message, /180000ms startup budget/); + }); + }); + + test('fails after output goes quiet for the output-idle budget', async () => { + return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const service = disposables.add(createService()); + const rejected = service.connect({ distro: 'Ubuntu', name: 'Ubuntu' }).then<IWSLConnectResult | Error, Error>( + result => result, + error => error instanceof Error ? error : new Error(String(error)), + ); + service.resolvePlatform(); + await Promise.resolve(); + + service.children[0].emitStdout('Downloading server 50%\n'); await timeout(60_001); const result = await rejected; assert.ok(result instanceof Error); - assert.match(result.message, /no output for 60000ms/); + assert.match(result.message, /60000ms output-idle budget after output started/); + }); + }); + + test('reports redacted, throttled server download progress', async () => { + return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const service = disposables.add(createService()); + const progress: IWSLConnectProgress[] = []; + disposables.add(service.onDidReportConnectProgress(update => progress.push(update))); + const connect = service.connect({ distro: 'Ubuntu', name: 'Ubuntu' }); + service.resolvePlatform(); + await Promise.resolve(); + + const child = service.children[0]; + child.emitStdout('bootstrap shell noise\n'); + for (const percentage of [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]) { + child.emitStdout(`Downloading server: ${percentage}/100 (${percentage}%) tkn=bootstrap-token\n`); + } + await timeout(250); + child.emitStdout('Downloading server: 99/100 (99%) tkn=bootstrap-token\n'); + child.emitStdout('ws://127.0.0.1:3000?tkn=token\n'); + await connect; + + assert.deepStrictEqual({ + downloadMessages: progress.filter(update => update.message.startsWith('Downloading server')).map(update => update.message), + hasNoise: progress.some(update => update.message === 'bootstrap shell noise'), + hasToken: progress.some(update => update.message.includes('bootstrap-token') || update.message.includes('tkn=token')), + }, { + downloadMessages: ['Downloading server (10%)', 'Downloading server (100%)', 'Downloading server (99%)'], + hasNoise: false, + hasToken: false, + }); }); }); }); diff --git a/src/vs/platform/agentPlugins/common/pluginParsers.ts b/src/vs/platform/agentPlugins/common/pluginParsers.ts index 0e88228ea2eb24..759cec5398ff65 100644 --- a/src/vs/platform/agentPlugins/common/pluginParsers.ts +++ b/src/vs/platform/agentPlugins/common/pluginParsers.ts @@ -500,6 +500,10 @@ export function normalizeMcpServerConfiguration(rawConfig: unknown): IMcpServerC .filter(([, value]) => typeof value === 'string') .map(([key, value]) => [key, value as string])) : undefined; + const rawOAuth = candidate['oauth'] && typeof candidate['oauth'] === 'object' ? candidate['oauth'] as Record<string, unknown> : undefined; + const oauthClientId = (typeof rawOAuth?.['clientId'] === 'string' ? rawOAuth['clientId'] : undefined) + ?? (typeof candidate['oauthClientId'] === 'string' ? candidate['oauthClientId'] : undefined); + const oauth = oauthClientId ? { clientId: oauthClientId } : undefined; const dev = candidate['dev'] && typeof candidate['dev'] === 'object' ? candidate['dev'] as IMcpStdioServerConfiguration['dev'] : undefined; if (type === 'ws') { @@ -517,7 +521,7 @@ export function normalizeMcpServerConfiguration(rawConfig: unknown): IMcpServerC if (!url) { return undefined; } - return { type: McpServerType.REMOTE, ...(type === 'sse' || transport === 'sse' ? { transport: 'sse' as const } : {}), url, headers, dev }; + return { type: McpServerType.REMOTE, ...(type === 'sse' || transport === 'sse' ? { transport: 'sse' as const } : {}), url, headers, ...(oauth ? { oauth } : {}), dev }; } return undefined; diff --git a/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts b/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts index 111864a3a5f129..0145a68e012275 100644 --- a/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts +++ b/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts @@ -206,6 +206,34 @@ suite('pluginParsers', () => { }); }); + test('preserves VS Code OAuth client configuration', () => { + assert.deepStrictEqual(normalizeMcpServerConfiguration({ + type: 'http', + url: 'https://mcp.slack.com/mcp', + oauth: { clientId: 'vscode-client-id' }, + }), { + type: McpServerType.REMOTE, + url: 'https://mcp.slack.com/mcp', + headers: undefined, + oauth: { clientId: 'vscode-client-id' }, + dev: undefined, + }); + }); + + test('normalizes Copilot SDK OAuth client configuration', () => { + assert.deepStrictEqual(normalizeMcpServerConfiguration({ + type: 'http', + url: 'https://mcp.slack.com/mcp', + oauthClientId: 'sdk-client-id', + }), { + type: McpServerType.REMOTE, + url: 'https://mcp.slack.com/mcp', + headers: undefined, + oauth: { clientId: 'sdk-client-id' }, + dev: undefined, + }); + }); + test('infers remote type from url without explicit type', () => { const result = normalizeMcpServerConfiguration({ url: 'https://example.com' }); assert.ok(result); 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<IFormatterChangeEvent>; /** @@ -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<void>; + /** 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<string, string>; +} + export interface ResourceLabelFormatting { label: string; // myLabel:/${path} separator: '/' | '\\' | ''; diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 3d017432d98eeb..a0b71ccb8b4e3c 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -32,6 +32,7 @@ This service supplies per-window policy to the shared editor: - available management sections; - whether the surface is in the Agents Window; - the active project root; +- the active project display label; - welcome-page capabilities. The editor workbench resolves project context from its workspace. The Agents Window resolves it from the scoped active session. 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/SESSIONS.md b/src/vs/sessions/SESSIONS.md index 69a62db57d14b7..1d52a2c1e306b5 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -84,6 +84,8 @@ An `ISession` has a provider-owned resource URI, provider identifier, session ty Consumers derive state from those observables. Provider events announce catalog membership changes; they are not a parallel state store. +Sessions backed by a remote agent host may expose `remoteConnectionStatus`, derived from their backing provider; it is absent when the session has no remote host. Its session-facing disconnected variant may include a machine-readable failure reason. + Providers may expose immutable creation provenance when a session was created by another session. `createdBySession` identifies the creating session and may also identify its chat and turn. The reference is observable so list presentation can diff --git a/src/vs/sessions/browser/parts/chatGroupView.ts b/src/vs/sessions/browser/parts/chatGroupView.ts index 8f8bf7ba88f817..dd6bc1ad8af62d 100644 --- a/src/vs/sessions/browser/parts/chatGroupView.ts +++ b/src/vs/sessions/browser/parts/chatGroupView.ts @@ -7,7 +7,7 @@ import { $, size, trackFocus } from '../../../base/browser/dom.js'; import { ISerializableView, IViewSize } from '../../../base/browser/ui/grid/grid.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; -import { autorun, derived, IObservable, observableFromEvent } from '../../../base/common/observable.js'; +import { autorun, derived, IObservable, observableFromEvent, observableValue } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { ICommandService } from '../../../platform/commands/common/commands.js'; @@ -19,7 +19,9 @@ import { IActiveSession } from '../../services/sessions/common/sessionsManagemen import { UNARCHIVE_SESSION_COMMAND_ID } from '../../common/sessionCommands.js'; import { IChatViewFactory } from '../../services/chatView/browser/chatViewFactory.js'; import { ChatCompositeBar, IChatCompositeBarDelegate } from './chatCompositeBar.js'; -import { SessionReadOnlyBanner } from './sessionReadOnlyBanner.js'; +import { type IRemoteHostUnavailableEmptyStateContent, RemoteHostUnavailableEmptyState } from './remoteHostUnavailableEmptyState.js'; +import { SessionRemoteConnection } from './sessionRemoteConnection.js'; +import { ISessionReadOnlyBannerContent, SessionReadOnlyBanner } from './sessionReadOnlyBanner.js'; import { AbstractChatView, ChatViewKind, IChatViewOptions } from './chatView.js'; /** @@ -59,6 +61,11 @@ export interface IChatGroupContext { onTabDragEnd(): void; } +interface IChatGroupSurface { + readonly banner: ISessionReadOnlyBannerContent | undefined; + readonly recovery: IRemoteHostUnavailableEmptyStateContent | undefined; +} + /** * A single leaf in the {@link ChatGroupsView} grid. Hosts a * {@link ChatCompositeBar} (this group's chats) on top of a kind-switched @@ -86,9 +93,11 @@ export class ChatGroupView extends Disposable implements ISerializableView { private readonly _barContainer: HTMLElement; private readonly _readOnlyBanner: SessionReadOnlyBanner; private readonly _contentContainer: HTMLElement; + private readonly _remoteHostUnavailableEmptyState: RemoteHostUnavailableEmptyState; private readonly _currentView = this._register(new MutableDisposable<AbstractChatView>()); private readonly _contextDisposables = this._register(new DisposableStore()); + private readonly _connection: SessionRemoteConnection; /** The configured wording for the archive/unarchive action (Archive vs Delete). */ private readonly _archiveActionWording: IObservable<ReturnType<typeof getChatSessionArchiveActionWording>>; @@ -101,17 +110,24 @@ export class ChatGroupView extends Disposable implements ISerializableView { private _sessionActive = true; /** Whether this group's session is currently visible in the sessions part. */ private _sessionVisible = true; + /** Whether this is the first group in the chat grid's logical order. */ + private _primary = false; /** Index of this group within the persisted layout, written into {@link toJSON}. */ private _serializationIndex = 0; constructor( @IChatViewFactory private readonly _chatViewFactory: IChatViewFactory, - @IInstantiationService instantiationService: IInstantiationService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, @ICommandService private readonly _commandService: ICommandService, @IConfigurationService configurationService: IConfigurationService, ) { super(); + // Assigned here rather than as a field initializer: `_instantiationService` + // is a parameter property of this class, which class-field semantics + // initialize after the field initializers run. + this._connection = this._register(this._instantiationService.createInstance(SessionRemoteConnection)); + this._archiveActionWording = observableFromEvent( this, configurationService.onDidChangeConfiguration, @@ -121,17 +137,18 @@ export class ChatGroupView extends Disposable implements ISerializableView { this._barContainer = $('.chat-group-view-bar'); this.element.appendChild(this._barContainer); - this._compositeBar = this._register(instantiationService.createInstance(ChatCompositeBar, undefined)); + this._compositeBar = this._register(this._instantiationService.createInstance(ChatCompositeBar, undefined)); this._barContainer.appendChild(this._compositeBar.element); - // Read-only status banner, shown flush below this group's tab bar when the - // group's active chat is non-interactive, in place of the composer which - // is hidden for read-only chats. + // Single status banner, shown flush below this group's tab bar when the + // active chat is non-interactive or its remote host is unavailable. this._readOnlyBanner = this._register(new SessionReadOnlyBanner()); this._barContainer.appendChild(this._readOnlyBanner.domNode); this._contentContainer = $('.chat-group-view-content'); this.element.appendChild(this._contentContainer); + this._remoteHostUnavailableEmptyState = this._register(new RemoteHostUnavailableEmptyState()); + this._contentContainer.appendChild(this._remoteHostUnavailableEmptyState.domNode); this._register(this._compositeBar.onDidChangeVisibility(() => this._layoutChildren())); this._register(this._compositeBar.onDidChangeHeight(() => this._layoutChildren())); @@ -141,6 +158,9 @@ export class ChatGroupView extends Disposable implements ISerializableView { } setGroupPosition(index: number, count: number): void { + this._primary = index === 0; + this._currentView.value?.setPrimary(this._primary); + if (count <= 1) { this.element.removeAttribute('role'); this.element.removeAttribute('aria-label'); @@ -156,11 +176,13 @@ export class ChatGroupView extends Disposable implements ISerializableView { /** Sets (or clears) the group this view renders. */ setContext(context: IChatGroupContext | undefined): void { this._contextDisposables.clear(); + this._connection.setSession(context?.session); if (!context) { this._compositeBar.setGroup(undefined); this._currentView.clear(); - this._contentContainer.replaceChildren(); + this._setRemoteHostUnavailableEmptyState(undefined); + this._contentContainer.replaceChildren(this._remoteHostUnavailableEmptyState.domNode); return; } @@ -181,6 +203,44 @@ export class ChatGroupView extends Disposable implements ISerializableView { const activeResource = context.activeChatResource.read(reader); return context.chats.read(reader).find(c => c.resource.toString() === activeResource); }); + const currentView = observableValue<AbstractChatView | undefined>(this._contextDisposables, this._currentView.value); + + const readOnlyContent = derived(reader => { + const chat = activeChat.read(reader); + if (!chat || chat.interactivity.read(reader) === ChatInteractivity.Full) { + return undefined; + } + + const archived = context.session.isArchived.read(reader); + if (archived) { + const action = getChatSessionArchiveActionPresentation(this._archiveActionWording.read(reader)).unarchive; + return { + message: localize('sessionReadOnlyBanner.archived', "Archived sessions are read-only."), + action: { + label: action.title.value, + run: () => this._commandService.executeCommand(UNARCHIVE_SESSION_COMMAND_ID, context.session), + }, + }; + } + return { message: localize('sessionReadOnlyBanner.message', "This chat is read-only") }; + }); + + const surface = derived<IChatGroupSurface>(reader => { + const readOnly = readOnlyContent.read(reader); + if (readOnly) { + return { banner: readOnly, recovery: undefined }; + } + + const view = currentView.read(reader); + const recovery = view?.hasVisibleTranscriptContent.read(reader) + ? undefined + : this._connection.recoveryContent.read(reader); + if (recovery) { + return { banner: undefined, recovery }; + } + + return { banner: this._connection.bannerContent.read(reader), recovery: undefined }; + }); this._contextDisposables.add(autorun(reader => { const session = context.session; @@ -202,8 +262,10 @@ export class ChatGroupView extends Disposable implements ISerializableView { view = desiredKind === 'chat' ? this._chatViewFactory.createChatView() : this._chatViewFactory.createNewChatView(desiredKind === 'newChatInSession', context.options); - this._contentContainer.replaceChildren(view.element); + this._contentContainer.replaceChildren(view.element, this._remoteHostUnavailableEmptyState.domNode); this._currentView.value = view; + currentView.set(view, undefined); + view.setPrimary(this._primary); view.setActive(this._sessionActive); view.setVisible(this._sessionVisible); this._layoutChildren(); @@ -213,34 +275,29 @@ export class ChatGroupView extends Disposable implements ISerializableView { view.setChat(chat, session.sessionId, session); } - // Show the read-only banner in place of the composer when the group's - // active chat is non-interactive (e.g. a subagent transcript or an - // archived session). - const readOnly = !!chat && chat.interactivity.read(reader) !== ChatInteractivity.Full; - if (readOnly) { - const archived = session.isArchived.read(reader); - if (archived) { - const action = getChatSessionArchiveActionPresentation(this._archiveActionWording.read(reader)).unarchive; - this._readOnlyBanner.setContent({ - message: localize('sessionReadOnlyBanner.archived', "Archived sessions are read-only."), - action: { - label: action.title.value, - run: () => this._commandService.executeCommand(UNARCHIVE_SESSION_COMMAND_ID, session), - }, - }); - } else { - this._readOnlyBanner.setContent({ message: localize('sessionReadOnlyBanner.message', "This chat is read-only") }); - } - } - // Only re-layout when the banner's visibility (and thus its - // contribution to the bar height) actually changes. - if (this._readOnlyBanner.visible !== readOnly) { - this._readOnlyBanner.setVisible(readOnly); - this._layoutChildren(); - } + const surfaceContent = surface.read(reader); + this._setRemoteHostUnavailableEmptyState(surfaceContent.recovery); + this._setReadOnlyBanner(surfaceContent.banner); })); } + private _setReadOnlyBanner(content: ISessionReadOnlyBannerContent | undefined): void { + if (content) { + this._readOnlyBanner.setContent(content); + } + // Only re-layout when the banner's visibility (and thus its + // contribution to the bar height) actually changes. + if (this._readOnlyBanner.visible !== !!content) { + this._readOnlyBanner.setVisible(!!content); + this._layoutChildren(); + } + } + + private _setRemoteHostUnavailableEmptyState(content: IRemoteHostUnavailableEmptyStateContent | undefined): void { + this._remoteHostUnavailableEmptyState.setContent(content); + this._contentContainer.classList.toggle('remote-host-unavailable', !!content); + } + /** Whether this group is the active (focused) group within the session. */ setGroupActive(active: boolean): void { this._groupActive = active; @@ -307,6 +364,7 @@ export class ChatGroupView extends Disposable implements ISerializableView { const bannerHeight = this._readOnlyBanner.visible ? this._readOnlyBanner.domNode.offsetHeight : 0; const barHeight = tabsHeight + bannerHeight; size(this._barContainer, width, barHeight); + size(this._contentContainer, width, height - barHeight); this._currentView.value?.layout(width, height - barHeight, top + barHeight, left); } @@ -320,6 +378,10 @@ export class ChatGroupView extends Disposable implements ISerializableView { } focus(): void { + if (this._remoteHostUnavailableEmptyState.visible) { + this._remoteHostUnavailableEmptyState.focus(); + return; + } this._currentView.value?.focus(); } } diff --git a/src/vs/sessions/browser/parts/chatView.ts b/src/vs/sessions/browser/parts/chatView.ts index 12b4fa219c7b80..7ad49b776284ec 100644 --- a/src/vs/sessions/browser/parts/chatView.ts +++ b/src/vs/sessions/browser/parts/chatView.ts @@ -8,6 +8,7 @@ import { ISerializableView, IViewSize } from '../../../base/browser/ui/grid/grid import { ProgressBar } from '../../../base/browser/ui/progressbar/progressbar.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; +import { constObservable, IObservable } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; import { defaultProgressBarStyles } from '../../../platform/theme/browser/defaultStyles.js'; import { IProgressScope, ScopedProgressIndicator } from '../../../workbench/services/progress/browser/progressIndicator.js'; @@ -60,6 +61,12 @@ export abstract class AbstractChatView extends Disposable implements ISerializab */ abstract readonly kind: ChatViewKind; + /** + * Whether the view has a visible transcript turn to retain when a remote + * host disconnects. New and unbound views intentionally report no content. + */ + readonly hasVisibleTranscriptContent: IObservable<boolean> = constObservable(false); + /** * Show the given chat in this view. The default implementation is a * no-op; subclasses that host a chat widget (e.g. `ChatView`) override @@ -128,6 +135,14 @@ export abstract class AbstractChatView extends Disposable implements ISerializab // no-op by default } + /** + * Notifies the view whether it occupies the first group in the chat grid. + * Session-scoped UI can use this to avoid repeating across split groups. + */ + setPrimary(_primary: boolean): void { + // no-op by default + } + /** * Shows an indeterminate progress bar at the top of this leaf while the * given promise is pending, mirroring how each editor group surfaces diff --git a/src/vs/sessions/browser/parts/media/remoteHostUnavailableEmptyState.css b/src/vs/sessions/browser/parts/media/remoteHostUnavailableEmptyState.css new file mode 100644 index 00000000000000..719e7ecc78bfdc --- /dev/null +++ b/src/vs/sessions/browser/parts/media/remoteHostUnavailableEmptyState.css @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.remote-host-unavailable-empty-state { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + box-sizing: border-box; + gap: var(--vscode-spacing-size120); + padding: var(--vscode-spacing-size240) var(--vscode-spacing-size160); + color: var(--session-view-foreground); + text-align: center; +} + +.remote-host-unavailable-empty-state.hidden, +.remote-host-unavailable-empty-state-progress.hidden, +.remote-host-unavailable-empty-state-action.hidden, +.remote-host-unavailable-empty-state-auto-connect.hidden { + display: none; +} + +.remote-host-unavailable-empty-state-icon { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--vscode-descriptionForeground); +} + +.remote-host-unavailable-empty-state-icon .codicon { + font-size: var(--vscode-codiconFontSize); +} + +.remote-host-unavailable-empty-state-title { + max-width: var(--session-view-centered-content-max-width); + margin: 0; + color: var(--session-view-foreground); + font-size: var(--vscode-fontSize-heading2); + font-weight: var(--vscode-fontWeight-semiBold); + line-height: 1.4; +} + +.remote-host-unavailable-empty-state-description, +.remote-host-unavailable-empty-state-progress { + max-width: var(--session-view-centered-content-max-width); + margin: 0; + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-body1); + line-height: 1.4; +} + +.remote-host-unavailable-empty-state-action { + margin-top: var(--vscode-spacing-size80); +} + +.remote-host-unavailable-empty-state-auto-connect { + margin-top: var(--vscode-spacing-size80); + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-body2); +} + +.remote-host-unavailable-empty-state-auto-connect-row { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size60); + cursor: pointer; +} + +.remote-host-unavailable-empty-state-auto-connect-label { + cursor: pointer; +} + +.chat-group-view-content.remote-host-unavailable > .chat-view { + visibility: hidden; + pointer-events: none; +} diff --git a/src/vs/sessions/browser/parts/media/sessionReadOnlyBanner.css b/src/vs/sessions/browser/parts/media/sessionReadOnlyBanner.css index 4f3880b350a1aa..c679b4e873bbdd 100644 --- a/src/vs/sessions/browser/parts/media/sessionReadOnlyBanner.css +++ b/src/vs/sessions/browser/parts/media/sessionReadOnlyBanner.css @@ -72,3 +72,16 @@ border-radius: var(--vscode-cornerRadius-small, 2px); } + +/* + * Announced by the banner's live region; the visible text is aria-hidden so a + * ticking countdown does not queue an utterance per update. + */ +.session-readonly-banner .session-readonly-banner-announcement { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} \ No newline at end of file diff --git a/src/vs/sessions/browser/parts/remoteHostUnavailableEmptyState.ts b/src/vs/sessions/browser/parts/remoteHostUnavailableEmptyState.ts new file mode 100644 index 00000000000000..ffcb92b8a7f15d --- /dev/null +++ b/src/vs/sessions/browser/parts/remoteHostUnavailableEmptyState.ts @@ -0,0 +1,152 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/remoteHostUnavailableEmptyState.css'; +import * as dom from '../../../base/browser/dom.js'; +import { renderIcon } from '../../../base/browser/ui/iconLabel/iconLabels.js'; +import { Button } from '../../../base/browser/ui/button/button.js'; +import { Checkbox } from '../../../base/browser/ui/toggle/toggle.js'; +import { Gesture, EventType as TouchEventType } from '../../../base/browser/touch.js'; +import { Codicon } from '../../../base/common/codicons.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; +import { defaultButtonStyles, defaultCheckboxStyles } from '../../../platform/theme/browser/defaultStyles.js'; + +export interface IRemoteHostUnavailableEmptyStateContent { + readonly title: string; + readonly description: string; + readonly progress?: string; + readonly action?: { + readonly label: string; + readonly run: () => void; + }; + /** Optional kind-scoped auto-start policy, rendered beneath the action. */ + readonly autoConnect?: { + readonly label: string; + readonly checked: boolean; + readonly onChange: (checked: boolean) => void; + }; +} + +/** + * Blocking recovery state for a new chat whose remote host is unavailable. + */ +export class RemoteHostUnavailableEmptyState extends Disposable { + + readonly domNode: HTMLElement; + private readonly _title: HTMLElement; + private readonly _description: HTMLElement; + private readonly _progress: HTMLElement; + private readonly _actionContainer: HTMLElement; + private readonly _action: Button; + private readonly _actionListener = this._register(new MutableDisposable()); + private readonly _autoConnectContainer: HTMLElement; + private readonly _autoConnectRow: HTMLElement; + private readonly _autoConnect: Checkbox; + private readonly _autoConnectLabel: HTMLElement; + private readonly _autoConnectListener = this._register(new MutableDisposable()); + + constructor() { + super(); + + this.domNode = dom.$('.remote-host-unavailable-empty-state.hidden'); + this.domNode.setAttribute('role', 'group'); + this.domNode.tabIndex = -1; + + const icon = dom.append(this.domNode, dom.$('.remote-host-unavailable-empty-state-icon')); + icon.setAttribute('aria-hidden', 'true'); + icon.appendChild(renderIcon(Codicon.debugDisconnect)); + + this._title = dom.append(this.domNode, dom.$('h2.remote-host-unavailable-empty-state-title')); + this._description = dom.append(this.domNode, dom.$('p.remote-host-unavailable-empty-state-description')); + this._progress = dom.append(this.domNode, dom.$('p.remote-host-unavailable-empty-state-progress.hidden')); + // Connect progress changes while the user waits (waiting → download + // percentage), so announce it politely rather than leaving it silent. + this._progress.setAttribute('role', 'status'); + this._actionContainer = dom.append(this.domNode, dom.$('.remote-host-unavailable-empty-state-action.hidden')); + // Primary styling: recovering the host is the one thing to do here. + this._action = this._register(new Button(this._actionContainer, { ...defaultButtonStyles, title: true })); + this._autoConnectContainer = dom.append(this.domNode, dom.$('.remote-host-unavailable-empty-state-auto-connect.hidden')); + this._autoConnectRow = dom.append(this._autoConnectContainer, dom.$('.remote-host-unavailable-empty-state-auto-connect-row')); + this._autoConnect = this._register(new Checkbox('', false, { ...defaultCheckboxStyles, size: 14 })); + dom.append(this._autoConnectRow, this._autoConnect.domNode); + this._autoConnectLabel = dom.append(this._autoConnectRow, dom.$('span.remote-host-unavailable-empty-state-auto-connect-label')); + this._autoConnectLabel.setAttribute('aria-hidden', 'true'); + this._register(Gesture.addTarget(this._autoConnectRow)); + } + + get visible(): boolean { + return !this.domNode.classList.contains('hidden'); + } + + setContent(content: IRemoteHostUnavailableEmptyStateContent | undefined): void { + this._actionListener.clear(); + this._autoConnectListener.clear(); + this.domNode.classList.toggle('hidden', !content); + if (!content) { + this._progress.textContent = ''; + this._progress.classList.add('hidden'); + this._actionContainer.classList.add('hidden'); + this._action.label = ''; + this._autoConnectContainer.classList.add('hidden'); + this._autoConnectLabel.textContent = ''; + this._autoConnect.checked = false; + this._autoConnect.setTitle(''); + return; + } + + this.domNode.setAttribute('aria-label', content.title); + this._title.textContent = content.title; + this._description.textContent = content.description; + this._progress.textContent = content.progress ?? ''; + this._progress.classList.toggle('hidden', !content.progress); + if (!content.action) { + this._actionContainer.classList.add('hidden'); + this._action.label = ''; + } else { + this._actionContainer.classList.remove('hidden'); + this._action.label = content.action.label; + this._action.setAriaLabel(content.action.label); + this._actionListener.value = this._action.onDidClick(() => content.action?.run()); + } + + const autoConnect = content.autoConnect; + this._autoConnectContainer.classList.toggle('hidden', !autoConnect); + this._autoConnectLabel.textContent = autoConnect?.label ?? ''; + this._autoConnect.checked = autoConnect?.checked ?? false; + this._autoConnect.setTitle(autoConnect?.label ?? ''); + if (!autoConnect) { + return; + } + + const listeners = new DisposableStore(); + listeners.add(this._autoConnect.onChange(() => autoConnect.onChange(this._autoConnect.checked))); + listeners.add(dom.addDisposableListener(this._autoConnectRow, dom.EventType.CLICK, e => { + if (!this._autoConnect.enabled) { + return; + } + dom.EventHelper.stop(e, true); + this._autoConnect.checked = !this._autoConnect.checked; + autoConnect.onChange(this._autoConnect.checked); + })); + listeners.add(dom.addDisposableListener(this._autoConnectRow, TouchEventType.Tap, e => { + if (!this._autoConnect.enabled) { + return; + } + dom.EventHelper.stop(e, true); + this._autoConnect.checked = !this._autoConnect.checked; + autoConnect.onChange(this._autoConnect.checked); + })); + this._autoConnectListener.value = listeners; + } + + focus(): void { + this.domNode.focus(); + } + + override dispose(): void { + this.domNode.remove(); + super.dispose(); + } +} diff --git a/src/vs/sessions/browser/parts/sessionReadOnlyBanner.ts b/src/vs/sessions/browser/parts/sessionReadOnlyBanner.ts index 2b697c26535f86..a7d1d55d7bb0be 100644 --- a/src/vs/sessions/browser/parts/sessionReadOnlyBanner.ts +++ b/src/vs/sessions/browser/parts/sessionReadOnlyBanner.ts @@ -10,25 +10,31 @@ import { StandardKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; import { Codicon } from '../../../base/common/codicons.js'; import { KeyCode } from '../../../base/common/keyCodes.js'; import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../base/common/themables.js'; import { localize } from '../../../nls.js'; /** - * Content shown by a {@link SessionReadOnlyBanner}: a message and an optional - * inline action (e.g. "Restore" for an archived session). The action's callback - * is supplied by the owner so the banner stays purely presentational. + * Content shown by a {@link SessionReadOnlyBanner}: an optional icon, message, + * and optional inline action. The action's callback is supplied by the owner so + * the banner stays purely presentational. */ export interface ISessionReadOnlyBannerContent { + readonly icon?: ThemeIcon; readonly message: string; + /** + * Text announced instead of {@link message}. Set this when the visible text + * changes faster than it is worth speaking — a per-second countdown, say — + * so the live region announces a stable summary rather than every tick. + */ + readonly ariaLabel?: string; readonly action?: { readonly label: string; readonly run: () => void }; } /** - * A small, self-contained status banner that indicates the current chat is - * read-only (non-interactive). Mirrors the read-only editor banner in VS Code: - * a subtle full-width bar with a leading icon and a single line of text. Shown - * in place of the composer for read-only chats (e.g. a subagent's transcript, - * or an archived session), where it explains why there is no input and — when - * the owner supplies one — offers an inline action to make it interactive again. + * A small, self-contained status banner for the current chat. It mirrors the + * read-only editor banner: a subtle full-width bar with a leading icon and one + * line of text. It can explain both a non-interactive chat and a remote-host + * state without adding another bar to the group. * * Purely presentational: visibility is driven by the owning chat view via * {@link setVisible} and its content via {@link setContent}. @@ -39,7 +45,9 @@ export class SessionReadOnlyBanner extends Disposable { private _visible = false; + private readonly _icon: HTMLElement; private readonly _text: HTMLElement; + private readonly _announcement: HTMLElement; private readonly _actionContainer: HTMLElement; private readonly _actionDisposables = this._register(new DisposableStore()); @@ -47,15 +55,18 @@ export class SessionReadOnlyBanner extends Disposable { super(); this.domNode = dom.$('.session-readonly-banner'); - // A `role="status"` live region is announced from its text content, so no - // `aria-label` is needed (setting one to the same string would just - // override the accessible name without changing the announcement). + // A `role="status"` live region is announced from its text content. The + // visible text is hidden from that content and mirrored into a dedicated + // element, so text that changes every second can be announced as a stable + // summary instead of queueing an utterance per update. this.domNode.setAttribute('role', 'status'); - const icon = dom.append(this.domNode, dom.$('.session-readonly-banner-icon')); - icon.appendChild(renderIcon(Codicon.lock)); + this._icon = dom.append(this.domNode, dom.$('.session-readonly-banner-icon')); + this._icon.setAttribute('aria-hidden', 'true'); this._text = dom.append(this.domNode, dom.$('span.session-readonly-banner-text')); + this._text.setAttribute('aria-hidden', 'true'); + this._announcement = dom.append(this.domNode, dom.$('span.session-readonly-banner-announcement')); this._actionContainer = dom.append(this.domNode, dom.$('span.session-readonly-banner-action')); this.setContent({ message: localize('sessionReadOnlyBanner.message', "This chat is read-only") }); @@ -72,7 +83,15 @@ export class SessionReadOnlyBanner extends Disposable { } setContent(content: ISessionReadOnlyBannerContent): void { + dom.clearNode(this._icon); + this._icon.appendChild(renderIcon(content.icon ?? Codicon.lock)); this._text.textContent = content.message; + // Re-writing identical text would queue a fresh announcement, which is + // exactly what a ticking countdown must avoid. + const announced = content.ariaLabel ?? content.message; + if (this._announcement.textContent !== announced) { + this._announcement.textContent = announced; + } this._actionDisposables.clear(); dom.clearNode(this._actionContainer); @@ -96,4 +115,3 @@ export class SessionReadOnlyBanner extends Disposable { } } } - diff --git a/src/vs/sessions/browser/parts/sessionRemoteConnection.ts b/src/vs/sessions/browser/parts/sessionRemoteConnection.ts new file mode 100644 index 00000000000000..cadf62c0953f61 --- /dev/null +++ b/src/vs/sessions/browser/parts/sessionRemoteConnection.ts @@ -0,0 +1,415 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { TimeoutTimer } from '../../../base/common/async.js'; +import { Codicon } from '../../../base/common/codicons.js'; +import { onUnexpectedError } from '../../../base/common/errors.js'; +import { Disposable, MutableDisposable } from '../../../base/common/lifecycle.js'; +import { autorun, derived, derivedObservableWithCache, IObservable, IReader, observableSignal, observableValue, transaction } from '../../../base/common/observable.js'; +import { localize } from '../../../nls.js'; +import { ILogService } from '../../../platform/log/common/log.js'; +import { isAgentHostProvider } from '../../common/agentHostSessionsProvider.js'; +import { SessionRemoteConnectionFailureReason, SessionRemoteConnectionStatus } from '../../services/sessions/common/session.js'; +import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; +import { ISessionsProvidersService } from '../../services/sessions/browser/sessionsProvidersService.js'; +import { IRemoteHostUnavailableEmptyStateContent } from './remoteHostUnavailableEmptyState.js'; +import { ISessionReadOnlyBannerContent } from './sessionReadOnlyBanner.js'; + +const RECONNECTING_BANNER_DELAY = 1_000; + +function isSameRemoteConnectionStatus(a: SessionRemoteConnectionStatus | undefined, b: SessionRemoteConnectionStatus | undefined): boolean { + if (!a || !b) { + return a === b; + } + if (a.kind === 'disconnected' && b.kind === 'disconnected') { + return a.reason === b.reason; + } + return a.kind === b.kind; +} + +type ConnectAttempt = + | { readonly kind: 'active'; readonly session: IActiveSession; readonly message: string | undefined; readonly statusBefore: SessionRemoteConnectionStatus | undefined } + | { readonly kind: 'failed'; readonly session: IActiveSession; readonly statusBefore: SessionRemoteConnectionStatus | undefined }; + +/** + * Connection presentation and recovery state for the session rendered by a chat group. + */ +export class SessionRemoteConnection extends Disposable { + + private readonly _session = observableValue<IActiveSession | undefined>(this, undefined); + private readonly _attempt = observableValue<ConnectAttempt | undefined>(this, undefined); + private readonly _progressListener = this._register(new MutableDisposable()); + private readonly _deadlineSignal = observableSignal(this); + // Kept separate so countdown ticks cannot restart the reconnecting-banner delay. + private readonly _reconnectCountdownSignal = observableSignal(this); + /** + * The session an automatic start has already been triggered for, cleared + * once the host is reachable again so each outage gets its own attempt. + * Latched separately from {@link _attempt} because a completed attempt + * clears that back to `undefined`; keying the gate on it would let a connect + * that resolves without reaching the host retrigger forever. + * + * Scoped to this view. A split session has one instance per chat group, so + * an outage can produce one attempt per group. That stays bounded — each + * instance latches independently — and providers collapse the duplicates: + * `connect()` joins an in-flight dial rather than starting a second one. + */ + private readonly _autoConnected = observableValue<IActiveSession | undefined>(this, undefined); + /** + * Whether the host should be started without waiting for a click. Gated on a + * stopped host the provider can start, and on not having tried yet — one + * automatic try per outage, after which the user gets the button. + */ + private readonly _autoConnectPending = derived(this, reader => { + const session = this._session.read(reader); + const provider = session && this._sessionsProvidersService.getProvider(session.providerId); + const status = this._getEffectiveStatus(reader); + if (!session || !provider || !isAgentHostProvider(provider) || !provider.connect || !provider.autoConnect) { + return false; + } + return provider.autoConnect.enabled.read(reader) + && status?.kind === 'disconnected' + && status.reason === SessionRemoteConnectionFailureReason.HostNotRunning + && this._autoConnected.read(reader) !== session + && this._attempt.read(reader) === undefined; + }); + + private readonly _reconnectingSince = derivedObservableWithCache<{ readonly session: IActiveSession; readonly since: number } | undefined>(this, (reader, last) => { + const session = this._session.read(reader); + const status = this._getEffectiveStatus(reader); + const attempt = this._attempt.read(reader); + if (!session || status?.kind !== 'reconnecting' || attempt?.kind === 'active') { + return undefined; + } + // Keyed by session only to guard future reuse: a ChatGroupView is currently + // created per session, so the cache cannot outlive the session it belongs to. + return last?.session === session ? last : { session, since: Date.now() }; + }); + + private readonly _reconnectingBannerVisible = derived(this, reader => { + const reconnecting = this._reconnectingSince.read(reader); + if (reconnecting === undefined) { + return false; + } + + const remaining = reconnecting.since + RECONNECTING_BANNER_DELAY - Date.now(); + if (remaining <= 0) { + return true; + } + + this._deadlineSignal.read(reader); + // The fixed deadline prevents unrelated observable updates from restarting the delay. + reader.store.add(new TimeoutTimer(() => this._deadlineSignal.trigger(undefined), remaining)); + return false; + }); + + private readonly _reconnectCountdownSeconds = derived(this, reader => { + const status = this._getEffectiveStatus(reader); + if (status?.kind !== 'reconnecting' || status.nextAttemptAt === undefined) { + return undefined; + } + + this._reconnectCountdownSignal.read(reader); + const now = Date.now(); + const remaining = status.nextAttemptAt - now; + if (remaining <= 0) { + return undefined; + } + + reader.store.add(new TimeoutTimer(() => this._reconnectCountdownSignal.trigger(undefined), 1_000 - now % 1_000)); + return Math.ceil(remaining / 1_000); + }); + + readonly bannerContent: IObservable<ISessionReadOnlyBannerContent | undefined> = derived(this, reader => this._getRemoteConnectionBannerContent(reader)); + readonly recoveryContent: IObservable<IRemoteHostUnavailableEmptyStateContent | undefined> = derived(this, reader => this._getRemoteHostUnavailableContent(reader)); + + constructor( + @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + this._register(autorun(reader => { + // A host that came back releases the latch, so a later outage gets its + // own automatic attempt instead of the session being limited to one. + if (this._getEffectiveStatus(reader)?.kind === 'connected' && this._autoConnected.read(reader) !== undefined) { + this._autoConnected.set(undefined, undefined); + } + if (!this._autoConnectPending.read(reader)) { + return; + } + const session = this._session.read(reader); + this._logService.info(`[SessionRemoteConnection] auto-connect triggered for ${session?.providerId}`); + this._autoConnected.set(session, undefined); + this.connect(); + })); + } + + setSession(session: IActiveSession | undefined): void { + this._progressListener.clear(); + // One transaction: these writes notify autoruns synchronously, and + // clearing the gates while the previous session is still selected would + // let the automatic start fire for the host being switched away from. + transaction(tx => { + this._session.set(session, tx); + this._attempt.set(undefined, tx); + this._autoConnected.set(undefined, tx); + }); + } + + connect(): void { + const session = this._session.get(); + if (!session) { + this._logService.info('[SessionRemoteConnection] connect: no session'); + return; + } + + const attempt = this._attempt.get(); + if (attempt?.kind === 'active' && attempt.session === session) { + this._logService.info('[SessionRemoteConnection] connect: attempt already active'); + return; + } + if (attempt?.kind === 'failed') { + this._attempt.set(undefined, undefined); + } + + const provider = this._sessionsProvidersService.getProvider(session.providerId); + if (!provider || !isAgentHostProvider(provider) || !provider.connect) { + this._logService.info(`[SessionRemoteConnection] connect: ${session.providerId} cannot connect on demand`); + return; + } + + const statusBefore = session.remoteConnectionStatus?.get(); + this._logService.info(`[SessionRemoteConnection] connect: starting ${provider.remoteAddress ?? provider.id}, statusBefore=${statusBefore?.kind}`); + this._attempt.set({ kind: 'active', session, message: undefined, statusBefore }, undefined); + const remoteAddress = provider.remoteAddress; + if (remoteAddress && provider.onDidReportConnectProgress) { + this._progressListener.value = provider.onDidReportConnectProgress(progress => { + if (progress.connectionKey !== remoteAddress) { + return; + } + const currentAttempt = this._attempt.get(); + if (currentAttempt?.kind === 'active' && currentAttempt.session === session) { + this._attempt.set({ ...currentAttempt, message: progress.message }, undefined); + } + }); + } + + void this._runConnect(session, statusBefore, () => provider.connect!()); + } + + private async _runConnect(session: IActiveSession, statusBefore: SessionRemoteConnectionStatus | undefined, connect: () => Promise<void>): Promise<void> { + try { + await connect(); + this._logService.info(`[SessionRemoteConnection] connect resolved, status=${session.remoteConnectionStatus?.get()?.kind}`); + this._finishAttempt(session); + } catch (error) { + this._logService.info(`[SessionRemoteConnection] connect rejected: ${error}`); + this._failAttempt(session, statusBefore); + onUnexpectedError(error); + } + } + + private _finishAttempt(session: IActiveSession): void { + const attempt = this._attempt.get(); + if (attempt?.kind !== 'active' || attempt.session !== session) { + return; + } + + this._progressListener.clear(); + this._attempt.set(undefined, undefined); + } + + private _failAttempt(session: IActiveSession, statusBefore: SessionRemoteConnectionStatus | undefined): void { + const attempt = this._attempt.get(); + if (attempt?.kind !== 'active' || attempt.session !== session) { + return; + } + + this._progressListener.clear(); + this._attempt.set({ kind: 'failed', session, statusBefore }, undefined); + } + + private _getEffectiveStatus(reader: IReader): SessionRemoteConnectionStatus | undefined { + const session = this._session.read(reader); + const status = session?.remoteConnectionStatus?.read(reader); + const attempt = this._attempt.read(reader); + return attempt?.kind === 'failed' && attempt.session === session && status?.kind === 'connecting' + ? attempt.statusBefore + : status; + } + + private _getRemoteHostConnectProgress(session: IActiveSession, status: SessionRemoteConnectionStatus | undefined, reader: IReader): string | undefined { + const attempt = this._attempt.read(reader); + if (attempt?.kind !== 'active' || attempt.session !== session || status?.kind === 'connected') { + return undefined; + } + if (attempt.message !== undefined) { + return attempt.message; + } + // Between starting an attempt and the host reporting `connecting`, the + // status is still the one the attempt started from. Treat that window as + // in flight so the recovery action cannot flash back into view. A status + // that actually changed means the host reported something newer — a fresh + // failure, say — which wins over this placeholder. + const settling = status?.kind === 'connecting' + || status?.kind === 'reconnecting' + || isSameRemoteConnectionStatus(status, attempt.statusBefore); + return settling + ? localize('sessionRemoteHost.waitingForConnection', "Waiting for agent host connection...") + : undefined; + } + + private _getRemoteHostUnavailableContent(reader: IReader): IRemoteHostUnavailableEmptyStateContent | undefined { + const session = this._session.read(reader); + if (!session) { + return undefined; + } + + const status = this._getEffectiveStatus(reader); + const provider = this._sessionsProvidersService.getProvider(session.providerId); + const hostLabel = provider?.label ?? localize('sessionRemoteHost.unknown', "The remote host"); + if (status?.kind === 'connected') { + return undefined; + } + if (status?.kind === 'incompatible') { + return { + title: localize('sessionRemoteHost.incompatibleTitle', "Cannot Connect to {0}", hostLabel), + description: localize('sessionRemoteHost.incompatibleDescription', "{0} is incompatible with this version of Visual Studio Code.", hostLabel), + }; + } + const progressMessage = this._getRemoteHostConnectProgress(session, status, reader); + const autoConnectPending = this._autoConnectPending.read(reader); + const canStartHost = !!provider && isAgentHostProvider(provider) && !!provider.connect; + const autoConnect = canStartHost && provider.autoConnect + ? { + label: provider.autoConnect.label, + checked: provider.autoConnect.enabled.read(reader), + onChange: (checked: boolean) => provider.autoConnect!.setEnabled(checked), + } + : undefined; + const attempt = this._attempt.read(reader); + const startedFromStoppedHost = autoConnectPending + || (attempt?.kind === 'active' + && attempt.session === session + && attempt.statusBefore?.kind === 'disconnected' + && attempt.statusBefore.reason === SessionRemoteConnectionFailureReason.HostNotRunning); + if (progressMessage || autoConnectPending) { + return { + title: localize('sessionRemoteHost.connectingTitle', "Connecting to {0}", hostLabel), + description: localize('sessionRemoteHost.startingDescription', "Starting {0}.", hostLabel), + progress: progressMessage ?? localize('sessionRemoteHost.waitingForConnection', "Waiting for agent host connection..."), + autoConnect: startedFromStoppedHost ? autoConnect : undefined, + }; + } + if (!status || status.kind !== 'disconnected') { + return undefined; + } + if (status.reason === SessionRemoteConnectionFailureReason.HostNotRunning) { + return { + title: localize('sessionRemoteHost.notRunningTitle', "Unable to Connect to {0}", hostLabel), + description: localize('sessionRemoteHost.notRunning', "{0} is not running.", hostLabel), + action: provider && isAgentHostProvider(provider) && provider.connect + ? { + label: localize('sessionRemoteHost.start', "Start {0}", hostLabel), + run: () => this.connect(), + } + : undefined, + autoConnect, + }; + } + return { + title: localize('sessionRemoteHost.disconnectedTitle', "Cannot Connect to {0}", hostLabel), + description: localize('sessionRemoteHost.disconnected', "Cannot reach {0}.", hostLabel), + // An unreachable host is often transient — a dropped tunnel, a sleeping + // machine — so offer a manual retry even though there is nothing local + // to start and so nothing to do automatically. + action: canStartHost + ? { + label: localize('sessionRemoteHost.retry', "Retry"), + run: () => this.connect(), + } + : undefined, + }; + } + + private _getRemoteConnectionBannerContent(reader: IReader): ISessionReadOnlyBannerContent | undefined { + const session = this._session.read(reader); + if (!session) { + return undefined; + } + + const status = this._getEffectiveStatus(reader); + const provider = this._sessionsProvidersService.getProvider(session.providerId); + const hostLabel = provider?.label ?? localize('sessionRemoteHost.unknown', "The remote host"); + const progressMessage = this._getRemoteHostConnectProgress(session, status, reader); + // Mirror the recovery state: while an automatic start is pending the + // action must not appear, even for the frame before the attempt registers. + if (progressMessage || this._autoConnectPending.read(reader)) { + return { + icon: Codicon.sync, + message: progressMessage ?? localize('sessionRemoteHost.waitingForConnection', "Waiting for agent host connection..."), + }; + } + + if (!status || status.kind === 'connected' || status.kind === 'connecting') { + return undefined; + } + + if (status.kind === 'incompatible') { + // The centered recovery state is skipped once a transcript is rendered, + // so this banner is the only place the incompatibility can be explained. + return { + icon: Codicon.debugDisconnect, + message: localize('sessionRemoteHost.incompatibleDescription', "{0} is incompatible with this version of Visual Studio Code.", hostLabel), + }; + } + + if (status.kind === 'reconnecting') { + const seconds = this._reconnectCountdownSeconds.read(reader); + return this._reconnectingBannerVisible.read(reader) + ? { + icon: Codicon.sync, + message: seconds === undefined + ? localize('sessionRemoteHost.reconnecting', "Reconnecting to {0}...", hostLabel) + : localize('sessionRemoteHost.reconnectingIn', "Reconnecting to {0} in {1}s", hostLabel, seconds), + // The banner is a live region, so the per-second countdown would + // otherwise queue an announcement every tick. Keep the spoken + // text stable and let only the visible text count down. + ariaLabel: localize('sessionRemoteHost.reconnecting', "Reconnecting to {0}...", hostLabel), + action: seconds !== undefined && provider && isAgentHostProvider(provider) && provider.reconnectNow + ? { + label: localize('sessionRemoteHost.tryNow', "Try Now"), + run: () => provider.reconnectNow?.(), + } + : undefined, + } + : undefined; + } + if (status.reason === SessionRemoteConnectionFailureReason.HostNotRunning) { + return { + icon: Codicon.debugDisconnect, + message: localize('sessionRemoteHost.notRunning', "{0} is not running.", hostLabel), + action: provider && isAgentHostProvider(provider) && provider.connect + ? { + label: localize('sessionRemoteHost.start', "Start {0}", hostLabel), + run: () => this.connect(), + } + : undefined, + }; + } + return { + icon: Codicon.debugDisconnect, + message: localize('sessionRemoteHost.disconnected', "Cannot reach {0}.", hostLabel), + // A dropped tunnel or a sleeping machine is usually transient, so a + // manual retry is worth offering even with nothing local to start. + action: provider && isAgentHostProvider(provider) && provider.connect + ? { + label: localize('sessionRemoteHost.retry', "Retry"), + run: () => this.connect(), + } + : undefined, + }; + } +} diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index 692b9819af886d..5a47d165a3349c 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -31,6 +31,21 @@ export interface IAgentMergeClientState { readonly overrides?: AgentMergeSessionOverrides; } +/** + * Opt-in policy offered on a remote host's recovery surface: when enabled, + * opening a chat whose host is not running starts the host instead of + * waiting for the user to click. Scoped to the host *kind* rather than an + * individual host, so the label names the kind ("WSL", not "Ubuntu-24.04") + * and toggling it from any one host applies to all hosts of that kind. + * Providers choose how the value is backed. + */ +export interface IAgentHostAutoConnect { + /** Checkbox label, e.g. "Automatically Start WSL When Opening Chats". */ + readonly label: string; + readonly enabled: IObservable<boolean>; + setEnabled(enabled: boolean): void; +} + /** * Declares that a provider is one of many interchangeable members of a single * user-facing host. Members collapse into one `IAgentHostFilterEntry` that @@ -126,6 +141,17 @@ export interface IAgentHostSessionsProvider extends ISessionsProvider { * it. Present on remote providers that manage their own transport. */ disconnect?(): Promise<void>; + /** + * Skips a pending reconnect backoff and retries at once. Present on remote + * providers whose transport is restored by a protocol client. + */ + reconnectNow?(): void; + /** + * Kind-scoped auto-start policy surfaced on the recovery screen. Present + * on remote providers whose host can be started locally; omitted where + * starting is not something VS Code can do. + */ + readonly autoConnect?: IAgentHostAutoConnect; /** * When `true`, the workspace picker keeps this provider's browse diff --git a/src/vs/sessions/contrib/automations/browser/automations.contribution.ts b/src/vs/sessions/contrib/automations/browser/automations.contribution.ts index e4444e439f3145..bd7e01248468cb 100644 --- a/src/vs/sessions/contrib/automations/browser/automations.contribution.ts +++ b/src/vs/sessions/contrib/automations/browser/automations.contribution.ts @@ -38,7 +38,7 @@ Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).regis properties: { [CHAT_AUTOMATIONS_ENABLED_SETTING]: { type: 'boolean', - default: false, + default: product.quality !== 'stable', scope: ConfigurationScope.MACHINE, tags: ['experimental', 'advanced'], description: localize('chat.automations.enabled', "Enables the Automations feature: scheduling agent sessions to run on a cadence. When disabled, the Automations entry in the Customizations sidebar, the Automations section in the Customizations editor, and the Automation option in the new-session composer are hidden, and scheduled automations are not dispatched."), diff --git a/src/vs/sessions/contrib/automations/test/browser/automations.contribution.test.ts b/src/vs/sessions/contrib/automations/test/browser/automations.contribution.test.ts new file mode 100644 index 00000000000000..24d7a7d1f4c1a5 --- /dev/null +++ b/src/vs/sessions/contrib/automations/test/browser/automations.contribution.test.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import product from '../../../../../platform/product/common/product.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; + +import '../../browser/automations.contribution.js'; + +const configurationRegistry = Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration); +const automationEnabledProperty = configurationRegistry.getConfigurationProperties()[CHAT_AUTOMATIONS_ENABLED_SETTING] + ?? configurationRegistry.getExcludedConfigurationProperties()[CHAT_AUTOMATIONS_ENABLED_SETTING]; + +suite('Automations Contribution', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('defaults Automations to enabled in non-Stable builds', () => { + assert.deepStrictEqual({ + default: automationEnabledProperty.default, + included: automationEnabledProperty.included, + }, { + default: product.quality !== 'stable', + included: product.quality !== 'stable', + }); + }); +}); diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 072b01cfbf5b57..85cd98d397de09 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -64,7 +64,7 @@ import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../workbench/ import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { IWorkspaceFolderLabelService } from '../../../../workbench/services/workspaces/common/workspaceFolderLabelService.js'; -import { IMultiDiffEditorOptions } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; +import { IMultiDiffEditorOptions } from '../../../../editor/common/multiDiffEditor.js'; import { isDiffEditor } from '../../../../editor/browser/editorBrowser.js'; import { getChangesEditorLabels } from './changesEditorLabels.js'; import { ISessionChangesService } from './sessionChangesService.js'; diff --git a/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditorInput.css b/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditorInput.css new file mode 100644 index 00000000000000..297b2904768994 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditorInput.css @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-workbench .part.editor .session-changes-editor-label.monaco-decoration-badge::after { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + min-width: var(--vscode-spacing-size160); + height: var(--vscode-spacing-size160); + padding: 0 var(--vscode-spacing-size40); + margin: auto 0 auto var(--vscode-spacing-size40); + border: var(--vscode-strokeThickness) solid var(--vscode-contrastBorder, transparent); + border-radius: var(--vscode-cornerRadius-small); + background-color: var(--vscode-agentsBadge-background); + color: var(--vscode-agentsBadge-foreground); + font-size: var(--vscode-fontSize-label3); + font-weight: var(--vscode-fontWeight-semiBold); + line-height: normal; + opacity: 1; +} diff --git a/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts index c97a4759304d37..2f296da8bebfa9 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts @@ -31,9 +31,10 @@ import { IEditorGroup, IEditorGroupsService } from '../../../../workbench/servic import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { MultiDiffEditorWidget } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; import { MultiDiffEditorViewModel } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.js'; -import { IMultiDiffEditorLayoutDebugState, IMultiDiffEditorOptions, IMultiDiffEditorViewState } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; +import { IMultiDiffEditorLayoutDebugState, IMultiDiffEditorViewState } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; import { MultiDiffEditorLogger } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorLogging.js'; import { IDiffEditorOptions } from '../../../../editor/common/config/editorOptions.js'; +import { IMultiDiffEditorOptions } from '../../../../editor/common/multiDiffEditor.js'; import { ITextResourceConfigurationService } from '../../../../editor/common/services/textResourceConfiguration.js'; import { IResourceLabel, IWorkbenchUIElementFactory, MultiDiffEditorItemLabelKind } from '../../../../editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.js'; import { Menus } from '../../../browser/menus.js'; diff --git a/src/vs/sessions/contrib/changes/browser/sessionChangesEditorInput.ts b/src/vs/sessions/contrib/changes/browser/sessionChangesEditorInput.ts index 1d686753d19645..37a319c2551116 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionChangesEditorInput.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionChangesEditorInput.ts @@ -3,9 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import './media/sessionChangesEditorInput.css'; import { localize } from '../../../../nls.js'; import { mainWindow } from '../../../../base/browser/window.js'; import { Codicon } from '../../../../base/common/codicons.js'; +import { Event } from '../../../../base/common/event.js'; +import { MutableDisposable } from '../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { EditorInputCapabilities, IEditorSerializer, IUntypedEditorInput, Verbosity } from '../../../../workbench/common/editor.js'; @@ -15,7 +18,8 @@ import { MultiDiffEditorInput } from '../../../../workbench/contrib/multiDiffEdi import { MultiDiffEditorViewModel } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.js'; import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; import { DockedEditorInput } from '../../../common/dockedEditorInput.js'; -import { MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { getSessionChangesFileCountLabel } from '../common/changes.js'; +import { ISessionChangesService } from '../common/sessionChangesService.js'; /** * Editor input for the Agents window Changes tab. It wraps the session's @@ -32,6 +36,7 @@ export class SessionChangesEditorInput extends DockedEditorInput { constructor( readonly multiDiffSource: URI, @IInstantiationService private readonly instantiationService: IInstantiationService, + @ISessionChangesService private readonly sessionChangesService: ISessionChangesService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, ) { super(); @@ -40,6 +45,9 @@ export class SessionChangesEditorInput extends DockedEditorInput { this._onDidChangeCapabilities.fire(); } })); + + const onDidChangeCount = Event.fromObservableLight(sessionChangesService.activeSessionChangeCountObs); + this._register(onDidChangeCount(() => this._onDidChangeLabel.fire())); } override get resource(): URI { @@ -63,6 +71,17 @@ export class SessionChangesEditorInput extends DockedEditorInput { return localize('sessionChangesEditor.name', "Changes"); } + override getAriaLabel(): string { + const changeCount = this.sessionChangesService.activeSessionChangeCountObs.get(); + return changeCount === 0 + ? this.getName() + : localize('sessionChangesEditor.ariaLabel', "{0}, {1}", this.getName(), getSessionChangesFileCountLabel(changeCount)); + } + + override getLabelExtraClasses(): string[] { + return ['session-changes-editor-label']; + } + override getIcon(): ThemeIcon { return Codicon.diffMultiple; } diff --git a/src/vs/sessions/contrib/changes/browser/sessionChangesService.ts b/src/vs/sessions/contrib/changes/browser/sessionChangesService.ts index 6d6a5f8302280a..f44ba6ff62479b 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionChangesService.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionChangesService.ts @@ -3,62 +3,27 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derived, IObservable } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { isEqual } from '../../../../base/common/resources.js'; import { localize } from '../../../../nls.js'; -import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IMultiDiffEditorOptions } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; +import { IMultiDiffEditorOptions } from '../../../../editor/common/multiDiffEditor.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { EditorInput } from '../../../../workbench/common/editor/editorInput.js'; import { MultiDiffEditorInput } from '../../../../workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.js'; -import { IEditorService, PreferredGroup } from '../../../../workbench/services/editor/common/editorService.js'; +import { IDecorationData, IDecorationsProvider, IDecorationsService } from '../../../../workbench/services/decorations/common/decorations.js'; import { IEditorGroup } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { IEditorService, PreferredGroup } from '../../../../workbench/services/editor/common/editorService.js'; import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; -import { ISessionChangeset } from '../../../services/sessions/common/session.js'; +import { getSessionChangesFileCountLabel } from '../common/changes.js'; import { IChangesViewService } from '../common/changesViewService.js'; import { SessionChangesEditorInput } from './sessionChangesEditorInput.js'; +import { ISessionChangesEditorOptions, ISessionChangesService } from '../common/sessionChangesService.js'; -export const ISessionChangesService = createDecorator<ISessionChangesService>('sessionChangesService'); - -/** Options for opening a session Changes editor with an optional changeset selection. */ -export interface ISessionChangesEditorOptions extends IMultiDiffEditorOptions { - readonly changesetSelection?: - | { readonly kind: 'id'; readonly id: string | undefined } - | { readonly kind: 'transient'; readonly changeset: ISessionChangeset }; -} - -/** - * Owns the identity of a session's **Changes** (multi-file diff) editor. It is - * the single source of truth for the `changes-multi-diff-source:` resource that - * the multi-diff editor is opened with, so callers don't have to know the URI - * shape: the session header action and the Changes view open the editor with - * {@link openChangesEditor}, the layout controller recognizes the active - * editor as a Changes editor with {@link getSessionResource}, and the - * multi-diff source resolver uses both. - */ -export interface ISessionChangesService { - readonly _serviceBrand: undefined; - - /** - * Build the multi-diff source URI that identifies the Changes editor for a - * session. Opening an editor with this resource shows the session's changes; - * reusing the same URI reuses the same editor input while the resource list - * updates reactively. - */ - getChangesEditorResource(sessionResource: URI): URI; - - /** - * If the given editor resource identifies a session Changes editor (one built - * by {@link getChangesEditorResource}), return the session it belongs to; - * otherwise `undefined`. - */ - getSessionResource(editorResource: URI): URI | undefined; - - /** - * Open the Changes editor for a session. In the single-pane layout this opens - * the custom {@link SessionChangesEditorInput}; otherwise a plain multi-diff editor. - */ - openChangesEditor(sessionResource: URI, options?: ISessionChangesEditorOptions, group?: PreferredGroup): Promise<IEditorGroup | undefined>; -} +export { ISessionChangesService } from '../common/sessionChangesService.js'; +export type { ISessionChangesEditorOptions } from '../common/sessionChangesService.js'; const CHANGES_MULTI_DIFF_SOURCE_SCHEME = 'changes-multi-diff-source'; @@ -66,16 +31,61 @@ interface IChangesMultiDiffUriFields { readonly sessionResource: string; } -export class SessionChangesService implements ISessionChangesService { +export class SessionChangesService extends Disposable implements ISessionChangesService { declare readonly _serviceBrand: undefined; + readonly activeSessionChangeCountObs: IObservable<number>; + + private readonly _onDidChangeDecorations = this._register(new Emitter<readonly URI[]>()); + + private _decoratedChangeCount = 0; + private _decoratedResource: URI | undefined; constructor( @IEditorService private readonly editorService: IEditorService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IAgentWorkbenchLayoutService private readonly layoutService: IAgentWorkbenchLayoutService, @IChangesViewService private readonly changesViewService: IChangesViewService, - ) { } + @IDecorationsService decorationsService: IDecorationsService, + ) { + super(); + + this.activeSessionChangeCountObs = derived(this, reader => changesViewService.activeSessionChangesObs.read(reader).length); + + if (!layoutService.isSinglePaneLayoutEnabled) { + return; + } + + const provider = { + label: localize('sessionChangesEditor.decorations', "Changes"), + onDidChange: this._onDidChangeDecorations.event, + provideDecorations: resource => this._provideDecoration(resource), + } satisfies IDecorationsProvider; + this._register(decorationsService.registerDecorationsProvider(provider)); + + this._register(autorun(reader => { + const activeSessionResource = changesViewService.activeSessionResourceObs.read(reader); + const changeCount = this.activeSessionChangeCountObs.read(reader); + const resource = activeSessionResource ? this.getChangesEditorResource(activeSessionResource) : undefined; + if (isEqual(this._decoratedResource, resource) && this._decoratedChangeCount === changeCount) { + return; + } + + const affectedResources: URI[] = []; + if (this._decoratedResource) { + affectedResources.push(this._decoratedResource); + } + if (resource && !isEqual(this._decoratedResource, resource)) { + affectedResources.push(resource); + } + + this._decoratedResource = resource; + this._decoratedChangeCount = changeCount; + if (affectedResources.length > 0) { + this._onDidChangeDecorations.fire(affectedResources); + } + })); + } getChangesEditorResource(sessionResource: URI): URI { return URI.from({ @@ -134,6 +144,18 @@ export class SessionChangesService implements ISessionChangesService { return pane?.group; } + private _provideDecoration(resource: URI): IDecorationData | undefined { + if (this._decoratedChangeCount === 0 || !isEqual(resource, this._decoratedResource)) { + return undefined; + } + + return { + weight: 100, + letter: this._decoratedChangeCount < 10 ? this._decoratedChangeCount.toString() : '9+', + tooltip: getSessionChangesFileCountLabel(this._decoratedChangeCount), + }; + } + private async expandRevealTarget(input: EditorInput | undefined, options: IMultiDiffEditorOptions | undefined): Promise<void> { const resource = options?.viewState?.revealData?.resource; if (!resource || !(input instanceof SessionChangesEditorInput || input instanceof MultiDiffEditorInput)) { diff --git a/src/vs/sessions/contrib/changes/common/changes.ts b/src/vs/sessions/contrib/changes/common/changes.ts index e66924b7512e70..a4315ef85c31b1 100644 --- a/src/vs/sessions/contrib/changes/common/changes.ts +++ b/src/vs/sessions/contrib/changes/common/changes.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { localize } from '../../../../nls.js'; import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; export const CHANGES_VIEW_ID = 'workbench.view.agentSessions.changes'; @@ -24,6 +25,12 @@ export const VIEW_SESSION_CHANGES_COMMAND_ID = 'workbench.agentSessions.action.v */ export const SESSIONS_CHANGES_OPEN_SINGLE_FILE_DIFF_SETTING = 'sessions.changes.openSingleFileDiff'; +export function getSessionChangesFileCountLabel(changeCount: number): string { + return changeCount === 1 + ? localize('sessionChangesEditor.oneChangedFile', "1 file") + : localize('sessionChangesEditor.changedFiles', "{0} files", changeCount); +} + export const enum ChangesViewMode { List = 'list', Tree = 'tree' diff --git a/src/vs/sessions/contrib/changes/common/sessionChangesService.ts b/src/vs/sessions/contrib/changes/common/sessionChangesService.ts new file mode 100644 index 00000000000000..2d01b7e0b5bc6b --- /dev/null +++ b/src/vs/sessions/contrib/changes/common/sessionChangesService.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IObservable } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IMultiDiffEditorOptions } from '../../../../editor/common/multiDiffEditor.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IEditorGroup } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { PreferredGroup } from '../../../../workbench/services/editor/common/editorService.js'; +import { ISessionChangeset } from '../../../services/sessions/common/session.js'; + +export const ISessionChangesService = createDecorator<ISessionChangesService>('sessionChangesService'); + +/** Options for opening a session Changes editor with an optional changeset selection. */ +export interface ISessionChangesEditorOptions extends IMultiDiffEditorOptions { + readonly changesetSelection?: + | { readonly kind: 'id'; readonly id: string | undefined } + | { readonly kind: 'transient'; readonly changeset: ISessionChangeset }; +} + +/** Owns the identity and presentation state of a session's Changes editor. */ +export interface ISessionChangesService { + readonly _serviceBrand: undefined; + readonly activeSessionChangeCountObs: IObservable<number>; + + /** Builds the multi-diff source URI that identifies a session's Changes editor. */ + getChangesEditorResource(sessionResource: URI): URI; + + /** Returns the session identified by a Changes editor resource. */ + getSessionResource(editorResource: URI): URI | undefined; + + /** Opens the Changes editor for a session. */ + openChangesEditor(sessionResource: URI, options?: ISessionChangesEditorOptions, group?: PreferredGroup): Promise<IEditorGroup | undefined>; +} diff --git a/src/vs/sessions/contrib/changes/test/browser/sessionChangesEditorInput.test.ts b/src/vs/sessions/contrib/changes/test/browser/sessionChangesEditorInput.test.ts index 6fff73fd0706bc..c918d70986ff64 100644 --- a/src/vs/sessions/contrib/changes/test/browser/sessionChangesEditorInput.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/sessionChangesEditorInput.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Emitter, Event, ValueWithChangeEvent } from '../../../../../base/common/event.js'; +import { constObservable, derived, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; @@ -16,6 +17,7 @@ import { MultiDiffEditorInput } from '../../../../../workbench/contrib/multiDiff import { IPartVisibilityChangeEvent, IWorkbenchLayoutService, Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { TestEditorGroupView, workbenchInstantiationService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; +import { ISessionFileChange } from '../../../../services/sessions/common/session.js'; import { SessionChangesEditor } from '../../browser/sessionChangesEditor.js'; import { SessionChangesEditorInput } from '../../browser/sessionChangesEditorInput.js'; import { ISessionChangesService } from '../../browser/sessionChangesService.js'; @@ -23,6 +25,12 @@ import { IChangesViewService } from '../../common/changesViewService.js'; suite('SessionChangesEditorInput', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + const emptyChangesViewService = new class extends mock<IChangesViewService>() { + override readonly activeSessionChangesObs = constObservable<readonly ISessionFileChange[]>([]); + }; + const emptySessionChangesService = new class extends mock<ISessionChangesService>() { + override readonly activeSessionChangeCountObs = constObservable(0); + }; test('releases resolved multi-diff models without disposing restorable input state', async () => { const instantiationService = disposables.add(new TestInstantiationService()); @@ -32,6 +40,8 @@ suite('SessionChangesEditorInput', () => { return true; } }); + instantiationService.stub(IChangesViewService, emptyChangesViewService); + instantiationService.stub(ISessionChangesService, emptySessionChangesService); const viewModel = disposables.add(new MultiDiffEditorViewModel({ documents: ValueWithChangeEvent.const([]), }, instantiationService)); @@ -87,9 +97,9 @@ suite('SessionChangesEditorInput', () => { } const instantiationService = workbenchInstantiationService(undefined, disposables); - instantiationService.stub(IChangesViewService, {}); + instantiationService.stub(IChangesViewService, emptyChangesViewService); instantiationService.stub(IAgentWorkbenchLayoutService, {}); - instantiationService.stub(ISessionChangesService, {}); + instantiationService.stub(ISessionChangesService, emptySessionChangesService); instantiationService.stub(IWorkbenchLayoutService, { onDidChangePartVisibility: Event.None, isVisible: () => true, @@ -124,9 +134,9 @@ suite('SessionChangesEditorInput', () => { } const instantiationService = workbenchInstantiationService(undefined, disposables); - instantiationService.stub(IChangesViewService, {}); + instantiationService.stub(IChangesViewService, emptyChangesViewService); instantiationService.stub(IAgentWorkbenchLayoutService, {}); - instantiationService.stub(ISessionChangesService, {}); + instantiationService.stub(ISessionChangesService, emptySessionChangesService); instantiationService.stub(IWorkbenchLayoutService, { onDidChangePartVisibility: Event.None, isVisible: () => true, @@ -155,7 +165,12 @@ suite('SessionChangesEditorInput', () => { return part === Parts.EDITOR_PART && editorVisible; } }; - const input = disposables.add(new SessionChangesEditorInput(URI.parse('test-changes:session'), instantiationService, layoutService)); + const input = disposables.add(new SessionChangesEditorInput( + URI.parse('test-changes:session'), + instantiationService, + emptySessionChangesService, + layoutService, + )); let capabilitiesChanges = 0; disposables.add(input.onDidChangeCapabilities(() => capabilitiesChanges++)); @@ -178,4 +193,54 @@ suite('SessionChangesEditorInput', () => { capabilitiesChanges: 1 }); }); + + test('updates the tab badge class and accessible label with the changed file count', () => { + const instantiationService = disposables.add(new TestInstantiationService()); + const changes = observableValue<readonly ISessionFileChange[]>('changes', []); + const layoutService = new class extends mock<IWorkbenchLayoutService>() { + override readonly onDidChangePartVisibility = Event.None; + override isVisible(): boolean { + return true; + } + }; + const resource = URI.parse('test-changes:session'); + const sessionChangesService = new class extends mock<ISessionChangesService>() { + override readonly activeSessionChangeCountObs = derived(reader => changes.read(reader).length); + }; + const input = disposables.add(new SessionChangesEditorInput( + resource, + instantiationService, + sessionChangesService, + layoutService, + )); + let labelChanges = 0; + disposables.add(input.onDidChangeLabel(() => labelChanges++)); + + changes.set([createFileChange(1)], undefined); + const oneChangeAriaLabel = input.getAriaLabel(); + changes.set(Array.from({ length: 10 }, (_, index) => createFileChange(index)), undefined); + + assert.deepStrictEqual({ + oneChangeAriaLabel, + tenChangesAriaLabel: input.getAriaLabel(), + labelExtraClasses: input.getLabelExtraClasses(), + labelChanges, + }, { + oneChangeAriaLabel: 'Changes, 1 file', + tenChangesAriaLabel: 'Changes, 10 files', + labelExtraClasses: ['session-changes-editor-label'], + labelChanges: 2, + }); + }); }); + +function createFileChange(index: number): ISessionFileChange { + const uri = URI.file(`/workspace/file${index}.ts`); + return { + uri, + originalUri: uri.with({ scheme: 'git', query: 'ref=base' }), + modifiedUri: uri.with({ scheme: 'git', query: 'ref=head' }), + insertions: 1, + deletions: 0, + }; +} diff --git a/src/vs/sessions/contrib/changes/test/browser/sessionChangesService.test.ts b/src/vs/sessions/contrib/changes/test/browser/sessionChangesService.test.ts index 83765ea745154a..be3088ecb7e7e8 100644 --- a/src/vs/sessions/contrib/changes/test/browser/sessionChangesService.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/sessionChangesService.test.ts @@ -4,28 +4,38 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Event } from '../../../../../base/common/event.js'; -import { constObservable } from '../../../../../base/common/observable.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { constObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { DocumentDiffItemViewModel, MultiDiffEditorViewModel } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.js'; -import { IMultiDiffEditorOptions } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; +import { IMultiDiffEditorOptions } from '../../../../../editor/common/multiDiffEditor.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ITextDiffEditorPane, isResourceMultiDiffEditorInput } from '../../../../../workbench/common/editor.js'; import { MultiDiffEditorInput } from '../../../../../workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.js'; +import { IDecorationsProvider, IDecorationsService } from '../../../../../workbench/services/decorations/common/decorations.js'; import { IEditorGroup } from '../../../../../workbench/services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; import { IWorkbenchLayoutService } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; -import { ISessionChangeset, TURN_CHANGES_CHANGESET_ID } from '../../../../services/sessions/common/session.js'; +import { ISessionChangeset, ISessionFileChange, TURN_CHANGES_CHANGESET_ID } from '../../../../services/sessions/common/session.js'; import { SessionChangesEditorInput } from '../../browser/sessionChangesEditorInput.js'; -import { SessionChangesService } from '../../browser/sessionChangesService.js'; +import { ISessionChangesService, SessionChangesService } from '../../browser/sessionChangesService.js'; import { IChangesViewService } from '../../common/changesViewService.js'; suite('SessionChangesService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + const noActiveSessionResource = constObservable<URI | undefined>(undefined); + const noChanges = constObservable<readonly ISessionFileChange[]>([]); + const emptyDecorationsService = new class extends mock<IDecorationsService>() { + override registerDecorationsProvider() { + return Disposable.None; + } + }; test('expands a revealed file in both Changes editor layouts', async () => { const originalUri = URI.file('/workspace/file.original.ts'); @@ -81,12 +91,19 @@ suite('SessionChangesService', () => { }(); } }(); - const service = new SessionChangesService( + const changesViewService = new class extends mock<IChangesViewService>() { + override readonly activeSessionResourceObs = noActiveSessionResource; + override readonly activeSessionChangesObs = noChanges; + }; + instantiationService.stub(IChangesViewService, changesViewService); + const service = disposables.add(new SessionChangesService( editorService, instantiationService, layoutService, - new class extends mock<IChangesViewService>() { }, - ); + changesViewService, + emptyDecorationsService, + )); + instantiationService.stub(ISessionChangesService, service); await service.openChangesEditor(URI.parse('test-session:/session'), options); } @@ -113,6 +130,8 @@ suite('SessionChangesService', () => { override readonly isSinglePaneLayoutEnabled = false; }(); const changesViewService = new class extends mock<IChangesViewService>() { + override readonly activeSessionResourceObs = noActiveSessionResource; + override readonly activeSessionChangesObs = noChanges; override setChangesetId(changesetId: string | undefined): void { selections.push({ changesetId }); } @@ -120,12 +139,13 @@ suite('SessionChangesService', () => { selections.push({ transientChangesetId: changeset.id }); } }(); - const service = new SessionChangesService( + const service = disposables.add(new SessionChangesService( editorService, disposables.add(new TestInstantiationService()), layoutService, changesViewService, - ); + emptyDecorationsService, + )); const sessionResource = URI.parse('agent-host:test-session'); await service.openChangesEditor(sessionResource, { @@ -180,17 +200,95 @@ suite('SessionChangesService', () => { }(); const layoutService = new class extends mock<IAgentWorkbenchLayoutService>() { override readonly isSinglePaneLayoutEnabled = true; + override readonly onDidChangePartVisibility = Event.None; + override isVisible(): boolean { + return true; + } }(); const changesViewService = new class extends mock<IChangesViewService>() { + override readonly activeSessionResourceObs = noActiveSessionResource; + override readonly activeSessionChangesObs = noChanges; override showChangeset(changeset: ISessionChangeset): void { selections.push(changeset.id); } }(); - const service = new SessionChangesService(editorService, instantiationService, layoutService, changesViewService); + instantiationService.stub(IWorkbenchLayoutService, layoutService); + instantiationService.stub(IChangesViewService, changesViewService); + const service = disposables.add(new SessionChangesService(editorService, instantiationService, layoutService, changesViewService, emptyDecorationsService)); + instantiationService.stub(ISessionChangesService, service); await service.openChangesEditor(URI.parse('agent-host:test-session'), { changesetSelection: { kind: 'transient', changeset: upcastPartial<ISessionChangeset>({ id: 'turn:request' }) }, }); assert.deepStrictEqual(selections, ['turn:request']); }); + + test('registers one decoration provider across repeated Changes editor opens', async () => { + const sessionResource = URI.parse('agent-host:test-session'); + const activeSessionResource = observableValue<URI | undefined>('activeSessionResource', undefined); + const changes = observableValue<readonly ISessionFileChange[]>('changes', []); + const changesViewService = new class extends mock<IChangesViewService>() { + override readonly activeSessionResourceObs = activeSessionResource; + override readonly activeSessionChangesObs = changes; + }; + const providers: IDecorationsProvider[] = []; + const decorationsService = new class extends mock<IDecorationsService>() { + override registerDecorationsProvider(provider: IDecorationsProvider) { + providers.push(provider); + return Disposable.None; + } + }; + const layoutService = new class extends mock<IAgentWorkbenchLayoutService>() { + override readonly isSinglePaneLayoutEnabled = true; + override readonly onDidChangePartVisibility = Event.None; + override isVisible(): boolean { + return true; + } + }; + const instantiationService = disposables.add(new TestInstantiationService(new ServiceCollection( + [IWorkbenchLayoutService, layoutService], + [IChangesViewService, changesViewService], + ))); + const editorService = new class extends mock<IEditorService>() { + override async openEditor(...args: unknown[]): Promise<undefined> { + const input = args[0]; + if (input instanceof SessionChangesEditorInput) { + disposables.add(input); + } + return undefined; + } + }; + const service = disposables.add(new SessionChangesService( + editorService, + instantiationService, + layoutService, + changesViewService, + decorationsService, + )); + instantiationService.stub(ISessionChangesService, service); + activeSessionResource.set(sessionResource, undefined); + changes.set([{ + uri: URI.file('/workspace/file.ts'), + insertions: 1, + deletions: 0, + }], undefined); + + await service.openChangesEditor(sessionResource); + await service.openChangesEditor(sessionResource); + await service.openChangesEditor(sessionResource); + + assert.deepStrictEqual({ + providerCount: providers.length, + changeCount: service.activeSessionChangeCountObs.get(), + decoration: providers[0].provideDecorations(service.getChangesEditorResource(sessionResource), CancellationToken.None), + }, { + providerCount: 1, + changeCount: 1, + decoration: { + weight: 100, + letter: '1', + tooltip: '1 file', + }, + }); + }); }); diff --git a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts index 5c29bdca4c5ff8..6f37887ef69d1b 100644 --- a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts +++ b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts @@ -19,6 +19,7 @@ import { IFileService } from '../../../../platform/files/common/files.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { localize } from '../../../../nls.js'; import { AGENT_HOST_SCHEME } from '../../../../platform/agentHost/common/agentHostUri.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; /** * Agent Sessions override of IAICustomizationWorkspaceService. @@ -34,6 +35,7 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization declare readonly _serviceBrand: undefined; readonly activeProjectRoot: IObservable<URI | undefined>; + readonly activeProjectLabel: IObservable<string | undefined>; readonly hasOverrideProjectRoot: IObservable<boolean>; /** @@ -50,6 +52,7 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization @ILogService private readonly logService: ILogService, @IFileService private readonly fileService: IFileService, @INotificationService private readonly notificationService: INotificationService, + @ILabelService private readonly labelService: ILabelService, ) { this._overrideRoot = observableValue(this, undefined); @@ -67,6 +70,15 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization return root; }); + this.activeProjectLabel = derived(reader => { + const override = this._overrideRoot.read(reader); + if (override) { + return this.labelService.getUriBasenameLabel(override); + } + const session = this.sessionsService.activeSession.read(reader); + return session?.workspace.read(reader)?.folders[0]?.name; + }); + this.hasOverrideProjectRoot = derived(reader => { return this._overrideRoot.read(reader) !== undefined; }); diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index 772a21f6d48e86..0affeda1917ba1 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -174,10 +174,13 @@ export class ChatView extends AbstractChatView { private _currentChatResource: URI | undefined; private readonly _currentChatResourceObs = observableValue<URI | undefined>(this, undefined); private readonly _currentSessionObs = observableValue<ISession | undefined>(this, undefined); + override readonly hasVisibleTranscriptContent = observableValue(this, false); private _historyKey: string | undefined; /** Whether this view currently represents the active session. */ private _isActive = true; + /** Whether this view occupies the first group in the session's chat grid. */ + private _isPrimary = false; /** Observable mirror of {@link _isActive} so the voice overlay can react. */ private readonly _isActiveObs = observableValue<boolean>(this, true); @@ -305,6 +308,10 @@ export class ChatView extends AbstractChatView { })); this._register(chatPillsDebugService.register(this._chatPills, this._banners, this._isActiveObs)); this._ensureBannersMounted(); + this._register(this.chatSessionsService.onDidChangeContentProviderSchemes(({ added }) => { + // Remote providers are registered after their host connects, possibly after this view's initial load. + this._retryUnresolvedChatLoad(added); + })); this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING)) { @@ -370,6 +377,8 @@ export class ChatView extends AbstractChatView { const model = chatModel.read(reader); model?.lastRequestObs.read(reader); const requests = model?.getRequests() ?? []; + const hasVisibleRequest = requests.some(request => !request.isHiddenFromTranscript); + this.hasVisibleTranscriptContent.set(hasVisibleRequest, undefined); const entry = findInitialTranscriptContextEntry(requests); if (entry?.id === currentEntryId) { return; @@ -405,7 +414,7 @@ export class ChatView extends AbstractChatView { this.chatPillsDebugService.clear(this._chatPills); const previousSession = this._currentSessionObs.get(); this._currentSessionObs.set(session, undefined); - this._externalSessionBanner.setSession(session); + this._externalSessionBanner.setSession(this._isPrimary ? session : undefined); const resource = chat.resource; const previousChatResource = this._currentChatResource; const chatChanged = !isEqual(previousChatResource, resource); @@ -439,10 +448,23 @@ export class ChatView extends AbstractChatView { return; } + this.hasVisibleTranscriptContent.set(false, undefined); this._currentChatResource = resource; this._currentChatResourceObs.set(resource, undefined); this.logService.trace(`[ChatView] setChat start uri=${resource.toString()} session=${session?.resource.toString()}`); + this._loadChat(resource, session, previousChatResource, previousSession); + } + + private _retryUnresolvedChatLoad(addedSessionTypes: readonly string[]): void { + const resource = this._currentChatResource; + if (!resource || this._modelRef.value || !addedSessionTypes.includes(getChatSessionType(resource))) { + return; + } + this._loadChat(resource, this._currentSessionObs.get()); + } + + private _loadChat(resource: URI, session: ISession | undefined, previousChatResource?: URI, previousSession?: ISession): void { // Cancel any in-flight load for the previous chat and start a fresh one. this._loadCts.value?.cancel(); if (previousChatResource) { @@ -459,12 +481,13 @@ export class ChatView extends AbstractChatView { const loadPromise = this.chatService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, token, 'ChatView').then(ref => { const isCurrentChat = isEqual(this._currentChatResource, resource); - if (token.isCancellationRequested || !ref || !isCurrentChat) { + const isCurrentLoad = this._loadCts.value === cts; + if (token.isCancellationRequested || !ref || !isCurrentChat || !isCurrentLoad) { ref?.dispose(); - if (!token.isCancellationRequested && !ref && isCurrentChat && session) { + if (!token.isCancellationRequested && !ref && isCurrentChat && isCurrentLoad && session) { this.sessionOpenTelemetryService.modelBindFailed(session.resource, resource); } - if (isCurrentChat) { + if (isCurrentChat && isCurrentLoad) { this._widget.setLoading(false); } this.logService.trace(`[ChatView] setChat abandoned uri=${resource.toString()}`); @@ -494,12 +517,12 @@ export class ChatView extends AbstractChatView { } else { this.logService.trace(`[ChatView] setChat cancelled uri=${resource.toString()}`); } - if (isEqual(this._currentChatResource, resource)) { // might have changed while we were waiting, only reset if it is still the same + if (isEqual(this._currentChatResource, resource) && this._loadCts.value === cts) { // might have changed while we were waiting, only reset if it is still the same this._currentChatResource = undefined; this._currentChatResourceObs.set(undefined, undefined); this._widget.setLoading(false); } - if (!token.isCancellationRequested && session) { + if (!token.isCancellationRequested && this._loadCts.value === cts && session) { this.sessionOpenTelemetryService.modelBindFailed(session.resource, resource); } }); @@ -637,6 +660,14 @@ export class ChatView extends AbstractChatView { } } + override setPrimary(primary: boolean): void { + if (this._isPrimary === primary) { + return; + } + this._isPrimary = primary; + this._externalSessionBanner.setSession(primary ? this._currentSessionObs.get() : undefined); + } + override setActive(active: boolean): void { if (this._isActive === active) { return; diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index cb02d27df0d2ff..26008a8a05829e 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -156,6 +156,10 @@ display: none; } +.new-chat-widget-container .compact-picker .sessions-chat-sandbox-icon { + display: none; +} + .agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .compact-picker.action-item, .agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .compact-picker.sessions-chat-picker-slot { box-sizing: border-box; @@ -306,6 +310,11 @@ min-width: 0; } +.sessions-chat-sandbox-icon { + flex-shrink: 0; + margin-left: var(--vscode-spacing-size60); +} + .sessions-chat-picker-slot { display: flex; align-items: center; diff --git a/src/vs/sessions/contrib/chat/browser/mobile/mobileWorkspacePickerSheet.ts b/src/vs/sessions/contrib/chat/browser/mobile/mobileWorkspacePickerSheet.ts index 94e3c491ccd10f..07b0375e7e35c1 100644 --- a/src/vs/sessions/contrib/chat/browser/mobile/mobileWorkspacePickerSheet.ts +++ b/src/vs/sessions/contrib/chat/browser/mobile/mobileWorkspacePickerSheet.ts @@ -161,6 +161,7 @@ export async function showMobileWorkspacePickerSheet( items: readonly IActionListItem<IWorkspacePickerItem>[], dispatch: (item: IWorkspacePickerItem) => void, browseActions: readonly ISessionWorkspaceBrowseAction[], + showSearch = false, ): Promise<void> { const { rowItems, headerBrowseActions } = partitionItems(items, dispatch, browseActions); @@ -177,13 +178,13 @@ export async function showMobileWorkspacePickerSheet( // No workspaces / commands, no inline search, and we have a single // browse action — invoke it directly rather than opening a sheet // that would only contain it. - if (rowItems.length === 0 && inlineFolderActions.length === 0 && headerBrowseActions.length === 1) { + if (!showSearch && rowItems.length === 0 && inlineFolderActions.length === 0 && headerBrowseActions.length === 1) { headerBrowseActions[0].invoke(); return; } // No rows AND no header actions AND no inline search — nothing to show. - if (rowItems.length === 0 && inlineFolderActions.length === 0 && headerBrowseActions.length === 0) { + if (!showSearch && rowItems.length === 0 && inlineFolderActions.length === 0 && headerBrowseActions.length === 0) { return; } @@ -201,15 +202,27 @@ export async function showMobileWorkspacePickerSheet( let currentFolder: BrowsedFolder | undefined; // Track the current search query so drill-down can append to it. let currentSearchQuery = ''; - const search: IMobilePickerSheetSearchSource | undefined = inlineFolderActions.length > 0 + const search: IMobilePickerSheetSearchSource | undefined = inlineFolderActions.length > 0 || showSearch ? { - placeholder: localize('mobileWorkspacePicker.searchFolders', "Search folders…"), - resultsSectionTitle: localize('mobileWorkspacePicker.foldersSection', "Folders"), - emptyMessage: localize('mobileWorkspacePicker.noFolders', "No folders match"), + placeholder: showSearch + ? localize('mobileWorkspacePicker.searchWorkspaces', "Search Workspaces...") + : localize('mobileWorkspacePicker.searchFolders', "Search folders…"), + resultsSectionTitle: showSearch ? undefined : localize('mobileWorkspacePicker.foldersSection', "Folders"), + emptyMessage: showSearch + ? localize('mobileWorkspacePicker.noWorkspaces', "No workspaces match") + : localize('mobileWorkspacePicker.noFolders', "No folders match"), loadItems: async (query, token) => { currentSearchQuery = query; folderRunById.clear(); folderLabelById.clear(); + const normalizedQuery = query.trim().toLocaleLowerCase(); + const filteredRows = showSearch + ? rows + .map(row => row.sheetItem) + .filter(item => !normalizedQuery + || item.label.toLocaleLowerCase().includes(normalizedQuery) + || item.description?.toLocaleLowerCase().includes(normalizedQuery)) + : []; const results = await Promise.all( inlineFolderActions.map(async action => { try { @@ -224,7 +237,7 @@ export async function showMobileWorkspacePickerSheet( return []; } const flattened = results.flat(); - const sheetItems: IMobilePickerSheetItem[] = []; + const sheetItems: IMobilePickerSheetItem[] = [...filteredRows]; flattened.forEach((entry, idx) => { const id = `${SEARCH_RESULT_ID_PREFIX}${idx}`; const folderUri = entry.workspace.folders[0]?.root; @@ -267,11 +280,13 @@ export async function showMobileWorkspacePickerSheet( result = await showMobilePickerSheet( layoutService.mainContainer, localize('mobileWorkspacePicker.title', "Choose Workspace"), - rows.map(r => r.sheetItem), + showSearch ? [] : rows.map(r => r.sheetItem), { headerActions, search, - caption: localize('mobileWorkspacePicker.caption', "Search to browse folders on the host"), + caption: inlineFolderActions.length > 0 + ? localize('mobileWorkspacePicker.caption', "Search to browse folders on the host") + : undefined, stayOpenOnSelect: true, doneLabel: localize('mobileWorkspacePicker.cancel', "Cancel"), onDidSelect: (id) => { diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index 58f9cebbf44e86..f7219710a68b55 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -44,6 +44,7 @@ import { imageToHash, isImage } from '../../../../workbench/contrib/chat/browser import { getExcludes, ISearchConfiguration, ISearchService, QueryType } from '../../../../workbench/services/search/common/search.js'; import { createFileIconThemableTreeContainerScope } from '../../../../workbench/contrib/files/browser/views/explorerView.js'; import { ADDITIONAL_REPOSITORY_CONTEXT_ID_PREFIX } from '../common/newChatContextIds.js'; +import type { IWorkspacePickerContextAction } from './sessionWorkspacePicker.js'; /** * The attachment surface of the composer, as seen by its input plumbing @@ -59,6 +60,7 @@ export interface INewChatAttachments { } const GITHUB_CONTEXT_ID_PREFIX = 'github-context:'; +type IContextQuickPickItem = IQuickPickItem & { readonly contextAction?: IWorkspacePickerContextAction }; /** * Manages context attachments for the sessions new-chat widget. @@ -257,25 +259,14 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt // --- Picker --- - showPicker(folderUri?: URI): void { - const picker = this.quickInputService.createQuickPick<IQuickPickItem>({ useSeparators: true }); + showPicker(folderUri?: URI, contextActions: readonly IWorkspacePickerContextAction[] = []): void { + const picker = this.quickInputService.createQuickPick<IContextQuickPickItem>({ useSeparators: true }); const disposables = new DisposableStore(); picker.placeholder = localize('chatContext.attach.placeholder', "Attach as context..."); picker.matchOnDescription = true; picker.sortByLabel = false; - const staticPicks: (IQuickPickItem | IQuickPickSeparator)[] = [ - { - label: localize('files', "Files..."), - iconClass: ThemeIcon.asClassName(Codicon.file), - id: 'sessions.filesAndFolders', - }, - { - label: localize('imageFromClipboard', "Image from Clipboard"), - iconClass: ThemeIcon.asClassName(Codicon.fileMedia), - id: 'sessions.imageFromClipboard', - }, - ]; + const staticPicks = this._getStaticPicks(contextActions); picker.items = staticPicks; picker.show(); @@ -330,7 +321,9 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt picker.hide(); - if (selected.id === 'sessions.filesAndFolders') { + if (selected.contextAction) { + await selected.contextAction.run(); + } else if (selected.id === 'sessions.filesAndFolders') { await this._handleFileDialog(); } else if (selected.id === 'sessions.imageFromClipboard') { await this._handleClipboardImage(); @@ -345,6 +338,28 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt })); } + private _getStaticPicks(contextActions: readonly IWorkspacePickerContextAction[]): (IContextQuickPickItem | IQuickPickSeparator)[] { + return [ + { + label: localize('files', "Files..."), + iconClass: ThemeIcon.asClassName(Codicon.file), + id: 'sessions.filesAndFolders', + }, + { + label: localize('imageFromClipboard', "Image from Clipboard"), + iconClass: ThemeIcon.asClassName(Codicon.fileMedia), + id: 'sessions.imageFromClipboard', + }, + ...(contextActions.length > 0 ? [{ type: 'separator' as const }] : []), + ...contextActions.map(action => ({ + label: action.label, + description: action.description, + iconClass: ThemeIcon.asClassName(action.icon), + contextAction: action, + })), + ]; + } + private async _collectFilePicks(rootUri: URI, filePattern?: string, token?: CancellationToken): Promise<IQuickPickItem[]> { const maxFiles = 200; diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index e6e3079deee19b..6ada9420d4d1ed 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -53,6 +53,7 @@ import * as aria from '../../../../base/browser/ui/aria/aria.js'; import { ContextMenuController } from '../../../../editor/contrib/contextmenu/browser/contextmenu.js'; import { getSimpleEditorOptions } from '../../../../workbench/contrib/codeEditor/browser/simpleEditorOptions.js'; import { NewChatContextAttachments } from './newChatContextAttachments.js'; +import type { IWorkspacePickerContextAction } from './sessionWorkspacePicker.js'; import { ChatDragAndDrop } from '../../../../workbench/contrib/chat/browser/widget/chatDragAndDrop.js'; import { EDITOR_DRAG_AND_DROP_BACKGROUND } from '../../../../workbench/common/theme.js'; import { inactiveSessionViewBackground, inactiveSessionViewForeground } from '../../../common/theme.js'; @@ -459,6 +460,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation private readonly options: { session: IObservable<IActiveSession | undefined>; getContextFolderUri: () => URI | undefined; + getContextPickerActions?: () => readonly IWorkspacePickerContextAction[]; getWorkspacePreselectionSource?: () => NewSessionWorkspacePreselectionSource; sendRequest: (request: INewChatInputSendRequest) => Promise<boolean>; canSendRequest: IObservable<boolean>; @@ -964,7 +966,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation if (e.equals(KeyMod.CtrlCmd | KeyCode.Slash)) { e.preventDefault(); e.stopPropagation(); - this._contextAttachments.showPicker(this.options.getContextFolderUri()); + this._showContextPicker(); } })); @@ -1068,10 +1070,14 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation })); dom.append(attachButton, renderIcon(Codicon.addCompact)); this._register(dom.addDisposableListener(attachButton, dom.EventType.CLICK, () => { - this._contextAttachments.showPicker(this.options.getContextFolderUri()); + this._showContextPicker(); })); } + private _showContextPicker(): void { + this._contextAttachments.showPicker(this.options.getContextFolderUri(), this.options.getContextPickerActions?.()); + } + private _createInputToolbar(container: HTMLElement): void { const toolbar = dom.append(container, dom.$('.sessions-chat-toolbar')); let dictationActionVisible = false; diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index 11b96dc835ec58..3099ac24e9f4dd 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -200,6 +200,7 @@ export class NewChatWidget extends Disposable { const newChatInput = this.instantiationService.createInstance(NewChatInputWidget, { session: this._session, getContextFolderUri: () => this._getContextFolderUri(), + getContextPickerActions: () => this._isQuickChatComposer.get() ? [] : this._workspacePicker.getContextPickerActions(), getWorkspacePreselectionSource: () => this._isQuickChatComposer.get() ? NewSessionWorkspacePreselectionSource.None : this._workspacePicker.preselectionSource, @@ -699,18 +700,8 @@ export class NewChatWidget extends Disposable { reflectsWorkspace: true, attachesContext: false, }; - const gitHubContextTrigger: IWorkspacePickerTrigger = { - label: localize('newSessionWorkspacePicker.githubContext', "Issue/PR"), - ariaLabel: localize('newSessionWorkspacePicker.githubContextAriaLabel', "Attach a GitHub issue or pull request to the new session"), - tooltip: localize('newSessionWorkspacePicker.githubContextTooltip', "Attach an issue or pull request as context"), - hideIconWhenAttached: true, - group: SESSION_WORKSPACE_GROUP_GITHUB, - attachesContext: true, - hideWhenNoGitHubRepository: true, - }; const row = this._workspacePicker.renderCategoryTriggers(container, [ workspaceTrigger, - gitHubContextTrigger, ]); this._renderSessionTypePicker(row, false); this._workspacePickerRow = row; @@ -726,8 +717,8 @@ export class NewChatWidget extends Disposable { className: 'sessions-chat-session-type-picker sessions-workspace-category-picker-slot', }); const sessionTypePicker = container.lastElementChild; - if (!isQuickChat && sessionTypePicker?.previousElementSibling) { - container.insertBefore(sessionTypePicker, sessionTypePicker.previousElementSibling); + if (!isQuickChat && sessionTypePicker && container.firstElementChild !== sessionTypePicker) { + container.insertBefore(sessionTypePicker, container.firstElementChild?.nextSibling ?? null); } } diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index 9006ea6a6564cf..da0d4b525832fa 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -47,12 +47,15 @@ import { markOnboardingTarget } from '../../../../workbench/contrib/onboarding/b import { NewSessionWorkspacePreselectionSource } from './newSessionComposerService.js'; import { type IResolvedFolderWorkspace, SessionWorkspaceFallback } from './sessionWorkspaceFallback.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; +import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js'; import { ADDITIONAL_FOLDER_CONTEXT_ID_PREFIX, ADDITIONAL_REPOSITORY_CONTEXT_ID_PREFIX, getAdditionalFolderContextId, getAdditionalRepositoryContextId } from '../common/newChatContextIds.js'; export type { IResolvedFolderWorkspace } from './sessionWorkspaceFallback.js'; const FILTER_THRESHOLD = 10; +export const AGENT_SESSIONS_CONSOLIDATED_REMOTE_WORKSPACES_SETTING = ChatConfiguration.ConsolidatedRemoteWorkspaces; + /** * Fixed picker width when the categorical tab bar is shown. Keeps the tab * row and the list aligned and prevents horizontal jitter when switching @@ -77,6 +80,7 @@ export interface IWorkspacePickerItem { /** The resolved workspace (used for unavailable-provider checks). */ readonly providerId?: string; readonly browseActionIndex?: number; + readonly browseAction?: ISessionWorkspaceBrowseAction; readonly attachAsContext?: boolean; readonly checked?: boolean; /** Command to execute when this item is selected. */ @@ -115,6 +119,13 @@ export interface IWorkspacePickerTrigger { readonly hideWhenNoGitHubRepository?: boolean; } +export interface IWorkspacePickerContextAction { + readonly label: string; + readonly description?: string; + readonly icon: ThemeIcon; + readonly run: () => Promise<void>; +} + export interface IResolvedBrowseSelection { readonly workspace: ISessionWorkspace; readonly providerId: string; @@ -345,7 +356,7 @@ export class WorkspacePicker extends Disposable { } constructor( - private readonly options: IWorkspacePickerOptions, + protected readonly options: IWorkspacePickerOptions, @IActionWidgetService protected readonly actionWidgetService: IActionWidgetService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @ISessionsProvidersService protected readonly sessionsProvidersService: ISessionsProvidersService, @@ -369,7 +380,6 @@ export class WorkspacePicker extends Disposable { this._register(this._tabbedWidget.onDidHide(() => { this._pickerGroupContext.reset(); })); - this._sessionWorkspaceFallback = this.options.restoreFromSessions === false ? undefined : this._register(this.instantiationService.createInstance(SessionWorkspaceFallback, { @@ -490,6 +500,20 @@ export class WorkspacePicker extends Disposable { return Array.from(this._triggerElements); } + getContextPickerActions(): readonly IWorkspacePickerContextAction[] { + return this.sessionsProvidersService.getProviders() + .flatMap(provider => provider.browseActions) + .filter(action => action.attachesContext === true) + .map(action => ({ + label: action.label, + description: action.description, + icon: action.icon, + run: async () => { + await this._dispatchPickerItem({ browseAction: action }); + }, + })); + } + /** * Shared trigger-creation core for {@link render}. Wires up the click / * keyboard / touch handlers and the per-trigger lifecycle. @@ -603,7 +627,7 @@ export class WorkspacePicker extends Disposable { // so picking a tab during one open of the picker doesn't permanently // override auto-tab. if (preferredGroup === undefined && tabs.length > 0) { - const selectedGroup = this._selectedResolved?.workspace.group; + const selectedGroup = this._getTabGroup(this._selectedResolved?.workspace.group); if (!this._userPickedTab && selectedGroup && tabs.some(t => t.id === selectedGroup)) { this._activeTab = selectedGroup; } @@ -656,8 +680,9 @@ export class WorkspacePicker extends Disposable { if (action.group === SESSION_WORKSPACE_GROUP_REMOTE && !remoteAgentHostsEnabled) { continue; } - if (action.group && !byLabel.has(action.group)) { - byLabel.set(action.group, { id: action.group }); + const group = this._getTabGroup(action.group); + if (group && !byLabel.has(group)) { + byLabel.set(group, { id: group }); } } } @@ -689,10 +714,12 @@ export class WorkspacePicker extends Disposable { }; } - private _buildListOptions(items: readonly IActionListItem<IWorkspacePickerItem>[], pickerWidth: number | undefined): IActionListOptions { - const showFilter = items.filter(i => i.kind === ActionListItemKind.Action).length > FILTER_THRESHOLD; + protected _buildListOptions(items: readonly IActionListItem<IWorkspacePickerItem>[], pickerWidth: number | undefined): IActionListOptions { + const isConsolidatedWorkspacePicker = this._useConsolidatedRemoteWorkspaces() && this._directPickerAttachesContext !== true; + const showFilter = isConsolidatedWorkspacePicker + || items.filter(i => i.kind === ActionListItemKind.Action).length > FILTER_THRESHOLD; return showFilter - ? { showFilter: true, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces..."), reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, minWidth: pickerWidth, maxWidth: pickerWidth, hideDefaultKeybindingTooltip: true } + ? { showFilter: true, focusFilterOnOpen: isConsolidatedWorkspacePicker, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces..."), reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, minWidth: pickerWidth, maxWidth: pickerWidth, hideDefaultKeybindingTooltip: true } : { reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, minWidth: pickerWidth, maxWidth: pickerWidth, hideDefaultKeybindingTooltip: true }; } @@ -753,7 +780,10 @@ export class WorkspacePicker extends Disposable { createActionList: (tab) => { this._activeTab = tab; const items = this._buildItems(); - return { items, listOptions: { inlineDescription: true, showGroupTitleOnFirstItem: true, hideDefaultKeybindingTooltip: true } }; + const listOptions = this._useConsolidatedRemoteWorkspaces() + ? this._buildListOptions(items, undefined) + : { inlineDescription: true, showGroupTitleOnFirstItem: true, hideDefaultKeybindingTooltip: true }; + return { items, listOptions }; }, delegate, accessibilityProvider, @@ -781,8 +811,8 @@ export class WorkspacePicker extends Disposable { // Workspace belongs to an unavailable remote — ignore selection return false; } - if (item.browseActionIndex !== undefined) { - const selection = await this._executeBrowseAction(item.browseActionIndex); + if (item.browseActionIndex !== undefined || item.browseAction) { + const selection = await this._executeBrowseAction(item.browseActionIndex, item.browseAction); const folderUri = selection?.workspace.folders[0]?.root; if (!folderUri || generation !== this._selectionGeneration) { return false; @@ -1148,11 +1178,10 @@ export class WorkspacePicker extends Disposable { } /** - * Executes a browse action from a provider, identified by index. + * Executes a provider browse action referenced directly or by picker index. */ - private async _executeBrowseAction(actionIndex: number): Promise<IBrowsedWorkspaceSelection | undefined> { - const allActions = this._getAllBrowseActions(); - const action = allActions[actionIndex]; + private async _executeBrowseAction(actionIndex: number | undefined, browseAction?: ISessionWorkspaceBrowseAction): Promise<IBrowsedWorkspaceSelection | undefined> { + const action = browseAction ?? (actionIndex !== undefined ? this._getAllBrowseActions()[actionIndex] : undefined); if (!action) { return undefined; } @@ -1164,7 +1193,11 @@ export class WorkspacePicker extends Disposable { const selection = await this._browseForLocalFolder(); return selection ? { ...selection, action } : undefined; } - const workspace = await action.run(this._getSelectedRepositoryWorkspace() ?? this._selectedResolved?.workspace); + const actionProvider = this.sessionsProvidersService.getProvider(action.providerId); + const currentWorkspace = action.attachesContext && this._selectedFolderUri + ? (actionProvider?.resolveWorkspace(this._selectedFolderUri) ?? this._selectedResolved?.workspace) + : (this._getSelectedRepositoryWorkspace() ?? this._selectedResolved?.workspace); + const workspace = await action.run(currentWorkspace); return workspace ? { workspace, providerId: action.providerId, action } : undefined; } catch { // browse action was cancelled or failed @@ -1187,15 +1220,31 @@ export class WorkspacePicker extends Disposable { if (hasLocalSupport) { all.unshift(this._localBrowseAction); } - if (!this._isTabFiltered()) { - return all; - } return all.filter(a => - a.group === this._activeTab + (!this._isTabFiltered() || this._isGroupInActiveTab(a.group)) && (this._directPickerAttachesContext === undefined || Boolean(a.attachesContext) === this._directPickerAttachesContext) ); } + protected _useConsolidatedRemoteWorkspaces(): boolean { + return this.configurationService.getValue<boolean>(AGENT_SESSIONS_CONSOLIDATED_REMOTE_WORKSPACES_SETTING); + } + + private _getTabGroup(group: string | undefined): string | undefined { + return this._useConsolidatedRemoteWorkspaces() && group === SESSION_WORKSPACE_GROUP_GITHUB + ? SESSION_WORKSPACE_GROUP_REMOTE + : group; + } + + private _isGroupInActiveTab(group: string | undefined): boolean { + if (this._directPickerGroup !== undefined) { + return group === this._directPickerGroup; + } + const availableTabs = this._getAvailableTabs(); + const activeTab = this._activeTab ?? (availableTabs.length === 1 ? availableTabs[0].id : undefined); + return this._getTabGroup(group) === activeTab; + } + /** * Opens a folder picker dialog and returns the chosen URI. The folder's * provider is rediscovered later by the management service when the @@ -1253,9 +1302,13 @@ export class WorkspacePicker extends Disposable { const providerIds = new Set(allProviders.map(p => p.id)); const availableTabs = this._getAvailableTabs(); const activeGroup = this._activeTab ?? (availableTabs.length === 1 ? availableTabs[0].id : undefined); - const workspaceGroupAction = this.options.getWorkspaceGroupAction?.(activeGroup); + let workspaceGroupAction = this.options.getWorkspaceGroupAction?.(activeGroup); + if (!workspaceGroupAction && activeGroup === SESSION_WORKSPACE_GROUP_REMOTE && this._useConsolidatedRemoteWorkspaces()) { + const gitHubGroupAction = this.options.getWorkspaceGroupAction?.(SESSION_WORKSPACE_GROUP_GITHUB); + workspaceGroupAction = gitHubGroupAction ? { ...gitHubGroupAction, hideWorkspaceItems: false } : undefined; + } const tabFilter = this._isTabFiltered() - ? (w: IResolvedFolderWorkspace) => w.workspace.group === this._activeTab + ? (w: IResolvedFolderWorkspace) => this._isGroupInActiveTab(w.workspace.group) : undefined; // Own recents first, then VS Code recents (merged and deduplicated by the service) const recentWorkspaces = workspaceGroupAction?.hideWorkspaceItems @@ -1338,7 +1391,7 @@ export class WorkspacePicker extends Disposable { && this._getActivePickerGroup() === SESSION_WORKSPACE_GROUP_LOCAL; const canAttachRepository = isRepositoryAction && this._selectedFolderUri - && this._getActivePickerGroup() === SESSION_WORKSPACE_GROUP_GITHUB; + && this._isGroupInActiveTab(SESSION_WORKSPACE_GROUP_GITHUB); if (canAttachFolder || canAttachRepository) { items.push({ kind: ActionListItemKind.Action, diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 5ecc8dd4b9f2be..a19980965ed8f2 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -42,7 +42,8 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.feedbackComments', "When pull requests have failing checks or unreviewed comments, one banner appears above the input. If several pull requests need attention, use the Previous Banner and Next Banner buttons to move between them. A pull request with both failing checks and comments uses a split button: activate the main action to address both, or use its More Actions button to address only the checks or comments. In-product agent review comments appear as their own carousel item.")); content.push(localize('sessionsChat.feedbackAttachment', "When a feedback comments attachment appears above the input, focus it and press Enter or Space. A single comment opens directly. Multiple comments open a tree grouped by file; use the arrow keys to navigate, Enter to reveal a comment, and Escape to close the tree.")); content.push(localize('sessionsChat.inputBackground', "Press Alt+Enter to start the session in the background without navigating into it. The started session appears in the Chat Sessions view.")); - content.push(localize('sessionsChat.workspace', "Shift+Tab to navigate to the workspace picker and choose a workspace for your session.")); + content.push(localize('sessionsChat.workspace', "Shift+Tab to navigate to the workspace picker and choose a workspace for your session. When consolidated remote workspaces are enabled, opening the picker focuses its search input so you can immediately type to filter workspaces.")); + content.push(localize('sessionsChat.githubContext', "Use Add Context to attach files, images, and, when available, GitHub issues or pull requests.")); content.push(localize('sessionsChat.devContainer', "When Dev Container Agent Host sessions are enabled, Docker is available, and the selected local folder contains a Dev Container configuration, a Dev Container checkbox appears before New Worktree. Select it to run the session on an Agent Host inside that folder's Dev Container.")); content.push(localize('sessionsChat.pullRequestSession', "In a repository section where New Session is a split button, focus New Session and press Right Arrow to reach its dropdown, then activate New Session from Pull Request to open a searchable pull request picker. Pull requests are grouped by review and assignment status. Use the arrow keys to navigate, Enter to create the session, and Escape to close the picker.")); content.push(localize('sessionsChat.githubReferences', "Pull request and issue pills above the chat input open their GitHub item in the GitHub Pull Requests extension when it is available. Pills that represent several items open a keyboard-accessible picker.")); diff --git a/src/vs/sessions/contrib/chat/browser/webWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/webWorkspacePicker.ts index b88eccd3fd0313..7033bb58f6f01e 100644 --- a/src/vs/sessions/contrib/chat/browser/webWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/webWorkspacePicker.ts @@ -119,6 +119,7 @@ export class WebWorkspacePicker extends WorkspacePicker { items, item => this._dispatchPickerItem(item), this._getAllBrowseActions(), + this._useConsolidatedRemoteWorkspaces() && attachesContext !== true, ); } @@ -146,9 +147,15 @@ export class WebWorkspacePicker extends WorkspacePicker { } // 1. Recent workspaces across every provider the entry scopes to. - const isGitHubCategory = this._directPickerGroup === SESSION_WORKSPACE_GROUP_GITHUB; + const isConsolidatedWorkspacePicker = this._useConsolidatedRemoteWorkspaces() + && this._directPickerGroup === undefined + && this._directPickerAttachesContext !== true; + const includeGitHub = this._directPickerGroup === SESSION_WORKSPACE_GROUP_GITHUB || isConsolidatedWorkspacePicker; + const gitHubGroupAction = isConsolidatedWorkspacePicker + ? this.options.getWorkspaceGroupAction?.(SESSION_WORKSPACE_GROUP_GITHUB) + : undefined; const recents = this._getRecentWorkspaces().filter(w => - (scopedProviderIds.has(w.providerId) || isGitHubCategory) + (scopedProviderIds.has(w.providerId) || (includeGitHub && w.workspace.group === SESSION_WORKSPACE_GROUP_GITHUB)) && this._directPickerAttachesContext !== true && (this._directPickerGroup === undefined || w.workspace.group === this._directPickerGroup) ); @@ -175,11 +182,21 @@ export class WebWorkspacePicker extends WorkspacePicker { const allBrowseActions = this._getAllBrowseActions(); const browseActions = allBrowseActions .map((action, index) => ({ action, index })) - .filter(({ action }) => (!scoped.grouped && scopedProviderIds.has(action.providerId)) || this._directPickerGroup === SESSION_WORKSPACE_GROUP_GITHUB); - if (browseActions.length > 0) { + .filter(({ action }) => (!scoped.grouped && scopedProviderIds.has(action.providerId)) + || (includeGitHub && action.group === SESSION_WORKSPACE_GROUP_GITHUB)); + if (gitHubGroupAction || browseActions.length > 0) { if (items.length > 0) { items.push({ kind: ActionListItemKind.Separator, label: '' }); } + if (gitHubGroupAction) { + items.push({ + kind: ActionListItemKind.Action, + label: gitHubGroupAction.label, + description: gitHubGroupAction.description, + group: { title: '', icon: gitHubGroupAction.icon }, + item: { commandId: gitHubGroupAction.commandId }, + }); + } for (const { action, index } of browseActions) { items.push({ kind: ActionListItemKind.Action, @@ -192,7 +209,7 @@ export class WebWorkspacePicker extends WorkspacePicker { } } - if (items.length === 0 && this._directPickerGroup === SESSION_WORKSPACE_GROUP_GITHUB) { + if (items.length === 0 && includeGitHub) { items.push({ kind: ActionListItemKind.Action, label: localize('scopedWorkspacePicker.githubLoading', "GitHub repositories are still loading"), diff --git a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts index a161588cb102ae..3271a229ba0adc 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts @@ -14,9 +14,9 @@ import { IChatRequestTranscriptContextVariableEntry } from '../../../../../workb import { ChatInputNoticeHost, ChatInputNoticeLane } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputNoticeHost.js'; import { isChatInputStackSlotShowing } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputStack.js'; import { ResponseModelState } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; -import { SessionStatus } from '../../../../services/sessions/common/session.js'; +import { ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; import { SessionsChatBackgroundRenderer } from '../../../../services/chatBackground/browser/chatBackgroundRenderer.js'; -import { findInitialTranscriptContextEntry, findTranscriptContextEntry, getTranscriptProgress, NewChatView, shouldShowSessionChatTip, shouldShowTranscriptPreparationCompletion, shouldShowTranscriptPreparationProgress } from '../../browser/chatView.js'; +import { ChatView, findInitialTranscriptContextEntry, findTranscriptContextEntry, getTranscriptProgress, NewChatView, shouldShowSessionChatTip, shouldShowTranscriptPreparationCompletion, shouldShowTranscriptPreparationProgress } from '../../browser/chatView.js'; import { SessionsChatViewStateService } from '../../browser/chatViewStateService.js'; import { NewChatInSessionWidget } from '../../browser/newChatInSessionWidget.js'; import { NewChatWidget } from '../../browser/newChatWidget.js'; @@ -29,6 +29,42 @@ suite('Sessions - Chat View', () => { _renderSubSessionTip(): void; } + test('retries an unresolved chat when its content provider is registered', () => { + const resource = URI.parse('remote-agent:/session'); + const loads: URI[] = []; + const modelRef = { value: undefined as object | undefined }; + const view = Object.assign(Object.create(ChatView.prototype), { + _currentChatResource: resource, + _currentSessionObs: { get: () => undefined }, + _modelRef: modelRef, + _loadChat: (chatResource: URI) => loads.push(chatResource), + }) as { + _retryUnresolvedChatLoad(addedSessionTypes: readonly string[]): void; + }; + + view._retryUnresolvedChatLoad(['other-agent']); + view._retryUnresolvedChatLoad(['remote-agent']); + modelRef.value = {}; + view._retryUnresolvedChatLoad(['remote-agent']); + + assert.deepStrictEqual(loads, [resource]); + }); + + test('shows the external session banner only in the primary chat group', () => { + const session = Object.create(null) as ISession; + const bannerSessions: Array<ISession | undefined> = []; + const view = Object.assign(Object.create(ChatView.prototype), { + _isPrimary: true, + _currentSessionObs: observableValue<ISession | undefined>(disposables, session), + _externalSessionBanner: { setSession: (value: ISession | undefined) => bannerSessions.push(value) }, + }) as ChatView; + + view.setPrimary(false); + view.setPrimary(true); + + assert.deepStrictEqual(bannerSessions, [undefined, session]); + }); + test('forwards new chat visibility to the aquarium host', () => { const forwarded: boolean[] = []; const isVisible = observableValue(disposables, true); diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts index ac3f9681174eed..d78cea3115ac2c 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts @@ -47,6 +47,7 @@ const updateAndSaveDraftState = Reflect.get(NewChatInputWidget.prototype, '_upda const updateSendButtonState = Reflect.get(NewChatInputWidget.prototype, '_updateSendButtonState') as (this: IUpdateSendButtonStateHarness) => void; const setInputEditorFocused = Reflect.get(NewChatInputWidget.prototype, '_setInputEditorFocused') as (container: HTMLElement, focused: boolean) => void; const updateAttachmentRendering = Reflect.get(NewChatContextAttachments.prototype, '_updateRendering') as (this: IAttachmentRenderingHarness) => void; +const getStaticContextPicks = Reflect.get(NewChatContextAttachments.prototype, '_getStaticPicks') as (contextActions: readonly { label: string; icon: ThemeIcon }[]) => readonly { label?: string; type?: string }[]; interface IDraftStateHarness { readonly storageService: { @@ -408,6 +409,20 @@ suite('NewChatInputWidget', () => { }); }); + test('orders native attachment picks before provider context actions', () => { + const picks = getStaticContextPicks([{ + label: 'Issue...', + icon: Codicon.issues, + }]); + + assert.deepStrictEqual(picks.map(pick => pick.label ?? pick.type), [ + 'Files...', + 'Image from Clipboard', + 'separator', + 'Issue...', + ]); + }); + test('enables send after restoring an unchanged retained input model', () => { const sendButton = { enabled: false }; const harness: IRestoreStateHarness & IUpdateSendButtonStateHarness = { diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts index bb10cb56d2acb1..6c3f0b6905e144 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts @@ -209,12 +209,10 @@ suite('NewChatWidget', () => { [ { label: 'Workspace', className: '' }, { label: 'Copilot', className: 'sessions-chat-session-type-picker sessions-workspace-category-picker-slot' }, - { label: 'Issue/PR', className: '' }, ], ); assert.deepStrictEqual(workspaceTriggers, [ { tooltip: 'Choose where the new session runs', icon: 'project', attachesContext: false }, - { tooltip: 'Attach an issue or pull request as context', icon: undefined, attachesContext: true }, ]); }); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index 141e14d1ae6be0..e8649159a643a4 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -34,7 +34,7 @@ import { ISendRequestOptions, ISessionChangeEvent, ISessionsProvider } from '../ import { AgentHostFilterConnectionStatus, IAgentHostFilterEntry } from '../../../../services/agentHostFilter/common/agentHostFilter.js'; import { IAgentHostSessionsProvider } from '../../../../common/agentHostSessionsProvider.js'; import { ISession, ISessionWorkspace, ISessionWorkspaceBrowseAction, SessionStatus, SESSION_WORKSPACE_GROUP_GITHUB, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../../services/sessions/common/session.js'; -import { IWorkspacePickerItem, IWorkspacePickerOptions, WorkspacePicker } from '../../browser/sessionWorkspacePicker.js'; +import { AGENT_SESSIONS_CONSOLIDATED_REMOTE_WORKSPACES_SETTING, IWorkspacePickerItem, IWorkspacePickerOptions, WorkspacePicker } from '../../browser/sessionWorkspacePicker.js'; import { WebWorkspacePicker } from '../../browser/webWorkspacePicker.js'; import { NewSessionWorkspacePreselectionSource } from '../../browser/newSessionComposerService.js'; import { ISessionsRecentWorkspacesService, SessionsRecentWorkspacesService } from '../../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; @@ -2947,6 +2947,32 @@ suite('AutomationsWorkspacePicker', () => { assert.deepStrictEqual(dispatched, [{ browseActionIndex: 0 }]); }); + + test('mobile workspace sheet shows search when requested without folder enumeration', async () => { + const workbench = document.createElement('div'); + document.body.append(workbench); + disposables.add({ dispose: () => workbench.remove() }); + const trigger = workbench.appendChild(document.createElement('button')); + const sheet = showMobileWorkspacePickerSheet( + upcastPartial<IWorkbenchLayoutService>({ mainContainer: workbench }), + trigger, + [{ + kind: ActionListItemKind.Action, + label: 'microsoft/vscode', + group: { title: '', icon: Codicon.repo }, + item: { folderUri: URI.file('/microsoft/vscode'), providerId: 'github' }, + }], + () => { }, + [], + true, + ); + + const searchInput = workbench.querySelector<HTMLInputElement>('.mobile-picker-sheet-search-input'); + assert.strictEqual(searchInput?.placeholder, 'Search Workspaces...'); + + workbench.querySelector<HTMLButtonElement>('.mobile-picker-sheet-done')?.click(); + await sheet; + }); }); // ---- Tab discovery ---------------------------------------------------------- @@ -2977,6 +3003,14 @@ class TestablePicker extends WorkspacePicker { return this.getItems().flatMap(entry => entry.label ? [entry.label] : []); } + showsFilter(): boolean { + return this._buildListOptions(this.getItems(), undefined).showFilter === true; + } + + focusesFilter(): boolean { + return this._buildListOptions(this.getItems(), undefined).focusFilterOnOpen === true; + } + async select(label: string): Promise<void> { const entry = this.getItems().find(candidate => candidate.label === label); assert.ok(entry?.item, `Expected picker item '${label}'`); @@ -3001,6 +3035,7 @@ function createTestablePicker( options: IWorkspacePickerOptions = {}, commandService: Partial<ICommandService> = { executeCommand: async () => { } }, storageService: IStorageService = disposables.add(new TestStorageService()), + consolidatedRemoteWorkspaces = false, ): TestablePicker { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IActionWidgetService, { isVisible: false, hide: () => { }, show: () => { } }); @@ -3013,7 +3048,10 @@ function createTestablePicker( instantiationService.stub(IClipboardService, {}); instantiationService.stub(IPreferencesService, {}); instantiationService.stub(IOutputService, {}); - instantiationService.stub(IConfigurationService, new TestConfigurationService({ [RemoteAgentHostsEnabledSettingId]: remoteAgentHostsEnabled })); + instantiationService.stub(IConfigurationService, new TestConfigurationService({ + [RemoteAgentHostsEnabledSettingId]: remoteAgentHostsEnabled, + [AGENT_SESSIONS_CONSOLIDATED_REMOTE_WORKSPACES_SETTING]: consolidatedRemoteWorkspaces, + })); instantiationService.stub(ICommandService, commandService); instantiationService.stub(IFileDialogService, {}); instantiationService.stub(IFileService, upcastPartial<IFileService>({ @@ -3041,6 +3079,8 @@ const buildWebWorkspacePickerItems = Reflect.get(WebWorkspacePicker.prototype, ' readonly sessionsProvidersService: ISessionsProvidersService; readonly _directPickerAttachesContext: boolean | undefined; readonly _directPickerGroup: string | undefined; + readonly options: IWorkspacePickerOptions; + _useConsolidatedRemoteWorkspaces(): boolean; _getRecentWorkspaces(): Array<{ readonly workspace: ISessionWorkspace; readonly providerId: string }>; _getAllBrowseActions(): ISessionWorkspaceBrowseAction[]; _isSelectedFolder(folderUri: URI): boolean; @@ -3100,6 +3140,186 @@ suite('WorkspacePicker - Tab discovery', () => { assert.deepStrictEqual(picker.getAvailableTabs(), [SESSION_WORKSPACE_GROUP_LOCAL, 'Cloud', SESSION_WORKSPACE_GROUP_REMOTE]); }); + test('combines GitHub and Remote groups with search when enabled', () => { + providersService.setProviders([ + createMockProvider('remote', { browseActions: [makeBrowseAction('remote', SESSION_WORKSPACE_GROUP_REMOTE, 'Select Remote...')] }), + createMockProvider('github', { browseActions: [{ ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...'), attachesContext: false }] }), + { ...createMockProvider('local'), supportsLocalWorkspaces: true }, + ]); + const picker = createTestablePicker(disposables, providersService, true, {}, undefined, undefined, true); + + picker.selectWorkspaceActions(); + picker.selectTab(SESSION_WORKSPACE_GROUP_REMOTE); + + assert.deepStrictEqual({ + tabs: picker.getAvailableTabs(), + items: picker.getItemLabels(), + showsFilter: picker.showsFilter(), + focusesFilter: picker.focusesFilter(), + }, { + tabs: [SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE], + items: ['Select Remote...', 'Repository...'], + showsFilter: true, + focusesFilter: true, + }); + }); + + test('keeps GitHub context actions separate when groups are combined', () => { + providersService.setProviders([ + createMockProvider('github', { + browseActions: [ + { ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...'), attachesContext: false }, + { ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Issue...'), attachesContext: true }, + ], + }), + ]); + const picker = createTestablePicker(disposables, providersService, true, {}, undefined, undefined, true); + + picker.selectWorkspaceGroup(SESSION_WORKSPACE_GROUP_GITHUB, true); + + assert.deepStrictEqual({ + items: picker.getItemLabels(), + showsFilter: picker.showsFilter(), + }, { + items: ['Issue...'], + showsFilter: false, + }); + }); + + test('exposes GitHub context actions through Add Context', async () => { + const localProvider = createMockProvider('local'); + const provider = createMockProvider('github'); + const issueUri = URI.parse('https://github.com/microsoft/vscode/issues/1'); + const issueWorkspace = { + ...provider.resolveWorkspace(URI.file('/github/issue'))!, + uri: issueUri, + group: SESSION_WORKSPACE_GROUP_GITHUB, + }; + let contextActionWorkspace: ISessionWorkspace | undefined; + providersService.setProviders([localProvider, { + ...provider, + resolveWorkspace: uri => { + const workspace = provider.resolveWorkspace(uri); + return workspace ? { ...workspace, label: 'GitHub-resolved selection', group: SESSION_WORKSPACE_GROUP_GITHUB } : undefined; + }, + browseActions: [{ + ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Issue...'), + attachesContext: true, + run: async workspace => { + contextActionWorkspace = workspace; + return issueWorkspace; + }, + }], + }]); + const picker = createTestablePicker(disposables, providersService); + const selectedContexts: string[] = []; + disposables.add(picker.onDidSelectContext(context => selectedContexts.push(context.uri.toString()))); + + const actionsWithoutRepository = picker.getContextPickerActions(); + picker.setSelectedWorkspace(URI.file('/microsoft/vscode'), { fireEvent: false, persist: false }); + const actions = picker.getContextPickerActions(); + await actions[0].run(); + + assert.deepStrictEqual({ + actionsWithoutRepository: actionsWithoutRepository.map(action => action.label), + actions: actions.map(action => action.label), + contextActionWorkspace: contextActionWorkspace && { + uri: contextActionWorkspace.uri.toString(), + label: contextActionWorkspace.label, + }, + selectedContexts, + }, { + actionsWithoutRepository: ['Issue...'], + actions: ['Issue...'], + contextActionWorkspace: { + uri: URI.file('/microsoft/vscode').toString(), + label: 'GitHub-resolved selection', + }, + selectedContexts: [issueUri.toString()], + }); + }); + + test('filters context actions and allows repository attachment with a sole consolidated tab', () => { + const baseProvider = createMockProvider('github', { + browseActions: [ + { ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...'), attachesContext: false }, + { ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Issue...'), attachesContext: true }, + ], + }); + providersService.setProviders([{ + ...baseProvider, + resolveWorkspace: uri => { + const workspace = baseProvider.resolveWorkspace(uri); + return workspace ? { ...workspace, group: SESSION_WORKSPACE_GROUP_GITHUB } : undefined; + }, + }]); + const picker = createTestablePicker(disposables, providersService, true, {}, undefined, undefined, true); + picker.setSelectedWorkspace(URI.file('/microsoft/vscode'), { fireEvent: false, persist: false }); + + picker.selectWorkspaceActions(); + + assert.deepStrictEqual({ + tabs: picker.getAvailableTabs(), + items: picker.getItemLabels(), + }, { + tabs: [SESSION_WORKSPACE_GROUP_REMOTE], + items: ['Repository...', 'Attach Repository...'], + }); + }); + + test('shows GitHub sign-in with remote and GitHub workspaces when groups are combined', () => { + const storage = disposables.add(new TestStorageService()); + const remoteUri = URI.parse('vscode-remote://host/remote-project'); + const gitHubUri = URI.parse('vscode-vfs://github/microsoft/vscode/HEAD'); + seedStorage(storage, [ + { uri: remoteUri, providerId: 'remote', checked: false }, + { uri: gitHubUri, providerId: 'github', checked: false }, + ]); + const remoteProvider = createMockProvider('remote', { + browseActions: [makeBrowseAction('remote', SESSION_WORKSPACE_GROUP_REMOTE, 'Select Remote...')], + }); + const gitHubProvider = createMockProvider('github', { + browseActions: [{ ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...'), attachesContext: false }], + }); + providersService.setProviders([ + { + ...remoteProvider, + resolveWorkspace: uri => { + const workspace = remoteProvider.resolveWorkspace(uri); + return workspace ? { ...workspace, group: SESSION_WORKSPACE_GROUP_REMOTE } : undefined; + }, + }, + { + ...gitHubProvider, + resolveWorkspace: uri => { + const workspace = gitHubProvider.resolveWorkspace(uri); + return workspace ? { ...workspace, group: SESSION_WORKSPACE_GROUP_GITHUB } : undefined; + }, + }, + ]); + const picker = createTestablePicker(disposables, providersService, true, { + restoreFromSessions: false, + getWorkspaceGroupAction: group => group === SESSION_WORKSPACE_GROUP_GITHUB ? { + label: 'Sign in to GitHub', + icon: Codicon.signIn, + commandId: AGENTIC_SIGN_IN_COMMAND_ID, + hideWorkspaceItems: true, + } : undefined, + }, undefined, storage, true); + + picker.selectWorkspaceActions(); + picker.selectTab(SESSION_WORKSPACE_GROUP_REMOTE); + + assert.deepStrictEqual(picker.getItemLabels(), [ + 'remote-project', + 'microsoft/vscode/HEAD', + 'Sign in to GitHub', + 'Select Remote...', + 'Repository...', + 'Attach Repository...', + ]); + }); + test('deduplicates groups contributed by multiple providers / actions', () => { providersService.setProviders([ createMockProvider('p1', { browseActions: [makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_LOCAL)] }), @@ -3188,6 +3408,8 @@ suite('WorkspacePicker - Tab discovery', () => { sessionsProvidersService: providersService, _directPickerAttachesContext: false, _directPickerGroup: SESSION_WORKSPACE_GROUP_GITHUB, + options: {}, + _useConsolidatedRemoteWorkspaces: () => false, _getRecentWorkspaces: () => [{ workspace: { ...repositoryWorkspace, group: SESSION_WORKSPACE_GROUP_GITHUB }, providerId: githubProvider.id }], _getAllBrowseActions: () => [repositoryAction], _isSelectedFolder: () => false, @@ -3206,6 +3428,57 @@ suite('WorkspacePicker - Tab discovery', () => { ]); }); + test('web consolidated workspace picker includes GitHub entries outside the selected host', () => { + const remoteProvider = createMockProvider('agenthost-remote-1'); + const gitHubProvider = createMockProvider('default-copilot'); + providersService.setProviders([remoteProvider, gitHubProvider]); + const remoteWorkspace = { ...remoteProvider.resolveWorkspace(URI.file('/remote/project'))!, group: SESSION_WORKSPACE_GROUP_REMOTE }; + const gitHubWorkspaceUri = URI.parse('vscode-vfs://github/microsoft/vscode/HEAD'); + const baseGitHubWorkspace = gitHubProvider.resolveWorkspace(URI.file('/copilot/repository'))!; + const gitHubWorkspace: ISessionWorkspace = { + ...baseGitHubWorkspace, + uri: gitHubWorkspaceUri, + label: 'microsoft/vscode/HEAD', + group: SESSION_WORKSPACE_GROUP_GITHUB, + folders: baseGitHubWorkspace.folders.map(folder => ({ ...folder, root: gitHubWorkspaceUri, workingDirectory: gitHubWorkspaceUri })), + }; + const remoteAction = makeBrowseAction(remoteProvider.id, SESSION_WORKSPACE_GROUP_REMOTE, 'Select Remote...'); + const repositoryAction = { ...makeBrowseAction(gitHubProvider.id, SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...'), attachesContext: false }; + + const items = buildWebWorkspacePickerItems.call({ + _agentHostFilterService: { selectedHost: hostEntry(remoteProvider.id) }, + sessionsProvidersService: providersService, + _directPickerAttachesContext: false, + _directPickerGroup: undefined, + options: { + getWorkspaceGroupAction: group => group === SESSION_WORKSPACE_GROUP_GITHUB ? { + label: 'Sign in to GitHub', + icon: Codicon.signIn, + commandId: AGENTIC_SIGN_IN_COMMAND_ID, + hideWorkspaceItems: true, + } : undefined, + }, + _useConsolidatedRemoteWorkspaces: () => true, + _getRecentWorkspaces: () => [ + { workspace: remoteWorkspace, providerId: remoteProvider.id }, + { workspace: gitHubWorkspace, providerId: gitHubProvider.id }, + ], + _getAllBrowseActions: () => [remoteAction, repositoryAction], + _isSelectedFolder: () => false, + _isProviderUnavailable: () => false, + _removeRecentWorkspace: () => { }, + }); + + assert.deepStrictEqual(items.map(item => item.label), [ + 'remote/project', + 'microsoft/vscode/HEAD', + '', + 'Sign in to GitHub', + 'Select Remote...', + 'Repository...', + ]); + }); + test('separates repository actions and recents from issue and pull request context', () => { const storage = disposables.add(new TestStorageService()); const recentUri = URI.parse('vscode-vfs://github/microsoft/vscode/HEAD'); diff --git a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts index 4f68574569d036..10ed3cf7cbcc19 100644 --- a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts +++ b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts @@ -25,6 +25,7 @@ import { IPartVisibilityChangeEvent, IWorkbenchLayoutService, Parts } from '../. import { IPaneCompositePartService } from '../../../../../workbench/services/panecomposite/browser/panecomposite.js'; import { IPaneComposite } from '../../../../../workbench/common/panecomposite.js'; import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; +import { IDecorationsService } from '../../../../../workbench/services/decorations/common/decorations.js'; import { EditorInput } from '../../../../../workbench/common/editor/editorInput.js'; import { IEditorWillOpenEvent, IUntypedEditorInput, isResourceEditorInput } from '../../../../../workbench/common/editor.js'; import { IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; @@ -350,9 +351,14 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption applyWorkingSetCalls: [], saveWorkingSetCalls: [], openChangesEditorCalls: [], - sessionChangesService: new SessionChangesService(new class extends mock<IEditorService>() { }, instaService, new class extends mock<IAgentWorkbenchLayoutService>() { + sessionChangesService: store.add(new SessionChangesService(new class extends mock<IEditorService>() { }, instaService, new class extends mock<IAgentWorkbenchLayoutService>() { override get isSinglePaneLayoutEnabled(): boolean { return options.singlePaneLayoutEnabled ?? false; } - }, new class extends mock<IChangesViewService>() { }), + }, new class extends mock<IChangesViewService>() { + override readonly activeSessionResourceObs = constObservable<URI | undefined>(undefined); + override readonly activeSessionChangesObs = constObservable<readonly ISessionFileChange[]>([]); + }, new class extends mock<IDecorationsService>() { + override registerDecorationsProvider() { return toDisposable(() => { }); } + })), contextKeyService, }; @@ -369,13 +375,16 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption override pinEditor() { } override getIndexOfEditor(editor: EditorInput) { return harness.activeGroupEditors.indexOf(editor); } override async replaceEditors(replacements: IEditorReplacement[]) { + for (const replacement of replacements) { + store.add(replacement.replacement); + } await harness.onReplaceEditors?.(replacements); for (const replacement of replacements) { const index = harness.activeGroupEditors.indexOf(replacement.editor); if (index === -1) { continue; } - harness.activeGroupEditors.splice(index, 1, store.add(replacement.replacement)); + harness.activeGroupEditors.splice(index, 1, replacement.replacement); if (harness.activeEditorInput === replacement.editor) { harness.activeEditorInput = replacement.replacement; } @@ -405,6 +414,7 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption }); instaService.stub(ISessionChangesService, new class extends mock<ISessionChangesService>() { + override readonly activeSessionChangeCountObs = harness.sessionChangesService.activeSessionChangeCountObs; override getChangesEditorResource(sessionResource: URI): URI { return harness.sessionChangesService.getChangesEditorResource(sessionResource); } override getSessionResource(editorResource: URI): URI | undefined { return harness.sessionChangesService.getSessionResource(editorResource); } override async openChangesEditor(sessionResource: URI, options?: { index?: number; inactive?: boolean }): Promise<IEditorGroup> { diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts index 39829d7e81b78a..8b9b96360e615d 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Codicon } from '../../../../../base/common/codicons.js'; import { Disposable, DisposableMap, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { derived, IObservable, IReader, observableSignal } from '../../../../../base/common/observable.js'; import { localize } from '../../../../../nls.js'; @@ -120,6 +121,7 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe ...meta, label: localize('agentHostPermissionPicker.manual.label', "Manual permissions"), detail: localize('agentHostPermissionPicker.askWhenNeeded.detail', "Asks when approval settings don't apply"), + icon: Codicon.key, }; case ChatPermissionLevel.Assisted: return { ...meta, detail: localize('agentHostPermissionPicker.approveWhenSafe.detail', "Evaluates risk before running tools") }; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts index 138c3a1f56d912..35a78f96715ced 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts @@ -13,11 +13,12 @@ import { isDefined } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { isMultiRootSession } from '../../../../../platform/agentHost/common/agentHostWorkingDirectories.js'; -import { resolveChangesetUriTemplate } from '../../../../../platform/agentHost/common/changesetUri.js'; +import { AGENT_MERGE_CHANGESET_ID, resolveChangesetUriTemplate } from '../../../../../platform/agentHost/common/changesetUri.js'; +import { isAgentMergeMessage } from '../../../../../platform/agentHost/common/meta/agentMergeMessageMeta.js'; import { ChangesetOperationTargetKind } from '../../../../../platform/agentHost/common/state/protocol/channels-changeset/commands.js'; import { ChangesetOperation, ChangesetOperationScope, type ChangesetFile, ChangesetOperationStatus } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; -import { buildDefaultChatUri, ChangesetStatus, Changeset, StateComponents, type ChangesetState, type ChatState, type ChatSummary, type SessionState } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildDefaultChatUri, ChangesetStatus, Changeset, MessageKind, StateComponents, TurnState, type ChangesetState, type ChatState, type ChatSummary, type SessionState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { ISessionChangeset, ISessionChangesetCapabilities, ISessionChangesetOperation, ISessionChangesetOperationTarget, ISessionFileChange, SessionChangesetOperationScope, SessionChangesetOperationStatus, sessionFileChangesEqual } from '../../../../services/sessions/common/session.js'; import { isIChatSessionFileChange2 } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; @@ -116,6 +117,10 @@ export function createChangesets( sessionChangesets.push(options.instantiationService.createInstance(AgentHostLastTurnChangeset, sessionUri, options, isActiveSessionObs, { ...changeset, isDefault })); + } else if (changeset.changeKind === AGENT_MERGE_CHANGESET_ID) { + sessionChangesets.push(options.instantiationService.createInstance(AgentHostAgentMergeChangeset, sessionUri, options, isActiveSessionObs, { + ...changeset, isDefault + })); } } @@ -596,3 +601,91 @@ class AgentHostLastTurnChangeset extends AbstractAgentHostChangeset { return filterChangesToPrimaryWorkingDirectory(changes, this._workingDirectoriesObs.read(reader)); } } + +class AgentHostAgentMergeChangeset extends AbstractAgentHostChangeset { + readonly id = AGENT_MERGE_CHANGESET_ID; + readonly label: string; + readonly description: string | undefined; + + readonly isEnabled = constObservable(true); + readonly isDefault: IObservable<boolean>; + + protected override readonly channelUriObs: IObservable<URI | undefined>; + protected readonly changesetStateObs: IObservable<IObservable<ChangesetState | Error | undefined | null>>; + + constructor( + sessionUri: URI, + options: IAgentHostAdapterOptions, + isActiveSessionObs: IObservable<boolean>, + changesetSummary: IAgentHostChangeset & { isDefault: boolean }, + @IDialogService dialogService: IDialogService, + ) { + super(changesetSummary, options, dialogService); + + this.label = changesetSummary.label; + this.description = changesetSummary.description; + this.isDefault = constObservable(changesetSummary.isDefault); + + const sessionStateObs = createActiveSessionSubscriptionObs<SessionState>( + options, + isActiveSessionObs, + StateComponents.Session, + constObservable(sessionUri), + ); + + const defaultChatUriObs = derivedOpts({ equalsFn: isEqual }, reader => { + const sessionState = sessionStateObs.read(reader).read(reader); + return URI.parse( + sessionState && !(sessionState instanceof Error) + ? sessionState.defaultChat ?? buildDefaultChatUri(sessionUri) + : buildDefaultChatUri(sessionUri) + ); + }); + + const chatStateObs = createActiveSessionSubscriptionObs<ChatState>( + options, + isActiveSessionObs, + StateComponents.Chat, + defaultChatUriObs, + ); + + this.channelUriObs = derivedOpts({ equalsFn: isEqual }, reader => { + const range = getAgentMergeChangesetRange(chatStateObs.read(reader).read(reader)); + if (!range) { + return undefined; + } + return URI.parse(changesetSummary.uriTemplate + .replace('{originalTurnId}', range.originalTurnId) + .replace('{modifiedTurnId}', range.modifiedTurnId)); + }); + + this.changesetStateObs = createActiveSessionSubscriptionObs<ChangesetState>( + options, + isActiveSessionObs, + StateComponents.Changeset, + this.channelUriObs, + ); + } +} + +function getAgentMergeChangesetRange(chatState: ChatState | Error | undefined | null): { readonly originalTurnId: string; readonly modifiedTurnId: string } | undefined { + if (!chatState || chatState instanceof Error || chatState.activeTurn?.message.origin.kind === MessageKind.User) { + return undefined; + } + + let originalTurnId: string | undefined; + let modifiedTurnId: string | undefined; + for (const turn of chatState.turns) { + if (turn.message.origin.kind === MessageKind.User) { + originalTurnId = turn.state === TurnState.Cancelled ? undefined : turn.id; + modifiedTurnId = undefined; + } else if (originalTurnId + && turn.state !== TurnState.Cancelled + && turn.message.origin.kind === MessageKind.SystemNotification + && isAgentMergeMessage(turn.message)) { + modifiedTurnId = turn.id; + } + } + + return originalTurnId && modifiedTurnId ? { originalTurnId, modifiedTurnId } : undefined; +} diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 6ee1eda0e0d150..49f058d851e42f 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -10,9 +10,10 @@ import { arrayEquals, structuralEquals } from '../../../../../base/common/equals import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString, MarkdownString, markdownStringEqual } from '../../../../../base/common/htmlContent.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, IReference, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { mapsStrictEqualIgnoreOrder } from '../../../../../base/common/map.js'; import { equals } from '../../../../../base/common/objects.js'; import { constObservable, derived, derivedOpts, IObservable, IReader, ISettableObservable, ITransaction, observableFromEvent, observableValueOpts, subtransaction, transaction, waitForState, autorun, observableValue } from '../../../../../base/common/observable.js'; -import { isEqual, isEqualOrParent, relativePath } from '../../../../../base/common/resources.js'; +import { basename, dirname, getComparisonKey, isEqual, isEqualOrParent, joinPath, relativePath } from '../../../../../base/common/resources.js'; import { themeColorFromId, ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; @@ -21,6 +22,8 @@ import { AgentSession, AuthenticateParams, AuthenticateResult, IAgentSessionMeta import { AgentMergeSessionOverrides, AgentMergeSessionState, readAgentMergeSessionState } from '../../../../../platform/agentHost/common/agentMerge.js'; import { IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; import type { AgentHostUriMapper } from '../../../../../platform/agentHost/common/agentHostUri.js'; +import type { RemoteAgentHostConnectionStatus } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { AgentHostTransportFailureReason } from '../../../../../platform/agentHost/common/state/sessionTransport.js'; import { getCustomizationDisabledReason, isCustomizationEnabled, withCustomizationEnablement } from '../../../../../platform/agentHost/common/customizationEnablement.js'; import { buildAnnotationsUri } from '../../../../../platform/agentHost/common/annotationsUri.js'; import { ChangesetKind } from '../../../../../platform/agentHost/common/changesetUri.js'; @@ -36,6 +39,7 @@ import { ActionType, isChatAction, isSessionAction, NotificationType } from '../ import { AgentCapabilities, AgentInfo, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, DEFAULT_CHAT_ID, getSessionChatResource, getSessionRelatedPullRequestUrls, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionCreationReference, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, ROOT_STATE_URI, SESSION_META_MULTI_ROOT_KEY, SessionMeta, SessionSourceControlOutcome, StateComponents, withSessionCreationReference, withSessionExternal, withSessionGitHubState, withSessionMultiRootMetadata, withSessionStatusFlag, withSessionWorkspaceless, type ChatState, type ChatSummary, type ISessionCreationReference as IProtocolSessionCreationReference, type ISessionGitHubState, type ISessionGitState, type ISessionMultiRootMetadata } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; @@ -53,7 +57,7 @@ import { getRegisteredLanguageModels, resolveConfiguredModel, resolveModelIdenti import { buildMutableConfigSchema, IAgentHostMcpServer, IAgentHostSessionsProvider, IAgentMergeClientState, resolvedConfigsEqual } from '../../../../common/agentHostSessionsProvider.js'; import { agentHostSessionWorkspaceKey } from '../../../../common/agentHostSessionWorkspace.js'; import { isSessionConfigComplete } from '../../../../common/sessionConfig.js'; -import { ChatInteractivity, ChatModelSource, ChatOriginKind, DEFAULT_CHAT_CAPABILITIES, effectiveChatInteractivity, getGitHubPullRequestRefs, getHighestPriorityPullRequestIcon, IChat, IChatCapabilities, IGitHubInfo, IGitHubIssueRef, IGitHubPullRequestRef, ISession, ISessionAgentRef, ISessionArtifact, ISessionCapabilities, ISessionChangeset, ISessionChangesSummary, ISessionChatCustomization, ISessionCreationReference, ISessionFileChange, ISessionTurnFileChange, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, ISideChatSelection, sessionFileChangesEqual, sessionWorkspaceEqual, SessionStatus, SessionTypeAuthRequirement, toSessionId, TURN_CHANGES_CHANGESET_ID } from '../../../../services/sessions/common/session.js'; +import { ChatInteractivity, ChatModelSource, ChatOriginKind, DEFAULT_CHAT_CAPABILITIES, effectiveChatInteractivity, getGitHubPullRequestRefs, getHighestPriorityPullRequestIcon, IChat, IChatCapabilities, IGitHubInfo, IGitHubIssueRef, IGitHubPullRequestRef, isActiveSessionStatus, ISession, ISessionAgentRef, ISessionArtifact, ISessionCapabilities, ISessionChangesSummary, ISessionChatCustomization, ISessionChangeset, ISessionCreationReference, ISessionFileChange, ISessionTurnFileChange, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, ISideChatSelection, sessionFileChangesEqual, sessionWorkspaceEqual, SessionRemoteConnectionFailureReason, SessionRemoteConnectionStatus, SessionStatus, SessionTypeAuthRequirement, toSessionId, TURN_CHANGES_CHANGESET_ID } from '../../../../services/sessions/common/session.js'; import { dedupeLinks, getPresentedArtifacts, linkKey, partitionSessionArtifacts } from './agentHostSessionArtifacts.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProviderCreateSessionOptions, ISessionWorktreeConfiguration } from '../../../../services/sessions/common/sessionsProvider.js'; @@ -569,6 +573,63 @@ export interface IAgentHostAdapterOptions { readonly mapBackendSessionResource: (resource: URI) => URI; /** `Changeset.changeKind` the Changes view selects by default. Defaults to `branch`. */ readonly defaultChangesetKind?: ChangesetKind.Branch | ChangesetKind.Uncommitted | ChangesetKind.Session; + /** Connection state from the backing remote provider, when there is one. */ + readonly connectionStatus?: IObservable<RemoteAgentHostConnectionStatus>; +} + +/** + * Projects the backing provider's transport status onto the session-facing + * shape, preserving the machine-readable disconnect reason so consumers can + * tell a resolvable outage (a stopped host) from a generic one. + * + * Returns `undefined` when the provider has no remote transport, which is how + * a local session reports "no remote host" rather than "host unavailable". + */ +function toSessionRemoteConnectionStatus(owner: object, connectionStatus: IObservable<RemoteAgentHostConnectionStatus> | undefined): IObservable<SessionRemoteConnectionStatus> | undefined { + if (!connectionStatus) { + return undefined; + } + return derived<SessionRemoteConnectionStatus>(owner, reader => { + const status = connectionStatus.read(reader); + switch (status.kind) { + case 'connected': + case 'connecting': + case 'incompatible': + return { kind: status.kind }; + case 'reconnecting': + // Omit the key rather than carrying an explicit `undefined`, so a + // plain reconnect stays structurally equal to the no-deadline case. + return status.nextAttemptAt === undefined + ? { kind: status.kind } + : { kind: status.kind, nextAttemptAt: status.nextAttemptAt }; + case 'disconnected': + switch (status.reason) { + case AgentHostTransportFailureReason.Unknown: + return { kind: status.kind, reason: SessionRemoteConnectionFailureReason.Unknown }; + case AgentHostTransportFailureReason.HostNotRunning: + return { kind: status.kind, reason: SessionRemoteConnectionFailureReason.HostNotRunning }; + } + } + }); +} + +/** + * An active status is only meaningful while the backing agent host can make + * progress. Keep the source status intact so it resumes when the host does, + * but present an error rather than a perpetual activity spinner while it is + * known to be unreachable. + */ +function toPresentedSessionStatus(owner: object, status: IObservable<SessionStatus>, connectionStatus: IObservable<RemoteAgentHostConnectionStatus> | undefined): IObservable<SessionStatus> { + if (!connectionStatus) { + return status; + } + return derived(owner, reader => { + const value = status.read(reader); + const connection = connectionStatus.read(reader); + return isActiveSessionStatus(value) && (connection.kind === 'disconnected' || connection.kind === 'incompatible') + ? SessionStatus.Error + : value; + }); } /** @@ -615,7 +676,7 @@ class AdditionalChat extends Disposable { private readonly _interactivity: ISettableObservable<ChatInteractivity>; private readonly _isNew: ISettableObservable<boolean>; - constructor(resource: URI, summary: ChatSummary, isNew: boolean = false, parentChat?: URI, sessionIsArchived: IObservable<boolean> = constObservable(false), output?: IChatOutputObs, sessionIsReadOnly: IObservable<boolean> = constObservable(false)) { + constructor(resource: URI, summary: ChatSummary, isNew: boolean = false, parentChat?: URI, sessionIsArchived: IObservable<boolean> = constObservable(false), output?: IChatOutputObs, sessionIsReadOnly: IObservable<boolean> = constObservable(false), connectionStatus?: IObservable<RemoteAgentHostConnectionStatus>) { super(); const modifiedAt = summary.modifiedAt ? new Date(summary.modifiedAt) : new Date(); this._title = observableValue('chatTitle', summary.title || localize('newChatTab', "New Chat")); @@ -628,12 +689,13 @@ class AdditionalChat extends Disposable { this._lastTurnEnd = observableValueOpts<Date | undefined>({ owner: this, debugName: 'chatLastTurnEnd', equalsFn: dateEquals }, modifiedAt); this._interactivity = observableValue<ChatInteractivity>('chatInteractivity', toChatInteractivity(summary.interactivity)); this._isNew = observableValue<boolean>('chatIsNew', isNew); + const status = derived(this, reader => this._isNew.read(reader) ? SessionStatus.Untitled : this._status.read(reader)); this.chat = { resource, createdAt: modifiedAt, title: this._title, updatedAt: this._updatedAt, - status: derived(reader => this._isNew.read(reader) ? SessionStatus.Untitled : this._status.read(reader)), + status: toPresentedSessionStatus(this, status, connectionStatus), changes: constObservable([]), lastTurnChanges: output?.lastTurnChanges, customizations: output?.customizations, @@ -744,6 +806,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { readonly isQuickChat: IObservable<boolean>; readonly isAutomation = observableValue('isAutomation', false); readonly isExternal: IObservable<boolean>; + readonly remoteConnectionStatus: IObservable<SessionRemoteConnectionStatus> | undefined; readonly createdBySession: IObservable<ISessionCreationReference | undefined>; /** See {@link ISession.worktreePending}. */ readonly worktreePending: IObservable<boolean>; @@ -962,6 +1025,8 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this._meta = metadata._meta; this._metaObs = observableValue<SessionMeta | undefined>('agentHostSessionMeta', this._meta); this.isExternal = derived(this, reader => readSessionExternal(this._metaObs.read(reader))); + const connectionStatus = _options.connectionStatus; + this.remoteConnectionStatus = toSessionRemoteConnectionStatus(this, connectionStatus); this.createdBySession = derived(this, reader => { const creationReference = readSessionCreationReference(this._metaObs.read(reader)); if (!creationReference) { @@ -1089,12 +1154,13 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this._sessionOutput = sessionOutput; this._currentTurnChanges = this._createCurrentTurnChangesObservable(); + const defaultChatStatus = derived(this, reader => this._defaultChatStatusOverride.read(reader) ?? this.status.read(reader)); const mainChat: IChat = { resource: this.resource, createdAt: this.createdAt, title: derived(this, reader => this._defaultChatTitleOverride.read(reader) ?? this.title.read(reader)), updatedAt: this.updatedAt, - status: derived(this, reader => this._defaultChatStatusOverride.read(reader) ?? this.status.read(reader)), + status: toPresentedSessionStatus(this, defaultChatStatus, connectionStatus), changes: this.changes, lastTurnChanges: sessionOutput.getLastTurnChanges(URI.parse(buildDefaultChatUri(this.backendUri))), customizations: sessionOutput.getChatCustomizations(URI.parse(buildDefaultChatUri(this.backendUri))), @@ -1265,7 +1331,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { lastTurnChanges: this._sessionOutput.getLastTurnChanges(backendUri), customizations: this._sessionOutput.getChatCustomizations(backendUri), }; - const chat = new AdditionalChat(resource, summary, this._newChatIds.has(chatId), this._resolveParentChatResource(summary.origin), this.isArchived, output, this._options.readOnly); + const chat = new AdditionalChat(resource, summary, this._newChatIds.has(chatId), this._resolveParentChatResource(summary.origin), this.isArchived, output, this._options.readOnly, this._options.connectionStatus); const selection = this._chatModelSelections.get(chatId); if (selection) { chat.setModelId(selection.modelId, selection.source); @@ -2055,6 +2121,7 @@ class NewSession extends Disposable { const authPending = ctx.authenticationPending; const loading = this._loading; const chats = this._mainChat.map(c => [c]); + const connectionStatus = _options.connectionStatus; this.session = { sessionId: `${ctx.providerId}:${resource.toString()}`, resource, @@ -2065,6 +2132,7 @@ class NewSession extends Disposable { workspace: this._workspace, isQuickChat: constObservable(this._kind.isQuickChat), worktreePending: this._worktreePending, + remoteConnectionStatus: toSessionRemoteConnectionStatus(this, connectionStatus), title, updatedAt, status: this._status, @@ -2524,6 +2592,10 @@ class NewSession extends Disposable { // BaseAgentHostSessionsProvider — shared base for local and remote providers // ============================================================================ +function escapeResourceLabelPathSeparators(label: string): string { + return label.replaceAll('/', '\u2215').replaceAll('\\', '\u29F5'); +} + /** * Shared base class for the local and remote agent host sessions providers. * @@ -2882,6 +2954,11 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement /** Provider-level authentication-pending observable used to derive `loading` for sessions. */ protected abstract get authenticationPending(): IObservable<boolean>; + /** Connection state for remote-host sessions. */ + protected get remoteConnectionStatus(): IObservable<RemoteAgentHostConnectionStatus> | undefined { + return undefined; + } + /** * Subclass-specific portion of the adapter options. Base fills in * the bits that are uniform across hosts (`icon`, `loading`, @@ -2942,6 +3019,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement agentCapabilities: this._agentCapabilities, backendSessionScheme: this._backendSessionScheme(provider), mapBackendSessionResource: resource => this._mapBackendSessionResource(resource), + connectionStatus: this.remoteConnectionStatus, ...this._adapterOptions(), } satisfies IAgentHostAdapterOptions; @@ -3200,7 +3278,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return sessions; } - getResourceLabelHomes(): { readonly uri: URI; readonly label: string }[] { + protected getResourceLabelHomes(): { readonly uri: URI; readonly label: string }[] { const homes: { readonly uri: URI; readonly label: string }[] = []; for (const session of this.getKnownSessions()) { if (session.isQuickChat?.get()) { @@ -3212,9 +3290,85 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return homes; } + private readonly _resourceLabelHomeRegistrations = this._register(new DisposableMap<string>()); + private readonly _resourceLabelHomeLabels = new Map<string, Map<string, string>>(); + private readonly _resourceLabelHomeFormattingEvents = this._register(new DisposableMap<string, Emitter<void>>()); + + protected updateResourceLabelHomeFormatters(homes: readonly { readonly uri: URI; readonly label: string }[], labelService: ILabelService): void { + const groups = new Map<string, { readonly parent: URI; readonly labels: Map<string, string> }>(); + for (const home of homes) { + const parent = dirname(home.uri); + const key = getComparisonKey(parent); + let group = groups.get(key); + if (!group) { + group = { parent, labels: new Map() }; + groups.set(key, group); + } + group.labels.set(basename(home.uri), home.label); + } + + const registrationKeys = new Set<string>(); + const removedGroupKeys = new Set(this._resourceLabelHomeLabels.keys()); + const changedFormattingEvents: Emitter<void>[] = []; + for (const [key, group] of groups) { + removedGroupKeys.delete(key); + const previousLabels = this._resourceLabelHomeLabels.get(key); + this._resourceLabelHomeLabels.set(key, group.labels); + let formattingEvent = this._resourceLabelHomeFormattingEvents.get(key); + if (!formattingEvent) { + formattingEvent = new Emitter<void>(); + this._resourceLabelHomeFormattingEvents.set(key, formattingEvent); + } else if (previousLabels && !mapsStrictEqualIgnoreOrder(previousLabels, group.labels)) { + changedFormattingEvents.push(formattingEvent); + } + const separator = labelService.getSeparator(group.parent.scheme, group.parent.authority); + const templateKey = `template:${key}`; + if (group.labels.size > (group.labels.has('') ? 1 : 0)) { + registrationKeys.add(templateKey); + if (!this._resourceLabelHomeRegistrations.has(templateKey)) { + this._resourceLabelHomeRegistrations.set(templateKey, labelService.registerFormatter({ + home: joinPath(group.parent, '${sessionId}'), + onDidChangeFormatting: formattingEvent.event, + formatting: context => { + const label = this._resourceLabelHomeLabels.get(key)?.get(context.parameters.get('sessionId') ?? ''); + return label === undefined ? undefined : { label, separator }; + }, + })); + } + } + const rootKey = `root:${key}`; + if (group.labels.has('')) { + registrationKeys.add(rootKey); + if (!this._resourceLabelHomeRegistrations.has(rootKey)) { + this._resourceLabelHomeRegistrations.set(rootKey, labelService.registerFormatter({ + home: group.parent, + onDidChangeFormatting: formattingEvent.event, + formatting: () => { + const label = this._resourceLabelHomeLabels.get(key)?.get(''); + return label === undefined ? undefined : { label, separator }; + }, + })); + } + } + } + for (const [key] of this._resourceLabelHomeRegistrations) { + if (!registrationKeys.has(key)) { + this._resourceLabelHomeRegistrations.deleteAndDispose(key); + } + } + for (const key of removedGroupKeys) { + this._resourceLabelHomeLabels.delete(key); + this._resourceLabelHomeFormattingEvents.deleteAndDispose(key); + } + for (const formattingEvent of changedFormattingEvents) { + formattingEvent.fire(); + } + } + protected getResourceLabelHomeLabel(session: ISession): string { - const providerLabel = this.sessionTypes.find(type => type.id === session.sessionType)?.label ?? session.sessionType; - return `${providerLabel}/${localize('sessionHome', "Session")}`; + const providerLabel = escapeResourceLabelPathSeparators(this.sessionTypes.find(type => type.id === session.sessionType)?.label ?? session.sessionType); + const sessionLabel = escapeResourceLabelPathSeparators(session.title.get() || localize('sessionHome', "Session")); + return `${providerLabel}/${sessionLabel}`; } protected getKnownSessions(): ISession[] { @@ -3347,6 +3501,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement getConnection: () => this.connection, agentCapabilities: this._agentCapabilities, mapBackendSessionResource: resource => this._mapBackendSessionResource(resource), + connectionStatus: this.remoteConnectionStatus, ...this._adapterOptions(), } satisfies IAgentHostAdapterOptions); } catch (err) { diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index f649b7e1323862..8631d5c598acbe 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -46,7 +46,6 @@ import { ILanguageModelsService, type ILanguageModelChatMetadata } from '../../. import { IWorkbenchEnvironmentService } from '../../../../../workbench/services/environment/common/environmentService.js'; import { isAgentHostProvider, LOCAL_AGENT_HOST_PROVIDER_ID, type IAgentHostSessionsProvider } from '../../../../common/agentHostSessionsProvider.js'; import { IPathService } from '../../../../../workbench/services/path/common/pathService.js'; -import { ResourceLabelHomeStore } from '../../../../../workbench/services/label/common/resourceLabelHomeStore.js'; import { buildAgentHostSessionWorkspace, readBranchProtectionPatterns } from '../../../../common/agentHostSessionWorkspace.js'; import { IDevContainerAgentHostService } from '../../../../common/devContainerAgentHostService.js'; import { ChatModelSource, IGitHubInfo, ISession, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL } from '../../../../services/sessions/common/session.js'; @@ -124,8 +123,6 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide private _automationSessionResources = new ResourceSet(); private readonly _devContainerAvailableDrafts = new Set<string>(); private readonly _devContainerDrafts = new Set<string>(); - private readonly _resourceLabelHomes: ResourceLabelHomeStore; - override get order(): number { return -1; } @@ -193,7 +190,6 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide @IPathService pathService: IPathService, ) { super(chatSessionsService, chatService, chatWidgetService, languageModelsService, _configurationService, logService, gitHubService, instantiationService, sessionsService, activeClientService, storageService, dialogService, workspaceTrustManagementService); - this._resourceLabelHomes = this._register(instantiationService.createInstance(ResourceLabelHomeStore)); const legacyAutomations = this._register(instantiationService.createInstance(AutomationStore, providerAutomationStorageKey(this.id))); const automations = this._register(instantiationService.createInstance(ReconnectableAgentHostAutomationStore, this.id, legacyAutomations, { toHost: resource => resource, @@ -215,6 +211,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide // authentication settling below) reconciles them. this._enableSessionCachePersistence(LOCAL_AGENT_HOST_CACHED_SESSIONS_STORAGE_KEY, LOCAL_AGENT_HOST_CACHED_SESSIONS_STORAGE_KEY_LEGACY); + const onDidChangeResourceLabelHomes = Event.any(this._onDidChangeSessionsImmediately, this._onDidChangeDraftSessions.event); const updateResourceLabelHomes = () => { const homes = this.getResourceLabelHomes(); const userHome = pathService.userHome({ preferLocal: true }); @@ -238,10 +235,10 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide } } } - this._resourceLabelHomes.set(homes); + + this.updateResourceLabelHomeFormatters(homes, this._labelService); }; - this._register(this._onDidChangeSessionsImmediately(updateResourceLabelHomes)); - this._register(this._onDidChangeDraftSessions.event(updateResourceLabelHomes)); + this._register(onDidChangeResourceLabelHomes(updateResourceLabelHomes)); updateResourceLabelHomes(); this._register(autorun(reader => { this._automationSessionResources = new ResourceSet(this.automations.runs.read(reader).flatMap(run => run.sessionResource ? [run.sessionResource] : [])); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts index 6fb98067d5ef9d..f0731be1bf72f4 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts @@ -256,16 +256,16 @@ suite('AgentHostPermissionPickerDelegate', () => { current: delegate.currentPermissionLevel.get(), metadata: delegate.availableLevels.map(level => { const baseMeta = getPermissionLevelMeta(level); - const { label, detail, hover } = delegate.getPermissionLevelMeta(level, baseMeta); - return { label, detail, hover }; + const { label, detail, hover, icon } = delegate.getPermissionLevelMeta(level, baseMeta); + return { label, detail, hover, icon: icon.id }; }), available: delegate.availableLevels, }, { current: ChatPermissionLevel.Assisted, metadata: [ - { label: 'Manual permissions', detail: 'Asks when approval settings don\'t apply', hover: undefined }, - { label: 'Assisted permissions', detail: 'Evaluates risk before running tools', hover: 'An LLM judge evaluates each tool call. Tools it doesn\'t approve require your approval.' }, - { label: 'Allow all', detail: 'Runs tool calls without asking', hover: undefined }, + { label: 'Manual permissions', detail: 'Asks when approval settings don\'t apply', hover: undefined, icon: 'key' }, + { label: 'Assisted permissions', detail: 'Evaluates risk before running tools', hover: 'An LLM judge evaluates each tool call. Tools it doesn\'t approve require your approval.', icon: 'sparkle' }, + { label: 'Allow all', detail: 'Runs tool calls without asking', hover: undefined, icon: 'warning' }, ], available: [ ChatPermissionLevel.Default, diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts index 25d7c6fdf1cc52..84eda27e44c7ac 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts @@ -6,19 +6,20 @@ import assert from 'assert'; import { DeferredPromise } from '../../../../../../base/common/async.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { Event } from '../../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; import { IReference } from '../../../../../../base/common/lifecycle.js'; -import { constObservable } from '../../../../../../base/common/observable.js'; +import { autorun, constObservable } from '../../../../../../base/common/observable.js'; import { isLinux } from '../../../../../../base/common/platform.js'; import { URI } from '../../../../../../base/common/uri.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; -import { ChangesetKind } from '../../../../../../platform/agentHost/common/changesetUri.js'; +import { AGENT_MERGE_CHANGESET_ID, buildCompareTurnsChangesetUriTemplate, ChangesetKind } from '../../../../../../platform/agentHost/common/changesetUri.js'; +import { toAgentMergeMessageMeta } from '../../../../../../platform/agentHost/common/meta/agentMergeMessageMeta.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import type { InvokeChangesetOperationResult } from '../../../../../../platform/agentHost/common/state/protocol/channels-changeset/commands.js'; import { ChangesetOperationScope, ChangesetOperationStatus } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { ChangesetStatus, StateComponents, type ChangesetState, type ComponentToState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { createChatState, ChangesetStatus, MessageKind, SessionLifecycle, SessionStatus, StateComponents, TurnState, type ChangesetState, type ChatState, type ChatSummary, type ComponentToState, type SessionState, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IChatSessionFileChange2 } from '../../../../../../workbench/contrib/chat/common/chatSessionsService.js'; @@ -59,6 +60,24 @@ suite('AgentHostSessionChangesets', () => { return changes.map(change => (change as IChatSessionFileChange2).uri.toString()); } + function createMutableSubscription<T>(initialValue: T): { readonly object: IAgentSubscription<T>; set(value: T): void } { + let value = initialValue; + const onDidChange = disposables.add(new Emitter<T>()); + return { + object: { + get value() { return value; }, + get verifiedValue() { return value; }, + onDidChange: onDidChange.event, + onWillApplyAction: Event.None, + onDidApplyAction: Event.None, + }, + set: newValue => { + value = newValue; + onDidChange.fire(newValue); + }, + }; + } + suite('filterChangesToPrimaryWorkingDirectory', () => { test('(a) multi-root: keeps only changes under the primary working directory', () => { const changes = [ @@ -232,6 +251,136 @@ suite('AgentHostSessionChangesets', () => { }); }); + test('binds Agent Merge changes to completed repair turns after the last default-chat user turn', () => { + const sessionUri = URI.parse('ahp-session:/session-1'); + const defaultChatUri = URI.parse('ahp-session:/session-1/chat/default'); + const modifiedAt = new Date(0).toISOString(); + const chatSummary: ChatSummary = { + resource: defaultChatUri.toString(), + title: 'Default', + status: SessionStatus.Idle, + modifiedAt, + }; + const sessionState: SessionState = { + provider: 'copilot', + title: 'Session', + status: SessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + chats: [chatSummary], + defaultChat: defaultChatUri.toString(), + }; + const makeTurn = (id: string, kind: MessageKind, agentMerge = false): Turn => ({ + id, + message: { + text: id, + origin: { kind }, + ...(agentMerge ? { _meta: toAgentMergeMessageMeta() } : {}), + }, + responseParts: [], + usage: undefined, + state: TurnState.Complete, + }); + const user1 = makeTurn('user-1', MessageKind.User); + const merge2 = makeTurn('merge-2', MessageKind.SystemNotification, true); + const user3 = makeTurn('user-3', MessageKind.User); + const merge4 = makeTurn('merge-4', MessageKind.SystemNotification, true); + const notice = makeTurn('notice', MessageKind.SystemNotification); + const automation = makeTurn('automation', MessageKind.Automation); + const tool = makeTurn('tool', MessageKind.Tool); + const merge5 = makeTurn('merge-5', MessageKind.SystemNotification, true); + const user6 = makeTurn('user-6', MessageKind.User); + const merge7 = makeTurn('merge-7', MessageKind.SystemNotification, true); + + const sessionSubscription = createMutableSubscription(sessionState); + const chatSubscription = createMutableSubscription<ChatState>({ + ...createChatState(chatSummary), + turns: [user1, merge2, user3], + }); + const changesetSubscription = createMutableSubscription<ChangesetState>({ + status: ChangesetStatus.Ready, + files: [], + }); + const acquiredChangesets: string[] = []; + const releasedChangesets: string[] = []; + const connection = new class extends mock<IAgentConnection>() { + override getSubscription<T extends StateComponents>(component: T, resource: URI): IReference<IAgentSubscription<ComponentToState[T]>> { + switch (component) { + case StateComponents.Session: + return { object: sessionSubscription.object as IAgentSubscription<ComponentToState[T]>, dispose: () => { } }; + case StateComponents.Chat: + return { object: chatSubscription.object as IAgentSubscription<ComponentToState[T]>, dispose: () => { } }; + case StateComponents.Changeset: { + const key = resource.toString(); + acquiredChangesets.push(key); + return { + object: changesetSubscription.object as IAgentSubscription<ComponentToState[T]>, + dispose: () => releasedChangesets.push(key), + }; + } + default: + throw new Error(`Unexpected subscription component: ${component}`); + } + } + }(); + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IDialogService, { confirm: async () => ({ confirmed: true }) }); + const options: IAgentHostAdapterOptions = { + icon: Codicon.copilot, + loading: constObservable(false), + buildWorkspace: () => undefined, + instantiationService, + getConnection: () => connection, + agentCapabilities: constObservable(undefined), + mapBackendSessionResource: resource => resource, + }; + const changeset = createChangesets(sessionUri, options, constObservable(true), [{ + label: 'Agent Merge Changes', + changeKind: AGENT_MERGE_CHANGESET_ID, + uriTemplate: buildCompareTurnsChangesetUriTemplate(sessionUri.toString()), + }])[0]; + if (!changeset) { + throw new Error('Expected Agent Merge changeset'); + } + + let visibleChangeCount = -1; + disposables.add(autorun(reader => { + visibleChangeCount = changeset.changes.read(reader).length; + })); + + const repairsAfterUser3 = [user1, merge2, user3, notice, merge4, automation, tool, merge5]; + chatSubscription.set({ ...createChatState(chatSummary), turns: repairsAfterUser3 }); + chatSubscription.set({ + ...createChatState(chatSummary), + turns: repairsAfterUser3, + activeTurn: { + id: user6.id, + startedAt: modifiedAt, + message: user6.message, + responseParts: [], + usage: undefined, + }, + }); + chatSubscription.set({ ...createChatState(chatSummary), turns: [...repairsAfterUser3, user6] }); + chatSubscription.set({ ...createChatState(chatSummary), turns: [...repairsAfterUser3, user6, merge7] }); + + const compareFromUser3 = `ahp-session:/session-1/changeset/compare/user-3/merge-5`; + const compareFromUser6 = `ahp-session:/session-1/changeset/compare/user-6/merge-7`; + assert.deepStrictEqual({ + id: changeset.id, + enabled: changeset.isEnabled.get(), + visibleChangeCount, + acquiredChangesets, + releasedChangesets, + }, { + id: AGENT_MERGE_CHANGESET_ID, + enabled: true, + visibleChangeCount: 0, + acquiredChangesets: [compareFromUser3, compareFromUser6], + releasedChangesets: [compareFromUser3], + }); + }); + test('marks an invoked operation running locally until the host request settles', async () => { const operationId = 'create-pr-auto-merge'; const operationResult = new DeferredPromise<InvokeChangesetOperationResult>(); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index c616099f223e75..56ec50ca917ca5 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -679,6 +679,13 @@ suite('LocalAgentHostSessionsProvider', () => { assert.strictEqual(provider.sessionTypes[0].label, 'Copilot'); }); + test('leaves remote connection status absent from local sessions', () => { + const provider = createProvider(disposables, agentHost); + fireSessionAdded(agentHost, 'local-connection-status'); + + assert.deepStrictEqual(provider.getSessions().map(session => session.remoteConnectionStatus), [undefined]); + }); + test('session types update when the local host advertises additional agents', () => { const provider = createProvider(disposables, agentHost); assert.deepStrictEqual(provider.sessionTypes.map(t => ({ id: t.id, label: t.label })), [ @@ -5937,7 +5944,9 @@ suite('LocalAgentHostSessionsProvider', () => { test('registers provider-neutral resource label homes for quick chats and provider state', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { const claudeHome = URI.file('/home/test/.agent/chats/claude-session'); + const rootHome = URI.file('/'); agentHost.addSession(createSession('claude-session', { provider: 'claude', summary: 'Claude Quick Chat', quickChat: true, workingDirectory: claudeHome })); + agentHost.addSession(createSession('root-session', { provider: 'claude', summary: 'Root Quick Chat', quickChat: true, workingDirectory: rootHome })); agentHost.addSession(createSession('copilot-session', { summary: 'Copilot Session' })); const pathService = new TestPathService(URI.file('/home/test')); const labelService = new MockLabelService(); @@ -5951,11 +5960,39 @@ suite('LocalAgentHostSessionsProvider', () => { }; assert.deepStrictEqual({ + formatterCount: labelService.formatterCount, quickChat: getHomeLabel(URI.joinPath(claudeHome, 'artifact.md')), + root: getHomeLabel(URI.file('/artifact.md')), + copilotState: getHomeLabel(URI.file('/home/test/.copilot/session-state/copilot-session/artifact.md')), + }, { + formatterCount: 4, + quickChat: 'claude/Claude Quick Chat', + root: 'claude/Root Quick Chat', + copilotState: 'Copilot/Copilot Session', + }); + + let formatterChanges = 0; + disposables.add(labelService.onDidChangeFormatters(() => formatterChanges++)); + const copilotSession = AgentSession.uri('copilotcli', 'copilot-session').toString(); + agentHost.fireAction({ + channel: copilotSession, + action: { type: ActionType.SessionIsReadChanged, isRead: true }, + serverSeq: 1, + origin: undefined, + } as ActionEnvelope); + agentHost.fireAction({ + channel: copilotSession, + action: { type: ActionType.SessionTitleChanged, title: 'Renamed/Session\\Title' }, + serverSeq: 2, + origin: undefined, + } as ActionEnvelope); + + assert.deepStrictEqual({ + formatterChanges, copilotState: getHomeLabel(URI.file('/home/test/.copilot/session-state/copilot-session/artifact.md')), }, { - quickChat: 'claude/Session', - copilotState: 'Copilot/Session', + formatterChanges: 1, + copilotState: 'Copilot/Renamed\u2215Session\u29F5Title', }); provider.dispose(); @@ -5968,6 +6005,31 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('shares one resource label formatter across session state homes', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { + for (let index = 0; index < 100; index++) { + agentHost.addSession(createSession(`session-${index}`, { summary: `Session ${index}` })); + } + const labelService = new MockLabelService(); + const provider = createProvider(disposables, agentHost, undefined, { + pathService: new TestPathService(URI.file('/home/test')), + labelService, + }); + provider.getSessions(); + await timeout(0); + const resource = URI.file('/home/test/.copilot/session-state/session-42/artifact.md'); + const home = labelService.getUriHome(resource); + + assert.deepStrictEqual({ + formatterCount: labelService.formatterCount, + home: home?.toString(), + label: home ? labelService.getUriLabel(home) : undefined, + }, { + formatterCount: 1, + home: URI.file('/home/test/.copilot/session-state/session-42').toString(), + label: 'Copilot/Session 42', + }); + })); + test('registers the session state home before a quick chat is materialized', () => { const pathService = new TestPathService(URI.file('/home/test')); const labelService = new MockLabelService(); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 5c97d1d9d9d704..4357c9a561d250 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -26,7 +26,7 @@ import { IChatResponseModel } from '../../../../../workbench/contrib/chat/common import { ChatSessionStatus, IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, SessionType } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ChatModelSource, ISession, IChat, ISessionGitRepository, ISessionFolder, ISessionWorkspace, ISideChatSelection, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, sessionFileChangesEqual, gitHubInfoEqual, sessionWorkspaceEqual, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_GITHUB, ISessionChangeset, IChatCheckpoints, ChatInteractivity, SessionTypeAuthRequirement } from '../../../../services/sessions/common/session.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; -import { basename, dirname, isEqual } from '../../../../../base/common/resources.js'; +import { basename, dirname, isEqual, isEqualOrParent } from '../../../../../base/common/resources.js'; import { IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { ISessionOptionGroup } from '../../../chat/browser/newSession.js'; import { ILanguageModelToolsService } from '../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; @@ -44,6 +44,7 @@ import { localize } from '../../../../../nls.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; @@ -54,9 +55,6 @@ import { structuralEquals } from '../../../../../base/common/equals.js'; import { CopilotCLISessionType } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; import { createChangesets } from './copilotChatSessionsChangesets.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; -import { IPathService } from '../../../../../workbench/services/path/common/pathService.js'; -import { ResourceLabelHomeStore } from '../../../../../workbench/services/label/common/resourceLabelHomeStore.js'; -import { buildLocalSessionStateUri } from '../../../../../workbench/contrib/chat/browser/copilotCliEventsUri.js'; import { IAgentHostEnablementService } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { isCloudSandboxEnabled } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { getWorkbenchContribution } from '../../../../../workbench/common/contributions.js'; @@ -902,6 +900,25 @@ function githubRemoteRepoLabel(uri: URI): string | undefined { return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : undefined; } +async function resolveGitHubRepositoryId(folder: ISessionFolder, gitService: Pick<IGitService, 'openRepository'>): Promise<string | undefined> { + const gitHubInfo = folder.gitRepository?.gitHubInfo.get(); + if (gitHubInfo) { + return `${gitHubInfo.owner}/${gitHubInfo.repo}`; + } + + const remoteRepositoryId = githubRemoteRepoLabel(folder.root); + if (remoteRepositoryId || folder.root.scheme !== Schemas.file) { + return remoteRepositoryId; + } + + const repository = await gitService.openRepository(folder.gitRepository?.uri ?? folder.root); + if (!repository || !isEqualOrParent(folder.root, repository.rootUri)) { + return undefined; + } + const repositoryInfo = repository && getGitHubRemoteInfo(repository.state.get()); + return repositoryInfo ? `${repositoryInfo.owner}/${repositoryInfo.repo}` : undefined; +} + /** * Adapts an existing {@link IAgentSession} from the chat layer into the new {@link ICopilotChatSession} facade. */ @@ -1404,7 +1421,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions * How long the first sandbox turn waits for the session's model catalog to arrive before * dispatching without the user's model. Long enough to cover the gap between the relay * connecting and the host publishing its models, short enough not to strand a send behind a - * catalog that is never coming. + * catalog that is never coming. Exceeding it is reported to the user, not only logged. */ private static readonly SANDBOX_MODEL_WAIT_MS = 5_000; @@ -1433,7 +1450,6 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions /** Cache of adapted sessions, keyed by resource URI string. */ private readonly _sessionCache = new Map<string, AgentSessionAdapter | CopilotCLISession | RemoteNewSession>(); - private readonly _resourceLabelHomes: ResourceLabelHomeStore; /** * Resources of committed sessions that are currently in-flight (i.e. @@ -1507,19 +1523,17 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions @IConfigurationService private readonly configurationService: IConfigurationService, @IAgentHostEnablementService private readonly agentHostEnablementService: IAgentHostEnablementService, @ILogService private readonly logService: ILogService, + @INotificationService private readonly notificationService: INotificationService, @IGitHubService private readonly gitHubService: IGitHubService, @IPullRequestIconCache private readonly pullRequestIconCache: IPullRequestIconCache, @ILabelService private readonly labelService: ILabelService, @IChatModeService private readonly chatModeService: IChatModeService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, - @IPathService private readonly pathService: IPathService, @IGitService private readonly gitService: IGitService, ) { super(); - this._resourceLabelHomes = this._register(this.instantiationService.createInstance(ResourceLabelHomeStore)); this._multiChatEnabled = this.configurationService.getValue<boolean>(COPILOT_MULTI_CHAT_SETTING) ?? true; - this._register(this._onDidChangeSessions.event(() => this._updateResourceLabelHomes())); this._register(runOnChange(this.agentHostEnablementService.enabled, () => { this._onDidChangeSessionTypes.fire(); @@ -2139,6 +2153,11 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return getWorkbenchContribution<CloudSandboxAgentHostContribution>(CloudSandboxAgentHostContribution.ID); } + /** Test seam: overridden so a test can reach the timeout without waiting out the real budget. */ + protected get _sandboxModelWaitMs(): number { + return CopilotChatSessionsProvider.SANDBOX_MODEL_WAIT_MS; + } + /** * Commit a cloud new-session into a GitHub-managed sandbox instead of the server-run cloud * agent: provision the sandbox, then hand the session over to the remote-agent-host provider @@ -2160,7 +2179,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions let provisioned: ICloudSandboxProvisionedSession | undefined; // Read before provisioning: the composer session is retired below, and its selection is the // only record of what the user picked for this turn. - const selectedRawModelId = this._rawCloudModelId(session); + const selectedModel = this._selectedCloudModel(session); try { provisioned = await this._getCloudSandboxContribution().provisionSession({ repoNwo, @@ -2171,7 +2190,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions // Send into the session's main chat rather than `createNewChat`, which would mint an // *additional* peer chat inside a session that already has one. const chat = provisioned.session.mainChat.get(); - await this._carryModelToSandbox(provisioned, chat.resource, selectedRawModelId); + await this._carryModelToSandbox(provisioned, chat.resource, selectedModel); const committed = await provisioned.provider.sendRequest(provisioned.session.sessionId, chat.resource, options); // Retire only once the turn is dispatched; swapping earlier bounces the view home. @@ -2200,20 +2219,25 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } /** - * The backend model id behind this composer's selection, as the sandbox knows it. + * The composer's model selection as the sandbox knows it, plus the label to name it by. * * Cloud sessions pick from the extension host's `models` option group, whose ids are the * group's own item ids, while a sandbox registers its models from what the agent host - * advertises. The two are different id spaces, so only the underlying model id crosses over. + * advertises. Different id spaces, so only the underlying model id crosses over. + * + * Only the model, because only the model exists: an option item's `modelMetadata` is hover and + * pricing detail with no configuration schema, so a cloud composer never offers a thinking + * level or context tier to carry alongside it. */ - private _rawCloudModelId(session: RemoteNewSession): string | undefined { + private _selectedCloudModel(session: RemoteNewSession): { readonly rawModelId: string; readonly label: string } | undefined { const selectedModelId = session.selectedModelId; if (!selectedModelId) { return undefined; } const { modelOption } = session.getModelOptionsSnapshot(); const item = modelOption?.group.items.find(i => i.id === selectedModelId); - return item?.modelMetadata?.id ?? item?.id ?? selectedModelId; + const rawModelId = item?.modelMetadata?.id ?? item?.id ?? selectedModelId; + return { rawModelId, label: item?.modelMetadata?.name ?? item?.name ?? rawModelId }; } /** @@ -2221,19 +2245,20 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions * * Mission Control starts no run, so this client sends that turn — and a session that has never * run has no model of its own to restore. Without this the turn carries no model at all and - * silently runs on whatever the agent host defaults to, discarding the user's pick along with - * the thinking level and context tier configured against it. + * runs on whatever the agent host defaults to. + * + * A freshly connected sandbox publishes its models asynchronously, so an empty catalog is "not + * yet" rather than "no": resolution is awaited while it reports `pending`, bounded because the + * turn cannot be held indefinitely. * - * A freshly connected sandbox publishes its models asynchronously, so an empty catalog here is - * "not yet" rather than "no": the model resolution is awaited while it reports `pending`, which - * is the wait {@link ISessionsProvider.getModelsSnapshot} documents. Bounded, because the turn - * cannot be held indefinitely — on timeout, or a model the sandbox genuinely does not offer, - * the host chooses, which is the behavior this had before. + * Every path that gives up tells the user: an absent `Message.model` means "host decides", so + * nothing downstream would report running at a capability and price they did not choose. */ - private async _carryModelToSandbox(provisioned: ICloudSandboxProvisionedSession, chatResource: URI, rawModelId: string | undefined): Promise<void> { - if (!rawModelId) { + private async _carryModelToSandbox(provisioned: ICloudSandboxProvisionedSession, chatResource: URI, selected: { readonly rawModelId: string; readonly label: string } | undefined): Promise<void> { + if (!selected) { return; } + const { rawModelId, label } = selected; const sessionId = provisioned.session.sessionId; const provider = provisioned.provider; @@ -2242,13 +2267,14 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions const modelTarget = provider.getModelsSnapshot(sessionId).modelTarget; if (!modelTarget) { this.logService.info(`[CopilotChatSessionsProvider] Sandbox session ${sessionId} reported no model target; letting the agent host choose.`); + this._notifySandboxModelNotApplied(label); return; } const desiredModelId = `${modelTarget}:${rawModelId}`; const store = new DisposableStore(); try { - const deadline = Date.now() + CopilotChatSessionsProvider.SANDBOX_MODEL_WAIT_MS; + const deadline = Date.now() + this._sandboxModelWaitMs; for (; ;) { const resolution = provider.getModelsSnapshot(sessionId, desiredModelId).desiredModelResolution; if (resolution.kind === 'available') { @@ -2257,6 +2283,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } if (resolution.kind !== 'pending') { this.logService.info(`[CopilotChatSessionsProvider] Sandbox session ${sessionId} does not advertise model '${rawModelId}'; letting the agent host choose.`); + this._notifySandboxModelNotApplied(label); return; } const remaining = deadline - Date.now(); @@ -2267,6 +2294,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions : undefined; if (!published) { this.logService.warn(`[CopilotChatSessionsProvider] Sandbox session ${sessionId} had not published model '${rawModelId}' in time; letting the agent host choose.`); + this._notifySandboxModelNotApplied(label); return; } } @@ -2275,6 +2303,11 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } } + /** Name the model so the substitution is attributable. A warning: the turn still runs. */ + private _notifySandboxModelNotApplied(label: string): void { + this.notificationService.warn(localize('sandboxModelNotApplied', "Couldn't use {0} for this session. The agent's default model will be used instead.", label)); + } + /** Retire the optimistic placeholder in favour of the session that now exists. */ private _retirePlaceholder(session: RemoteNewSession, placeholder: ISession, committed: ISession): void { this._sessionCache.delete(session.resource.toString()); @@ -2724,12 +2757,21 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } private async _browseForGitHubContext(commandId: string, icon: ThemeIcon, currentWorkspace: ISessionWorkspace | undefined): Promise<ISessionWorkspace | undefined> { - const gitHubInfo = currentWorkspace?.folders - .map(folder => folder.gitRepository?.gitHubInfo.get()) - .find(info => info !== undefined); - const repoId = gitHubInfo - ? `${gitHubInfo.owner}/${gitHubInfo.repo}` - : currentWorkspace?.folders.map(folder => githubRemoteRepoLabel(folder.root)).find(id => id !== undefined); + const repositoryIds = new Set<string>(); + for (const folder of currentWorkspace?.folders ?? []) { + const repositoryId = await resolveGitHubRepositoryId(folder, this.gitService); + if (repositoryId) { + repositoryIds.add(repositoryId); + } + } + + const repoId = repositoryIds.size === 1 + ? repositoryIds.values().next().value + : await this.commandService.executeCommand<string>(OPEN_REPO_COMMAND); + if (!repoId) { + return undefined; + } + const selection = await this.commandService.executeCommand<IGitHubContextSelection>(commandId, repoId); if (!selection) { return undefined; @@ -3060,20 +3102,6 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } } - private _updateResourceLabelHomes(): void { - const sessionStateRoot = buildLocalSessionStateUri(this.pathService.userHome({ preferLocal: true })); - const homes: { readonly uri: URI; readonly label: string }[] = []; - for (const session of this._sessionCache.values()) { - if (session.sessionType === SessionType.CopilotCLI) { - const rawId = session.resource.path.replace(/^\//, ''); - if (rawId) { - homes.push({ uri: URI.joinPath(sessionStateRoot, rawId), label: `${CopilotCLISessionType.label}/${localize('sessionHome', "Session")}` }); - } - } - } - this._resourceLabelHomes.set(homes); - } - private _refreshSessionCacheMultiChat( addedData: ICopilotChatSession[], removedData: ICopilotChatSession[], diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts index f401ff7813d7a9..e0ab11b8eaded3 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts @@ -187,7 +187,7 @@ export class PermissionPicker extends Disposable { if (this._delegate.getPermissionLevelHover) { this._renderDisposables.add(this.hoverService.setupDelayedHover(trigger, () => { const meta = this._getPermissionLevelMeta(this._currentLevel); - return { content: this._getPermissionLevelHover(this._currentLevel, meta) ?? '' }; + return { content: this._getTriggerHover(this._currentLevel, meta) }; })); } @@ -410,18 +410,24 @@ export class PermissionPicker extends Disposable { dom.clearNode(trigger); const meta = this._getPermissionLevelMeta(this._currentLevel); - const label = this._isSandboxToggleAvailable() && this._isSandboxingEnabled() + const sandboxed = this._isSandboxToggleAvailable() && this._isSandboxingEnabled(); + const accessibleLabel = sandboxed ? localize('permissionPicker.sandboxedLabel', "{0} (sandboxed)", meta.label) : meta.label; dom.append(trigger, renderIcon(meta.icon)); const labelSpan = dom.append(trigger, dom.$('span.sessions-chat-dropdown-label')); - labelSpan.textContent = label; + labelSpan.textContent = meta.label; + if (sandboxed) { + const sandboxIcon = dom.append(trigger, renderIcon(Codicon.shield)); + sandboxIcon.classList.add('sessions-chat-sandbox-icon'); + sandboxIcon.ariaHidden = 'true'; + } const hover = this._getPermissionLevelHover(this._currentLevel, meta); trigger.ariaLabel = hover - ? localize('permissionPicker.triggerAriaLabelWithDescription', "Pick Permission Level, {0}, {1}", label, hover) - : localize('permissionPicker.triggerAriaLabel', "Pick Permission Level, {0}", label); + ? localize('permissionPicker.triggerAriaLabelWithDescription', "Pick Permission Level, {0}, {1}", accessibleLabel, hover) + : localize('permissionPicker.triggerAriaLabel', "Pick Permission Level, {0}", accessibleLabel); trigger.classList.toggle('warning', this._currentLevel === ChatPermissionLevel.Autopilot || this._currentLevel === ChatPermissionLevel.Assisted); trigger.classList.toggle('info', this._currentLevel === ChatPermissionLevel.AutoApprove); @@ -483,6 +489,13 @@ export class PermissionPicker extends Disposable { return this._delegate.getPermissionLevelHover?.(level, meta) ?? meta.hover; } + private _getTriggerHover(level: ChatPermissionLevel, meta: IPermissionLevelMeta): string { + const hover = this._getPermissionLevelHover(level, meta) ?? ''; + return this._isSandboxToggleAvailable() && this._isSandboxingEnabled() + ? localize('permissionPicker.sandboxedHover', "{0} Terminal commands are sandboxed.", hover) + : hover; + } + protected _getPermissionLevelMeta(level: ChatPermissionLevel): IPermissionLevelMeta { const meta = getPermissionLevelMeta(level); return this._delegate.getPermissionLevelMeta(level, meta); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index 34f041f2d1a271..9f75811203a094 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -36,7 +36,7 @@ import { IChatResponseModel } from '../../../../../../workbench/contrib/chat/com import { IChatAgentData } from '../../../../../../workbench/contrib/chat/common/participants/chatAgents.js'; import { IGitRepository, IGitService } from '../../../../../../workbench/contrib/git/common/gitService.js'; import { ISessionChangeEvent } from '../../../../../services/sessions/common/sessionsProvider.js'; -import { ChatModelSource, GITHUB_REMOTE_FILE_SCHEME, IChat, ISession, ISessionWorkspace, SESSION_WORKSPACE_GROUP_GITHUB, SessionStatus } from '../../../../../services/sessions/common/session.js'; +import { ChatModelSource, GITHUB_REMOTE_FILE_SCHEME, IChat, ISession, ISessionWorkspace, SESSION_WORKSPACE_GROUP_GITHUB, SESSION_WORKSPACE_GROUP_LOCAL, SessionStatus } from '../../../../../services/sessions/common/session.js'; import { CloudSandboxEnabledSettingId, type ICloudSandboxCreateSessionRequest } from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { RemoteAgentHostsEnabledSettingId } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { CloudSandboxAgentHostContribution, type ICloudSandboxProvisionedSession } from '../../../remoteAgentHost/browser/cloudSandboxAgentHostContribution.js'; @@ -44,6 +44,7 @@ import { CloudSandboxSessionsProvider } from '../../../remoteAgentHost/browser/c import { ChatConfiguration, ChatPermissionLevel } from '../../../../../../workbench/contrib/chat/common/constants.js'; import { CopilotChatSessionsProvider, COPILOT_PROVIDER_ID, CopilotCloudSessionType, ICopilotChatSession } from '../../browser/copilotChatSessionsProvider.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { IPathService } from '../../../../../../workbench/services/path/common/pathService.js'; import { MockLabelService } from '../../../../../../workbench/services/label/test/common/mockLabelService.js'; @@ -62,6 +63,7 @@ import { computePullRequestIcon, GitHubPullRequestState, IGitHubPullRequest } fr interface IGitHubContextBrowseHarness { readonly commandService: Pick<ICommandService, 'executeCommand'>; + readonly gitService: Pick<IGitService, 'openRepository'>; } const browseForGitHubContext = Reflect.get(CopilotChatSessionsProvider.prototype, '_browseForGitHubContext') as ( @@ -314,6 +316,9 @@ function createProviderWithConfig( onDidChangeFocusedSession: Event.None, }); instantiationService.stub(ILanguageModelsService, opts?.languageModelsService ?? { lookupLanguageModel: () => undefined }); + instantiationService.stub(INotificationService, new class extends mock<INotificationService>() { + override warn(): void { } + }()); instantiationService.stub(ILanguageModelToolsService, { toToolReferences: () => [], }); @@ -348,19 +353,26 @@ function createProviderWithConfig( class TestSandboxCopilotProvider extends CopilotChatSessionsProvider { sandboxContribution: Pick<CloudSandboxAgentHostContribution, 'provisionSession'> | undefined; + /** Only the timeout test lowers this; the rest keep the real budget so they cannot race it. */ + sandboxModelWaitMs: number | undefined; + protected override _getCloudSandboxContribution(): Pick<CloudSandboxAgentHostContribution, 'provisionSession'> { if (!this.sandboxContribution) { throw new Error('No cloud sandbox contribution was registered'); } return this.sandboxContribution; } + + protected override get _sandboxModelWaitMs(): number { + return this.sandboxModelWaitMs ?? super._sandboxModelWaitMs; + } } function createProviderForSendTests( disposables: DisposableStore, model: MockAgentSessionsModel, sendRequest: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise<ChatSendResult>, - opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; configurationService?: TestConfigurationService; agentHostEnabled?: boolean; getOptionGroups?: () => IChatSessionProviderOptionGroup[] | undefined }, + opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; configurationService?: TestConfigurationService; agentHostEnabled?: boolean; getOptionGroups?: () => IChatSessionProviderOptionGroup[] | undefined; notifications?: string[] }, ): TestSandboxCopilotProvider { const instantiationService = disposables.add(new TestInstantiationService()); @@ -406,6 +418,9 @@ function createProviderForSendTests( onDidChangeFocusedSession: Event.None, }); instantiationService.stub(ILanguageModelsService, { lookupLanguageModel: () => undefined }); + instantiationService.stub(INotificationService, new class extends mock<INotificationService>() { + override warn(message: unknown): void { opts?.notifications?.push(String(message)); } + }()); instantiationService.stub(ILanguageModelToolsService, { toToolReferences: () => [] }); instantiationService.stub(IGitService, { openRepository: async () => undefined }); instantiationService.stub(IInstantiationService, instantiationService); @@ -456,6 +471,7 @@ suite('CopilotChatSessionsProvider', () => { } as T; } }(), + gitService: upcastPartial<IGitService>({ openRepository: async () => undefined }), }; const repositoryRoot = URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, @@ -495,6 +511,170 @@ suite('CopilotChatSessionsProvider', () => { }); }); + test('selects a repository before browsing GitHub context when the repository is ambiguous', async () => { + const calls: { commandId: string; repoId: unknown }[] = []; + const harness: IGitHubContextBrowseHarness = { + commandService: new class extends mock<ICommandService>() { + override async executeCommand<T>(commandId: string, repoId?: unknown): Promise<T | undefined> { + calls.push({ commandId, repoId }); + if (commandId === 'github.copilot.chat.cloudSessions.openRepository') { + return 'microsoft/vscode' as T; + } + return { + repoId: 'microsoft/vscode', + url: `https://github.com/microsoft/vscode/${commandId === 'openIssue' ? 'issues/1' : 'pull/2'}`, + label: `microsoft/vscode#${commandId === 'openIssue' ? '1' : '2'}`, + } as T; + } + }(), + gitService: upcastPartial<IGitService>({ openRepository: async () => undefined }), + }; + const repositoryRoot = (repositoryId: string) => URI.from({ + scheme: GITHUB_REMOTE_FILE_SCHEME, + authority: 'github', + path: `/${repositoryId}/HEAD`, + }); + const multiRootWorkspace: ISessionWorkspace = { + uri: URI.parse('https://github.com'), + label: 'Multiple repositories', + icon: Codicon.repo, + group: SESSION_WORKSPACE_GROUP_GITHUB, + folders: ['microsoft/vscode', 'microsoft/typescript'].map(repositoryId => { + const root = repositoryRoot(repositoryId); + return { + root, + workingDirectory: root, + name: repositoryId, + description: undefined, + gitRepository: undefined, + }; + }), + requiresWorkspaceTrust: false, + isVirtualWorkspace: true, + }; + + const issue = await browseForGitHubContext.call(harness, 'openIssue', Codicon.issues, undefined); + const pullRequest = await browseForGitHubContext.call(harness, 'openPullRequest', Codicon.gitPullRequest, multiRootWorkspace); + + assert.deepStrictEqual({ + calls, + issue: { uri: issue?.uri.toString(), label: issue?.label }, + pullRequest: { uri: pullRequest?.uri.toString(), label: pullRequest?.label }, + }, { + calls: [ + { commandId: 'github.copilot.chat.cloudSessions.openRepository', repoId: undefined }, + { commandId: 'openIssue', repoId: 'microsoft/vscode' }, + { commandId: 'github.copilot.chat.cloudSessions.openRepository', repoId: undefined }, + { commandId: 'openPullRequest', repoId: 'microsoft/vscode' }, + ], + issue: { uri: 'https://github.com/microsoft/vscode/issues/1', label: 'microsoft/vscode#1' }, + pullRequest: { uri: 'https://github.com/microsoft/vscode/pull/2', label: 'microsoft/vscode#2' }, + }); + }); + + test('resolves an initially unknown GitHub remote before browsing context', async () => { + const calls: { commandId: string; repoId: unknown }[] = []; + const repositoryState = observableValue('repositoryState', { + HEAD: undefined, + remotes: [{ name: 'origin', fetchUrl: 'https://github.com/microsoft/vscode.git', pushUrl: undefined, isReadOnly: false }], + mergeChanges: [], + indexChanges: [], + workingTreeChanges: [], + untrackedChanges: [], + }); + const harness: IGitHubContextBrowseHarness = { + commandService: new class extends mock<ICommandService>() { + override async executeCommand<T>(commandId: string, repoId?: unknown): Promise<T | undefined> { + calls.push({ commandId, repoId }); + return { + repoId: 'microsoft/vscode', + url: 'https://github.com/microsoft/vscode/issues/1', + label: 'microsoft/vscode#1', + } as T; + } + }(), + gitService: upcastPartial<IGitService>({ + openRepository: async () => upcastPartial<IGitRepository>({ rootUri: root, state: repositoryState }), + }), + }; + const root = URI.file('/test/vscode'); + const workspace: ISessionWorkspace = { + uri: root, + label: 'vscode', + icon: Codicon.folder, + group: SESSION_WORKSPACE_GROUP_LOCAL, + folders: [{ + root, + workingDirectory: root, + name: 'vscode', + description: undefined, + gitRepository: undefined, + }], + requiresWorkspaceTrust: true, + isVirtualWorkspace: false, + }; + + const issue = await browseForGitHubContext.call(harness, 'openIssue', Codicon.issues, workspace); + + assert.deepStrictEqual({ + calls, + issue: { uri: issue?.uri.toString(), label: issue?.label }, + }, { + calls: [{ commandId: 'openIssue', repoId: 'microsoft/vscode' }], + issue: { uri: 'https://github.com/microsoft/vscode/issues/1', label: 'microsoft/vscode#1' }, + }); + }); + + test('selects a repository when the selected folder has no matching Git root', async () => { + const calls: { commandId: string; repoId: unknown }[] = []; + const staleRepositoryState = observableValue('staleRepositoryState', { + HEAD: undefined, + remotes: [{ name: 'origin', fetchUrl: 'https://github.com/microsoft/old.git', pushUrl: undefined, isReadOnly: false }], + mergeChanges: [], + indexChanges: [], + workingTreeChanges: [], + untrackedChanges: [], + }); + const harness: IGitHubContextBrowseHarness = { + commandService: new class extends mock<ICommandService>() { + override async executeCommand<T>(commandId: string, repoId?: unknown): Promise<T | undefined> { + calls.push({ commandId, repoId }); + if (commandId === 'github.copilot.chat.cloudSessions.openRepository') { + return 'microsoft/vscode' as T; + } + return { + repoId: 'microsoft/vscode', + url: 'https://github.com/microsoft/vscode/issues/1', + label: 'microsoft/vscode#1', + } as T; + } + }(), + gitService: upcastPartial<IGitService>({ + openRepository: async () => upcastPartial<IGitRepository>({ + rootUri: URI.file('/test/old-repository'), + state: staleRepositoryState, + }), + }), + }; + const root = URI.file('/test/new-folder'); + const workspace: ISessionWorkspace = { + uri: root, + label: 'new-folder', + icon: Codicon.folder, + group: SESSION_WORKSPACE_GROUP_LOCAL, + folders: [{ root, workingDirectory: root, name: 'new-folder', description: undefined, gitRepository: undefined }], + requiresWorkspaceTrust: true, + isVirtualWorkspace: false, + }; + + await browseForGitHubContext.call(harness, 'openIssue', Codicon.issues, workspace); + + assert.deepStrictEqual(calls, [ + { commandId: 'github.copilot.chat.cloudSessions.openRepository', repoId: undefined }, + { commandId: 'openIssue', repoId: 'microsoft/vscode' }, + ]); + }); + test('sessionTypes excludes Local', () => { const provider = createProvider(disposables, model); assert.ok(!provider.sessionTypes.some(type => type.id === SessionType.Local)); @@ -560,18 +740,6 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(sessions.length, 2); }); - test('registers Copilot CLI session state directories as resource label homes', () => { - const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); - model.addSession(createMockAgentSession(resource)); - - const { labelService } = createProviderWithConfig(disposables, model); - - assert.strictEqual( - labelService.getUriHome(URI.file('/home/test/.copilot/session-state/session-1/artifact.md'))?.toString(), - URI.file('/home/test/.copilot/session-state/session-1').toString() - ); - }); - test('getSessions does not emit session changes while reading the initial cache', () => { const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session' }); model.addSession(createMockAgentSession(resource)); @@ -1964,11 +2132,12 @@ suite('CopilotChatSessionsProvider', () => { configurationService.setUserConfiguration(RemoteAgentHostsEnabledSettingId, true); const cloudSends: string[] = []; + const notifications: string[] = []; const provider = createProviderForSendTests(disposables, model, async (_resource, message) => { cloudSends.push(message); // Never settles: these tests only assert which path the send took. return new Promise<ChatSendResult>(() => { }); - }, { configurationService, getOptionGroups: opts.getOptionGroups }); + }, { configurationService, getOptionGroups: opts.getOptionGroups, notifications }); const provisionRequests: ICloudSandboxCreateSessionRequest[] = []; provider.sandboxContribution = { @@ -1980,7 +2149,7 @@ suite('CopilotChatSessionsProvider', () => { throw new Error('provisioning failed'); }, }; - return { provider, provisionRequests, cloudSends }; + return { provider, provisionRequests, cloudSends, notifications }; } /** @@ -2058,7 +2227,7 @@ suite('CopilotChatSessionsProvider', () => { // Mission Control starts no run, so a session that has never run has no model to // restore: without this the first turn would silently take the agent host default. const provisioned = provisionedSession(undefined, () => [sandboxModel('claude-sonnet-4.6')]); - const { provider } = createSandboxProvider({ + const { provider, notifications } = createSandboxProvider({ provision: async () => provisioned, getOptionGroups: () => cloudModelOptionGroup('synthetic-cloud-model', 'claude-sonnet-4.6'), }); @@ -2070,8 +2239,12 @@ suite('CopilotChatSessionsProvider', () => { await provider.sendRequest(sessionInfo.sessionId, session.mainChat.get().resource, { query: 'fix it' }); // The id crosses id spaces by backend model id, and arrives as carried over: the user - // picked it for the composer, not for the session that replaced it. - assert.deepStrictEqual(provisioned.modelSelections, [{ modelId: 'agent-host-copilot:claude-sonnet-4.6', source: ChatModelSource.CarriedOver }]); + // picked it for the composer, not for the session that replaced it. Applying the pick + // is the silent case — nothing to tell the user about. + assert.deepStrictEqual( + { selections: provisioned.modelSelections, notifications }, + { selections: [{ modelId: 'agent-host-copilot:claude-sonnet-4.6', source: ChatModelSource.CarriedOver }], notifications: [] } + ); }); test('waits for a sandbox catalog that is still arriving rather than sending without the model', async () => { @@ -2102,11 +2275,12 @@ suite('CopilotChatSessionsProvider', () => { ); }); - test('leaves the model to the agent host when the sandbox does not advertise it', async () => { - // Sending an unroutable id would fail the turn outright, so an unmatched pick keeps - // the previous behavior of letting the host choose. + test('tells the user when the sandbox does not advertise the model they picked', async () => { + // Sending an unroutable id would fail the turn outright, so an unmatched pick still + // lets the host choose — but an absent `Message.model` means "host decides", so + // nothing else would report the substitution. const provisioned = provisionedSession(undefined, () => [sandboxModel('gpt-5')]); - const { provider } = createSandboxProvider({ + const { provider, notifications } = createSandboxProvider({ provision: async () => provisioned, getOptionGroups: () => cloudModelOptionGroup('synthetic-cloud-model', 'claude-sonnet-4.6'), }); @@ -2117,7 +2291,32 @@ suite('CopilotChatSessionsProvider', () => { await provider.sendRequest(sessionInfo.sessionId, session.mainChat.get().resource, { query: 'fix it' }); - assert.deepStrictEqual(provisioned.modelSelections, []); + assert.deepStrictEqual( + { selections: provisioned.modelSelections, notified: notifications.length, namesModel: notifications[0]?.includes('claude-sonnet-4.6') }, + { selections: [], notified: 1, namesModel: true } + ); + }); + + test('tells the user when the catalog never arrives before the turn is dispatched', async () => { + // The likeliest fallback in practice is a slow sandbox rather than a missing model, so + // the timeout has to be as visible as a conclusive miss. + const provisioned = provisionedSession(undefined, () => []); + const { provider, notifications } = createSandboxProvider({ + provision: async () => provisioned, + getOptionGroups: () => cloudModelOptionGroup('synthetic-cloud-model', 'claude-sonnet-4.6'), + }); + provider.sandboxModelWaitMs = 1; + const sessionInfo = provider.createNewSession(repoWorkspace, CopilotCloudSessionType.id); + const session = provider.getSession(sessionInfo.sessionId)!; + session.setUseSandbox(true); + provider.setModel(sessionInfo.sessionId, session.mainChat.get().resource, 'synthetic-cloud-model', ChatModelSource.Chosen); + + await provider.sendRequest(sessionInfo.sessionId, session.mainChat.get().resource, { query: 'fix it' }); + + assert.deepStrictEqual( + { selections: provisioned.modelSelections, notified: notifications.length, namesModel: notifications[0]?.includes('claude-sonnet-4.6') }, + { selections: [], notified: 1, namesModel: true } + ); }); test('provisions a sandbox and replaces the draft with the committed session', async () => { diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts index fe67cb3b06736d..ee9ea1507d4233 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Codicon } from '../../../../../../base/common/codicons.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IActionListDelegate, IActionListItem } from '../../../../../../platform/actionWidget/browser/actionList.js'; @@ -12,8 +13,9 @@ import { TestConfigurationService } from '../../../../../../platform/configurati import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { AgentSandboxEnabledValue } from '../../../../../../platform/sandbox/common/settings.js'; import { NullTelemetryService } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; -import { ChatPermissionLevel } from '../../../../../../workbench/contrib/chat/common/constants.js'; +import { ChatConfiguration, ChatPermissionLevel } from '../../../../../../workbench/contrib/chat/common/constants.js'; import { TestStorageService } from '../../../../../../workbench/test/common/workbenchTestServices.js'; import { DEFAULT_PERMISSION_LEVELS, getPermissionLevelMeta, IPermissionPickerDelegate, PermissionPicker } from '../../browser/permissionPicker.js'; @@ -93,4 +95,46 @@ suite('Copilot PermissionPicker', () => { }, ]); }); + + test('uses a shield icon for the visible sandboxed state', () => { + const sandboxSettingId = 'test.sandbox.enabled'; + const configurationService = new TestConfigurationService(); + configurationService.setUserConfiguration(ChatConfiguration.PermissionsSandboxToggleEnabled, true); + configurationService.setUserConfiguration(sandboxSettingId, AgentSandboxEnabledValue.On); + const delegate: IPermissionPickerDelegate = { + getPermissionLevelMeta: (_level, meta) => ({ ...meta, label: 'Manual permissions', icon: Codicon.key }), + setPermissionLevel: () => { }, + sandboxTogglePresentation: 'standalone', + isSandboxToggleApplicable: () => true, + getSandboxToggleSettingId: () => sandboxSettingId, + }; + const picker = store.add(new PermissionPicker( + delegate, + new class extends mock<IActionWidgetService>() { + override readonly isVisible = false; + }(), + configurationService, + new class extends mock<IDialogService>() { }(), + new class extends mock<IOpenerService>() { }(), + store.add(new TestStorageService()), + NullTelemetryService, + new class extends mock<IHoverService>() { }(), + )); + const container = document.createElement('div'); + picker.render(container); + const trigger = container.querySelector<HTMLElement>('a.action-label'); + assert.ok(trigger); + + assert.deepStrictEqual({ + visibleLabel: trigger.querySelector('.sessions-chat-dropdown-label')?.textContent, + permissionIcon: trigger.querySelector('.codicon-key')?.className, + sandboxIcon: trigger.querySelector('.sessions-chat-sandbox-icon')?.className, + triggerAriaLabel: trigger.ariaLabel, + }, { + visibleLabel: 'Manual permissions', + permissionIcon: 'codicon codicon-key', + sandboxIcon: 'codicon codicon-shield sessions-chat-sandbox-icon', + triggerAriaLabel: 'Pick Permission Level, Manual permissions (sandboxed)', + }); + }); }); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts index d5917f152ef94c..49c7d2ab6f285a 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts @@ -316,7 +316,8 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo if (present.has(address) || this._provisioning.has(address)) { continue; } - const connected = this._remoteAgentHostService.connections.some(c => c.address === address); + const connected = this._remoteAgentHostService.connections.some( + c => c.address === address && RemoteAgentHostConnectionStatus.isConnected(c.status)); if (!connected) { this._teardownEnvironment(address); } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts index f065cb1fdf0a0f..45ce6c557ec03c 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts @@ -25,7 +25,7 @@ import { type CloudSandboxConnectResult, type ICloudSandboxClientToken, } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; -import { getEntryAddress, IRemoteAgentHostService, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, type IRemoteAgentHostConnectOptions, type IRemoteAgentHostConnectionFactory, type IRemoteAgentHostCreatedConnection, type IRemoteAgentHostEntry } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { getEntryAddress, IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, type IRemoteAgentHostConnectOptions, type IRemoteAgentHostConnectionFactory, type IRemoteAgentHostCreatedConnection, type IRemoteAgentHostEntry } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -254,8 +254,12 @@ export class CloudSandboxAgentHostService extends Disposable implements ICloudSa this._logService.info(`${LOG_PREFIX} Protocol handshake completed with ${address}`); return address; } catch (error) { - const connectionStillRegistered = this._remoteAgentHostService.connections.some(connection => connection.address === address); - if (token.isCancellationRequested || !connectionStillRegistered) { + // A failed dial now retains a client-less entry, so mere presence no + // longer means the connection survived — require a live one. + const connectionStillLive = this._remoteAgentHostService.connections.some(connection => + connection.address === address + && (RemoteAgentHostConnectionStatus.isConnected(connection.status) || RemoteAgentHostConnectionStatus.isReconnecting(connection.status))); + if (token.isCancellationRequested || !connectionStillLive) { this._connectionFactory.unstageConfiguration(address); await this._remoteAgentHostService.removeRemoteAgentHost(address); } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devContainerAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devContainerAgentHostService.ts index 47b9818240e501..644bf381b3c90f 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devContainerAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devContainerAgentHostService.ts @@ -263,8 +263,10 @@ export class DevContainerAgentHostService extends Disposable implements IDevCont } catch (error) { providerStore.dispose(); if (stagedAddress !== undefined) { - const connectionStillRegistered = this._remoteAgentHostService.connections.some(connection => connection.address === stagedAddress); - if (token.isCancellationRequested || !connectionStillRegistered) { + // A failed dial now retains a client-less entry, so mere presence no + // longer means the connection survived — require a live one. + const connectionStillLive = this._isConnectedOrReconnecting(stagedAddress); + if (token.isCancellationRequested || !connectionStillLive) { this._connectionFactory.unstageConnection(stagedAddress); await this._remoteAgentHostService.removeRemoteAgentHost(stagedAddress); } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/entryDrivenProviderContribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/entryDrivenProviderContribution.ts index 46493a09cbb975..5c32a10c622f2e 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/entryDrivenProviderContribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/entryDrivenProviderContribution.ts @@ -9,7 +9,7 @@ import { type IRemoteAgentHostEntry, IRemoteAgentHostService, RemoteAgentHostCon import { type IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { type IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { type INotificationService } from '../../../../../platform/notification/common/notification.js'; -import { type IAgentHostConnectProgress } from '../../../../common/agentHostSessionsProvider.js'; +import { type IAgentHostAutoConnect, type IAgentHostConnectProgress } from '../../../../common/agentHostSessionsProvider.js'; import { type ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js'; import { watchForIncompatibleNotifications } from './remoteHostOptions.js'; @@ -19,6 +19,7 @@ export interface IEntryDrivenProviderOptions { readonly connectOnDemand?: () => Promise<void>; readonly disconnectOnDemand?: () => Promise<void>; readonly onDidReportConnectProgress?: Event<IAgentHostConnectProgress>; + readonly autoConnect?: IAgentHostAutoConnect; readonly initialStatus?: RemoteAgentHostConnectionStatus; readonly preferenceKey?: string; } @@ -62,14 +63,6 @@ export abstract class EntryDrivenProviderContribution extends Disposable { /** Supplies kind-specific on-demand behavior for an entry's provider. */ protected abstract _getProviderOptions(entry: IRemoteAgentHostEntry): IEntryDrivenProviderOptions; - /** - * Whether a vanished connection should clear the provider's active - * connection. Defaults to false to preserve existing WSL behavior. - */ - protected get _clearConnectionOnRemoval(): boolean { - return false; - } - protected _reconcile(): void { this._reconcileProviders(); this._wireConnections(); @@ -107,6 +100,7 @@ export abstract class EntryDrivenProviderContribution extends Disposable { connectOnDemand: options.connectOnDemand, disconnectOnDemand: options.disconnectOnDemand, onDidReportConnectProgress: options.onDidReportConnectProgress, + autoConnect: options.autoConnect, preferenceKey: options.preferenceKey, }); if (options.initialStatus !== undefined) { @@ -131,11 +125,9 @@ export abstract class EntryDrivenProviderContribution extends Disposable { const connection = this._remoteAgentHostService.getConnection(address); if (connection) { provider.setConnection(connection, connectionInfo.defaultDirectory); - if (this._clearConnectionOnRemoval) { - this._wiredAddresses.add(address); - } + this._wiredAddresses.add(address); } - } else if (this._clearConnectionOnRemoval && !connectionInfo && this._wiredAddresses.delete(address)) { + } else if (this._wiredAddresses.delete(address)) { provider.clearConnection(); } } @@ -146,8 +138,6 @@ export abstract class EntryDrivenProviderContribution extends Disposable { const connectionInfo = this._remoteAgentHostService.connections.find(connection => connection.address === address); if (connectionInfo) { provider.setConnectionStatus(connectionInfo.status); - } else if (!RemoteAgentHostConnectionStatus.isIncompatible(provider.connectionStatus.get())) { - provider.setConnectionStatus(RemoteAgentHostConnectionStatus.disconnected); } } } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 8f64e57ce2392f..c10dac504457c4 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -13,6 +13,7 @@ import { type AgentProvider, type AuthenticateParams, type AuthenticateResult } import { type IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostService, RemoteAgentHostAutoConnectSettingId, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, getEntryAddress } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { TunnelAgentHostsSettingId } from '../../../../../platform/agentHost/common/tunnelAgentHost.js'; +import { WslAutoStartSettingId } from '../../../../../platform/agentHost/common/wslRemoteAgentHost.js'; import { CloudSandboxEnabledSettingId } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { AgentHostLocalFilePermissionsSettingId } from '../../../../../platform/agentHost/common/agentHostResourceService.js'; import { type ProtectedResourceMetadata } from '../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -176,7 +177,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc const currentConnections = this._remoteAgentHostService.connections; const connectedAddresses = new Set( currentConnections - .filter(c => RemoteAgentHostConnectionStatus.isConnected(c.status)) + .filter(c => RemoteAgentHostConnectionStatus.isConnected(c.status) && c.clientId !== undefined) .map(c => c.address) ); const allAddresses = new Set(currentConnections.map(c => c.address)); @@ -195,7 +196,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc // Add or update connections for (const connectionInfo of currentConnections) { // Only set up contribution state for connected entries - if (!RemoteAgentHostConnectionStatus.isConnected(connectionInfo.status)) { + if (!RemoteAgentHostConnectionStatus.isConnected(connectionInfo.status) || connectionInfo.clientId === undefined) { continue; } const existing = this._connections.get(connectionInfo.address); @@ -368,7 +369,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc requiresCustomModels: true, supportsAutoModel: agentHostProviderSupportsAutoModel(agent.provider), agentHostProviderId: agent.provider, - supportsDelegation: true, + supportsDelegation: false, capabilities: { supportsCheckpoints: true, supportsPromptAttachments: true, @@ -622,6 +623,13 @@ Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).regis scope: ConfigurationScope.APPLICATION, tags: ['experimental', 'advanced'], }, + [WslAutoStartSettingId]: { + type: 'boolean', + description: nls.localize('chat.agentHost.wsl.autoStart', "Automatically start a WSL distribution when opening a chat whose distribution is not running. When disabled, the chat shows a Start button instead."), + default: false, + scope: ConfigurationScope.APPLICATION, + tags: ['experimental', 'advanced'], + }, [RemoteAgentHostsSettingId]: { type: 'array', items: { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 086040dca14a59..5cb5124e969233 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -8,7 +8,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; -import { constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { constObservable, derived, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { isWeb } from '../../../../../base/common/platform.js'; import { basename, dirname } from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; @@ -34,8 +34,7 @@ import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browse import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; -import { ResourceLabelHomeStore } from '../../../../../workbench/services/label/common/resourceLabelHomeStore.js'; -import { IAgentHostConnectProgress, IAgentHostGroup } from '../../../../common/agentHostSessionsProvider.js'; +import { IAgentHostAutoConnect, IAgentHostConnectProgress, IAgentHostGroup } from '../../../../common/agentHostSessionsProvider.js'; import { buildAgentHostSessionWorkspace, readBranchProtectionPatterns } from '../../../../common/agentHostSessionWorkspace.js'; import { IGitHubInfo, ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../../services/sessions/common/session.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; @@ -74,6 +73,8 @@ export interface IRemoteAgentHostSessionsProviderConfig { readonly disconnectOnDemand?: () => Promise<void>; /** Optional progress messages during on-demand connect. */ readonly onDidReportConnectProgress?: Event<IAgentHostConnectProgress>; + /** Optional kind-scoped policy for automatically starting the host. */ + readonly autoConnect?: IAgentHostAutoConnect; /** * Set when the host addresses sessions under a scheme that differs from its agent provider, as * the cloud sandbox host does (sessions are `ahp-session:/<id>` while the agent is `copilot`). @@ -139,6 +140,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; readonly canConnectOnDemand: boolean; readonly onDidReportConnectProgress: Event<IAgentHostConnectProgress> | undefined; + readonly autoConnect?: IAgentHostAutoConnect; readonly automations: ISessionsProviderAutomations; private readonly _automationStore: ReconnectableAgentHostAutomationStore; @@ -151,12 +153,22 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid private readonly _readOnly = observableValue<boolean>('providerReadOnly', false); readonly connectionStatus: IObservable<RemoteAgentHostConnectionStatus> = this._connectionStatus; + protected override get remoteConnectionStatus(): IObservable<RemoteAgentHostConnectionStatus> { + return this.connectionStatus; + } + /** * `true` while we are still resolving and pushing tokens for the host's * `protectedResources`. Defaults to `true` so that sessions surface as * loading until the first authentication pass settles. */ private readonly _authenticationPending = observableValue('authenticationPending', true); + private readonly _effectiveAuthenticationPending = derived(this, reader => { + const status = this._connectionStatus.read(reader); + return this._authenticationPending.read(reader) + && !RemoteAgentHostConnectionStatus.isDisconnected(status) + && !RemoteAgentHostConnectionStatus.isIncompatible(status); + }); private _authenticationSettled = false; private readonly _onDidDisconnect = this._register(new Emitter<void>()); @@ -173,7 +185,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid private _connection: IAgentConnection | undefined; private _defaultDirectory: string | undefined; private readonly _connectionListeners = this._register(new DisposableStore()); - private readonly _resourceLabelHomes: ResourceLabelHomeStore; + private readonly _onDidChangeResourceLabelHomes = Event.any(this._onDidChangeSessionsImmediately, this._onDidChangeDraftSessions.event); private readonly _connectionAuthority: string; private readonly _connectOnDemand: (() => Promise<void>) | undefined; private readonly _disconnectOnDemand: (() => Promise<void>) | undefined; @@ -217,7 +229,6 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid @IWorkspaceTrustManagementService workspaceTrustManagementService: IWorkspaceTrustManagementService, ) { super(chatSessionsService, chatService, chatWidgetService, languageModelsService, _configurationService, logService, gitHubService, instantiationService, sessionsService, activeClientService, storageService, dialogService, workspaceTrustManagementService); - this._resourceLabelHomes = this._register(instantiationService.createInstance(ResourceLabelHomeStore)); this._connectionAuthority = agentHostAuthority(config.address); this._connectOnDemand = config.connectOnDemand; @@ -228,9 +239,9 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid this._defaultChangesetKind = config.defaultChangesetKind; this._devContainerWorktreeScope = config.devContainerWorktreeScope; this.onDidReportConnectProgress = config.onDidReportConnectProgress; + this.autoConnect = config.autoConnect; this.canConnectOnDemand = !!config.connectOnDemand; - this._register(this._onDidChangeSessionsImmediately(() => this.updateResourceLabelHomes())); - this._register(this._onDidChangeDraftSessions.event(() => this.updateResourceLabelHomes())); + this._register(this._onDidChangeResourceLabelHomes(() => this.updateResourceLabelHomes())); this.updateResourceLabelHomes(); const displayName = config.name || config.address; @@ -385,7 +396,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid protected get connection(): IAgentConnection | undefined { return this._connection; } - protected get authenticationPending(): IObservable<boolean> { return this._authenticationPending; } + protected get authenticationPending(): IObservable<boolean> { return this._effectiveAuthenticationPending; } /** * Suspend cache-change tracking while sessions are unpublished (offline) so @@ -463,6 +474,10 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid this._remoteAgentHostService.reconnect(this.remoteAddress); } + reconnectNow(): void { + this._remoteAgentHostService.reconnectNow(this.remoteAddress); + } + /** * Tear down the active connection for this host. Tunnel-backed providers * use their relay hook; other providers fall back to the generic remote @@ -656,7 +671,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid } } } - this._resourceLabelHomes.set(homes); + this.updateResourceLabelHomeFormatters(homes, this._labelService); } /** diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/sshAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/sshAgentHost.contribution.ts index 9169efa9edab13..d8e7432dee8245 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/sshAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/sshAgentHost.contribution.ts @@ -59,10 +59,6 @@ export class SSHAgentHostContribution extends ManagedReconnectAgentHostContribut protected readonly _entryType = RemoteAgentHostEntryType.SSH; - protected override get _clearConnectionOnRemoval(): boolean { - return true; - } - constructor( @IRemoteAgentHostService remoteAgentHostService: IRemoteAgentHostService, @ISSHRemoteAgentHostService private readonly _sshService: ISSHRemoteAgentHostService, diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webSocketAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webSocketAgentHost.contribution.ts index fe1f5ced78077e..0ff8eca4dc4a24 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webSocketAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webSocketAgentHost.contribution.ts @@ -17,10 +17,6 @@ export class WebSocketAgentHostContribution extends EntryDrivenProviderContribut protected readonly _entryType = RemoteAgentHostEntryType.WebSocket; - protected override get _clearConnectionOnRemoval(): boolean { - return true; - } - constructor( @IRemoteAgentHostService remoteAgentHostService: IRemoteAgentHostService, @IConfigurationService configurationService: IConfigurationService, diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/wslAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/wslAgentHost.contribution.ts index e161c7685a3656..c6df198dbef18d 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/wslAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/wslAgentHost.contribution.ts @@ -4,14 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import { IntervalTimer } from '../../../../../base/common/async.js'; -import { isCancellationError } from '../../../../../base/common/errors.js'; +import { isCancellationError, onUnexpectedError } from '../../../../../base/common/errors.js'; +import { localize } from '../../../../../nls.js'; import { type IRemoteAgentHostEntry, IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, getEntryAddress, getEntryTypeConfig } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; -import { IWSLRemoteAgentHostService, WSL_ADDRESS_PREFIX } from '../../../../../platform/agentHost/common/wslRemoteAgentHost.js'; +import { IWSLRemoteAgentHostService, WSL_ADDRESS_PREFIX, WslAutoStartSettingId } from '../../../../../platform/agentHost/common/wslRemoteAgentHost.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; +import { type IAgentHostAutoConnect } from '../../../../common/agentHostSessionsProvider.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { ManagedReconnectAgentHostContribution } from './managedReconnectAgentHostContribution.js'; @@ -39,6 +42,14 @@ export class WSLAgentHostContribution extends ManagedReconnectAgentHostContribut protected readonly _entryType = RemoteAgentHostEntryType.WSL; + private readonly _autoConnect: IAgentHostAutoConnect = { + label: localize('wslAgentHost.autoStart', "Automatically Start WSL When Opening Chats"), + enabled: observableConfigValue(WslAutoStartSettingId, false, this._configurationService), + setEnabled: enabled => { + this._configurationService.updateValue(WslAutoStartSettingId, enabled).catch(onUnexpectedError); + }, + }; + constructor( @IRemoteAgentHostService remoteAgentHostService: IRemoteAgentHostService, @IWSLRemoteAgentHostService private readonly _wslService: IWSLRemoteAgentHostService, @@ -117,6 +128,7 @@ export class WSLAgentHostContribution extends ManagedReconnectAgentHostContribut connectOnDemand: () => this._connectWSLOnDemand(distro, entry.name, address), disconnectOnDemand: () => this._disconnectWSLOnDemand(distro, address), onDidReportConnectProgress: this._wslService.onDidReportConnectProgress, + autoConnect: this._autoConnect, }; } @@ -126,12 +138,15 @@ export class WSLAgentHostContribution extends ManagedReconnectAgentHostContribut if (!inFlight) { break; } + this._logService.info(`[WSLAgentHost] connectOnDemand: awaiting in-flight reconnect for ${distro}`); await inFlight.catch(() => undefined); const live = this._remoteAgentHostService.connections.find(connection => connection.address === address); if (live && RemoteAgentHostConnectionStatus.isConnected(live.status)) { + this._logService.info(`[WSLAgentHost] connectOnDemand: ${distro} connected by in-flight reconnect`); return; } } + this._logService.info(`[WSLAgentHost] connectOnDemand: starting user-initiated reconnect for ${distro}`); this._reconnectStates.get(distro)?.resetForResume(); await this._attemptWSLReconnect(distro, name, address, true); } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHost.contribution.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHost.contribution.test.ts index 7125b48dadfd2a..cd3b2f4d2cfa5f 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHost.contribution.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHost.contribution.test.ts @@ -5,6 +5,8 @@ import assert from 'assert'; import { timeout } from '../../../../../../base/common/async.js'; +import { DisposableMap, DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; import { AgentHostAuthenticationRecovery, AgentHostAuthTokenCache } from '../../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; @@ -12,8 +14,10 @@ import { ICommandService } from '../../../../../../platform/commands/common/comm import { type IRemoteAgentHostEntry, getEntryAddress, RemoteAgentHostEntryType } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { AuthRequiredReason, NotificationType, type INotification } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { type ProtectedResourceMetadata } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { type AgentInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { type IChatSessionsExtensionPoint } from '../../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IAuthenticationService } from '../../../../../../workbench/services/authentication/common/authentication.js'; import { RemoteAgentHostContribution } from '../../browser/remoteAgentHost.contribution.js'; import { SSHAgentHostContribution } from '../../browser/sshAgentHost.contribution.js'; @@ -169,6 +173,17 @@ interface IProviderOwnerHarness { _reconcileProviders(): void; } +interface IRemoteAgentRegistrationHarness { + _connections: Map<string, { + readonly agents: DisposableMap<string, DisposableStore>; + readonly store: DisposableStore; + }>; + _chatSessionsService: { + registerChatSessionContribution(contribution: IChatSessionsExtensionPoint): never; + }; + _registerAgent(address: string, connection: IAgentConnection, agent: AgentInfo, configuredName: string | undefined): void; +} + suite('Remote agent host provider ownership', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -216,3 +231,44 @@ suite('Remote agent host provider ownership', () => { }); }); }); + +suite('Remote Agent Host chat session contribution', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('advertises target-only delegation', () => { + const address = 'test-host'; + const agent: AgentInfo = { + provider: 'copilot', + displayName: 'Copilot', + description: 'test', + models: [], + }; + const connection = new class extends mock<IAgentConnection>() { }(); + const connectionStore = store.add(new DisposableStore()); + const agents = store.add(new DisposableMap<string, DisposableStore>()); + const harness = Object.create(RemoteAgentHostContribution.prototype) as IRemoteAgentRegistrationHarness; + harness._connections = new Map([[address, { agents, store: connectionStore }]]); + + let registeredContribution: IChatSessionsExtensionPoint | undefined; + harness._chatSessionsService = { + registerChatSessionContribution: contribution => { + registeredContribution = contribution; + throw new Error('Stop after registering the chat session contribution'); + }, + }; + + assert.throws( + () => harness._registerAgent(address, connection, agent, 'Test Host'), + /Stop after registering the chat session contribution/ + ); + assert.deepStrictEqual(registeredContribution && { + type: registeredContribution.type, + canDelegate: registeredContribution.canDelegate, + supportsDelegation: registeredContribution.supportsDelegation, + }, { + type: 'remote-test-host-copilot', + canDelegate: true, + supportsDelegation: false, + }); + }); +}); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index e73f66b5cc1c3a..3bc1ebb5f511a4 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -17,6 +17,8 @@ import { AgentSession, type IAgentSessionMetadata } from '../../../../../../plat import { agentHostAuthority, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { ChangesetKind } from '../../../../../../platform/agentHost/common/changesetUri.js'; import { IAgentHostService, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { RemoteAgentHostConnectionStatus } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { AgentHostTransportFailureReason } from '../../../../../../platform/agentHost/common/state/sessionTransport.js'; import { SessionArtifactType, withSessionArtifacts } from '../../../../../../platform/agentHost/common/sessionArtifacts.js'; import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { MessageKind, SessionLifecycle, type AgentInfo, type AutomationState, type RootState, type SessionConfigState, type SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -36,7 +38,7 @@ import { IChatService, type ChatSendResult, type IChatSendRequestOptions } from import { IChatSessionsService } from '../../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../../../../workbench/contrib/chat/common/languageModels.js'; import { ISessionChangeEvent } from '../../../../../services/sessions/common/sessionsProvider.js'; -import { ChatModelSource, SessionStatus, type ISession } from '../../../../../services/sessions/common/session.js'; +import { ChatModelSource, SessionRemoteConnectionFailureReason, SessionStatus, type ISession } from '../../../../../services/sessions/common/session.js'; import { RemoteAgentHostSessionsProvider, type IRemoteAgentHostSessionsProviderConfig } from '../../browser/remoteAgentHostSessionsProvider.js'; import { CloudSandboxSessionsProvider } from '../../browser/cloudSandboxSessionsProvider.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; @@ -226,12 +228,13 @@ class MockAgentConnection extends mock<IAgentConnection>() { // ---- Test helpers ----------------------------------------------------------- -function createSession(id: string, opts?: { provider?: string; summary?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number; _meta?: IAgentSessionMetadata['_meta'] }): IAgentSessionMetadata { +function createSession(id: string, opts?: { provider?: string; summary?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number; status?: ProtocolSessionStatus; _meta?: IAgentSessionMetadata['_meta'] }): IAgentSessionMetadata { return { session: AgentSession.uri(opts?.provider ?? 'copilotcli', id), startTime: opts?.startTime ?? 1000, modifiedTime: opts?.modifiedTime ?? 2000, summary: opts?.summary, + status: opts?.status, project: opts?.project, workingDirectories: opts?.workingDirectory ? [opts?.workingDirectory] : undefined, _meta: opts?._meta, @@ -423,6 +426,53 @@ suite('RemoteAgentHostSessionsProvider', () => { assert.strictEqual(provider.label, 'myhost:9999'); }); + test('derives session remote connection status from the backing provider', () => { + const provider = createProvider(disposables, connection); + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.connected); + fireSessionAdded(connection, 'connection-status'); + const session = provider.getSessions()[0]; + const statuses = [session.remoteConnectionStatus?.get()]; + + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.reconnecting); + statuses.push(session.remoteConnectionStatus?.get()); + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.disconnected); + statuses.push(session.remoteConnectionStatus?.get()); + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.disconnectedBecause(AgentHostTransportFailureReason.HostNotRunning)); + statuses.push(session.remoteConnectionStatus?.get()); + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.incompatible('Protocol version mismatch', ['1'])); + statuses.push(session.remoteConnectionStatus?.get()); + + assert.deepStrictEqual({ + hasRemoteHost: session.remoteConnectionStatus !== undefined, + statuses, + }, { + hasRemoteHost: true, + statuses: [ + { kind: 'connected' }, + { kind: 'reconnecting' }, + { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.Unknown }, + { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }, + { kind: 'incompatible' }, + ], + }); + }); + + test('does not present an active chat as busy while its remote host is unavailable', () => { + const provider = createProvider(disposables, connection); + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.connected); + provider.seedSessions([createSession('active-session', { status: ProtocolSessionStatus.InProgress })]); + const session = provider.getSessions()[0]; + const chat = session.mainChat.get(); + const statuses = [chat.status.get()]; + + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.disconnectedBecause(AgentHostTransportFailureReason.HostNotRunning)); + statuses.push(chat.status.get()); + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.connected); + statuses.push(chat.status.get()); + + assert.deepStrictEqual(statuses, [SessionStatus.InProgress, SessionStatus.Error, SessionStatus.InProgress]); + }); + test('remoteLocationPreferenceKey defaults to the live address when no stable preference key is given (e.g. tunnels/WSL)', () => { const provider = createProvider(disposables, connection, { address: 'tunnel:abc123' }); assert.strictEqual(provider.remoteLocationPreferenceKey, 'tunnel:abc123'); @@ -1060,6 +1110,7 @@ suite('RemoteAgentHostSessionsProvider', () => { test('cached session loading reflects authenticationPending', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { connection.addSession(createSession('cached-auth', { summary: 'Cached' })); const provider = createProvider(disposables, connection); + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.connected); await timeout(0); const session = provider.getSessions().find(s => s.title.get() === 'Cached'); @@ -1075,6 +1126,29 @@ suite('RemoteAgentHostSessionsProvider', () => { assert.strictEqual(session!.loading.get(), false); })); + test('cached session loading settles while the host is unavailable but stays pending while it connects', async () => { + connection.addSession(createSession('cached-connection-state', { summary: 'Cached' })); + const provider = createProvider(disposables, connection); + await timeout(0); + + const session = provider.getSessions().find(s => s.title.get() === 'Cached'); + assert.ok(session); + const loading = [session!.loading.get()]; + + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.connecting); + loading.push(session!.loading.get()); + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.reconnecting); + loading.push(session!.loading.get()); + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.incompatible('Protocol version mismatch', ['1'])); + loading.push(session!.loading.get()); + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.disconnected); + loading.push(session!.loading.get()); + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.connected); + loading.push(session!.loading.get()); + + assert.deepStrictEqual(loading, [false, true, true, false, false, true]); + }); + test('unpublishCachedSessions hides sessions but retains persisted cache', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { const storageService = disposables.add(new InMemoryStorageService()); connection.addSession(createSession('keep-me', { summary: 'Keep Me' })); @@ -1269,7 +1343,14 @@ suite('RemoteAgentHostSessionsProvider', () => { const root = URI.file('/home/remote/.copilot/session-state/sdk-session'); const resource = toAgentHostUri(URI.joinPath(root, 'files/plan.md'), agentHostAuthority('localhost:4321')); - assert.strictEqual(labelService.getUriHome(resource)?.path, root.path); + const providerLabel = provider.sessionTypes.find(type => type.id === CopilotCLISessionType.id)?.label; + assert.deepStrictEqual({ + home: labelService.getUriHome(resource)?.path, + label: labelService.getUriLabel(resource), + }, { + home: root.path, + label: `${providerLabel}/Remote Session/files/plan.md`, + }); })); test('session adapter uses raw ID as fallback title', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/wslAgentHost.contribution.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/wslAgentHost.contribution.test.ts index 3a07e1d8a6edd0..2f37f36b692f9c 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/wslAgentHost.contribution.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/wslAgentHost.contribution.test.ts @@ -7,6 +7,9 @@ import assert from 'assert'; import { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { RemoteAgentHostConnectionStatus } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { AgentHostTransportFailureReason } from '../../../../../../platform/agentHost/common/state/sessionTransport.js'; import { shouldPauseWSLReconnectAfterFailure, WSLAgentHostContribution } from '../../browser/wslAgentHost.contribution.js'; suite('shouldPauseWSLReconnectAfterFailure', () => { @@ -31,6 +34,37 @@ interface IWSLDisconnectHarness { _disconnectWSLOnDemand(distro: string, address: string): Promise<void>; } +interface IWSLConnectionWiringHarness { + _remoteAgentHostService: { + readonly connections: readonly { + readonly address: string; + readonly defaultDirectory?: string; + readonly status: RemoteAgentHostConnectionStatus; + }[]; + getConnection(address: string): IAgentConnection | undefined; + }; + _providerInstances: Map<string, { + setConnection(connection: IAgentConnection, defaultDirectory?: string): void; + clearConnection(): void; + }>; + _wiredAddresses: Set<string>; + _wireConnections(): void; +} + +interface IWSLConnectionStatusHarness { + _remoteAgentHostService: { + readonly connections: readonly { + readonly address: string; + readonly status: RemoteAgentHostConnectionStatus; + }[]; + }; + _providerInstances: Map<string, { + readonly connectionStatus: { get(): RemoteAgentHostConnectionStatus }; + setConnectionStatus(status: RemoteAgentHostConnectionStatus): void; + }>; + _updateConnectionStatuses(): void; +} + suite('WSLAgentHostContribution disconnect', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -62,3 +96,64 @@ suite('WSLAgentHostContribution disconnect', () => { assert.deepStrictEqual(calls, ['state:Ubuntu', 'wsl:Ubuntu', 'remove:wsl:Ubuntu', 'reconcile']); }); }); + +suite('WSLAgentHostContribution connection wiring', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('clears a wired provider when its connection disconnects or vanishes without clearing an unwired provider', () => { + const address = 'wsl:Ubuntu'; + const connection = {} as IAgentConnection; + const calls: string[] = []; + let connections: { address: string; defaultDirectory?: string; status: RemoteAgentHostConnectionStatus }[] = [{ + address, + status: RemoteAgentHostConnectionStatus.connected, + }]; + const contribution = Object.create(WSLAgentHostContribution.prototype) as IWSLConnectionWiringHarness; + contribution._remoteAgentHostService = { + get connections() { return connections; }, + getConnection: requestedAddress => requestedAddress === address ? connection : undefined, + }; + contribution._providerInstances = new Map([ + [address, { + setConnection: () => calls.push('wired:set'), + clearConnection: () => calls.push('wired:clear'), + }], + ['wsl:unwired', { + setConnection: () => calls.push('unwired:set'), + clearConnection: () => calls.push('unwired:clear'), + }], + ]); + contribution._wiredAddresses = new Set(); + + contribution._wireConnections(); + connections = [{ address, defaultDirectory: '/home/ubuntu', status: RemoteAgentHostConnectionStatus.disconnected }]; + contribution._wireConnections(); + connections = []; + contribution._wireConnections(); + + assert.deepStrictEqual({ calls, wiredAddresses: [...contribution._wiredAddresses] }, { + calls: ['wired:set', 'wired:clear'], + wiredAddresses: [], + }); + }); + + test('propagates the status of a failed WSL connection entry', () => { + const address = 'wsl:Ubuntu'; + let status = RemoteAgentHostConnectionStatus.connecting; + const contribution = Object.create(WSLAgentHostContribution.prototype) as IWSLConnectionStatusHarness; + contribution._remoteAgentHostService = { + connections: [{ address, status: RemoteAgentHostConnectionStatus.disconnectedBecause(AgentHostTransportFailureReason.HostNotRunning) }], + }; + contribution._providerInstances = new Map([[ + address, + { + connectionStatus: { get: () => status }, + setConnectionStatus: nextStatus => { status = nextStatus; }, + }, + ]]); + + contribution._updateConnectionStatuses(); + + assert.deepStrictEqual(status, RemoteAgentHostConnectionStatus.disconnectedBecause(AgentHostTransportFailureReason.HostNotRunning)); + }); +}); diff --git a/src/vs/sessions/contrib/sessions/browser/automationsNewBadge.ts b/src/vs/sessions/contrib/sessions/browser/automationsNewBadge.ts new file mode 100644 index 00000000000000..62445fc9f2a4b3 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/automationsNewBadge.ts @@ -0,0 +1,137 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derived, observableValue } from '../../../../base/common/observable.js'; +import { onUnexpectedError } from '../../../../base/common/errors.js'; +import { IConfigurationService, isConfigured } from '../../../../platform/configuration/common/configuration.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { observableMemento, type ObservableMemento } from '../../../../platform/observable/common/observableMemento.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IAutomationService } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { IWorkbenchAssignmentService } from '../../../../workbench/services/assignment/common/assignmentService.js'; +import { ICustomViewService } from '../../../services/customView/browser/customViewService.js'; +import { AUTOMATIONS_CUSTOM_VIEW_ID } from './automationsConstants.js'; + +export const AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY = 'sessions.automations.newBadgeSeen'; +export const AUTOMATIONS_NEW_BADGE_STYLE_SETTING = 'sessions.automations.newBadgeStyle'; +export const AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT = 'agentSessionsAutomationsNewBadgeStyle'; + +export type AutomationsNewBadgeStyle = 'accent' | 'soft' | 'outline'; + +const DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE: AutomationsNewBadgeStyle = 'outline'; + +const automationsNewBadgeSeenMemento = observableMemento<boolean>({ + key: AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, + defaultValue: false, + toStorage: value => String(value), + fromStorage: value => value === 'true', +}); + +/** Owns the first-use state for the Automations shortcut badge. */ +export class AutomationsNewBadgeState extends Disposable { + + private readonly seen: ObservableMemento<boolean>; + private readonly resolvedStyle = observableValue<AutomationsNewBadgeStyle | undefined>(this, undefined); + private observingActiveView = false; + private initializationPromise: Promise<void> | undefined; + private styleRequest = 0; + readonly presentation = derived(this, reader => this.seen.read(reader) ? undefined : this.resolvedStyle.read(reader)); + readonly showNewBadge = derived(this, reader => this.presentation.read(reader) !== undefined); + + constructor( + @IAutomationService private readonly automationService: IAutomationService, + @ICustomViewService private readonly customViewService: ICustomViewService, + @IStorageService private readonly storageService: IStorageService, + @IWorkbenchAssignmentService private readonly assignmentService: IWorkbenchAssignmentService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @ILogService private readonly logService: ILogService, + ) { + super(); + this.seen = this._register(automationsNewBadgeSeenMemento(StorageScope.APPLICATION, StorageTarget.MACHINE, storageService)); + } + + initialize(): Promise<void> { + if (!this.observingActiveView) { + this.observingActiveView = true; + this._register(autorun(reader => { + if (this.customViewService.activeCustomView.read(reader)?.id === AUTOMATIONS_CUSTOM_VIEW_ID) { + this.markSeen(); + } + })); + } + if (!this.initializationPromise) { + this.initializationPromise = this.doInitialize(); + this._register(this.configurationService.onDidChangeConfiguration(event => { + if (event.affectsConfiguration(AUTOMATIONS_NEW_BADGE_STYLE_SETTING)) { + void this.updateStyle().catch(onUnexpectedError); + } + })); + this._register(this.assignmentService.onDidRefetchAssignments(() => { + void this.updateStyle().catch(onUnexpectedError); + })); + } + return this.initializationPromise; + } + + async reset(): Promise<void> { + this.seen.set(false, undefined); + this.storageService.remove(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION); + this.resolvedStyle.set(undefined, undefined); + await this.updateStyle(); + } + + private async doInitialize(): Promise<void> { + const hasPriorUse = this.seen.get() + || this.automationService.automations.get().length > 0 + || this.automationService.runs.get().length > 0; + if (hasPriorUse) { + this.markSeen(); + return; + } + + await this.updateStyle(); + } + + private async updateStyle(): Promise<void> { + if (this.seen.get()) { + return; + } + + const request = ++this.styleRequest; + const inspection = this.configurationService.inspect<string>(AUTOMATIONS_NEW_BADGE_STYLE_SETTING); + let value: string | undefined; + if (isConfigured(inspection)) { + value = inspection.value; + } else { + try { + value = await this.assignmentService.getTreatment<string>(AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT); + } catch (error) { + this.logService.warn(`[AutomationsNewBadgeState] Failed to resolve badge style treatment; using '${DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE}'.`, error); + } + } + if (request !== this.styleRequest || this.seen.get()) { + return; + } + this.resolvedStyle.set(this.normalizeStyle(value), undefined); + } + + private normalizeStyle(value: string | undefined): AutomationsNewBadgeStyle { + if (value === undefined || value === DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE) { + return DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE; + } + if (value === 'accent' || value === 'soft') { + return value; + } + this.logService.warn(`[AutomationsNewBadgeState] Unsupported badge style treatment '${value}'; using '${DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE}'.`); + return DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE; + } + + private markSeen(): void { + if (!this.seen.get()) { + this.seen.set(true, undefined); + } + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts index bd0df7d233d268..6954d78071f6ec 100644 --- a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts @@ -125,13 +125,13 @@ export const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ ]; export async function openCustomizationOverviewPage(editorService: IEditorService, harnessService: ICustomizationHarnessService, sessionsService: ISessionsService): Promise<void> { - const sessionResource = sessionsService.activeSession.get()?.resource; - if (sessionResource) { - harnessService.setActiveSession(sessionResource); + const session = sessionsService.activeSession.get(); + if (session) { + harnessService.setActiveSession(session.resource); } const input = AICustomizationManagementEditorInput.getOrCreate(); - input.setTargetLabel(harnessService.getActiveDescriptor().label); + input.setTargetLabels(harnessService.getActiveDescriptor().label, session?.workspace.get()?.folders[0]?.name); const pane = await editorService.openEditor(input, { pinned: true }); if (pane instanceof AICustomizationManagementEditor) { pane.showWelcomePage(); @@ -139,13 +139,13 @@ export async function openCustomizationOverviewPage(editorService: IEditorServic } async function openCustomizationSectionPage(editorService: IEditorService, harnessService: ICustomizationHarnessService, sessionsService: ISessionsService, section: typeof AICustomizationManagementSection[keyof typeof AICustomizationManagementSection]): Promise<void> { - const sessionResource = sessionsService.activeSession.get()?.resource; - if (sessionResource) { - harnessService.setActiveSession(sessionResource); + const session = sessionsService.activeSession.get(); + if (session) { + harnessService.setActiveSession(session.resource); } const input = AICustomizationManagementEditorInput.getOrCreate(); - input.setTargetLabel(harnessService.getActiveDescriptor().label); + input.setTargetLabels(harnessService.getActiveDescriptor().label, session?.workspace.get()?.folders[0]?.name); const pane = await editorService.openEditor(input, { pinned: true }); if (pane instanceof AICustomizationManagementEditor) { pane.selectSectionById(section); diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index 75e27e16a6b68e..73c9cf83b78f17 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -724,6 +724,39 @@ margin-right: 4px; } + .session-section-new-badge { + display: none; + align-items: center; + flex-shrink: 0; + height: var(--vscode-spacing-size160); + margin-left: var(--vscode-spacing-size40); + padding: 0 var(--vscode-spacing-size40); + box-sizing: border-box; + border: var(--vscode-strokeThickness) solid transparent; + border-radius: var(--vscode-cornerRadius-circle); + color: var(--vscode-foreground); + font-size: var(--vscode-fontSize-label3); + font-weight: var(--vscode-fontWeight-semiBold); + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; + pointer-events: none; + } + + .session-section-new-badge-accent { + border-color: var(--vscode-activityBarBadge-background); + background-color: var(--vscode-activityBarBadge-background); + color: var(--vscode-activityBarBadge-foreground); + } + + .session-section-new-badge-soft { + background-color: color-mix(in srgb, var(--vscode-foreground) 12%, transparent); + } + + .session-section-new-badge-outline { + border-color: var(--vscode-descriptionForeground); + } + .session-section-toolbar { flex-shrink: 0; margin-left: 4px; @@ -750,6 +783,11 @@ } } +.hc-black .session-section-new-badge, +.hc-light .session-section-new-badge { + border-color: var(--vscode-contrastBorder); +} + .monaco-list-row:hover .session-section .session-section-toolbar, .monaco-list-row.focused .session-section .session-section-toolbar, .monaco-list-row .session-section.dropdown-active .session-section-toolbar { diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 38a00abdd3d8a4..4a2518f6be12f9 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -18,8 +18,9 @@ import { SessionsView, SessionsViewId } from './views/sessionsView.js'; import { AutomationsCustomViewContribution } from './views/automationsView.js'; import './views/sessionsViewActions.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; -import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING } from './views/sessionsList.js'; +import { AUTOMATIONS_NEW_BADGE_STYLE_SETTING, AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT } from './automationsNewBadge.js'; import { SessionsMouseNavigationContribution } from './sessionsMouseNavigation.js'; import './sessionDetailsAction.js'; import { SessionsWindowNotifier } from './sessionsWindowNotifier.js'; @@ -69,6 +70,19 @@ Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).regis default: true, experiment: { mode: 'auto' } }, + [AUTOMATIONS_NEW_BADGE_STYLE_SETTING]: { + type: 'string', + enum: ['accent', 'soft', 'outline'], + default: 'outline', + scope: ConfigurationScope.APPLICATION, + included: false, + tags: ['experimental'], + experiment: { + mode: 'auto', + name: AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT, + }, + description: localize('sessions.automations.newBadgeStyle', "Controls the visual style of the Automations first-use badge."), + }, }, }); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 109218084ae841..6f4d91d9ec37d1 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -96,6 +96,7 @@ import { ChatAutomationsEnabledContext } from '../../../../../workbench/contrib/ import { IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { ICustomViewService } from '../../../../services/customView/browser/customViewService.js'; import { AUTOMATIONS_CUSTOM_VIEW_ID } from '../automationsConstants.js'; +import { AutomationsNewBadgeState, type AutomationsNewBadgeStyle } from '../automationsNewBadge.js'; import { Menus } from '../../../../browser/menus.js'; import { getSessionConversationStatusAriaLabel } from '../../../../browser/sessionConversationGroups.js'; import { getAgentMergeAwarePullRequestIcon, getSessionAgentMergeConfigurationObservable, ISessionAgentMergeConfiguration, isAgentMergePullRequestIcon } from '../../../../browser/sessionAgentMerge.js'; @@ -1266,6 +1267,7 @@ interface ISessionSectionTemplate extends ISessionHeaderTemplate { readonly icon: HTMLElement; readonly label: HTMLElement; readonly count: HTMLElement; + readonly newBadge: HTMLElement; readonly chevron: HTMLElement; readonly contextKeyService: IContextKeyService; readonly disposables: DisposableStore; @@ -1318,6 +1320,7 @@ export class SessionSectionRenderer implements ITreeRenderer<SessionListItem, Fu private readonly contextKeyService: IContextKeyService, private readonly automationService: IAutomationService, private readonly automationSessions: IObservable<readonly ISession[]>, + private readonly automationNewBadgePresentation: IObservable<AutomationsNewBadgeStyle | undefined>, private readonly uriIdentityService: IUriIdentityService, private readonly customViewService: ICustomViewService, private readonly menuService: IMenuService, @@ -1339,6 +1342,9 @@ export class SessionSectionRenderer implements ITreeRenderer<SessionListItem, Fu icon.setAttribute('aria-hidden', 'true'); const label = DOM.append(container, $('span.session-section-label')); const count = DOM.append(container, $('span.session-section-count')); + const newBadge = DOM.append(container, $('span.session-section-new-badge')); + newBadge.setAttribute('aria-hidden', 'true'); + newBadge.textContent = localize('automationsNewBadge', "New"); const toolbarContainer = DOM.append(container, $('.session-section-toolbar')); const contextKeyService = disposables.add(this.contextKeyService.createScoped(container)); @@ -1383,7 +1389,7 @@ export class SessionSectionRenderer implements ITreeRenderer<SessionListItem, Fu }, })); - return { container, icon, label, count, toolbarContainer, toolbar, chevron, contextKeyService, elementDisposables, disposables }; + return { container, icon, label, count, newBadge, toolbarContainer, toolbar, chevron, contextKeyService, elementDisposables, disposables }; } renderElement(node: ITreeNode<SessionListItem, FuzzyScore>, _index: number, template: ISessionSectionTemplate): void { @@ -1397,6 +1403,12 @@ export class SessionSectionRenderer implements ITreeRenderer<SessionListItem, Fu this.templatesById.set(element.id, template); template.container.classList.remove(SESSION_HEADER_DROP_TARGET_CLASS); template.container.classList.remove('session-section-shortcut'); + template.newBadge.style.display = 'none'; + template.newBadge.classList.remove( + 'session-section-new-badge-accent', + 'session-section-new-badge-soft', + 'session-section-new-badge-outline', + ); if (element.id === AUTOMATIONS_SECTION_ID) { template.container.classList.add('session-section-shortcut'); } @@ -1412,6 +1424,11 @@ export class SessionSectionRenderer implements ITreeRenderer<SessionListItem, Fu template.elementDisposables.add(autorun(reader => { const activeCustomView = this.customViewService.activeCustomView.read(reader); template.container.classList.toggle('active', activeCustomView?.id === AUTOMATIONS_CUSTOM_VIEW_ID); + const badgeStyle = this.automationNewBadgePresentation.read(reader); + template.newBadge.style.display = badgeStyle ? 'inline-flex' : 'none'; + template.newBadge.classList.toggle('session-section-new-badge-accent', badgeStyle === 'accent'); + template.newBadge.classList.toggle('session-section-new-badge-soft', badgeStyle === 'soft'); + template.newBadge.classList.toggle('session-section-new-badge-outline', badgeStyle === 'outline'); })); const statusIcon = template.elementDisposables.add(this.instantiationService.createInstance(SessionStatusIcon, template.icon)); template.elementDisposables.add(autorun(reader => { @@ -1747,6 +1764,7 @@ interface ISessionsAccessibilityProviderOptions { readonly isPinned: (session: ISession) => boolean; readonly isRenderedInCustomGroup?: (session: ISession) => boolean; readonly includeQuickChatInAriaLabel?: boolean; + readonly automationNewBadgeVisible?: IObservable<boolean>; /** Mirrors {@link SessionItemRenderer}'s option of the same name — see there for rationale. */ readonly deriveStatusFromMainChat?: boolean; } @@ -1776,20 +1794,23 @@ class SessionsAccessibilityProvider { } if (isSessionSection(element)) { if (element.id === AUTOMATIONS_SECTION_ID) { - return this.automationStatus - ? derived(this, reader => { - switch (this.automationStatus?.read(reader)) { - case SessionStatus.NeedsInput: - return localize('automationsNeedsInputAria', "{0}, run needs input", element.label); - case SessionStatus.InProgress: - return localize('automationsActiveAria', "{0}, run in progress", element.label); - case SessionStatus.Completed: - return localize('automationsUnreadRunAria', "{0}, unread run", element.label); - default: - return element.label; - } - }) - : element.label; + return derived(this, reader => { + let label = element.label; + switch (this.automationStatus?.read(reader)) { + case SessionStatus.NeedsInput: + label = localize('automationsNeedsInputAria', "{0}, run needs input", element.label); + break; + case SessionStatus.InProgress: + label = localize('automationsActiveAria', "{0}, run in progress", element.label); + break; + case SessionStatus.Completed: + label = localize('automationsUnreadRunAria', "{0}, unread run", element.label); + break; + } + return this.options?.automationNewBadgeVisible?.read(reader) + ? localize('automationsNewFeatureAria', "{0}, new feature", label) + : label; + }); } return `${element.label}, ${element.sessions.length}`; } @@ -2323,6 +2344,7 @@ export class SessionsList extends Disposable implements ISessionsList { */ private readonly chatApprovalHeightReconcile = this._register(new MutableDisposable()); private readonly automationSessions = observableValue<readonly ISession[]>(this, []); + private readonly automationsNewBadgeState: AutomationsNewBadgeState; /** * Session IDs whose hierarchy indent/connector guides should be visible: * the union of the currently-hovered session (if any) and every session @@ -2396,6 +2418,13 @@ export class SessionsList extends Disposable implements ISessionsList { get element(): HTMLElement { return this.listContainer; } + async resetAutomationsNewBadge(): Promise<void> { + if (this.customViewService.activeCustomView.get()?.id === AUTOMATIONS_CUSTOM_VIEW_ID) { + this.customViewService.hideCustomView(); + } + await this.automationsNewBadgeState.reset(); + } + constructor( container: HTMLElement, private readonly options: ISessionsListControlOptions, @@ -2424,6 +2453,7 @@ export class SessionsList extends Disposable implements ISessionsList { @IPreferencesService private readonly preferencesService: IPreferencesService, ) { super(); + this.automationsNewBadgeState = this._register(instantiationService.createInstance(AutomationsNewBadgeState)); // Load excluded session types from storage this.excludedSessionTypes = this.loadExcludedSessionTypes(); @@ -2518,7 +2548,18 @@ export class SessionsList extends Disposable implements ISessionsList { this.tree.setFocus([element], event); this.tree.setSelection([element], event); }; - const sectionRenderer = new SessionSectionRenderer(true /* hideSectionCount */, selectHeader, instantiationService, contextKeyService, this.automationService, this.automationSessions, this.uriIdentityService, this.customViewService, this.menuService); + const sectionRenderer = new SessionSectionRenderer( + true /* hideSectionCount */, + selectHeader, + instantiationService, + contextKeyService, + this.automationService, + this.automationSessions, + this.automationsNewBadgeState.presentation, + this.uriIdentityService, + this.customViewService, + this.menuService, + ); this._sectionRenderer = sectionRenderer; const groupRenderer = new SessionGroupRenderer({ commitEdit: (group, name) => this.commitGroupEdit(group, name), @@ -2561,6 +2602,7 @@ export class SessionsList extends Disposable implements ISessionsList { isPinned: session => this.isSessionPinned(session), isRenderedInCustomGroup: session => this.isRenderedInCustomGroup(session), deriveStatusFromMainChat: true, + automationNewBadgeVisible: this.automationsNewBadgeState.showNewBadge, }), dnd: this._register(new SessionsListDragAndDrop({ isReorderable: session => this.isReorderable(session), @@ -3200,6 +3242,7 @@ export class SessionsList extends Disposable implements ISessionsList { }; if (this.contextKeyService.getContextKeyValue<boolean>(ChatAutomationsEnabledContext.key)) { + void this.automationsNewBadgeState.initialize().catch(onUnexpectedError); children.push(renderSection({ id: AUTOMATIONS_SECTION_ID, label: localize('automations', "Automations"), sessions: [] })); } diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts index dc530fc8137b81..ce50eaef16dfa9 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts @@ -60,6 +60,40 @@ export const SessionsViewGroupingContext = new RawContextKey<string>('sessionsVi export const SessionsViewSortingContext = new RawContextKey<string>('sessionsViewPane.sorting', SessionsSorting.Created); export const IsWorkspaceGroupCappedContext = new RawContextKey<boolean>('sessionsViewPane.workspaceGroupCapped', true); +export interface ISessionsHeaderElements { + readonly row: HTMLElement; + readonly label: HTMLElement; + readonly actions: HTMLElement; + readonly toolbar: MenuWorkbenchToolBar | undefined; +} + +export function renderSessionsHeader( + parent: HTMLElement, + phoneLayout: boolean, + instantiationService: IInstantiationService, + contextKeyService: IContextKeyService, + disposables: DisposableStore, +): ISessionsHeaderElements { + const row = DOM.append(parent, $('.agent-sessions-header-row')); + const label = DOM.append(row, $('.agent-sessions-header-label')); + const actions = DOM.append(row, $('.agent-sessions-header-actions')); + let toolbar: MenuWorkbenchToolBar | undefined; + + if (!phoneLayout) { + label.textContent = localize('sessionsHeader', "Sessions"); + const scopedInstantiationService = disposables.add(instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService]))); + toolbar = disposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, actions, Menus.SidebarSessionsHeader, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + telemetrySource: 'sessionsView.header', + toolbarOptions: { primaryGroup: () => true }, + })); + } else { + row.classList.add('phone-layout-empty'); + } + + return { row, label, actions, toolbar }; +} + export class SessionsView extends ViewPane { private viewPaneContainer: HTMLElement | undefined; @@ -156,32 +190,15 @@ export class SessionsView extends ViewPane { // Sessions content container const sessionsContent = DOM.append(sessionsSection, $('.agent-sessions-content')); - // Header row: "Sessions" label (left) + compact "New" button (right) - const headerRow = this.headerRow = DOM.append(sessionsContent, $('.agent-sessions-header-row')); - const headerLabel = this.headerLabel = DOM.append(headerRow, $('.agent-sessions-header-label')); - - const headerActions = this.headerActions = DOM.append(headerRow, $('.agent-sessions-header-actions')); - // On phone, the desktop header content (label + new button + filter/find toolbar) // is hidden in favor of the mobile filter chip row + the (+) button in the // MobileTitlebarPart. We still create the row container because the find // widget mounts inside it. const phoneLayout = isPhoneLayout(this.layoutService); - if (!phoneLayout) { - headerLabel.textContent = localize('sessionsHeader', "Sessions"); - - // Header actions (visual order: New, Filter, Search). The "New" button is - // contributed to Menus.SidebarSessionsHeader and rendered as a compact pill - // by NewSessionActionViewItem. - const scopedInstantiationService = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this.scopedContextKeyService]))); - this._register(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, headerActions, Menus.SidebarSessionsHeader, { - hiddenItemStrategy: HiddenItemStrategy.NoHide, - telemetrySource: 'sessionsView.header', - toolbarOptions: { primaryGroup: () => true }, - })); - } else { - headerRow.classList.add('phone-layout-empty'); - } + const header = renderSessionsHeader(sessionsContent, phoneLayout, this.instantiationService, this.scopedContextKeyService, this._register(new DisposableStore())); + const headerRow = this.headerRow = header.row; + this.headerLabel = header.label; + this.headerActions = header.actions; // Container for the tree's find widget (toggled by the toolbar's Find action) const findWidgetContainer = this.findWidgetContainer = DOM.append(headerRow, $('.agent-sessions-find-widget-container')); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index d878e9c6f118ae..4fcb2e70c0d845 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -9,10 +9,12 @@ import { KeyChord, KeyCode, KeyMod } from '../../../../../base/common/keyCodes.j import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { isMobile, isWeb } from '../../../../../base/common/platform.js'; import { localize, localize2 } from '../../../../../nls.js'; +import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IsDevelopmentContext } from '../../../../../platform/contextkey/common/contextkeys.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; @@ -40,6 +42,7 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { registerExternalSessionsFilterMenu } from '../../../../../workbench/contrib/chat/browser/agentSessions/externalSessionsFilterMenu.js'; import { ICustomViewService } from '../../../../services/customView/browser/customViewService.js'; import { IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { ChatAutomationsEnabledContext } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { AUTOMATIONS_CUSTOM_VIEW_ID } from '../automationsConstants.js'; const CLOSE_SESSION_COMMAND_ID = 'sessionsViewPane.closeSession'; @@ -1336,6 +1339,23 @@ registerAction2(class ManageAutomationsAction extends Action2 { } }); +registerAction2(class ResetAutomationsNewBadgeAction extends Action2 { + constructor() { + super({ + id: 'sessions.developer.resetAutomationsNewBadge', + title: localize2('resetAutomationsNewBadge', "Reset Automations New Badge"), + category: Categories.Developer, + f1: true, + precondition: ContextKeyExpr.and(IsDevelopmentContext, IsSessionsWindowContext, ChatAutomationsEnabledContext), + }); + } + + override async run(accessor: ServicesAccessor): Promise<void> { + const view = await accessor.get(IViewsService).openView<SessionsView>(SessionsViewId, false); + await view?.sessionsControl?.resetAutomationsNewBadge(); + } +}); + const MARK_ALL_AUTOMATION_RUNS_READ_COMMAND_ID = 'sessionsView.markAllAutomationRunsRead'; registerAction2(class MarkAllAutomationRunsReadAction extends Action2 { diff --git a/src/vs/sessions/contrib/sessions/test/browser/automationsNewBadge.test.ts b/src/vs/sessions/contrib/sessions/test/browser/automationsNewBadge.test.ts new file mode 100644 index 00000000000000..a95823f476db64 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/automationsNewBadge.test.ts @@ -0,0 +1,236 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Emitter } from '../../../../../base/common/event.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import type { IConfigurationChangeEvent } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import type { IAutomationDescriptor, IAutomationRun } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; +import { IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { IWorkbenchAssignmentService } from '../../../../../workbench/services/assignment/common/assignmentService.js'; +import type { ICustomViewDescriptor } from '../../../../services/customView/browser/customView.js'; +import { ICustomViewService } from '../../../../services/customView/browser/customViewService.js'; +import { AUTOMATIONS_CUSTOM_VIEW_ID } from '../../browser/automationsConstants.js'; +import { AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, AUTOMATIONS_NEW_BADGE_STYLE_SETTING, AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT, AutomationsNewBadgeState, type AutomationsNewBadgeStyle } from '../../browser/automationsNewBadge.js'; + +suite('AutomationsNewBadgeState', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + class TestAssignmentService extends mock<IWorkbenchAssignmentService>() { + readonly treatments: string[] = []; + override readonly onDidRefetchAssignments; + + constructor( + private readonly style: AutomationsNewBadgeStyle | undefined, + refetchAssignments: Emitter<void>, + private readonly error?: Error, + ) { + super(); + this.onDidRefetchAssignments = refetchAssignments.event; + } + + override async getTreatment<T extends string | number | boolean>(name: string): Promise<T | undefined> { + this.treatments.push(name); + if (this.error) { + throw this.error; + } + return this.style as T | undefined; + } + } + + function createState(options: { + readonly automations?: readonly IAutomationDescriptor[]; + readonly runs?: readonly IAutomationRun[]; + readonly activeView?: ICustomViewDescriptor; + readonly seen?: boolean; + readonly style?: AutomationsNewBadgeStyle; + readonly configuredStyle?: AutomationsNewBadgeStyle; + readonly treatmentError?: Error; + } = {}) { + const storageService = disposables.add(new InMemoryStorageService()); + if (options.seen) { + storageService.store(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + } + const automations = observableValue<readonly IAutomationDescriptor[]>(disposables, options.automations ?? []); + const runs = observableValue<readonly IAutomationRun[]>(disposables, options.runs ?? []); + const activeView = observableValue<ICustomViewDescriptor | undefined>(disposables, options.activeView); + const automationService = new class extends mock<IAutomationService>() { + override readonly automations = automations; + override readonly runs = runs; + }; + const customViewService = new class extends mock<ICustomViewService>() { + override readonly activeCustomView = activeView; + }; + const refetchAssignments = disposables.add(new Emitter<void>()); + const assignmentService = new TestAssignmentService(options.style, refetchAssignments, options.treatmentError); + const configurationService = new TestConfigurationService(); + if (options.configuredStyle) { + void configurationService.setUserConfiguration(AUTOMATIONS_NEW_BADGE_STYLE_SETTING, options.configuredStyle); + } + const state = disposables.add(new AutomationsNewBadgeState( + automationService, + customViewService, + storageService, + assignmentService, + configurationService, + new NullLogService(), + )); + return { state, storageService, automations, runs, activeView, assignmentService, configurationService, refetchAssignments }; + } + + test('keeps the resolved style stable until Automations is activated', async () => { + const { state, storageService, automations, runs, activeView } = createState(); + + await state.initialize(); + automations.set([upcastPartial<IAutomationDescriptor>({ id: 'late-automation' })], undefined); + runs.set([upcastPartial<IAutomationRun>({ id: 'late-run' })], undefined); + const beforeActivation = { + showNewBadge: state.showNewBadge.get(), + style: state.presentation.get(), + stored: storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + }; + + activeView.set(upcastPartial<ICustomViewDescriptor>({ id: AUTOMATIONS_CUSTOM_VIEW_ID }), undefined); + const afterActivation = { + showNewBadge: state.showNewBadge.get(), + stored: storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + }; + + assert.deepStrictEqual({ beforeActivation, afterActivation }, { + beforeActivation: { showNewBadge: true, style: 'outline', stored: undefined }, + afterActivation: { + showNewBadge: false, + stored: 'true', + }, + }); + }); + + test('resolves accent, soft, and outline from the hidden treatment', async () => { + const snapshots = []; + for (const style of ['accent', 'soft', 'outline'] as const) { + const fixture = createState({ style }); + await fixture.state.initialize(); + snapshots.push({ + style: fixture.state.presentation.get(), + treatments: fixture.assignmentService.treatments, + }); + } + + assert.deepStrictEqual(snapshots, [ + { style: 'accent', treatments: [AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT] }, + { style: 'soft', treatments: [AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT] }, + { style: 'outline', treatments: [AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT] }, + ]); + }); + + test('falls back to outline when treatment resolution fails', async () => { + const fixture = createState({ treatmentError: new Error('Unavailable') }); + + await fixture.state.initialize(); + + assert.deepStrictEqual({ + style: fixture.state.presentation.get(), + treatments: fixture.assignmentService.treatments, + }, { + style: 'outline', + treatments: [AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT], + }); + }); + + test('lets the hidden setting override and live-update the treatment', async () => { + const fixture = createState({ style: 'outline', configuredStyle: 'soft' }); + await fixture.state.initialize(); + const initial = fixture.state.presentation.get(); + + await fixture.configurationService.setUserConfiguration(AUTOMATIONS_NEW_BADGE_STYLE_SETTING, 'accent'); + fixture.configurationService.onDidChangeConfigurationEmitter.fire(upcastPartial<IConfigurationChangeEvent>({ + affectsConfiguration: (key: string) => key === AUTOMATIONS_NEW_BADGE_STYLE_SETTING, + })); + + assert.deepStrictEqual({ + initial, + updated: fixture.state.presentation.get(), + treatments: fixture.assignmentService.treatments, + }, { + initial: 'soft', + updated: 'accent', + treatments: [], + }); + }); + + test('resets seen state for development even when prior Automation evidence exists', async () => { + const fixture = createState({ + automations: [upcastPartial<IAutomationDescriptor>({ id: 'existing-automation' })], + style: 'accent', + }); + await fixture.state.initialize(); + + await fixture.state.reset(); + + assert.deepStrictEqual({ + showNewBadge: fixture.state.showNewBadge.get(), + style: fixture.state.presentation.get(), + stored: fixture.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + }, { + showNewBadge: true, + style: 'accent', + stored: undefined, + }); + }); + + test('suppresses the badge when synchronous Automation evidence exists', async () => { + const definition = createState({ + automations: [upcastPartial<IAutomationDescriptor>({ id: 'existing-automation' })], + }); + const run = createState({ + runs: [upcastPartial<IAutomationRun>({ id: 'existing-run' })], + }); + + await definition.state.initialize(); + await run.state.initialize(); + + assert.deepStrictEqual({ + definition: { + showNewBadge: definition.state.showNewBadge.get(), + stored: definition.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + }, + run: { + showNewBadge: run.state.showNewBadge.get(), + stored: run.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + }, + }, { + definition: { showNewBadge: false, stored: 'true' }, + run: { showNewBadge: false, stored: 'true' }, + }); + }); + + test('honors persisted and restored seen state before the row renders', async () => { + const persisted = createState({ seen: true }); + const restored = createState({ + activeView: upcastPartial<ICustomViewDescriptor>({ id: AUTOMATIONS_CUSTOM_VIEW_ID }), + }); + + await persisted.state.initialize(); + await restored.state.initialize(); + + assert.deepStrictEqual({ + persisted: { + showNewBadge: persisted.state.showNewBadge.get(), + }, + restored: { + showNewBadge: restored.state.showNewBadge.get(), + stored: restored.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + }, + }, { + persisted: { showNewBadge: false }, + restored: { showNewBadge: false, stored: 'true' }, + }); + }); +}); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index 526c469fc604b4..541fc0caec3725 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -19,6 +19,7 @@ import { IMenuService } from '../../../../../platform/actions/common/actions.js' import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; @@ -27,12 +28,14 @@ import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/ import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IAutomationRun } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; import { IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { ChatAutomationsEnabledContext } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { IPreferencesService, IOpenSettingsOptions } from '../../../../../workbench/services/preferences/common/preferences.js'; import { AgentMergeSessionState } from '../../../../../platform/agentHost/common/agentMerge.js'; import { getSessionChatDragData, isSessionChatDrag, SessionsDataTransfers } from '../../../../browser/dnd.js'; import { IsPhoneLayoutContext } from '../../../../common/contextkeys.js'; import { IAgentHostSessionsProvider, LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; import { ICustomViewService } from '../../../../services/customView/browser/customViewService.js'; +import type { ICustomViewDescriptor } from '../../../../services/customView/browser/customView.js'; import { ISessionsListModelService } from '../../../../services/sessions/browser/sessionsListModelService.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { ChatInteractivity, ChatOriginKind, IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; @@ -45,6 +48,8 @@ import { getSessionSummaryHoverData } from '../../browser/sessionHoverContent.js import { createListHarness, createTestSession } from './sessionsListTestUtils.js'; import '../../browser/views/sessionsViewActions.js'; import { computePullRequestIcon, GitHubPullRequestState } from '../../../github/common/types.js'; +import { AUTOMATIONS_CUSTOM_VIEW_ID } from '../../browser/automationsConstants.js'; +import { AUTOMATIONS_NEW_BADGE_STYLE_SETTING } from '../../browser/automationsNewBadge.js'; function createSession(id: string, opts: { workspaceLabel?: string; @@ -119,6 +124,7 @@ suite('Sessions - SessionsList', () => { contextKeyService, automationService, constObservable([]), + constObservable(undefined), new class extends mock<IUriIdentityService>() { override readonly extUri = new ExtUri(() => true); }, @@ -171,6 +177,7 @@ suite('Sessions - SessionsList', () => { contextKeyService, automationService, constObservable([]), + constObservable(undefined), new class extends mock<IUriIdentityService>() { override readonly extUri = new ExtUri(() => true); }, @@ -201,6 +208,106 @@ suite('Sessions - SessionsList', () => { }); }); + test('renders the new badge only on the Automations section when templates are recycled', () => { + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stubInstance(MenuWorkbenchToolBar, new class extends mock<MenuWorkbenchToolBar>() { + override set context(_context: unknown) { } + override dispose(): void { } + }); + instantiationService.stub(IAccessibilityService, new class extends TestAccessibilityService { + override isMotionReduced(): boolean { return false; } + }()); + instantiationService.stub(ISessionsListModelService, new class extends mock<ISessionsListModelService>() { }); + const contextKeyService = disposables.add(new ContextKeyService(new TestConfigurationService())); + const automationService = new class extends mock<IAutomationService>() { + override readonly runs = constObservable<readonly IAutomationRun[]>([]); + }; + const renderer = new SessionSectionRenderer( + true, + () => { }, + instantiationService, + contextKeyService, + automationService, + constObservable([]), + constObservable('outline'), + new class extends mock<IUriIdentityService>() { + override readonly extUri = new ExtUri(() => true); + }, + new class extends mock<ICustomViewService>() { + override readonly activeCustomView = constObservable(undefined); + }, + new class extends mock<IMenuService>() { }, + ); + const container = document.createElement('div'); + const template = renderer.renderTemplate(container); + disposables.add(template.disposables); + + renderer.renderElement(upcastPartial<Parameters<SessionSectionRenderer['renderElement']>[0]>({ + element: { id: 'automations', label: 'Automations', sessions: [] }, + collapsible: false, + collapsed: false, + }), 0, template); + const automationSnapshot = { + text: template.newBadge.textContent, + display: template.newBadge.style.display, + ariaHidden: template.newBadge.getAttribute('aria-hidden'), + }; + + renderer.renderElement(upcastPartial<Parameters<SessionSectionRenderer['renderElement']>[0]>({ + element: { id: 'workspace:test', label: 'Test', sessions: [] }, + collapsible: true, + collapsed: false, + }), 0, template); + + assert.deepStrictEqual({ + automationSnapshot, + recycledDisplay: template.newBadge.style.display, + recycledShortcutClass: template.container.classList.contains('session-section-shortcut'), + }, { + automationSnapshot: { + text: 'New', + display: 'inline-flex', + ariaHidden: 'true', + }, + recycledDisplay: 'none', + recycledShortcutClass: false, + }); + }); + + test('updates the Automations row accessible label when the new badge is dismissed', () => { + const activeCustomView = observableValue<ICustomViewDescriptor | undefined>(disposables, undefined); + const harness = createListHarness(disposables, [], instantiationService => { + ChatAutomationsEnabledContext.bindTo(instantiationService.get(IContextKeyService)).set(true); + void (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(AUTOMATIONS_NEW_BADGE_STYLE_SETTING, 'outline'); + instantiationService.stub(IAutomationService, new class extends mock<IAutomationService>() { + override readonly automations = constObservable([]); + override readonly runs = constObservable([]); + }); + instantiationService.stub(ICustomViewService, new class extends mock<ICustomViewService>() { + override readonly activeCustomView = activeCustomView; + }); + }); + const container = harness.createContainer(); + const list = harness.store.add(harness.instantiationService.createInstance(SessionsList, container, { + grouping: () => SessionsGrouping.Date, + sorting: () => SessionsSorting.Created, + onSessionOpen: () => { }, + })); + list.layout(300, 400); + const row = container.querySelector<HTMLElement>('.monaco-list-row'); + const before = row?.getAttribute('aria-label'); + + activeCustomView.set(upcastPartial<ICustomViewDescriptor>({ id: AUTOMATIONS_CUSTOM_VIEW_ID }), undefined); + + assert.deepStrictEqual({ + before, + after: row?.getAttribute('aria-label'), + }, { + before: 'Automations, new feature', + after: 'Automations', + }); + }); + test('derives terminal automation status from the supplied session snapshot', () => { const session = createSession('automation', { isRead: false, @@ -234,6 +341,7 @@ suite('Sessions - SessionsList', () => { new class extends mock<IContextKeyService>() { }, automationService, automationSessions, + constObservable(undefined), uriIdentityService, new class extends mock<ICustomViewService>() { }, new class extends mock<IMenuService>() { }, @@ -301,6 +409,7 @@ suite('Sessions - SessionsList', () => { new class extends mock<IContextKeyService>() { }, automationService, constObservable([runningSession, needsInputSession]), + constObservable(undefined), uriIdentityService, new class extends mock<ICustomViewService>() { }, new class extends mock<IMenuService>() { }, diff --git a/src/vs/sessions/contrib/terminal/browser/agentHostSessionTaskRunner.ts b/src/vs/sessions/contrib/terminal/browser/agentHostSessionTaskRunner.ts index 8e190ac3315f60..a7cb0b3938143c 100644 --- a/src/vs/sessions/contrib/terminal/browser/agentHostSessionTaskRunner.ts +++ b/src/vs/sessions/contrib/terminal/browser/agentHostSessionTaskRunner.ts @@ -53,10 +53,13 @@ export class AgentHostSessionTaskRunner implements ISessionTaskRunner { ) { } canRun(session: ISession): boolean { - return this._getAddress(session) !== undefined; + return this._isSessionRemoteHostAvailable(session) && this._getAddress(session) !== undefined; } async runTask(task: ITaskEntry, session: ISession): Promise<IDisposable | undefined> { + if (!this._isSessionRemoteHostAvailable(session)) { + return undefined; + } const address = this._getAddress(session); if (!address) { return undefined; @@ -81,6 +84,9 @@ export class AgentHostSessionTaskRunner implements ISessionTaskRunner { return undefined; } + if (!this._isSessionRemoteHostAvailable(session)) { + return undefined; + } const instance = await this._agentHostTerminalService.createTerminalForEntry(address, { cwd, name: localize('agentHostSessionTaskTerminalName', "Task: {0}", task.label), @@ -107,6 +113,11 @@ export class AgentHostSessionTaskRunner implements ISessionTaskRunner { return provider.remoteAddress ?? LOCAL_AGENT_HOST_ADDRESS; } + private _isSessionRemoteHostAvailable(session: ISession): boolean { + const status = session.remoteConnectionStatus?.get(); + return status === undefined || status.kind === 'connected'; + } + private _getCwd(session: ISession): URI | undefined { const folder = session.workspace.get()?.folders[0]; const cwd = folder?.workingDirectory ?? folder?.root; diff --git a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts index 12a18d43949b6e..59062c64b91cb1 100644 --- a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts +++ b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts @@ -150,7 +150,9 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben // This is a little hacky but I don't see any better approach. this._register(autorun(reader => { const session = this._sessionsService.activeSession.read(reader); - if (session?.loading.read(reader) || session?.isArchived.read(reader) || session?.worktreePending?.read(reader)) { + const remoteConnectionStatus = session?.remoteConnectionStatus?.read(reader); + const remoteHostAvailable = remoteConnectionStatus === undefined || remoteConnectionStatus.kind === 'connected'; + if (session?.loading.read(reader) || session?.isArchived.read(reader) || session?.worktreePending?.read(reader) || !remoteHostAvailable) { this._agentHostTerminalService.setDefaultCwd(undefined); return; } @@ -163,15 +165,23 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben const session = this._sessionsService.activeSession.read(reader); const isArchived = session?.isArchived.read(reader); const worktreePending = session?.worktreePending?.read(reader); + const remoteConnectionStatus = session?.remoteConnectionStatus?.read(reader); + const remoteHostAvailable = remoteConnectionStatus === undefined || remoteConnectionStatus.kind === 'connected'; + const remoteHostPermanentlyUnavailable = remoteConnectionStatus?.kind === 'disconnected' || remoteConnectionStatus?.kind === 'incompatible'; + const preserveActiveTerminalState = !remoteHostPermanentlyUnavailable + && !remoteHostAvailable + && this._activeSessionId === session?.sessionId; if (session && !isArchived && this._archivedSessionIds.delete(session.sessionId)) { this._invalidateTerminalOperations(session.sessionId); } - if (session?.loading.read(reader) || isArchived || worktreePending) { - if (session && (isArchived || worktreePending)) { + if (session?.loading.read(reader) || isArchived || worktreePending || !remoteHostAvailable) { + if (session && (isArchived || worktreePending || !remoteHostAvailable)) { this._invalidateTerminalOperations(session.sessionId); } - this._activeKey = undefined; - this._activeSessionId = undefined; + if (!preserveActiveTerminalState) { + this._activeKey = undefined; + this._activeSessionId = undefined; + } return; } this._onActiveSessionChanged(session); @@ -287,6 +297,9 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben if (!session) { return this._ensureTerminal(cwd, focus, session); } + if (!this._isSessionRemoteHostAvailable(session)) { + return []; + } const generation = this._getTerminalOperationGeneration(session.sessionId); this._beginTerminalOperation(session.sessionId); @@ -350,7 +363,13 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben || this._getTerminalOperationGeneration(session.sessionId) !== generation || this._archivedSessionIds.has(session.sessionId) || session.isArchived.get() - || session.worktreePending?.get() === true; + || session.worktreePending?.get() === true + || !this._isSessionRemoteHostAvailable(session); + } + + private _isSessionRemoteHostAvailable(session: ISession): boolean { + const status = session.remoteConnectionStatus?.get(); + return status === undefined || status.kind === 'connected'; } private _getTerminalOperationGeneration(sessionId: string): number { diff --git a/src/vs/sessions/contrib/terminal/test/browser/agentHostSessionTaskRunner.test.ts b/src/vs/sessions/contrib/terminal/test/browser/agentHostSessionTaskRunner.test.ts index 9c1b735a0597f6..017bf7a762ac5a 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/agentHostSessionTaskRunner.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/agentHostSessionTaskRunner.test.ts @@ -19,7 +19,7 @@ import { IAgentHostTerminalCreateOptions, IAgentHostTerminalService } from '../. import { ITerminalGroupService, ITerminalInstance, ITerminalService } from '../../../../../workbench/contrib/terminal/browser/terminal.js'; import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { IAgentHostSessionsProvider, LOCAL_AGENT_HOST_PROVIDER_ID, REMOTE_AGENT_HOST_PROVIDER_PREFIX } from '../../../../common/agentHostSessionsProvider.js'; -import { IChat, ISession, ISessionFolder, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { IChat, ISession, ISessionFolder, ISessionWorkspace, SessionRemoteConnectionFailureReason, SessionRemoteConnectionStatus, SessionStatus } from '../../../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { IConfigurationResolverService } from '../../../../../workbench/services/configurationResolver/common/configurationResolver.js'; import { IWorkspaceFolderData } from '../../../../../platform/workspace/common/workspace.js'; @@ -27,7 +27,7 @@ import { ITaskEntry, ISessionsTasksService, ISessionTaskWithTarget } from '../.. import { osToTaskTargetOS } from '../../../chat/browser/taskCommand.js'; import { AgentHostSessionTaskRunner } from '../../browser/agentHostSessionTaskRunner.js'; -function makeSession(opts: { providerId: string; cwd?: URI }): ISession { +function makeSession(opts: { providerId: string; cwd?: URI; remoteConnectionStatus?: SessionRemoteConnectionStatus }): ISession { const folder: ISessionFolder | undefined = opts.cwd ? { root: opts.cwd, workingDirectory: opts.cwd, @@ -60,6 +60,7 @@ function makeSession(opts: { providerId: string; cwd?: URI }): ISession { modelId: observableValue('modelId', undefined), mode: observableValue('mode', undefined), loading: observableValue('loading', false), + ...(opts.remoteConnectionStatus ? { remoteConnectionStatus: observableValue('remoteConnectionStatus', opts.remoteConnectionStatus) } : {}), isArchived: observableValue('isArchived', false), isRead: observableValue('isRead', true), lastTurnEnd: observableValue('lastTurnEnd', undefined), @@ -163,6 +164,26 @@ suite('AgentHostSessionTaskRunner', () => { assert.strictEqual(runner.canRun(makeSession({ providerId: 'agenthost-myhost' })), true); }); + test('does not run tasks for an unavailable remote agent host', async () => { + const session = makeSession({ + providerId: 'agenthost-myhost', + cwd: toAgentHostUri(URI.file('/remote/worktree'), 'remote-agenthost-myhost'), + remoteConnectionStatus: { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }, + }); + + const handle = await runner.runTask(shellTask(), session); + + assert.deepStrictEqual({ + canRun: runner.canRun(session), + handle, + createdTerminals, + }, { + canRun: false, + handle: undefined, + createdTerminals: [], + }); + }); + test('local agent-host sessions pass through file: cwd', async () => { const cwd = URI.parse('file:///path/to/worktree'); const session = makeSession({ providerId: LOCAL_AGENT_HOST_PROVIDER_ID, cwd }); diff --git a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts index b2d1cbc17ce85a..a2ca55ad79d697 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts @@ -20,7 +20,7 @@ import { ITerminalInstance, ITerminalService } from '../../../../../workbench/co import { ITerminalCapabilityStore, ICommandDetectionCapability, TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; import { toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; import { AgentSessionProviders } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; -import { ChatInteractivity, IChat, ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; +import { ChatInteractivity, IChat, ISession, ISessionWorkspace, SessionRemoteConnectionFailureReason, SessionRemoteConnectionStatus } from '../../../../services/sessions/common/session.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { SessionsTerminalContribution } from '../../browser/sessionsTerminalContribution.js'; import { TestPathService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; @@ -59,6 +59,7 @@ type TestActiveSession = IActiveSession & { loading: ReturnType<typeof observableValue<boolean>>; isArchived: ReturnType<typeof observableValue<boolean>>; worktreePending: ReturnType<typeof observableValue<boolean>>; + remoteConnectionStatus?: ReturnType<typeof observableValue<SessionRemoteConnectionStatus>>; }; function makeAgentSession(opts: { @@ -70,6 +71,7 @@ function makeAgentSession(opts: { worktreePending?: boolean; sessionId?: string; providerId?: string; + remoteConnectionStatus?: SessionRemoteConnectionStatus; }): TestActiveSession { const folder = opts.repository || opts.worktree ? { root: opts.repository ?? opts.worktree!, @@ -121,6 +123,7 @@ function makeAgentSession(opts: { mode: chat.mode, loading: observableValue('test.loading', opts.loading ?? false), worktreePending: observableValue('test.worktreePending', opts.worktreePending ?? false), + ...(opts.remoteConnectionStatus ? { remoteConnectionStatus: observableValue('test.remoteConnectionStatus', opts.remoteConnectionStatus) } : {}), isArchived: chat.isArchived, isRead: chat.isRead, lastTurnEnd: chat.lastTurnEnd, @@ -572,6 +575,83 @@ suite('SessionsTerminalContribution', () => { assert.deepStrictEqual(createdTerminals.map(terminal => terminal.cwd.fsPath), [worktreeUri.fsPath]); }); + test('defers remote terminal creation until the host connects', async () => { + const worktreeUri = URI.file('/remote/worktree'); + sessionProviders.set('agenthost-test', { id: 'agenthost-test', remoteAddress: 'remote-test' } as unknown as ISessionsProvider); + const session = makeAgentSession({ + providerId: 'agenthost-test', + providerType: AgentSessionProviders.Background, + worktree: toAgentHostUri(worktreeUri, 'remote-test'), + remoteConnectionStatus: { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }, + }); + + activeSessionObs.set(session, undefined); + await tick(); + session.remoteConnectionStatus!.set({ kind: 'incompatible' }, undefined); + await tick(); + session.remoteConnectionStatus!.set({ kind: 'connecting' }, undefined); + await tick(); + const ensured = await contribution.ensureTerminal(worktreeUri, false, session); + + assert.deepStrictEqual({ + created: createdTerminals, + defaultCwd: defaultCwdCalls.at(-1), + ensured, + }, { + created: [], + defaultCwd: undefined, + ensured: [], + }); + + session.remoteConnectionStatus!.set({ kind: 'connected' }, undefined); + await tick(); + + assert.deepStrictEqual({ + created: createdTerminals.map(terminal => terminal.cwd.path), + addresses: agentHostTerminalAddresses, + defaultCwd: defaultCwdCalls.at(-1)?.path, + }, { + created: [worktreeUri.path], + addresses: ['remote-test'], + defaultCwd: worktreeUri.path, + }); + }); + + test('keeps an existing remote terminal during a transient reconnect', async () => { + const worktreeUri = URI.file('/remote/worktree'); + sessionProviders.set('agenthost-test', { id: 'agenthost-test', remoteAddress: 'remote-test' } as unknown as ISessionsProvider); + const session = makeAgentSession({ + providerId: 'agenthost-test', + providerType: AgentSessionProviders.Background, + worktree: toAgentHostUri(worktreeUri, 'remote-test'), + remoteConnectionStatus: { kind: 'connected' }, + }); + + activeSessionObs.set(session, undefined); + await tick(); + session.remoteConnectionStatus!.set({ kind: 'reconnecting' }, undefined); + await tick(); + const duringReconnect = { + created: createdTerminals.map(terminal => terminal.cwd.path), + defaultCwd: defaultCwdCalls.at(-1), + }; + session.remoteConnectionStatus!.set({ kind: 'connected' }, undefined); + await tick(); + + assert.deepStrictEqual({ + duringReconnect, + created: createdTerminals.map(terminal => terminal.cwd.path), + defaultCwd: defaultCwdCalls.at(-1)?.path, + }, { + duringReconnect: { + created: [worktreeUri.path], + defaultCwd: undefined, + }, + created: [worktreeUri.path], + defaultCwd: worktreeUri.path, + }); + }); + test('disposes terminal creation that becomes stale while the worktree is pending', async () => { const worktreeUri = URI.file('/worktree'); const session = makeAgentSession({ diff --git a/src/vs/sessions/services/sessions/browser/sessionsService.ts b/src/vs/sessions/services/sessions/browser/sessionsService.ts index 9896b563f6a5fb..72b52483b43239 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsService.ts @@ -5,6 +5,7 @@ import { disposableTimeout, raceTimeout } from '../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { onUnexpectedError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; @@ -49,6 +50,8 @@ const RESTORE_SESSION_WAIT_TIMEOUT = 30_000; /** Maximum number of recently opened sessions reported by {@link SessionsService.getRecentlyOpenedSessions}. */ const MAX_RECENTLY_OPENED_SESSIONS = 10; +type SessionNavigationIntent = 'explicit' | 'automatic'; + /** * Options for {@link ISessionsService.openNewSession}. */ @@ -543,12 +546,12 @@ export class SessionsService extends Disposable implements ISessionsService { const isArchived = activeSession.isArchived.read(reader); if (isArchived && !wasArchived) { if (activeSession.isQuickChat?.read(undefined)) { - this.openQuickChat(); + this._openQuickChat(undefined, 'automatic'); } else { const folderUri = activeSession.workspace.read(undefined)?.folders[0]?.root; - this.openNewSession(folderUri + void this._openNewSession(folderUri ? { folderUri, ...inheritableSessionTarget(this.sessionsManagementService, activeSession, folderUri) } - : undefined); + : undefined, CancellationToken.None, 'automatic').catch(onUnexpectedError); } } wasArchived = isArchived; @@ -564,7 +567,7 @@ export class SessionsService extends Disposable implements ISessionsService { const visible = chats.filter(c => c.interactivity.read(reader) !== ChatInteractivity.Hidden); const fallback = visible[visible.length - 1] ?? activeSession.mainChat.read(reader); if (fallback) { - this.openChat(activeSession, fallback.resource); + void this._openChatSession(activeSession, fallback.resource, undefined, 'automatic').catch(onUnexpectedError); } } })); @@ -636,9 +639,9 @@ export class SessionsService extends Disposable implements ISessionsService { if (e.removed.length && e.removed.some(r => r.sessionId === currentActive.sessionId)) { const fallback = this._visibility.activeSession.get(); if (fallback && this.sessionsManagementService.getSession(fallback.resource)) { - this.openSession(fallback.resource, { source: 'fallback' }); + void this._openSession(fallback.resource, { source: 'fallback' }, 'automatic').catch(onUnexpectedError); } else { - this.openNewSession(); + void this._openNewSession(undefined, CancellationToken.None, 'automatic').catch(onUnexpectedError); } } } @@ -701,16 +704,18 @@ export class SessionsService extends Disposable implements ISessionsService { * Cancel any in-flight open-session/restore and return a fresh cancellation token. */ private _startOpenSession(): CancellationToken { - // Opening a session is the gesture that dismisses a custom view; the - // workbench then restores the sessions grid and its side panel state. - this.customViewService.hideCustomView(); - this._openSessionCts.value?.cancel(); const cts = new CancellationTokenSource(); this._openSessionCts.value = cts; return cts.token; } + private _dismissCustomViewForNavigation(intent: SessionNavigationIntent): void { + if (intent === 'explicit') { + this.customViewService.hideCustomView(); + } + } + /** * Cancel an in-flight {@link restoreVisibleSessions}. Called when the user * explicitly navigates to a specific session, so restore stops fighting @@ -734,9 +739,14 @@ export class SessionsService extends Disposable implements ISessionsService { return this._visibility.setActive(session, preserveFocus); } - async openChat(session: ISession, chatUri: URI, options?: IOpenSessionOptions): Promise<void> { + openChat(session: ISession, chatUri: URI, options?: IOpenSessionOptions): Promise<void> { + return this._openChatSession(session, chatUri, options, 'explicit'); + } + + private async _openChatSession(session: ISession, chatUri: URI, options: IOpenSessionOptions | undefined, intent: SessionNavigationIntent): Promise<void> { const t0 = Date.now(); this._cancelRestore(); + this._dismissCustomViewForNavigation(intent); const token = this._startOpenSession(); // Redirect a superseded resource (e.g. a legacy session adopted into another // provider) before activating, the same way `openSession` does for a URI, so @@ -846,12 +856,17 @@ export class SessionsService extends Disposable implements ISessionsService { }); } - async openSession(sessionResource: URI, options?: IOpenSessionOptions): Promise<void> { + openSession(sessionResource: URI, options?: IOpenSessionOptions): Promise<void> { + return this._openSession(sessionResource, options, 'explicit'); + } + + private async _openSession(sessionResource: URI, options: IOpenSessionOptions | undefined, intent: SessionNavigationIntent): Promise<void> { this.logService.trace(`[SessionsView] openSession requested uri=${sessionResource.toString()}`); // Claim the open before resolving: resolution can take seconds for a legacy // Copilot CLI resource, and a newer open must win regardless of which // resolution finishes first. this._cancelRestore(); + this._dismissCustomViewForNavigation(intent); const token = this._startOpenSession(); await this.sessionOpenTelemetryService.withOpenRequest(options?.source ?? 'unknown', token, async telemetryAttempt => { // Redirect a superseded resource (legacy session adopted into another @@ -877,6 +892,7 @@ export class SessionsService extends Disposable implements ISessionsService { showSession(sessionResource: URI, options?: { preserveFocus?: boolean }): void { this._cancelRestore(); + this._dismissCustomViewForNavigation('explicit'); this._startOpenSession(); this._showSession(this._getSession(sessionResource), options); } @@ -925,6 +941,7 @@ export class SessionsService extends Disposable implements ISessionsService { } async openSessionToSide(session: ISession, options?: IOpenSessionOptions & { chatResource?: URI }): Promise<void> { + this._dismissCustomViewForNavigation('explicit'); const token = this._startOpenSession(); // Redirect a superseded resource before inserting a slot, so the side-by-side // view/terminal never briefly binds to the old facade. @@ -955,6 +972,7 @@ export class SessionsService extends Disposable implements ISessionsService { * `resolveSessionResource` hook, not a scheme check. */ async openChatToSide(session: ISession, chatResource: URI, options?: { preserveFocus?: boolean }): Promise<void> { + this._dismissCustomViewForNavigation('explicit'); const token = this._startOpenSession(); const resolved = await this._resolveSessionForOpen(session, chatResource); if (token.isCancellationRequested) { @@ -1027,7 +1045,11 @@ export class SessionsService extends Disposable implements ISessionsService { this._activate(undefined); } - async openNewSession(options?: IOpenNewSessionOptions, token: CancellationToken = CancellationToken.None): Promise<IOpenNewSessionResult> { + openNewSession(options?: IOpenNewSessionOptions, token: CancellationToken = CancellationToken.None): Promise<IOpenNewSessionResult> { + return this._openNewSession(options, token, 'explicit'); + } + + private async _openNewSession(options: IOpenNewSessionOptions | undefined, token: CancellationToken, intent: SessionNavigationIntent): Promise<IOpenNewSessionResult> { const folderUri = options?.folderUri; if (folderUri) { // Single trust gate for every path that creates a concrete session for @@ -1054,6 +1076,7 @@ export class SessionsService extends Disposable implements ISessionsService { if (token.isCancellationRequested) { return { session: undefined, trustDeclined: false }; } + this._dismissCustomViewForNavigation(intent); this._startOpenSession(); try { const session = this.sessionsManagementService.createNewSession(folderUri, options); @@ -1070,6 +1093,9 @@ export class SessionsService extends Disposable implements ISessionsService { // Without a folder (or when folder resolution failed above): switch to // the new-session composer view. // No-op when no session is active (empty new-session placeholder showing). + if (!folderUri) { + this._dismissCustomViewForNavigation(intent); + } if (this._visibility.activeSession.get() === undefined) { return { session: undefined, trustDeclined: false }; } @@ -1096,6 +1122,11 @@ export class SessionsService extends Disposable implements ISessionsService { } openQuickChat(options?: ICreateNewSessionOptions): IActiveSession | undefined { + return this._openQuickChat(options, 'explicit'); + } + + private _openQuickChat(options: ICreateNewSessionOptions | undefined, intent: SessionNavigationIntent): IActiveSession | undefined { + this._dismissCustomViewForNavigation(intent); this._startOpenSession(); try { const session = this.sessionsManagementService.createQuickChat(options); @@ -1110,6 +1141,7 @@ export class SessionsService extends Disposable implements ISessionsService { async openNewChatInSession(session: ISession, options?: ICreateNewChatInSessionOptions): Promise<void> { this._cancelRestore(); + this._dismissCustomViewForNavigation('explicit'); this._startOpenSession(); const chat = await this.sessionsManagementService.createNewChatInSession(session, options); if (!chat) { diff --git a/src/vs/sessions/services/sessions/browser/visibleSessions.ts b/src/vs/sessions/services/sessions/browser/visibleSessions.ts index 8fad58ef39b396..7cf101bcd319ac 100644 --- a/src/vs/sessions/services/sessions/browser/visibleSessions.ts +++ b/src/vs/sessions/services/sessions/browser/visibleSessions.ts @@ -243,6 +243,7 @@ export class VisibleSession extends Disposable implements IActiveSession { get isQuickChat() { return this._session.isQuickChat; } get isAutomation() { return this._session.isAutomation; } get isExternal() { return this._session.isExternal; } + get remoteConnectionStatus() { return this._session.remoteConnectionStatus; } get createdBySession() { return this._session.createdBySession; } get title() { return this._session.title; } get updatedAt() { return this._session.updatedAt; } @@ -294,6 +295,7 @@ class ResourceOverrideSession implements ISession { get isQuickChat() { return this._session.isQuickChat; } get isAutomation() { return this._session.isAutomation; } get isExternal() { return this._session.isExternal; } + get remoteConnectionStatus() { return this._session.remoteConnectionStatus; } get createdBySession() { return this._session.createdBySession; } get title() { return this._session.title; } get updatedAt() { return this._session.updatedAt; } diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index e18e23c2dfa465..7002c36acdef20 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -81,6 +81,22 @@ export const enum SessionStatus { Error = 4, } +/** + * Connection state of the remote agent host backing a session. + */ +export type SessionRemoteConnectionStatus = + | { readonly kind: 'connected' } + | { readonly kind: 'connecting' } + | { readonly kind: 'reconnecting'; readonly nextAttemptAt?: number } + | { readonly kind: 'disconnected'; readonly reason: SessionRemoteConnectionFailureReason } + | { readonly kind: 'incompatible' }; + +/** Machine-readable reasons a remote session's host is disconnected. */ +export const enum SessionRemoteConnectionFailureReason { + Unknown = 'unknown', + HostNotRunning = 'hostNotRunning', +} + /** Whether a session still has active work, including work blocked on user input. */ export function isActiveSessionStatus(status: SessionStatus): boolean { return status === SessionStatus.InProgress || status === SessionStatus.NeedsInput; @@ -753,6 +769,8 @@ export interface ISession { readonly isAutomation?: IObservable<boolean>; /** Whether this session was discovered in an application other than the current host. Absent means `false`. */ readonly isExternal?: IObservable<boolean>; + /** Connection state of the backing remote host. Absent when the session has no remote host. */ + readonly remoteConnectionStatus?: IObservable<SessionRemoteConnectionStatus>; /** Session turn that created this session, when it was created by another agent session. */ readonly createdBySession?: IObservable<ISessionCreationReference | undefined>; diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 629d72cae84c73..05d954673f50d1 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -17,6 +17,7 @@ import { mock } from '../../../../../base/test/common/mock.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; @@ -41,6 +42,7 @@ import { ISessionsManagementService, ICreateNewSessionOptions, inheritableSessio import { SessionsService } from '../../browser/sessionsService.js'; import { ISessionOpenTelemetryService, SessionOpenTelemetryService } from '../../browser/sessionOpenTelemetryService.js'; import { ISessionsPartService } from '../../browser/sessionsPartService.js'; +import { AbstractCustomView } from '../../../customView/browser/customView.js'; import { CustomViewService, ICustomViewService } from '../../../customView/browser/customViewService.js'; import { ISessionsProvidersService } from '../../browser/sessionsProvidersService.js'; import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; @@ -228,12 +230,13 @@ function createSessionsManagementService( workspaceTrustManagementService = new TestWorkspaceTrustManagementService(), workspaceTrustRequestService?: IWorkspaceTrustRequestService, configurationService: IConfigurationService = new TestConfigurationService(), -): { service: ISessionsManagementService; view: SessionsService; chatWidgetService: TestChatWidgetService; chatService: TestChatService; contextKeyService: MockContextKeyService } { +): { service: ISessionsManagementService; view: SessionsService; chatWidgetService: TestChatWidgetService; chatService: TestChatService; contextKeyService: MockContextKeyService; customViewService: ICustomViewService } { const instantiationService = disposables.add(new TestInstantiationService()); const chatWidgetService = new TestChatWidgetService(); const chatService = disposables.add(new TestChatService()); const providers = Array.isArray(provider) ? provider : [provider]; const contextKeyService = disposables.add(new MockContextKeyService()); + const customViewService = disposables.add(new CustomViewService(new NullLogService(), disposables.add(new InMemoryStorageService()))); instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService())); instantiationService.stub(ILogService, new NullLogService()); @@ -253,8 +256,8 @@ function createSessionsManagementService( } const service = disposables.add(instantiationService.createInstance(SessionsManagementService)); - const view = createView(instantiationService, service, disposables); - return { service, view, chatWidgetService, chatService, contextKeyService }; + const view = createView(instantiationService, service, disposables, customViewService); + return { service, view, chatWidgetService, chatService, contextKeyService, customViewService }; } /** @@ -268,15 +271,35 @@ class TestSessionsPartService extends mock<ISessionsPartService>() { override focusSession(): void { } } +class TestCustomView extends AbstractCustomView { + readonly title = constObservable('Test Custom View'); + + render(_container: HTMLElement): void { } + layout(_width: number, _height: number): void { } +} + +function showTestCustomView(customViewService: ICustomViewService, disposables: ReturnType<typeof ensureNoDisposablesAreLeakedInTestSuite>): void { + disposables.add(customViewService.registerCustomView({ + id: 'test.customView', + ctor: new SyncDescriptor(TestCustomView), + })); + customViewService.showCustomView('test.customView'); +} + /** * Builds a {@link SessionsService} over an already-created management * service, stubbing the management service instance and a passive part so the * view's opening/restore/visible-session behaviour can be tested. */ -function createView(instantiationService: TestInstantiationService, service: ISessionsManagementService, disposables: ReturnType<typeof ensureNoDisposablesAreLeakedInTestSuite>): SessionsService { +function createView( + instantiationService: TestInstantiationService, + service: ISessionsManagementService, + disposables: ReturnType<typeof ensureNoDisposablesAreLeakedInTestSuite>, + customViewService: ICustomViewService = disposables.add(new CustomViewService(new NullLogService(), disposables.add(new InMemoryStorageService()))), +): SessionsService { instantiationService.stub(ISessionsManagementService, service); instantiationService.stub(ISessionsPartService, new TestSessionsPartService()); - instantiationService.stub(ICustomViewService, disposables.add(new CustomViewService(new NullLogService(), disposables.add(new InMemoryStorageService())))); + instantiationService.stub(ICustomViewService, customViewService); instantiationService.stub(IConfigurationService, new TestConfigurationService()); instantiationService.stub(ISessionOpenTelemetryService, disposables.add(new SessionOpenTelemetryService(NullTelemetryService))); return disposables.add(instantiationService.createInstance(SessionsService)); @@ -391,6 +414,163 @@ suite('SessionsManagementService', () => { ); }); + test('archiving the active session keeps the custom view open', async () => { + const isArchived = observableValue('isArchived', false); + const session = stubSession({ sessionId: 'active', providerId: 'test', isArchived }); + const { customViewService, view } = createSessionsManagementService(session, disposables); + + await view.openSession(session.resource); + showTestCustomView(customViewService, disposables); + isArchived.set(true, undefined); + await timeout(0); + + assert.deepStrictEqual({ + activeCustomView: customViewService.activeCustomView.get()?.id, + activeSession: view.activeSession.get()?.sessionId, + }, { + activeCustomView: 'test.customView', + activeSession: undefined, + }); + }); + + test('automatic no-fallback removal keeps the custom view open', async () => { + const session = stubSession({ sessionId: 'active', providerId: 'test' }); + const onDidChangeSessions = disposables.add(new Emitter<ISessionChangeEvent>()); + const provider = new class extends TestSessionsProvider { + override readonly onDidChangeSessions = onDidChangeSessions.event; + }(session); + const { customViewService, view } = createSessionsManagementService(session, disposables, provider); + + await view.openSession(session.resource); + showTestCustomView(customViewService, disposables); + onDidChangeSessions.fire({ added: [], removed: [session], changed: [] }); + await timeout(0); + + assert.deepStrictEqual({ + activeCustomView: customViewService.activeCustomView.get()?.id, + activeSession: view.activeSession.get()?.sessionId, + }, { + activeCustomView: 'test.customView', + activeSession: undefined, + }); + }); + + test('automatic no-fallback removal stays inert during pending explicit navigation', async () => { + const active = stubSession({ sessionId: 'active', providerId: 'test' }); + const target = stubSession({ sessionId: 'target', providerId: 'test' }); + const onDidChangeSessions = disposables.add(new Emitter<ISessionChangeEvent>()); + const resolutionStarted = new DeferredPromise<void>(); + const targetResolution = new DeferredPromise<URI | undefined>(); + const provider = new class extends TestSessionsProvider { + override readonly onDidChangeSessions = onDidChangeSessions.event; + override getSessions(): ISession[] { return [active, target]; } + override resolveSessionResource(resource: URI): Promise<URI | undefined> { + if (extUriBiasedIgnorePathCase.isEqual(resource, target.resource)) { + resolutionStarted.complete(); + return targetResolution.p; + } + return Promise.resolve(undefined); + } + }(active); + const { view } = createSessionsManagementService(active, disposables, provider); + + await view.openSession(active.resource); + const openTarget = view.openSession(target.resource); + await resolutionStarted.p; + onDidChangeSessions.fire({ added: [], removed: [active], changed: [] }); + targetResolution.complete(undefined); + await openTarget; + + assert.strictEqual(view.activeSession.get()?.sessionId, 'target'); + }); + + test('removing the active session with a fallback keeps the custom view open', async () => { + const fallback = stubSession({ sessionId: 'fallback', providerId: 'test' }); + const active = stubSession({ sessionId: 'active', providerId: 'test' }); + const onDidChangeSessions = disposables.add(new Emitter<ISessionChangeEvent>()); + const provider = new class extends TestSessionsProvider { + override readonly onDidChangeSessions = onDidChangeSessions.event; + override getSessions(): ISession[] { return [fallback, active]; } + }(active); + const { customViewService, view } = createSessionsManagementService(active, disposables, provider); + + await view.openSession(fallback.resource); + await view.openSessionToSide(active); + showTestCustomView(customViewService, disposables); + onDidChangeSessions.fire({ added: [], removed: [active], changed: [] }); + await timeout(0); + + assert.deepStrictEqual({ + activeCustomView: customViewService.activeCustomView.get()?.id, + activeSession: view.activeSession.get()?.sessionId, + }, { + activeCustomView: 'test.customView', + activeSession: 'fallback', + }); + }); + + test('removing the active chat keeps the custom view open', async () => { + const sideChat: IChat = { + ...stubChat, + resource: URI.parse('test:///side-chat'), + title: constObservable('Side Chat'), + status: constObservable(SessionStatus.Completed), + }; + const chats = observableValue<readonly IChat[]>('chats', [stubChat, sideChat]); + const session = stubSession({ + sessionId: 'active', + providerId: 'test', + status: constObservable(SessionStatus.Completed), + chats, + }); + const { customViewService, view } = createSessionsManagementService(session, disposables); + + await view.openChat(session, sideChat.resource); + showTestCustomView(customViewService, disposables); + chats.set([stubChat], undefined); + await timeout(0); + + assert.deepStrictEqual({ + activeCustomView: customViewService.activeCustomView.get()?.id, + activeChat: view.activeSession.get()?.activeChat.get()?.resource.toString(), + }, { + activeCustomView: 'test.customView', + activeChat: stubChat.resource.toString(), + }); + }); + + test('explicit session navigation dismisses the custom view', async () => { + const isArchived = observableValue('isArchived', false); + const session = stubSession({ sessionId: 'active', providerId: 'test', isArchived }); + const { customViewService, view } = createSessionsManagementService(session, disposables); + + await view.openSession(session.resource); + showTestCustomView(customViewService, disposables); + await view.openNewSession(); + const afterOpenNewSession = customViewService.activeCustomView.get()?.id; + + await view.openSession(session.resource); + customViewService.showCustomView('test.customView'); + isArchived.set(true, undefined); + await timeout(0); + await view.openNewSession(); + const afterOpenNewSessionWithoutActiveSession = customViewService.activeCustomView.get()?.id; + + customViewService.showCustomView('test.customView'); + await view.openSession(session.resource); + const afterOpenSession = customViewService.activeCustomView.get()?.id; + + assert.deepStrictEqual({ + afterOpenNewSession, + afterOpenNewSessionWithoutActiveSession, + afterOpenSession, + }, { + afterOpenNewSession: undefined, + afterOpenNewSessionWithoutActiveSession: undefined, + afterOpenSession: undefined, + }); + }); + test('does not change active session when added session is not displayed in any widget', async () => { const originalSession = stubSession({ sessionId: 'original', providerId: 'test' }); const onDidChangeSessions = disposables.add(new Emitter<ISessionChangeEvent>()); diff --git a/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts b/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts index c00288c359594b..00e80673c05fe1 100644 --- a/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts @@ -12,7 +12,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { VisibleSession, VisibleSessions } from '../../browser/visibleSessions.js'; -import { ChatInteractivity, ChatOriginKind, IChat, ISession, SessionStatus } from '../../common/session.js'; +import { ChatInteractivity, ChatOriginKind, IChat, ISession, SessionRemoteConnectionFailureReason, SessionRemoteConnectionStatus, SessionStatus } from '../../common/session.js'; const stubChat: IChat = { resource: URI.parse('test:///chat'), @@ -88,7 +88,8 @@ suite('VisibleSessions', () => { const hasGitRepository = observableValue('hasGitRepository', false); const completedStateIcon = observableValue('completedStateIcon', Codicon.gitMerge); const isExternal = observableValue('isExternal', true); - const session = { ...stubSession('A'), completedStateIcon, hasGitRepository, isExternal }; + const remoteConnectionStatus = constObservable<SessionRemoteConnectionStatus>({ kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.Unknown }); + const session = { ...stubSession('A'), completedStateIcon, hasGitRepository, isExternal, remoteConnectionStatus }; const model = createModel(); model.setActive(session); const visible = model.activeSession.get(); @@ -101,6 +102,8 @@ suite('VisibleSessions', () => { resourceOverrideCompletedStateIcon: resourceOverride.completedStateIcon === completedStateIcon, visibleExternal: visible?.isExternal === isExternal, resourceOverrideExternal: resourceOverride.isExternal === isExternal, + visibleRemoteConnectionStatus: visible?.remoteConnectionStatus === remoteConnectionStatus, + resourceOverrideRemoteConnectionStatus: resourceOverride.remoteConnectionStatus === remoteConnectionStatus, }, { visible: true, resourceOverride: true, @@ -108,6 +111,8 @@ suite('VisibleSessions', () => { resourceOverrideCompletedStateIcon: true, visibleExternal: true, resourceOverrideExternal: true, + visibleRemoteConnectionStatus: true, + resourceOverrideRemoteConnectionStatus: true, }); }); diff --git a/src/vs/sessions/test/browser/chatGroupsView.test.ts b/src/vs/sessions/test/browser/chatGroupsView.test.ts index 04d4661d597d15..4cc39dd79ef7f3 100644 --- a/src/vs/sessions/test/browser/chatGroupsView.test.ts +++ b/src/vs/sessions/test/browser/chatGroupsView.test.ts @@ -5,27 +5,33 @@ import assert from 'assert'; import { mainWindow } from '../../../base/browser/window.js'; -import { DeferredPromise } from '../../../base/common/async.js'; -import { Event } from '../../../base/common/event.js'; +import { DeferredPromise, timeout } from '../../../base/common/async.js'; +import { errorHandler, setUnexpectedErrorHandler } from '../../../base/common/errors.js'; +import { Emitter, Event } from '../../../base/common/event.js'; import { DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; import { constObservable, derived, IObservable, ISettableObservable, observableValue, transaction } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; import { mock } from '../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../base/test/common/timeTravelScheduler.js'; import { TestInstantiationService } from '../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { workbenchInstantiationService } from '../../../workbench/test/browser/workbenchTestServices.js'; import { AbstractChatView, ChatViewKind } from '../../browser/parts/chatView.js'; import { ChatGroupsView } from '../../browser/parts/chatGroupsView.js'; +import { type IAgentHostAutoConnect, type IAgentHostConnectProgress, IAgentHostSessionsProvider } from '../../common/agentHostSessionsProvider.js'; import { IChatViewFactory } from '../../services/chatView/browser/chatViewFactory.js'; import { ISessionsProvidersService } from '../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsPartService } from '../../services/sessions/browser/sessionsPartService.js'; import { ISessionsService } from '../../services/sessions/browser/sessionsService.js'; -import { ChatInteractivity, ChatOriginKind, IChat, ISession, ISessionCapabilities, SessionStatus } from '../../services/sessions/common/session.js'; +import { ChatInteractivity, ChatOriginKind, IChat, ISession, ISessionCapabilities, SessionRemoteConnectionFailureReason, SessionRemoteConnectionStatus, SessionStatus } from '../../services/sessions/common/session.js'; import { IActiveSession, ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js'; +import { ISessionsProvider } from '../../services/sessions/common/sessionsProvider.js'; class TestChatView extends AbstractChatView { private readonly _focusTarget = mainWindow.document.createElement('button'); + override readonly hasVisibleTranscriptContent = observableValue(this, false); layoutCount = 0; + primary = false; constructor(readonly kind: ChatViewKind) { super(); @@ -44,6 +50,10 @@ class TestChatView extends AbstractChatView { focus(): void { this._focusTarget.focus(); } + + override setPrimary(primary: boolean): void { + this.primary = primary; + } } class TestChatViewFactory extends mock<IChatViewFactory>() { @@ -64,22 +74,33 @@ class TestChatViewFactory extends mock<IChatViewFactory>() { } } -function createChat(id: string, status: SessionStatus = SessionStatus.Completed, parentChat?: URI): IChat { - const resource = URI.parse(`test-chat://${id}`); - return new class extends mock<IChat>() { - override readonly resource = resource; - override readonly origin = parentChat ? { kind: ChatOriginKind.Tool, parentChat } : undefined; - override readonly title: IObservable<string> = constObservable(id); - override readonly status: IObservable<SessionStatus> = constObservable(status); - override readonly isRead: IObservable<boolean> = constObservable(true); - override readonly interactivity: IObservable<ChatInteractivity> = constObservable(ChatInteractivity.Full); - }(); +class TestChat extends mock<IChat>() { + override readonly resource: URI; + override readonly origin: IChat['origin']; + override readonly title: IObservable<string>; + override readonly status: ISettableObservable<SessionStatus>; + override readonly isRead: IObservable<boolean> = constObservable(true); + override readonly interactivity: ISettableObservable<ChatInteractivity>; + + constructor(id: string, status = SessionStatus.Completed, parentChat?: URI) { + super(); + this.resource = URI.parse(`test-chat://${id}`); + this.origin = parentChat ? { kind: ChatOriginKind.Tool, parentChat } : undefined; + this.title = constObservable(id); + this.status = observableValue(this, status); + this.interactivity = observableValue(this, ChatInteractivity.Full); + } +} + +function createChat(id: string, status: SessionStatus = SessionStatus.Completed, parentChat?: URI): TestChat { + return new TestChat(id, status, parentChat); } class TestActiveSession extends mock<IActiveSession>() { override readonly sessionId = 'session'; override readonly resource = URI.parse('test-session://session'); - override readonly providerId = 'test'; + override readonly providerId: string; + override readonly remoteConnectionStatus: ISettableObservable<SessionRemoteConnectionStatus> | undefined; readonly allChats: ISettableObservable<readonly IChat[]>; override readonly visibleChatTabs: ISettableObservable<readonly IChat[]>; override readonly activeChat: ISettableObservable<IChat>; @@ -91,11 +112,13 @@ class TestActiveSession extends mock<IActiveSession>() { override readonly capabilities: IObservable<ISessionCapabilities> = constObservable({ supportsMultipleChats: true }); override readonly isCreated: IObservable<boolean>; override readonly isNewSessionRequestInProgress = observableValue(this, false); - override readonly isArchived: IObservable<boolean> = constObservable(false); + override readonly isArchived = observableValue(this, false); override readonly loading: IObservable<boolean> = constObservable(false); - constructor(chats: readonly IChat[], visibleChats: readonly IChat[] = chats, isCreated = true) { + constructor(chats: readonly IChat[], visibleChats: readonly IChat[] = chats, isCreated = true, providerId = 'test', remoteConnectionStatus?: SessionRemoteConnectionStatus) { super(); + this.providerId = providerId; + this.remoteConnectionStatus = remoteConnectionStatus && observableValue(this, remoteConnectionStatus); const mainChat = chats[0]; if (!mainChat) { throw new Error('A test session requires a main chat'); @@ -141,9 +164,53 @@ class TestSessionsService extends mock<ISessionsService>() { } +class TestSessionsProvidersService extends mock<ISessionsProvidersService>() { + override readonly onDidChangeProviders = Event.None; + provider: ISessionsProvider | undefined; + + override getProvider<T extends ISessionsProvider>(_providerId: string): T | undefined { + return this.provider as T | undefined; + } +} + +class TestAgentHostProvider extends mock<IAgentHostSessionsProvider>() { + override readonly id = 'agenthost-test'; + override readonly label = 'WSL: Ubuntu'; + override readonly remoteAddress = 'wsl:Ubuntu'; + private readonly _autoConnectEnabled = observableValue(this, false); + override readonly autoConnect: IAgentHostAutoConnect = { + label: 'Automatically Start WSL: Ubuntu', + enabled: this._autoConnectEnabled, + setEnabled: enabled => this._autoConnectEnabled.set(enabled, undefined), + }; + private readonly _onDidReportConnectProgress = new Emitter<IAgentHostConnectProgress>(); + override readonly onDidReportConnectProgress = this._onDidReportConnectProgress.event; + connectCalls = 0; + reconnectNowCalls = 0; + connectGate: Promise<void> | undefined; + + override async connect(): Promise<void> { + this.connectCalls++; + await this.connectGate; + } + + override reconnectNow(): void { + this.reconnectNowCalls++; + } + + reportConnectProgress(connectionKey: string, message: string): void { + this._onDidReportConnectProgress.fire({ connectionKey, message }); + } + + get hasProgressListener(): boolean { + return this._onDidReportConnectProgress.hasListeners(); + } +} + interface IChatGroupsHarness { readonly instantiationService: TestInstantiationService; readonly sessionsService: TestSessionsService; + readonly sessionsProvidersService: TestSessionsProvidersService; readonly chatViewFactory: TestChatViewFactory; readonly view: ChatGroupsView; } @@ -153,22 +220,48 @@ function createHarness(disposables: Pick<DisposableStore, 'add'>, tabsReplaceHea const instantiationService = workbenchInstantiationService(undefined, store); const sessionsService = new TestSessionsService(); const chatViewFactory = new TestChatViewFactory(); + const sessionsProvidersService = new TestSessionsProvidersService(); instantiationService.stub(IChatViewFactory, chatViewFactory); instantiationService.stub(ISessionsService, sessionsService); instantiationService.stub(ISessionsManagementService, new class extends mock<ISessionsManagementService>() { override readonly onDidChangeSessions = Event.None; }()); instantiationService.stub(ISessionsPartService, new class extends mock<ISessionsPartService>() { }); - instantiationService.stub(ISessionsProvidersService, new class extends mock<ISessionsProvidersService>() { - override readonly onDidChangeProviders = Event.None; - override getProvider() { return undefined; } - }()); + instantiationService.stub(ISessionsProvidersService, sessionsProvidersService); const view = store.add(instantiationService.createInstance(ChatGroupsView)); view.setSingleGroupTabsReplaceHeader(tabsReplaceHeader); mainWindow.document.body.appendChild(view.element); store.add(toDisposable(() => view.element.remove())); - return { instantiationService, sessionsService, chatViewFactory, view }; + return { instantiationService, sessionsService, sessionsProvidersService, chatViewFactory, view }; +} + +function readBanner(view: ChatGroupsView): { readonly visible: boolean; readonly message: string | undefined; readonly action: string | undefined } { + const banner = view.element.querySelector<HTMLElement>('.session-readonly-banner'); + return { + visible: !banner?.classList.contains('hidden'), + message: banner?.querySelector('.session-readonly-banner-text')?.textContent ?? undefined, + action: banner?.querySelector('.session-readonly-banner-action-link')?.textContent ?? undefined, + }; +} + +function readRemoteHostUnavailableState(view: ChatGroupsView): { readonly visible: boolean; readonly title: string | undefined; readonly description: string | undefined; readonly progress: string | undefined; readonly action: string | undefined; readonly actionHidden: boolean; readonly autoConnect: string | undefined; readonly autoConnectChecked: boolean; readonly autoConnectHidden: boolean } { + const state = view.element.querySelector<HTMLElement>('.remote-host-unavailable-empty-state'); + // The action container is always present and hidden when there is no action, + // so report the label only while it is actually offered. + const action = state?.querySelector<HTMLElement>('.remote-host-unavailable-empty-state-action'); + const autoConnect = state?.querySelector<HTMLElement>('.remote-host-unavailable-empty-state-auto-connect'); + return { + visible: !state?.classList.contains('hidden'), + title: state?.querySelector('.remote-host-unavailable-empty-state-title')?.textContent ?? undefined, + description: state?.querySelector('.remote-host-unavailable-empty-state-description')?.textContent ?? undefined, + progress: state?.querySelector('.remote-host-unavailable-empty-state-progress:not(.hidden)')?.textContent ?? undefined, + action: action && !action.classList.contains('hidden') ? action.textContent ?? undefined : undefined, + actionHidden: action?.classList.contains('hidden') ?? true, + autoConnect: autoConnect && !autoConnect.classList.contains('hidden') ? autoConnect.querySelector('.remote-host-unavailable-empty-state-auto-connect-label')?.textContent ?? undefined : undefined, + autoConnectChecked: autoConnect?.querySelector('.monaco-checkbox')?.getAttribute('aria-checked') === 'true', + autoConnectHidden: autoConnect?.classList.contains('hidden') ?? true, + }; } suite('Sessions - ChatGroupsView', () => { @@ -442,7 +535,7 @@ suite('Sessions - ChatGroupsView', () => { }); test('left split updates logical and accessible group order', async () => { - const { view } = createHarness(disposables); + const { view, chatViewFactory } = createHarness(disposables); const main = createChat('main'); const secondary = createChat('secondary'); const session = new TestActiveSession([main, secondary]); @@ -453,11 +546,14 @@ suite('Sessions - ChatGroupsView', () => { const groups = Array.from(view.element.querySelectorAll<HTMLElement>('.chat-group-view')); const labelByChat = Object.fromEntries(groups.map(group => [ group.querySelector<HTMLElement>('.chat-composite-bar-tab')?.dataset.chatResource, - group.getAttribute('aria-label'), + { + label: group.getAttribute('aria-label'), + primary: chatViewFactory.views.find(candidate => candidate.element.parentElement === group.querySelector('.chat-group-view-content'))?.primary, + }, ])); assert.deepStrictEqual(labelByChat, { - [secondary.resource.toString()]: 'Chat Group 1 of 2', - [main.resource.toString()]: 'Chat Group 2 of 2', + [secondary.resource.toString()]: { label: 'Chat Group 1 of 2', primary: true }, + [main.resource.toString()]: { label: 'Chat Group 2 of 2', primary: false }, }); }); @@ -560,4 +656,547 @@ suite('Sessions - ChatGroupsView', () => { assert.strictEqual(view.element.querySelector<HTMLElement>('.session-chat-tabs-actions')?.classList.contains('hidden'), true); }); + test('hides the remote host banner when connected or when no remote host backs the session', () => { + const { view } = createHarness(disposables); + view.setSession(new TestActiveSession([createChat('main')]), options); + const withoutRemoteHost = readBanner(view); + + view.setSession(new TestActiveSession([createChat('connected')], undefined, true, 'agenthost-test', { kind: 'connected' }), options); + + assert.deepStrictEqual({ withoutRemoteHost, connected: readBanner(view) }, { + withoutRemoteHost: { visible: false, message: 'This chat is read-only', action: undefined }, + connected: { visible: false, message: 'This chat is read-only', action: undefined }, + }); + }); + + test('presents host-not-running and unknown remote host disconnections distinctly', () => { + const { chatViewFactory, sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + sessionsProvidersService.provider = provider; + const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }); + const remoteConnectionStatus = session.remoteConnectionStatus; + assert.ok(remoteConnectionStatus); + view.setSession(session, options); + chatViewFactory.views[chatViewFactory.views.length - 1].hasVisibleTranscriptContent.set(true, undefined); + const hostNotRunning = readBanner(view); + + view.element.querySelector<HTMLElement>('.session-readonly-banner-action-link')?.click(); + remoteConnectionStatus.set({ kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.Unknown }, undefined); + + assert.deepStrictEqual({ + hostNotRunning, + unknownDisconnected: readBanner(view), + connectCalls: provider.connectCalls, + }, { + hostNotRunning: { visible: true, message: 'WSL: Ubuntu is not running.', action: 'Start WSL: Ubuntu' }, + unknownDisconnected: { visible: true, message: 'Cannot reach WSL: Ubuntu.', action: 'Retry' }, + connectCalls: 1, + }); + }); + + test('uses a centered recovery state for an unloaded remote session and a banner for a chat transcript', () => { + const { chatViewFactory, sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + sessionsProvidersService.provider = provider; + // Cached metadata produces an existing chat tab, but no chat model or + // rendered transcript until the remote host can be reached. + const unloaded = new TestActiveSession([createChat('cached')], undefined, true, provider.id, { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }); + const status = unloaded.remoteConnectionStatus; + assert.ok(status); + view.setSession(unloaded, options); + const hostNotRunning = { + state: readRemoteHostUnavailableState(view), + banner: readBanner(view).visible, + }; + + view.element.querySelector<HTMLElement>('.remote-host-unavailable-empty-state-action .monaco-button')?.click(); + status.set({ kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.Unknown }, undefined); + const unknownDisconnected = { + state: readRemoteHostUnavailableState(view), + banner: readBanner(view).visible, + }; + + const existing = new TestActiveSession([createChat('existing')], undefined, true, provider.id, { kind: 'connected' }); + const existingStatus = existing.remoteConnectionStatus; + assert.ok(existingStatus); + view.setSession(existing, options); + // Model a transcript already rendered before the transport drops. The + // second session creates its own view, so target the current one rather + // than the view left over from the unloaded session above. + chatViewFactory.views[chatViewFactory.views.length - 1].hasVisibleTranscriptContent.set(true, undefined); + existingStatus.set({ kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }, undefined); + const chatWithContent = { + state: readRemoteHostUnavailableState(view).visible, + banner: readBanner(view), + }; + + assert.deepStrictEqual({ + hostNotRunning, + unknownDisconnected, + chatWithContent, + connectCalls: provider.connectCalls, + }, { + hostNotRunning: { + state: { + visible: true, + title: 'Unable to Connect to WSL: Ubuntu', + description: 'WSL: Ubuntu is not running.', + progress: undefined, + action: 'Start WSL: Ubuntu', + actionHidden: false, + autoConnect: 'Automatically Start WSL: Ubuntu', + autoConnectChecked: false, + autoConnectHidden: false, + }, + banner: false, + }, + unknownDisconnected: { + state: { + visible: true, + title: 'Cannot Connect to WSL: Ubuntu', + description: 'Cannot reach WSL: Ubuntu.', + progress: undefined, + action: 'Retry', + actionHidden: false, + autoConnect: undefined, + autoConnectChecked: false, + autoConnectHidden: true, + }, + banner: false, + }, + chatWithContent: { + state: false, + banner: { + visible: true, + message: 'WSL: Ubuntu is not running.', + action: 'Start WSL: Ubuntu', + }, + }, + connectCalls: 1, + }); + }); + + test('automatically starts the host again when it drops after an earlier automatic start', async () => { + const { chatViewFactory, sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + provider.autoConnect.setEnabled(true); + sessionsProvidersService.provider = provider; + const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }); + const status = session.remoteConnectionStatus; + assert.ok(status); + view.setSession(session, options); + await Promise.resolve(); + await Promise.resolve(); + + // The host comes up and the transcript renders, so a later outage is shown + // as a banner rather than the centered state. Each outage gets its own + // automatic attempt: latching for the whole session would leave a dropped + // host sitting behind a manual button. + status.set({ kind: 'connected' }, undefined); + chatViewFactory.views[chatViewFactory.views.length - 1].hasVisibleTranscriptContent.set(true, undefined); + const connectsWhileConnected = provider.connectCalls; + + provider.connectGate = new DeferredPromise<void>().p; + status.set({ kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }, undefined); + + assert.deepStrictEqual({ + connectsWhileConnected, + connectCalls: provider.connectCalls, + banner: readBanner(view), + }, { + connectsWhileConnected: 1, + connectCalls: 2, + banner: { visible: true, message: 'Waiting for agent host connection...', action: undefined }, + }); + }); + + test('automatically starts a stopped host without exposing the recovery action', () => { + const { sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + const connect = new DeferredPromise<void>(); + provider.connectGate = connect.p; + provider.autoConnect.setEnabled(true); + sessionsProvidersService.provider = provider; + view.setSession(new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }), options); + + assert.deepStrictEqual({ + connectCalls: provider.connectCalls, + state: readRemoteHostUnavailableState(view), + }, { + connectCalls: 1, + state: { + visible: true, + title: 'Connecting to WSL: Ubuntu', + description: 'Starting WSL: Ubuntu.', + progress: 'Waiting for agent host connection...', + action: undefined, + actionHidden: true, + autoConnect: 'Automatically Start WSL: Ubuntu', + autoConnectChecked: true, + autoConnectHidden: false, + }, + }); + }); + + test('shows the recovery action without starting a stopped host when auto-connect is disabled', () => { + const { sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + sessionsProvidersService.provider = provider; + view.setSession(new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }), options); + + assert.deepStrictEqual({ + connectCalls: provider.connectCalls, + state: readRemoteHostUnavailableState(view), + }, { + connectCalls: 0, + state: { + visible: true, + title: 'Unable to Connect to WSL: Ubuntu', + description: 'WSL: Ubuntu is not running.', + progress: undefined, + action: 'Start WSL: Ubuntu', + actionHidden: false, + autoConnect: 'Automatically Start WSL: Ubuntu', + autoConnectChecked: false, + autoConnectHidden: false, + }, + }); + }); + + test('toggles auto-connect exactly once when its checkbox is clicked', () => { + const { sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + const connect = new DeferredPromise<void>(); + provider.connectGate = connect.p; + sessionsProvidersService.provider = provider; + view.setSession(new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }), options); + + const checkbox = view.element.querySelector<HTMLElement>('.remote-host-unavailable-empty-state-auto-connect .monaco-checkbox'); + assert.ok(checkbox); + checkbox.click(); + + assert.deepStrictEqual({ + autoConnectEnabled: provider.autoConnect.enabled.get(), + connectCalls: provider.connectCalls, + state: readRemoteHostUnavailableState(view), + }, { + autoConnectEnabled: true, + connectCalls: 1, + state: { + visible: true, + title: 'Connecting to WSL: Ubuntu', + description: 'Starting WSL: Ubuntu.', + progress: 'Waiting for agent host connection...', + action: undefined, + actionHidden: true, + autoConnect: 'Automatically Start WSL: Ubuntu', + autoConnectChecked: true, + autoConnectHidden: false, + }, + }); + }); + + test('does not retrigger an automatic start when the connect resolves without reaching the host', async () => { + const { sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + provider.autoConnect.setEnabled(true); + sessionsProvidersService.provider = provider; + // The provider resolves without the host coming up, so the status stays + // stopped. The automatic start is latched per session rather than on the + // attempt, which a resolved attempt clears — otherwise this would spin. + view.setSession(new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }), options); + await Promise.resolve(); + await Promise.resolve(); + + assert.deepStrictEqual({ + connectCalls: provider.connectCalls, + state: readRemoteHostUnavailableState(view), + }, { + connectCalls: 1, + state: { + visible: true, + title: 'Unable to Connect to WSL: Ubuntu', + description: 'WSL: Ubuntu is not running.', + progress: undefined, + action: 'Start WSL: Ubuntu', + actionHidden: false, + autoConnect: 'Automatically Start WSL: Ubuntu', + autoConnectChecked: true, + autoConnectHidden: false, + }, + }); + }); + + test('offers the recovery action after an automatic connection attempt fails', async () => { + const { sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + const connect = new DeferredPromise<void>(); + provider.connectGate = connect.p; + provider.autoConnect.setEnabled(true); + sessionsProvidersService.provider = provider; + view.setSession(new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }), options); + + const originalErrorHandler = errorHandler.getUnexpectedErrorHandler(); + setUnexpectedErrorHandler(() => { }); + try { + connect.error(new Error('Expected automatic connect failure')); + await Promise.resolve(); + await Promise.resolve(); + } finally { + setUnexpectedErrorHandler(originalErrorHandler); + } + + assert.deepStrictEqual({ + connectCalls: provider.connectCalls, + state: readRemoteHostUnavailableState(view), + }, { + connectCalls: 1, + state: { + visible: true, + title: 'Unable to Connect to WSL: Ubuntu', + description: 'WSL: Ubuntu is not running.', + progress: undefined, + action: 'Start WSL: Ubuntu', + actionHidden: false, + autoConnect: 'Automatically Start WSL: Ubuntu', + autoConnectChecked: true, + autoConnectHidden: false, + }, + }); + }); + + test('shows only its host connection progress and disposes listeners with its session and view', async () => { + const { sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + const firstConnect = new DeferredPromise<void>(); + provider.connectGate = firstConnect.p; + sessionsProvidersService.provider = provider; + const firstSession = new TestActiveSession([createChat('first')], undefined, true, provider.id, { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }); + view.setSession(firstSession, options); + + view.element.querySelector<HTMLElement>('.remote-host-unavailable-empty-state-action .monaco-button')?.click(); + provider.reportConnectProgress('wsl:Debian', 'Downloading server (24%)'); + const otherHostProgress = readRemoteHostUnavailableState(view); + provider.reportConnectProgress('wsl:Ubuntu', 'Downloading server (80%)'); + const ownHostProgress = readRemoteHostUnavailableState(view); + firstConnect.complete(); + await Promise.resolve(); + await Promise.resolve(); + const completedAttempt = readRemoteHostUnavailableState(view); + const listenerAfterAttempt = provider.hasProgressListener; + + const secondConnect = new DeferredPromise<void>(); + provider.connectGate = secondConnect.p; + view.element.querySelector<HTMLElement>('.remote-host-unavailable-empty-state-action .monaco-button')?.click(); + const secondSession = new TestActiveSession([createChat('second')], undefined, true, provider.id, { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }); + view.setSession(secondSession, options); + const listenerAfterSessionChange = provider.hasProgressListener; + view.element.querySelector<HTMLElement>('.remote-host-unavailable-empty-state-action .monaco-button')?.click(); + const listenerBeforeDispose = provider.hasProgressListener; + view.dispose(); + const listenerAfterDispose = provider.hasProgressListener; + secondConnect.complete(); + await Promise.resolve(); + + assert.deepStrictEqual({ + otherHostProgress, + ownHostProgress, + completedAttempt, + listenerAfterAttempt, + listenerAfterSessionChange, + listenerBeforeDispose, + listenerAfterDispose, + }, { + // Another host's progress is ignored, so the attempt still shows only + // its own placeholder. It stays on the connecting presentation rather + // than falling back to the action: an in-flight attempt must not flash + // the recovery button while the host has yet to report `connecting`. + otherHostProgress: { + visible: true, + title: 'Connecting to WSL: Ubuntu', + description: 'Starting WSL: Ubuntu.', + progress: 'Waiting for agent host connection...', + action: undefined, + actionHidden: true, + autoConnect: 'Automatically Start WSL: Ubuntu', + autoConnectChecked: false, + autoConnectHidden: false, + }, + ownHostProgress: { + visible: true, + title: 'Connecting to WSL: Ubuntu', + description: 'Starting WSL: Ubuntu.', + progress: 'Downloading server (80%)', + action: undefined, + actionHidden: true, + autoConnect: 'Automatically Start WSL: Ubuntu', + autoConnectChecked: false, + autoConnectHidden: false, + }, + completedAttempt: { + visible: true, + title: 'Unable to Connect to WSL: Ubuntu', + description: 'WSL: Ubuntu is not running.', + progress: undefined, + action: 'Start WSL: Ubuntu', + actionHidden: false, + autoConnect: 'Automatically Start WSL: Ubuntu', + autoConnectChecked: false, + autoConnectHidden: false, + }, + listenerAfterAttempt: false, + listenerAfterSessionChange: false, + listenerBeforeDispose: true, + listenerAfterDispose: false, + }); + }); + + test('preserves the archived-session banner when its remote host is unavailable', () => { + const { sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + sessionsProvidersService.provider = provider; + const chat = createChat('main'); + chat.interactivity.set(ChatInteractivity.ReadOnly, undefined); + const session = new TestActiveSession([chat], undefined, true, provider.id, { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }); + session.isArchived.set(true, undefined); + + view.setSession(session, options); + + assert.deepStrictEqual(readBanner(view), { + visible: true, + message: 'Archived sessions are read-only.', + action: 'Unarchive', + }); + }); + + test('does not show a reconnecting banner when the connection settles before its delay', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + sessionsProvidersService.provider = provider; + const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'connected' }); + const remoteConnectionStatus = session.remoteConnectionStatus; + assert.ok(remoteConnectionStatus); + view.setSession(session, options); + + remoteConnectionStatus.set({ kind: 'reconnecting' }, undefined); + remoteConnectionStatus.set({ kind: 'connected' }, undefined); + await timeout(1_000); + + assert.deepStrictEqual(readBanner(view), { visible: false, message: 'This chat is read-only', action: undefined }); + }); + }); + + test('shows the reconnecting banner after its delay despite unrelated observable updates', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + sessionsProvidersService.provider = provider; + const chat = createChat('main'); + const session = new TestActiveSession([chat], undefined, true, provider.id, { kind: 'reconnecting' }); + view.setSession(session, options); + + await timeout(500); + chat.status.set(SessionStatus.Error, undefined); + await timeout(500); + + assert.deepStrictEqual(readBanner(view), { + visible: true, + message: 'Reconnecting to WSL: Ubuntu...', + action: undefined, + }); + }); + }); + + test('shows a reconnect countdown and retries immediately on demand', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const { chatViewFactory, sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + sessionsProvidersService.provider = provider; + const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'reconnecting', nextAttemptAt: Date.now() + 6_000 }); + view.setSession(session, options); + chatViewFactory.views[chatViewFactory.views.length - 1].hasVisibleTranscriptContent.set(true, undefined); + + await timeout(1_000); + const banner = readBanner(view); + view.element.querySelector<HTMLElement>('.session-readonly-banner-action-link')?.click(); + + assert.deepStrictEqual({ banner, reconnectNowCalls: provider.reconnectNowCalls }, { + banner: { visible: true, message: 'Reconnecting to WSL: Ubuntu in 5s', action: 'Try Now' }, + reconnectNowCalls: 1, + }); + }); + }); + + test('updates the reconnect countdown every second', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const { chatViewFactory, sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + sessionsProvidersService.provider = provider; + const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'reconnecting', nextAttemptAt: Date.now() + 7_000 }); + view.setSession(session, options); + chatViewFactory.views[chatViewFactory.views.length - 1].hasVisibleTranscriptContent.set(true, undefined); + + await timeout(1_000); + const beforeTick = readBanner(view); + await timeout(1_000); + + assert.deepStrictEqual({ beforeTick, afterTick: readBanner(view) }, { + beforeTick: { visible: true, message: 'Reconnecting to WSL: Ubuntu in 6s', action: 'Try Now' }, + afterTick: { visible: true, message: 'Reconnecting to WSL: Ubuntu in 5s', action: 'Try Now' }, + }); + }); + }); + + test('shows a plain reconnecting banner while a reconnect attempt is in flight', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const { chatViewFactory, sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + sessionsProvidersService.provider = provider; + const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'reconnecting' }); + view.setSession(session, options); + chatViewFactory.views[chatViewFactory.views.length - 1].hasVisibleTranscriptContent.set(true, undefined); + + await timeout(1_000); + + assert.deepStrictEqual(readBanner(view), { + visible: true, + message: 'Reconnecting to WSL: Ubuntu...', + action: undefined, + }); + }); + }); + + test('shows the reconnecting banner after a failed connect attempt', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + const failedConnect = new DeferredPromise<void>(); + provider.connectGate = failedConnect.p; + sessionsProvidersService.provider = provider; + const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'disconnected', reason: SessionRemoteConnectionFailureReason.HostNotRunning }); + const remoteConnectionStatus = session.remoteConnectionStatus!; + view.setSession(session, options); + + const originalErrorHandler = errorHandler.getUnexpectedErrorHandler(); + setUnexpectedErrorHandler(() => { }); + try { + view.element.querySelector<HTMLElement>('.remote-host-unavailable-empty-state-action .monaco-button')?.click(); + failedConnect.error(new Error('Expected connect failure')); + await Promise.resolve(); + await Promise.resolve(); + } finally { + setUnexpectedErrorHandler(originalErrorHandler); + } + + remoteConnectionStatus.set({ kind: 'reconnecting' }, undefined); + await timeout(1_000); + + assert.deepStrictEqual({ connectCalls: provider.connectCalls, banner: readBanner(view) }, { + connectCalls: 1, + banner: { visible: true, message: 'Reconnecting to WSL: Ubuntu...', action: undefined }, + }); + }); + }); + }); diff --git a/src/vs/sessions/test/browser/remoteHostUnavailableEmptyState.fixture.ts b/src/vs/sessions/test/browser/remoteHostUnavailableEmptyState.fixture.ts new file mode 100644 index 00000000000000..00aa9d3efa5728 --- /dev/null +++ b/src/vs/sessions/test/browser/remoteHostUnavailableEmptyState.fixture.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { type IRemoteHostUnavailableEmptyStateContent, RemoteHostUnavailableEmptyState } from '../../browser/parts/remoteHostUnavailableEmptyState.js'; + +export default defineThemedFixtureGroup({ path: 'sessions/remoteHostUnavailable/' }, { + HostNotRunning: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderUnavailableState(context, { + title: 'Unable to Connect to WSL: Ubuntu', + description: 'WSL: Ubuntu is not running.', + action: { label: 'Start WSL: Ubuntu', run: () => { } }, + autoConnect: { label: 'Automatically Start WSL: Ubuntu', checked: false, onChange: () => { } }, + }), + }), + + HostNotRunningAutoStart: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderUnavailableState(context, { + title: 'Unable to Connect to WSL: Ubuntu', + description: 'WSL: Ubuntu is not running.', + action: { label: 'Start WSL: Ubuntu', run: () => { } }, + autoConnect: { label: 'Automatically Start WSL: Ubuntu', checked: true, onChange: () => { } }, + }), + }), + + HostDisconnected: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderUnavailableState(context, { + title: 'Cannot Connect to WSL: Ubuntu', + description: 'Cannot reach WSL: Ubuntu.', + }), + }), + + Connecting: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderUnavailableState(context, { + title: 'Connecting to WSL: Ubuntu', + description: 'Starting WSL: Ubuntu.', + progress: 'Downloading server (80%)', + }), + }), +}); + +function renderUnavailableState({ container, disposableStore }: ComponentFixtureContext, content: IRemoteHostUnavailableEmptyStateContent): void { + container.style.position = 'relative'; + container.style.width = 'var(--session-view-centered-content-max-width)'; + container.style.height = 'calc(var(--vscode-spacing-size400) * 6)'; + container.style.backgroundColor = 'var(--vscode-editorWidget-background)'; + + const state = disposableStore.add(new RemoteHostUnavailableEmptyState()); + state.setContent(content); + container.appendChild(state.domNode); +} diff --git a/src/vs/sessions/test/browser/sessionReadOnlyBanner.fixture.ts b/src/vs/sessions/test/browser/sessionReadOnlyBanner.fixture.ts index 5599a678ba650a..426de4c05c2a7c 100644 --- a/src/vs/sessions/test/browser/sessionReadOnlyBanner.fixture.ts +++ b/src/vs/sessions/test/browser/sessionReadOnlyBanner.fixture.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Codicon } from '../../../base/common/codicons.js'; import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; import { ISessionReadOnlyBannerContent, SessionReadOnlyBanner } from '../../browser/parts/sessionReadOnlyBanner.js'; @@ -21,6 +22,31 @@ export default defineThemedFixtureGroup({ path: 'sessions/readOnlyBanner/' }, { action: { label: 'Restore', run: () => console.log('Restore') }, }), }), + + Reconnecting: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (context) => renderBanner(context, { + icon: Codicon.sync, + message: 'Reconnecting to WSL: Ubuntu...', + }), + }), + + HostNotRunning: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (context) => renderBanner(context, { + icon: Codicon.debugDisconnect, + message: 'WSL: Ubuntu is not running.', + action: { label: 'Start WSL: Ubuntu', run: () => console.log('Start') }, + }), + }), + + HostDisconnected: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (context) => renderBanner(context, { + icon: Codicon.debugDisconnect, + message: 'Cannot reach WSL: Ubuntu.', + }), + }), }); function renderBanner({ container, disposableStore }: ComponentFixtureContext, content: ISessionReadOnlyBannerContent): void { diff --git a/src/vs/workbench/api/common/extHostComments.ts b/src/vs/workbench/api/common/extHostComments.ts index 3f1378c31dd1f0..7b80cda4201580 100644 --- a/src/vs/workbench/api/common/extHostComments.ts +++ b/src/vs/workbench/api/common/extHostComments.ts @@ -12,7 +12,7 @@ import { MarshalledId } from '../../../base/common/marshallingIds.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; import { IRange } from '../../../editor/common/core/range.js'; import * as languages from '../../../editor/common/languages.js'; -import { ExtensionIdentifierMap, IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; +import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import { ExtHostDocuments } from './extHostDocuments.js'; import * as extHostTypeConverter from './extHostTypeConverters.js'; import * as types from './extHostTypes.js'; @@ -38,9 +38,6 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo private _commentControllers: Map<ProviderHandle, ExtHostCommentController> = new Map<ProviderHandle, ExtHostCommentController>(); - private _commentControllersByExtension: ExtensionIdentifierMap<ExtHostCommentController[]> = new ExtensionIdentifierMap<ExtHostCommentController[]>(); - - constructor( ) { commands.registerArgumentProcessor({ @@ -150,13 +147,11 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo createCommentController(extension: IExtensionDescription, id: string, label: string): vscode.CommentController { const handle = ExtHostCommentsImpl.handlePool++; - const commentController = new ExtHostCommentController(extension, handle, id, label); + const commentController = new ExtHostCommentController(extension, handle, id, label, () => { + this._commentControllers.delete(handle); + }); this._commentControllers.set(commentController.handle, commentController); - const commentControllers = this._commentControllersByExtension.get(extension.identifier) || []; - commentControllers.push(commentController); - this._commentControllersByExtension.set(extension.identifier, commentControllers); - return commentController.value; } @@ -665,7 +660,8 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo private _extension: IExtensionDescription, private _handle: number, private _id: string, - private _label: string + private _label: string, + onDidDispose: () => void ) { proxy.$registerCommentController(this.handle, _id, _label, this._extension.identifier.value); @@ -693,6 +689,7 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo proxy.$unregisterCommentController(this.handle); } }); + this._localDisposables.push({ dispose: onDidDispose }); } createCommentThread(resource: vscode.Uri, range: vscode.Range | undefined, comments: vscode.Comment[]): ExtHostCommentThread { diff --git a/src/vs/workbench/api/node/proxyResolver.ts b/src/vs/workbench/api/node/proxyResolver.ts index 307d542efbf3b1..8e5e42072a701e 100644 --- a/src/vs/workbench/api/node/proxyResolver.ts +++ b/src/vs/workbench/api/node/proxyResolver.ts @@ -102,6 +102,7 @@ export function connectProxyResolver( return intervalSeconds * 1000; }, loadAdditionalCertificates: async () => { + const start = Date.now(); const useNodeSystemCerts = getExtHostConfigValue<boolean>(configProvider, isRemote, 'http.systemCertificatesNode', systemCertificatesNodeDefault); const promises: Promise<string[]>[] = []; if (isRemote) { @@ -126,6 +127,7 @@ export function connectProxyResolver( const result = (await Promise.all(promises)).flat(); mainThreadTelemetry.$publicLog2<AdditionalCertificatesEvent, AdditionalCertificatesClassification>('additionalCertificates', { count: result.length, + duration: Date.now() - start, isRemote, loadLocalCertificates, useNodeSystemCerts, @@ -299,8 +301,9 @@ function recordFetchFeatureUse(mainThreadTelemetry: MainThreadTelemetryShape, fe type AdditionalCertificatesClassification = { owner: 'chrmarti'; - comment: 'Tracks the number of additional certificates loaded for TLS connections'; + comment: 'Tracks loading additional certificates for TLS connections'; count: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of additional certificates loaded' }; + duration: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds spent loading additional certificates' }; isRemote: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether this is a remote extension host' }; loadLocalCertificates: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether local certificates are loaded' }; useNodeSystemCerts: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether Node.js system certificates are used' }; @@ -308,6 +311,7 @@ type AdditionalCertificatesClassification = { type AdditionalCertificatesEvent = { count: number; + duration: number; isRemote: boolean; loadLocalCertificates: boolean; useNodeSystemCerts: boolean; diff --git a/src/vs/workbench/api/test/common/extHostComments.test.ts b/src/vs/workbench/api/test/common/extHostComments.test.ts new file mode 100644 index 00000000000000..a3612009b55bd4 --- /dev/null +++ b/src/vs/workbench/api/test/common/extHostComments.test.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { URI } from '../../../../base/common/uri.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; +import { nullExtensionDescription } from '../../../services/extensions/common/extensions.js'; +import { MainContext, MainThreadCommentsShape } from '../../common/extHost.protocol.js'; +import { ArgumentProcessor, ExtHostCommands } from '../../common/extHostCommands.js'; +import { createExtHostComments } from '../../common/extHostComments.js'; +import { ExtHostDocuments } from '../../common/extHostDocuments.js'; +import { TestRPCProtocol } from './testRPCProtocol.js'; + +suite('ExtHostComments', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('disposed comment controllers are removed from extension host bookkeeping', async () => { + let controllerHandle = -1; + let createdThreadCount = 0; + const rpcProtocol = new TestRPCProtocol(); + rpcProtocol.set(MainContext.MainThreadComments, new class extends mock<MainThreadCommentsShape>() { + override $registerCommentController(handle: number): void { + controllerHandle = handle; + } + override $unregisterCommentController(): void { } + override $createCommentThread(): undefined { + createdThreadCount++; + return undefined; + } + override $deleteCommentThread(): void { } + }); + + const commands = new class extends mock<ExtHostCommands>() { + override registerArgumentProcessor(_processor: ArgumentProcessor): void { } + }; + const extension = { + ...nullExtensionDescription, + identifier: new ExtensionIdentifier('test.comments'), + name: 'comments', + displayName: 'Comments', + extensionLocation: URI.file('/extension') + }; + const extHostComments = createExtHostComments(rpcProtocol, commands, {} as ExtHostDocuments); + const controller = extHostComments.createCommentController(extension, 'comments', 'Comments'); + + controller.dispose(); + await extHostComments.$createCommentThreadTemplate(controllerHandle, URI.file('/file'), undefined); + + assert.strictEqual(createdThreadCount, 0); + }); +}); diff --git a/src/vs/workbench/browser/parts/editor/editorCommands.ts b/src/vs/workbench/browser/parts/editor/editorCommands.ts index 648eeccef065d6..7538dd869be42f 100644 --- a/src/vs/workbench/browser/parts/editor/editorCommands.ts +++ b/src/vs/workbench/browser/parts/editor/editorCommands.ts @@ -29,6 +29,7 @@ import { SideBySideEditor } from './sideBySideEditor.js'; import { TextDiffEditor } from './textDiffEditor.js'; import { ActiveEditorCanSplitInGroupContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupLockedContext, ActiveEditorStickyContext, EditorPartModalContext, EditorPartModalMaximizedContext, EditorPartModalNavigationContext, EditorPartModalSidebarContext, IsSessionsWindowContext, MultipleEditorGroupsContext, SideBySideEditorActiveContext, TextCompareEditorActiveContext } from '../../../common/contextkeys.js'; import { CloseDirection, EditorInputCapabilities, EditorsOrder, IResourceDiffEditorInput, IUntitledTextResourceEditorInput, isDiffEditorInput, isEditorInputWithOptionsAndGroup } from '../../../common/editor.js'; +import { IMultiDiffEditorOptions } from '../../../../editor/common/multiDiffEditor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { SideBySideEditorInput } from '../../../common/editor/sideBySideEditorInput.js'; import { EditorGroupColumn, columnToEditorGroup } from '../../../services/editor/common/editorGroupColumn.js'; @@ -45,7 +46,6 @@ import { DIFF_FOCUS_OTHER_SIDE, DIFF_FOCUS_PRIMARY_SIDE, DIFF_FOCUS_SECONDARY_SI import { IResolvedEditorCommandsContext, resolveCommandsContext } from './editorCommandsContext.js'; import { prepareMoveCopyEditors } from './editor.js'; import { IRange } from '../../../../editor/common/core/range.js'; -import { IMultiDiffEditorOptions } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; export const CLOSE_SAVED_EDITORS_COMMAND_ID = 'workbench.action.closeUnmodifiedEditors'; export const CLOSE_EDITORS_IN_GROUP_COMMAND_ID = 'workbench.action.closeEditorsInGroup'; diff --git a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css index 398a3e0c0478b6..b838cbed36aab0 100644 --- a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css +++ b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css @@ -206,3 +206,8 @@ .monaco-modal-editor-block.compact-header .modal-editor-title .monaco-icon-label > .monaco-icon-label-iconpath { display: none; } + +.monaco-modal-editor-block.compact-header .modal-editor-title .ai-customization-management-editor-label .label-description { + font-size: inherit; + font-weight: var(--vscode-fontWeight-regular); +} diff --git a/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPane.ts b/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPane.ts index 4d5d76768f0d51..653d9edd66bfaa 100644 --- a/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPane.ts +++ b/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPane.ts @@ -14,8 +14,8 @@ import { Mutable } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import './bulkEdit.css'; import { ResourceEdit } from '../../../../../editor/browser/services/bulkEditService.js'; -import { IMultiDiffEditorOptions, IMultiDiffResourceId } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; import { IRange } from '../../../../../editor/common/core/range.js'; +import { IMultiDiffEditorOptions, IMultiDiffResourceId } from '../../../../../editor/common/multiDiffEditor.js'; import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; import { localize } from '../../../../../nls.js'; import { MenuId } from '../../../../../platform/actions/common/actions.js'; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts index 9e01a27d9e7c38..e4d9b16b83cb0f 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts @@ -11,9 +11,10 @@ import { localize } from '../../../../../nls.js'; import { agentHostAuthority } from '../../../../../platform/agentHost/common/agentHostUri.js'; import { IRemoteAgentHostService } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; -import { IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; +import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { EditorResourceAccessor, SideBySideEditor } from '../../../../common/editor.js'; import { DiffEditorInput } from '../../../../common/editor/diffEditorInput.js'; @@ -38,6 +39,21 @@ import { URI } from '../../../../../base/common/uri.js'; import { ITerminalCommand, TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { buildHostLocalEventsPath } from '../copilotCliEventsUri.js'; +import { IGitService } from '../../../git/common/gitService.js'; +import { getGitHubRemoteInfo } from '../../../git/common/utils.js'; + +const OPEN_GITHUB_ISSUE_COMMAND = 'github.copilot.chat.cloudSessions.openIssue'; +const OPEN_GITHUB_PULL_REQUEST_COMMAND = 'github.copilot.chat.cloudSessions.openPullRequest'; + +interface IGitHubContextSelection { + readonly repoId: string; + readonly url: string; + readonly label: string; +} + +interface IGitHubRepositoryQuickPickItem extends IQuickPickItem { + readonly repoId: string; +} /** * Command ID that extensions can call to enable debug tools for the current @@ -87,6 +103,89 @@ export class ChatContextContributions extends Disposable implements IWorkbenchCo this._store.add(contextPickService.registerChatContextItem(instantiationService.createInstance(ClipboardImageContextValuePick))); this._store.add(contextPickService.registerChatContextItem(instantiationService.createInstance(ScreenshotContextValuePick))); this._store.add(contextPickService.registerChatContextItem(instantiationService.createInstance(SessionReferenceContextPickerPick))); + this._store.add(contextPickService.registerChatContextItem(instantiationService.createInstance(GitHubContextValuePick, 'issue'))); + this._store.add(contextPickService.registerChatContextItem(instantiationService.createInstance(GitHubContextValuePick, 'pullRequest'))); + } +} + +export class GitHubContextValuePick implements IChatContextValueItem { + + readonly type = 'valuePick'; + readonly label: string; + readonly icon: ThemeIcon; + readonly ordinal = -450; + + private readonly _commandId: string; + + constructor( + kind: 'issue' | 'pullRequest', + @IGitService private readonly gitService: IGitService, + @IQuickInputService private readonly quickInputService: IQuickInputService, + @ICommandService private readonly commandService: ICommandService, + ) { + if (kind === 'issue') { + this.label = localize('chatContext.githubIssue', "Issue..."); + this.icon = Codicon.issues; + this._commandId = OPEN_GITHUB_ISSUE_COMMAND; + } else { + this.label = localize('chatContext.githubPullRequest', "Pull Request..."); + this.icon = Codicon.gitPullRequest; + this._commandId = OPEN_GITHUB_PULL_REQUEST_COMMAND; + } + } + + isEnabled(): boolean { + return true; + } + + async asAttachment(): Promise<IChatRequestVariableEntry | undefined> { + const repositories = this.getRepositories(); + let repoId: string | undefined; + + if (repositories.length === 1) { + repoId = repositories[0]; + } else if (repositories.length > 1) { + repoId = await this.pickRepository(repositories); + } + + if (repositories.length > 1 && !repoId) { + return undefined; + } + + const selection = await this.commandService.executeCommand<IGitHubContextSelection | undefined>(this._commandId, repoId); + if (!selection) { + return undefined; + } + + const resource = URI.parse(selection.url); + return { + kind: 'generic', + id: selection.url, + fullName: selection.label, + name: selection.label, + value: resource, + icon: this.icon, + references: [{ reference: resource, kind: 'reference' }], + }; + } + + protected async pickRepository(repositories: readonly string[]): Promise<string | undefined> { + const repository = await this.quickInputService.pick( + repositories.map((repoId): IGitHubRepositoryQuickPickItem => ({ label: repoId, repoId })), + { placeHolder: localize('chatContext.githubRepository.placeholder', "Select a repository") } + ); + return repository?.repoId; + } + + private getRepositories(): readonly string[] { + const repositories = new Set<string>(); + for (const repository of this.gitService.repositories) { + const info = getGitHubRemoteInfo(repository.state.get()); + if (info) { + repositories.add(`${info.owner}/${info.repo}`); + } + } + return Array.from(repositories).sort(); } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts index c6e41f38e0d120..e76bc31e71a55a 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Action } from '../../../../../base/common/actions.js'; import { VSBuffer, newWriteableBufferStream, streamToBuffer, type VSBufferReadableStream } from '../../../../../base/common/buffer.js'; import { Schemas } from '../../../../../base/common/network.js'; import { basename, dirname, joinPath } from '../../../../../base/common/resources.js'; @@ -16,6 +17,7 @@ import { AGENT_HOST_ENABLED_CONTEXT_KEY } from '../../../../../platform/agentHos import { IAgentHostService, type AgentHostDebugLogsArtifactKind, type IAgentConnection, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../../../../../platform/agentHost/common/agentService.js'; import { IRemoteAgentHostService, remoteAgentHostLogOutputChannelId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { DEFAULT_CHAT_ID, getSessionChatResource, StateComponents, type SessionState } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IsWebContext } from '../../../../../platform/contextkey/common/contextkeys.js'; import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; @@ -82,7 +84,7 @@ export const IAgentHostDebugLogsExportService = createDecorator<IAgentHostDebugL export interface IAgentHostDebugLogsExportService { readonly _serviceBrand: undefined; readonly hostArtifactKind: AgentHostDebugLogsArtifactKind; - save(exportName: string, files: readonly IAgentHostDebugLogFile[], hostArtifact: IAgentHostDebugLogsHostArtifact | undefined): Promise<boolean>; + save(exportName: string, files: readonly IAgentHostDebugLogFile[], hostArtifact: IAgentHostDebugLogsHostArtifact | undefined): Promise<URI | undefined>; } export class BrowserAgentHostDebugLogsExportService implements IAgentHostDebugLogsExportService { @@ -93,7 +95,7 @@ export class BrowserAgentHostDebugLogsExportService implements IAgentHostDebugLo @IInstantiationService private readonly instantiationService: IInstantiationService, ) { } - async save(exportName: string, files: readonly IAgentHostDebugLogFile[], hostArtifact: IAgentHostDebugLogsHostArtifact | undefined): Promise<boolean> { + async save(exportName: string, files: readonly IAgentHostDebugLogFile[], hostArtifact: IAgentHostDebugLogsHostArtifact | undefined): Promise<URI | undefined> { return this.instantiationService.invokeFunction(accessor => exportFilesToLocalFolder(accessor, exportName, files, hostArtifact)); } } @@ -349,6 +351,7 @@ export async function exportAgentHostDebugLogs( const exportService = accessor.get(IAgentHostDebugLogsExportService); const notificationService = accessor.get(INotificationService); const chatEntitlementService = accessor.get(IChatEntitlementService); + const clipboardService = accessor.get(IClipboardService); const fileService = accessor.get(IFileService); const logService = accessor.get(ILogService); const progressService = accessor.get(IProgressService); @@ -360,11 +363,9 @@ export async function exportAgentHostDebugLogs( delay: 500, }, () => collectAgentHostDebugLogs(accessor, activeSession, artifact => hostArtifact = artifact)); try { - const saved = await exportService.save(logs.exportName, logs.files, logs.hostArtifact); - if (saved) { - notificationService.warn(chatEntitlementService.isInternal - ? localize('exportDebugLogs.privacyWarning.internal', "Note: This log may contain personal information such as auth tokens, file contents, or terminal output. It MUST be shared privately via Slack or in an issue filed on the microsoft/vscode-internalbacklog repo.") - : localize('exportDebugLogs.privacyWarning', "Note: This log may contain personal information such as auth tokens, file contents, or terminal output. Please consider sharing privately or reviewing the contents carefully before sharing.")); + const savedResource = await exportService.save(logs.exportName, logs.files, logs.hostArtifact); + if (savedResource) { + notifyAgentHostDebugLogsExported(notificationService, clipboardService, chatEntitlementService.isInternal, savedResource); } } catch (error) { notificationService.notify({ @@ -388,6 +389,26 @@ export async function exportAgentHostDebugLogs( } } +export function notifyAgentHostDebugLogsExported( + notificationService: INotificationService, + clipboardService: IClipboardService, + isInternal: boolean, + savedResource: URI, +): void { + const savedPath = savedResource.scheme === Schemas.file ? savedResource.fsPath : savedResource.toString(true); + notificationService.notify({ + severity: Severity.Warning, + message: isInternal + ? localize('exportDebugLogs.privacyWarning.internal', "Note: This log may contain personal information such as auth tokens, file contents, or terminal output. It MUST be shared privately via Slack or in an issue filed on the microsoft/vscode-internalbacklog repo.") + : localize('exportDebugLogs.privacyWarning', "Note: This log may contain personal information such as auth tokens, file contents, or terminal output. Please consider sharing privately or reviewing the contents carefully before sharing."), + actions: { + primary: [ + new Action('copyAgentHostDebugLogsPath', localize('exportDebugLogs.copyPath', "Copy Path"), undefined, true, () => clipboardService.writeText(savedPath)), + ], + }, + }); +} + /** * Workbench-side action. Uses the last-focused chat widget's view model to * find the active Copilot CLI chat session. Suitable for vscode where the @@ -441,7 +462,7 @@ async function exportFilesToLocalFolder( exportName: string, files: readonly IAgentHostDebugLogFile[], hostArtifact: IAgentHostDebugLogsHostArtifact | undefined, -): Promise<boolean> { +): Promise<URI | undefined> { const fileDialogService = accessor.get(IFileDialogService); const fileService = accessor.get(IFileService); const logService = accessor.get(ILogService); @@ -455,7 +476,7 @@ async function exportFilesToLocalFolder( const parentFolder = folders?.[0]; if (!parentFolder) { - return false; + return undefined; } const exportFolder = joinPath(parentFolder, exportName); @@ -489,7 +510,7 @@ async function exportFilesToLocalFolder( await fileService.writeFile(target, source.value); } } - return true; + return exportFolder; } async function copyHostArtifactDirectory( diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts index eabc546178fc08..ecf4417386a857 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts @@ -7,6 +7,7 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; +import { Schemas } from '../../../../../../base/common/network.js'; import { autorun, type IObservable } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { basename, dirname, extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; @@ -134,10 +135,10 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto }; } - private toDirectoryItems(customization: DirectoryCustomization, source: AICustomizationSource, isRemote: boolean): ICustomizationItem[] { + private toDirectoryItems(customization: DirectoryCustomization, source: AICustomizationSource, isRemote: boolean, workingDirectories: readonly string[]): ICustomizationItem[] { const items: ICustomizationItem[] = []; for (const child of customization.children ?? []) { - const item = this.toDirectoryChildItem(child, source, isRemote); + const item = this.toDirectoryChildItem(child, getDirectoryChildSource(workingDirectories, child.uri, source), isRemote); if (item) { items.push(item); } @@ -320,7 +321,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto for (const sessionCustomization of directoryCustomizations) { const source = isUnderAnyRoot(workingDirectories, sessionCustomization.uri) ? AICustomizationSources.local : AICustomizationSources.user; const isRemote = sessionCustomization.clientId !== undefined; - for (const child of this.toDirectoryItems(sessionCustomization, source, isRemote)) { + for (const child of this.toDirectoryItems(sessionCustomization, source, isRemote, workingDirectories)) { items.set(child.itemKey ?? child.uri.toString(), { ...child, status: toStatusString(sessionCustomization.load), @@ -426,6 +427,17 @@ function isUnderAnyRoot(roots: readonly string[], childURI: string): boolean { return roots.some(root => isParentOrEqual(root, childURI)); } +function getDirectoryChildSource(roots: readonly string[], childURI: string, fallback: AICustomizationSource): AICustomizationSource { + try { + if (URI.parse(childURI).scheme !== Schemas.file) { + return fallback; + } + } catch { + return fallback; + } + return isUnderAnyRoot(roots, childURI) ? AICustomizationSources.local : AICustomizationSources.user; +} + function toStatusString(load: CustomizationLoadState | undefined): 'loading' | 'loaded' | 'degraded' | 'error' | undefined { return load?.kind; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index 346bf95d44e8d3..a2a28a1520b887 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -285,9 +285,6 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr const ahService = this._agentHostService; // Chat session contribution. - // Keep the delegation picker available for local agent host sessions in - // both VS Code and the Agents app so users can hand off (continue) their - // conversation to any other agent host session or remote target. store.add(this._chatSessionsService.registerChatSessionContribution({ type: sessionType, name: agentId, @@ -310,7 +307,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr }, onDidChangeRequiresCopilotSignIn: Event.signal(Event.filter(this._protectedResourcesService.onDidChange, provider => provider === agent.provider, store)), agentHostProviderId: agent.provider, - supportsDelegation: true, + supportsDelegation: false, capabilities: { supportsCheckpoints: true, supportsPromptAttachments: true, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts index 1ca811c4114c62..5028d80a5bbd77 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts @@ -82,7 +82,7 @@ function getConfigIcon(property: string, value: unknown | undefined): ThemeIcon if (value === 'assisted') { return Codicon.sparkle; } - return Codicon.shield; + return Codicon.key; } if (property === ClaudeSessionConfigKey.PermissionMode && typeof value === 'string') { switch (value) { @@ -140,7 +140,7 @@ export function getAgentHostSandboxSettingId(sessionType: string | undefined, cu return getAgentHostCopilotSandboxSettingId(customTerminalToolEnabled, windows); } -export function getConfigPickerTriggerLabel(schema: SessionConfigPropertySchema, value: unknown | undefined, sandboxed: boolean): string { +export function getConfigPickerTriggerLabel(schema: SessionConfigPropertySchema, value: unknown | undefined): string { let label: string; if (schema.type === 'boolean') { label = value === true @@ -152,6 +152,10 @@ export function getConfigPickerTriggerLabel(schema: SessionConfigPropertySchema, } else { label = schema.title; } + return label; +} + +export function getConfigPickerAccessibleTriggerLabel(label: string, sandboxed: boolean): string { return sandboxed ? localize('agentHostChatInputPicker.sandboxedLabel', "{0} (sandboxed)", label) : label; @@ -186,7 +190,7 @@ function getEnumValueDescription(schema: SessionConfigPropertySchema, value: unk return index >= 0 ? schema.enumDescriptions?.[index] : undefined; } -export function getConfigPickerTriggerHover(property: string, schema: SessionConfigPropertySchema, value: unknown | undefined, isReadOnly: boolean): string { +export function getConfigPickerTriggerHover(property: string, schema: SessionConfigPropertySchema, value: unknown | undefined, isReadOnly: boolean, sandboxed = false): string { if (property === CodexSessionConfigKey.PermissionsPreset) { return getEnumValueDescription(schema, value) ?? schema.description ?? schema.title; } @@ -194,9 +198,12 @@ export function getConfigPickerTriggerHover(property: string, schema: SessionCon return schema.description ?? schema.title; } - const hover = getAutoApproveHover(value, getEnumValueDescription(schema, value)); + let hover = getAutoApproveHover(value, getEnumValueDescription(schema, value)); if (isReadOnly) { - return localize('agentHostChatInputPicker.approvalsLevelHoverReadOnly', "{0} Read-only.", hover); + hover = localize('agentHostChatInputPicker.approvalsLevelHoverReadOnly', "{0} Read-only.", hover); + } + if (sandboxed) { + hover = localize('agentHostChatInputPicker.approvalsLevelHoverSandboxed', "{0} Terminal commands are sandboxed.", hover); } return hover; } @@ -546,10 +553,9 @@ export class AgentHostChatInputPicker extends Disposable { const isReadOnly = !!ctx.schema.readOnly || (isStartedSession && ctx.schema.sessionMutable === false); const trigger = renderPickerTrigger(slot, isReadOnly, this._renderDisposables, () => this._showPicker(trigger)); this._trigger = trigger; - const tooltip = getConfigPickerTriggerHover(this._property, ctx.schema, ctx.value, isReadOnly); - if (tooltip) { - this._renderDisposables.add(this._hoverService.setupDelayedHover(trigger, { content: tooltip })); - } + this._renderDisposables.add(this._hoverService.setupDelayedHover(trigger, () => ({ + content: getConfigPickerTriggerHover(this._property, ctx.schema, ctx.value, isReadOnly, this._isSandboxed()) + }))); this._renderTrigger(trigger, ctx.schema, ctx.value, isReadOnly); } @@ -566,12 +572,19 @@ export class AgentHostChatInputPicker extends Disposable { trigger.classList.toggle('warning', value === 'autopilot' || value === 'assisted'); trigger.classList.toggle('info', value === 'autoApprove'); } - const label = this._labelFor(schema, value); + const label = getConfigPickerTriggerLabel(schema, value); const labelSpan = dom.append(trigger, dom.$('span.agent-host-chat-input-picker-label')); labelSpan.textContent = label; + const sandboxed = this._isSandboxed(); + if (sandboxed) { + const sandboxIcon = dom.append(trigger, renderIcon(Codicon.shield)); + sandboxIcon.classList.add('agent-host-chat-input-picker-sandbox-icon'); + sandboxIcon.ariaHidden = 'true'; + } + const accessibleLabel = getConfigPickerAccessibleTriggerLabel(label, sandboxed); trigger.setAttribute('aria-label', isReadOnly - ? localize('agentHostChatInputPicker.triggerAriaReadOnly', "{0}: {1}, Read-Only", schema.title, label) - : localize('agentHostChatInputPicker.triggerAria', "{0}: {1}", schema.title, label)); + ? localize('agentHostChatInputPicker.triggerAriaReadOnly', "{0}: {1}, Read-Only", schema.title, accessibleLabel) + : localize('agentHostChatInputPicker.triggerAria', "{0}: {1}", schema.title, accessibleLabel)); } private _refreshTrigger(): void { @@ -586,11 +599,10 @@ export class AgentHostChatInputPicker extends Disposable { this._renderTrigger(trigger, ctx.schema, ctx.value, isReadOnly); } - private _labelFor(schema: SessionConfigPropertySchema, value: unknown | undefined): string { - const sandboxed = this._property === SessionConfigKey.AutoApprove + private _isSandboxed(): boolean { + return this._property === SessionConfigKey.AutoApprove && this._isSandboxToggleSettingEnabled() && this._isSandboxingEnabled(); - return getConfigPickerTriggerLabel(schema, value, sandboxed); } private _readContext(): { backendSession: URI; schema: SessionConfigPropertySchema; value: unknown | undefined } | undefined { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts index 5d013ce2ff325c..bf4571c1ec3ad0 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts @@ -434,7 +434,7 @@ export function getPresentableMcpServerCustomizations(customizations: readonly C return entries.filter(entry => entry.isTopLevel || !topLevelNames.has(entry.server.name)); } -class WorkbenchAgentHostCustomizationService extends AbstractAgentHostCustomizationService { +export class WorkbenchAgentHostCustomizationService extends AbstractAgentHostCustomizationService { private readonly _sessionStateSubscriptions = this._register(new DisposableResourceMap<IDisposable & { readonly connection: IAgentConnection; readonly backendSession: URI; readonly sub: IAgentSubscription<SessionState> }>()); @@ -484,14 +484,17 @@ class WorkbenchAgentHostCustomizationService extends AbstractAgentHostCustomizat return undefined; } const sessionState = this._readSessionState(sessionResource); + const workingDirectories = sessionState === undefined + ? this._provisionalSessionService.getProvisionalWorkingDirectories(sessionResource)?.map(uri => uri.toString()) + : sessionState.workingDirectories; const rootState = target.connection.rootState.value; const channel = target.backendSession.toString(); return { customizations: sessionState?.customizations ?? [], resourceUris: target.connection.resourceUris, folderPickerDecision: readSessionFolderPickerDecision(sessionState?._meta), - workingDirectory: sessionState?.workingDirectories?.[0], - workingDirectories: sessionState?.workingDirectories, + workingDirectory: workingDirectories?.[0], + workingDirectories, rootConfig: rootState && !(rootState instanceof Error) ? rootState.config : undefined, isBundledMcpServer: (pluginUri, serverName) => this._activeClientService.isBundledMcpServer(pluginUri, serverName), authenticate: request => target.connection.authenticate(request), @@ -527,8 +530,9 @@ class WorkbenchAgentHostCustomizationService extends AbstractAgentHostCustomizat private _readSessionState(sessionResource: URI): SessionState | undefined { const target = this._resolveSessionTarget(sessionResource); - const value = target ? this._ensureSessionStateSubscription(sessionResource, target)?.sub.value : undefined; - return value && !(value instanceof Error) ? value : undefined; + const subscription = target ? this._ensureSessionStateSubscription(sessionResource, target)?.sub : undefined; + const value = subscription?.value; + return value instanceof Error ? subscription?.verifiedValue : value; } private _ensureSessionStateSubscription(sessionResource: URI, target: IAgentHostSessionResolution): (IDisposable & { readonly connection: IAgentConnection; readonly backendSession: URI; readonly sub: IAgentSubscription<SessionState> }) | undefined { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts index 455c2e36dbdde9..984ef4715e6204 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts @@ -7,7 +7,6 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; -import { formatTokenCount } from '../../../../../../base/common/numbers.js'; import { localize } from '../../../../../../nls.js'; import { readAgentModelNoticesMeta } from '../../../../../../platform/agentHost/common/agentModelNotices.js'; import { ConfigSchema, SessionModelInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; @@ -18,20 +17,6 @@ import { getReasoningEffortDescription, getReasoningEffortLabel } from '../../.. import { nullExtensionDescription } from '../../../../../services/extensions/common/extensions.js'; import { AUTO_RAW_MODEL_ID, COPILOT_VENDOR_ID, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelConfigurationSchema, ILanguageModelsService } from '../../../common/languageModels.js'; -/** - * Config key naming the context-window tier a host accepts on a model selection. Its values are - * tier names (`default` / `long_context`), because a host driving the Copilot SDK has no per-model - * token counts to offer. - */ -const CONTEXT_TIER_CONFIG_KEY = 'contextTier'; - -/** - * Config key naming the numeric context-window picker the workbench's own Copilot catalogue - * synthesizes from CAPI billing. Read here only as the source of the token counts used to label - * {@link CONTEXT_TIER_CONFIG_KEY}; it is never surfaced to a host, which would not understand it. - */ -const CONTEXT_SIZE_CONFIG_KEY = 'contextSize'; - /** * Returns whether an agent host provider exposes a synthetic "Auto" model to * fall back to. @@ -171,7 +156,7 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu toolCalling: true, agentMode: true, }, - configurationSchema: this._toLanguageModelConfigurationSchema(m.configSchema, known), + configurationSchema: this._toLanguageModelConfigurationSchema(m.configSchema), }, }; }); @@ -180,14 +165,18 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu /** * The workbench catalogue entry describing the same model, when one is known. * - * A host that derives its model list from the Copilot SDK advertises only what the SDK gave it: - * no billing, and no per-tier context windows. The workbench already holds that detail for the - * same models — the Copilot vendor's catalogue is CAPI-backed — so the two are matched by model - * id and the host's list is enriched from it, which is how the GitHub desktop app renders real - * token counts for a sandbox session. + * A host driving the Copilot SDK advertises no billing and, where the SDK omits them, no token + * limits — so the CAPI-backed Copilot catalogue fills the gaps. * - * Restricted to models billed through Copilot (a `copilot` picker group), so a model reached - * over a direct third-party transport is never labelled with Copilot's prices. + * Matching by raw model id does not prove the two describe the same offering — they can differ + * by entitlement, policy, rollout or billing route — so this is restricted to models billed + * through Copilot and only ever fills fields the host left absent. + * + * The token-limit half is a shim: those are native protocol fields, and a host that populates + * them makes the fallback dead. The billing half is not. Pricing is deliberately kept off the + * agent host protocol as operator-sensitive, so a client that wants to show it has to source it + * itself. Removing this join therefore means dropping pricing for these models, which is a + * product decision rather than a cleanup. */ private _catalogueEntryFor(model: SessionModelInfo, group: ILanguageModelChatMetadata['modelGroup']): ILanguageModelChatMetadata | undefined { if (!this._catalogue || group?.id !== COPILOT_VENDOR_ID) { @@ -203,45 +192,17 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu } /** - * The distinct context-window sizes a catalogue entry offers, ascending, or `undefined` when it - * offers no real choice. Sourced from the numeric `contextSize` picker the Copilot catalogue - * synthesizes from CAPI billing, which is the only place these token counts exist. - */ - private static _contextWindowTiers(metadata: ILanguageModelChatMetadata | undefined): number[] | undefined { - const values = metadata?.configurationSchema?.properties?.[CONTEXT_SIZE_CONFIG_KEY]?.enum; - if (!values?.length) { - return undefined; - } - const sizes = [...new Set(values.filter((value): value is number => typeof value === 'number'))].sort((a, b) => a - b); - return sizes.length > 1 ? sizes : undefined; - } - - /** - * Labels for a host's `contextTier` enum, as token counts rather than tier names. + * Translate a host's model {@link ConfigSchema} into the picker's schema shape. * - * The host names the tiers (`default` / `long_context`) because the SDK exposes no per-model - * windows, but the picker is far more useful showing "264K" / "1M" — what the GitHub desktop - * app displays for the same session. The wire value stays the tier name the host accepts; only - * the label changes. + * Values and display text belong to the producer; the workbench only picks the group + * ({@link _groupForConfigKey}). The one exception is a reasoning-effort enum with no display + * text at all, labelled locally rather than rendering raw values like `xhigh`. * - * Returns `undefined` when the catalogue offers no distinct long-context tier (or does not know - * the model), which drops the property and hides the picker rather than offering a choice that - * has no effect — matching how the desktop app suppresses it. + * A property is never dropped for want of local enrichment: a host advertises one because it + * will honour it, and a new model, a staged rollout and an unresolved catalogue all look + * identical from here. */ - private static _contextTierLabels(values: readonly unknown[] | undefined, known: ILanguageModelChatMetadata | undefined): string[] | undefined { - const tiers = AgentHostLanguageModelProvider._contextWindowTiers(known); - if (!tiers || !values?.length) { - return undefined; - } - // The host orders its tiers from smallest window to largest, so they align with the sorted - // sizes by position. A tier list of a different length is not one this mapping understands. - if (values.length !== tiers.length || !values.every(value => typeof value === 'string')) { - return undefined; - } - return tiers.map(formatTokenCount); - } - - private _toLanguageModelConfigurationSchema(schema: ConfigSchema | undefined, known?: ILanguageModelChatMetadata): ILanguageModelConfigurationSchema | undefined { + private _toLanguageModelConfigurationSchema(schema: ConfigSchema | undefined): ILanguageModelConfigurationSchema | undefined { if (!schema) { return undefined; } @@ -255,25 +216,13 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu ? AgentHostLanguageModelProvider._reasoningEffortDisplay(key, property.enum) : undefined; - let enumItemLabels = property.enumLabels ?? effortDisplay?.labels; - if (key === CONTEXT_TIER_CONFIG_KEY) { - const tierLabels = AgentHostLanguageModelProvider._contextTierLabels(property.enum, known); - if (!tierLabels) { - // No real choice to offer (or no catalogue entry to size it with): drop the - // property so the picker hides rather than showing tier names that read as a - // setting the user cannot evaluate. - continue; - } - enumItemLabels = tierLabels; - } - properties[key] = { type: property.type, title: property.title, description: property.description, default: property.default, enum: property.enum, - enumItemLabels, + enumItemLabels: property.enumLabels ?? effortDisplay?.labels, enumDescriptions: property.enumDescriptions ?? effortDisplay?.descriptions, readOnly: property.readOnly, group: AgentHostLanguageModelProvider._groupForConfigKey(key), @@ -332,7 +281,7 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu // choose a thinking level or a context window. case 'reasoningEffort': return 'navigation'; case 'contextSize': - case CONTEXT_TIER_CONFIG_KEY: return 'tokens'; + case 'contextTier': return 'tokens'; default: return undefined; } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts index 1bdf457ab68ef2..9c7716bf8828f7 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts @@ -105,6 +105,9 @@ export interface IAgentHostUntitledProvisionalSessionService { */ get(sessionResource: URI): URI | undefined; + /** Working directories used to create the current provisional generation. */ + getProvisionalWorkingDirectories(sessionResource: URI): readonly URI[] | undefined; + /** * Initial config the editor window applies to every new Agent Host session. * Returns `undefined` in the Agents window, where the sessions provider owns @@ -373,6 +376,14 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple return this._generationMatchingDesiredState(entry)?.backendSession; } + getProvisionalWorkingDirectories(sessionResource: URI): readonly URI[] | undefined { + const entry = this._entries.get(sessionResource); + if (!entry || entry.disposed) { + return undefined; + } + return this._generationMatchingDesiredState(entry)?.workingDirectories; + } + private _computeWorkingDirectories(primary: URI | undefined, provider: string): readonly URI[] | undefined { return computeWorkingDirectories(primary, this._workspaceContextService.getWorkspace().folders.map(folder => folder.uri), this._agentHostService.rootState.value, provider); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css index 58e754bf226518..5689d7f3fb9c5c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css @@ -95,6 +95,10 @@ display: none; } +.interactive-session .compact-picker .agent-host-chat-input-picker-sandbox-icon { + display: none; +} + .interactive-session .compact-picker .agent-host-chat-input-picker-slot .action-label { box-sizing: border-box; width: 22px; @@ -148,3 +152,8 @@ .agent-host-chat-input-picker-slot .action-label .codicon + .agent-host-chat-input-picker-label { margin-left: 6px; } + +.agent-host-chat-input-picker-sandbox-icon { + flex-shrink: 0; + margin-left: var(--vscode-spacing-size60); +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 44d02bf689e634..d413de68f5e421 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -1572,6 +1572,10 @@ function buildTerminalToolSpecificData( ...existing, kind: 'terminal', commandLine, + // Read-only for the same reason as a generic confirmation input: this + // adapter never returns an edited command to the host, so an editable + // field would collect a change and then run what the agent proposed. + editable: false, intention: tc.intention ?? existing?.intention, language: existing?.language ?? getTerminalLanguage(tc), autoApproveRuleResolvable: readToolCallMeta(tc).autoApproveRuleResolvable ?? existing?.autoApproveRuleResolvable, @@ -2354,7 +2358,11 @@ export function toolCallStateToInvocation(tc: ToolCallState, subAgentInvocationI if (toolInput) { let rawInput: unknown; try { rawInput = JSON.parse(toolInput); } catch { rawInput = { input: toolInput }; } - toolSpecificData = { kind: 'input', rawInput }; + // Read-only regardless of `tc.editable`: approving with an edited input means + // sending it back as `chat/toolCallConfirmed.editedToolInput`, which this adapter + // does not do, so an editable field would collect a change and then run the + // command the agent originally proposed. + toolSpecificData = { kind: 'input', rawInput, editable: false }; } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index 931b7b85cf3ec3..985f11a577531a 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -802,7 +802,10 @@ class AICustomizationManagementActionsContribution extends Disposable implements } const input = AICustomizationManagementEditorInput.getOrCreate(); - input.setTargetLabel(harnessService.getActiveDescriptor().label); + input.setTargetLabels( + harnessService.getActiveDescriptor().label, + accessor.get(IAICustomizationWorkspaceService).activeProjectLabel.get(), + ); const pane = await editorService.openEditor(input, { pinned: true }); if (section && pane instanceof AICustomizationManagementEditor) { pane.selectSectionById(section); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 90e17c1db674bb..9108eeedc3b1b8 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -439,11 +439,13 @@ export class AICustomizationManagementEditor extends EditorPane { this.inEditorContextKey = CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_EDITOR.bindTo(contextKeyService); this.sectionContextKey = CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_SECTION.bindTo(contextKeyService); this.harnessContextKey = CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_HARNESS.bindTo(contextKeyService); - this.updateHarnessLabelPresentation(); + this.updateTargetLabelPresentation(); // Track workspace changes for embedded editor this._register(autorun(reader => { this.workspaceService.activeProjectRoot.read(reader); + this.workspaceService.activeProjectLabel.read(reader); + this.updateTargetLabelPresentation(); if (this.viewMode === 'editor') { this.currentEditingProjectRoot = this.workspaceService.getActiveProjectRoot(); } @@ -585,11 +587,12 @@ export class AICustomizationManagementEditor extends EditorPane { return label || (this.workspaceService.isSessionsWindow ? '' : localize('localHarnessLabel', "Local")); } - private updateHarnessLabelPresentation(): void { + private updateTargetLabelPresentation(): void { const harnessLabel = this.getActiveHarnessLabel(); + const workspaceLabel = this.workspaceService.activeProjectLabel.get(); this.welcomePage?.setHarnessLabel(harnessLabel); if (this.input instanceof AICustomizationManagementEditorInput) { - this.input.setTargetLabel(harnessLabel); + this.input.setTargetLabels(harnessLabel, workspaceLabel); } } @@ -761,7 +764,7 @@ export class AICustomizationManagementEditor extends EditorPane { } private updateHomeButtonHarnessPresentation(): void { - this.updateHarnessLabelPresentation(); + this.updateTargetLabelPresentation(); if (!this.homeButton || !this.homeButtonIcon || !this.homeButtonLabel) { return; @@ -2285,7 +2288,7 @@ export class AICustomizationManagementEditor extends EditorPane { }); await super.setInput(input, options, context, token); - input.setTargetLabel(this.getActiveHarnessLabel()); + input.setTargetLabels(this.getActiveHarnessLabel(), this.workspaceService.activeProjectLabel.get()); if (this.dimension) { this.layout(this.dimension); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.ts index 56800039fe1be8..1f37a9e82add7e 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.ts @@ -23,7 +23,8 @@ export class AICustomizationManagementEditorInput extends EditorInput implements private _isDirty = false; private _saveHandler?: () => Promise<boolean>; - private _targetLabel: string | undefined; + private _harnessLabel: string | undefined; + private _workspaceLabel: string | undefined; override get capabilities(): EditorInputCapabilities { return super.capabilities | EditorInputCapabilities.Singleton | EditorInputCapabilities.RequiresModal; @@ -54,16 +55,27 @@ export class AICustomizationManagementEditorInput extends EditorInput implements } override getName(): string { - if (this._targetLabel) { - return localize('aiCustomizationManagementEditorNameWithTarget', "Agent Customizations - {0}", this._targetLabel); - } return localize('aiCustomizationManagementEditorName', "Agent Customizations"); } + override getDescription(): string | undefined { + if (this._harnessLabel && this._workspaceLabel) { + return localize('aiCustomizationManagementEditorDescriptionWithHarnessAndWorkspace', "({0} · {1})", this._harnessLabel, this._workspaceLabel); + } + if (this._harnessLabel || this._workspaceLabel) { + return localize('aiCustomizationManagementEditorDescriptionWithTarget', "({0})", this._harnessLabel ?? this._workspaceLabel); + } + return undefined; + } + override getIcon(): ThemeIcon { return Codicon.settingsGear; } + override getLabelExtraClasses(): string[] { + return ['ai-customization-management-editor-label']; + } + getModalEditorOptions(): IModalEditorOptions { return { compactHeader: true }; } @@ -102,11 +114,12 @@ export class AICustomizationManagementEditorInput extends EditorInput implements this._saveHandler = handler; } - setTargetLabel(label: string | undefined): void { - if (this._targetLabel === label) { + setTargetLabels(harnessLabel: string | undefined, workspaceLabel?: string): void { + if (this._harnessLabel === harnessLabel && this._workspaceLabel === workspaceLabel) { return; } - this._targetLabel = label; + this._harnessLabel = harnessLabel; + this._workspaceLabel = workspaceLabel; this._onDidChangeLabel.fire(); } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts index b9fbbd9bef525b..65692071f8d1d1 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts @@ -24,6 +24,7 @@ class AICustomizationWorkspaceService implements IAICustomizationWorkspaceServic declare readonly _serviceBrand: undefined; readonly activeProjectRoot: IObservable<URI | undefined>; + readonly activeProjectLabel: IObservable<string | undefined>; constructor( @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @@ -39,6 +40,10 @@ class AICustomizationWorkspaceService implements IAICustomizationWorkspaceServic const folders = workspaceFolders.read(reader); return folders[0]?.uri; }); + this.activeProjectLabel = derived(reader => { + const folders = workspaceFolders.read(reader); + return folders[0]?.name; + }); } getActiveProjectRoot(): URI | undefined { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/agentGlobalConfigurationSettings.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/agentGlobalConfigurationSettings.css index adfe8c4553f289..e274d2ed82e222 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/agentGlobalConfigurationSettings.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/agentGlobalConfigurationSettings.css @@ -58,7 +58,6 @@ .agent-global-configuration-settings-card { border: var(--vscode-strokeThickness) solid var(--vscode-agentsPanel-border); border-radius: var(--vscode-cornerRadius-small); - background: color-mix(in srgb, var(--vscode-foreground) 8%, var(--vscode-agentsPanel-background)); overflow: hidden; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index 766e6d6603db51..e83efb3cc2ba37 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -2517,7 +2517,6 @@ per-word capitalization does not survive translation. */ gap: 0; border: var(--vscode-strokeThickness) solid var(--vscode-agentsPanel-border); border-radius: var(--vscode-cornerRadius-medium); - background: color-mix(in srgb, var(--vscode-foreground) 8%, var(--vscode-agentsPanel-background)); overflow: hidden; } @@ -2838,7 +2837,7 @@ per-word capitalization does not survive translation. */ width: auto; } -.plugin-list-widget .plugin-card-section-actions .plugin-update-available-button.monaco-button { +.plugin-list-widget .plugin-card-section-actions .plugin-update-button.monaco-button { width: var(--vscode-spacing-size280); min-width: var(--vscode-spacing-size280); padding: var(--vscode-spacing-size40) 0; @@ -2937,7 +2936,6 @@ per-word capitalization does not survive translation. */ padding: var(--vscode-spacing-size120); border: var(--vscode-strokeThickness) solid var(--vscode-agentsPanel-border); border-radius: var(--vscode-cornerRadius-large); - background: color-mix(in srgb, var(--vscode-foreground) 8%, var(--vscode-agentsPanel-background)); color: var(--vscode-foreground); box-sizing: border-box; cursor: pointer; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts index 0885ce840f8b43..a6d1729aa6b96c 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts @@ -1264,6 +1264,7 @@ export class PluginListWidget extends Disposable { const content = this.createCardScrollContent(); const installedPlugins = this.installedItems; + const hasMarketplaceInstalledPlugins = this.pluginMarketplaceService.installedPlugins.get().length > 0; this.renderDiscoverySnapshot(content); if (shouldLoadPluginMarketplaceSnapshot(this.visible, this.marketplaceSnapshot.state, this.isBrowseMarketplaceAvailable())) { @@ -1276,7 +1277,7 @@ export class PluginListWidget extends Disposable { undefined, 'installed-plugins-section', installedPlugins.length, - header => this.renderInstalledSectionActions(header), + header => this.renderInstalledSectionActions(header, hasMarketplaceInstalledPlugins), ); installedList.classList.add('plugin-inventory-list'); if (installedPlugins.length === 0) { @@ -1309,7 +1310,7 @@ export class PluginListWidget extends Disposable { this.renderAvailablePlugins(content, this.getUninstalledMarketplaceItems(this.marketplaceSnapshot.items), true); } - private renderInstalledSectionActions(header: HTMLElement): void { + private renderInstalledSectionActions(header: HTMLElement, hasInstalledPlugins: boolean): void { const actions = DOM.append(header, $('.plugin-card-section-actions')); const createLabel = localize('createPlugin', "Create Plugin"); const create = this.installedCreateButton = this.cardDisposables.add(new Button(actions, { ...defaultButtonStyles, secondary: true, ariaLabel: createLabel })); @@ -1317,6 +1318,14 @@ export class PluginListWidget extends Disposable { this.updateInstalledCreateButtonLabel(); this.rememberCardFocusElement(create.element); this.cardDisposables.add(create.onDidClick(() => this.runCreatePluginAction())); + + if (hasInstalledPlugins) { + const updateLabel = localize('checkForAndApplyPluginUpdates', "Check for and apply updates"); + const update = this.cardDisposables.add(new Button(actions, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: updateLabel, ariaLabel: updateLabel })); + update.element.classList.add('plugin-card-icon-button', 'plugin-update-button'); + update.label = `$(${Codicon.refresh.id})`; + this.cardDisposables.add(update.onDidClick(() => this.runUpdatePluginsAction(update))); + } } private renderAvailablePlugins( @@ -1375,12 +1384,6 @@ export class PluginListWidget extends Disposable { install.label = installLabel; this.cardDisposables.add(install.onDidClick(() => this.runInstallFromSourceAction())); } - - const updateLabel = localize('updatePlugins', "Update Plugins"); - const update = this.cardDisposables.add(new Button(actions, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: updateLabel, ariaLabel: updateLabel })); - update.element.classList.add('plugin-card-icon-button', 'plugin-update-available-button'); - update.label = `$(${Codicon.refresh.id})`; - this.cardDisposables.add(update.onDidClick(() => this.runUpdatePluginsAction(update))); } private appendInstalledPluginRow(parent: HTMLElement, item: IInstalledPluginItem): void { diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 07108033a66ac9..fc0a697f67d905 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -459,6 +459,14 @@ configurationRegistry.registerConfiguration({ experiment: { mode: 'auto' }, agentHost: { key: AgentHostShowExternalSessionsConfigKey }, }, + [ChatConfiguration.ConsolidatedRemoteWorkspaces]: { + type: 'boolean', + default: false, + scope: ConfigurationScope.APPLICATION, + description: nls.localize('chat.agentSessions.consolidatedRemoteWorkspaces', "Controls whether GitHub and remote workspaces are combined under Remote in the Agents Window workspace picker, with search always available."), + tags: ['preview'], + experiment: { mode: 'auto' }, + }, [ChatConfiguration.SaveBeforeSend]: { type: 'boolean', description: nls.localize('chat.saveBeforeSend', "Controls whether all dirty editors except untitled editors are saved before sending a chat message."), diff --git a/src/vs/workbench/contrib/chat/browser/promptTimeline/promptTimelineModel.ts b/src/vs/workbench/contrib/chat/browser/promptTimeline/promptTimelineModel.ts index d1b499919528e7..5fecd9ad47477e 100644 --- a/src/vs/workbench/contrib/chat/browser/promptTimeline/promptTimelineModel.ts +++ b/src/vs/workbench/contrib/chat/browser/promptTimeline/promptTimelineModel.ts @@ -11,10 +11,10 @@ import { basename, isEqual } from '../../../../../base/common/resources.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IMultiDiffEditorOptions } from '../../../../../editor/common/multiDiffEditor.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { MultiDiffEditorInput } from '../../../multiDiffEditor/browser/multiDiffEditorInput.js'; import { MultiDiffEditorItem } from '../../../multiDiffEditor/browser/multiDiffSourceResolverService.js'; -import { IMultiDiffEditorOptions } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; import { ChatWidget } from '../widget/chatWidget.js'; import { getChatRequestText } from '../chatRequestText.js'; import { ChatTreeItem } from '../chat.js'; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatAgentMergeContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatAgentMergeContentPart.ts index bf499d7d29ada1..ee55faad376e84 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatAgentMergeContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatAgentMergeContentPart.ts @@ -6,11 +6,12 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js'; import { Button, IButtonStyles } from '../../../../../../base/browser/ui/button/button.js'; +import { Action } from '../../../../../../base/common/actions.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { shorten } from '../../../../../../base/common/labels.js'; -import { Disposable, IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { posix } from '../../../../../../base/common/path.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -20,6 +21,8 @@ import { CommandsRegistry, ICommandService } from '../../../../../../platform/co import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { ChatPillActionViewItem } from '../../../../../browser/chatPills.js'; +import { formatChatRequestTimestamp } from '../../../common/chatProgressFormatting.js'; import { AgentFeedbackReviewCommandId, IChatAgentFeedbackPullRequestThreadLink } from '../../../common/chatService/chatService.js'; import { IChatRequestViewModel } from '../../../common/model/chatViewModel.js'; import './media/chatAgentMergeContent.css'; @@ -62,38 +65,45 @@ interface IAgentMergeCommentItem extends IAgentMergeFileLocation { readonly threadId?: string; } -const agentMergeTitle = localize('chat.agentMerge.title', "Agent Merge"); +const agentMergeSource = localize('chat.agentMerge.source', "Agent Merge"); -/** The counts shown next to the title, describing why the turn was started. */ -function describeBadges(summary: IAgentMergePromptSummary, commentCount: number): { readonly icon: ThemeIcon; readonly label: string }[] { - const badges: { icon: ThemeIcon; label: string }[] = []; +/** The status shown in the header, describing why the turn was started. */ +function describeAgentMergeStatus(summary: IAgentMergePromptSummary, commentCount: number): string { + const events: string[] = []; if (commentCount > 0) { - badges.push({ - icon: Codicon.commentCompact, - label: commentCount === 1 - ? localize('chat.agentMerge.oneComment', "1 comment") - : localize('chat.agentMerge.comments', "{0} comments", commentCount), - }); + events.push(commentCount === 1 + ? localize('chat.agentMerge.oneReviewComment', "1 Review Comment") + : localize('chat.agentMerge.reviewComments', "{0} Review Comments", commentCount)); } if (summary.failedChecks.length > 0) { - badges.push({ - icon: Codicon.errorCompact, - label: summary.failedChecks.length === 1 - ? localize('chat.agentMerge.oneCheck', "1 check failing") - : localize('chat.agentMerge.checks', "{0} checks failing", summary.failedChecks.length), - }); + events.push(summary.failedChecks.length === 1 + ? localize('chat.agentMerge.oneFailingCheck', "1 Failing Check") + : localize('chat.agentMerge.failingChecks', "{0} Failing Checks", summary.failedChecks.length)); } - if (badges.length === 0) { - badges.push({ - icon: summary.conflicting ? Codicon.warningCompact : Codicon.arrowDown, - label: summary.conflicting - ? localize('chat.agentMerge.conflicting', "Merge conflicts") - : summary.behind - ? localize('chat.agentMerge.behind', "Behind base branch") - : localize('chat.agentMerge.upToDate', "No pending feedback"), - }); + if (summary.conflicting) { + events.push(localize('chat.agentMerge.mergeConflicts', "Merge Conflicts")); + } + if (summary.behind) { + events.push(localize('chat.agentMerge.behindBaseBranch', "Behind Base Branch")); + } + if (events.length === 0) { + events.push(localize('chat.agentMerge.noPendingFeedback', "No Pending Feedback")); + } + + return formatAgentMergeEvents(events); +} + +function formatAgentMergeEvents(events: readonly string[]): string { + switch (events.length) { + case 1: + return events[0]; + case 2: + return localize('chat.agentMerge.twoEvents', "{0} and {1}", events[0], events[1]); + case 3: + return localize('chat.agentMerge.threeEvents', "{0}, {1}, and {2}", events[0], events[1], events[2]); + default: + return localize('chat.agentMerge.fourEvents', "{0}, {1}, {2}, and {3}", events[0], events[1], events[2], events[3]); } - return badges; } /** @@ -102,8 +112,8 @@ function describeBadges(summary: IAgentMergePromptSummary, commentCount: number) * transcript find use this in its place. */ export function getAgentMergeSummaryLabel(summary: IAgentMergePromptSummary): string { - const badges = describeBadges(summary, collectComments(summary).length); - return [agentMergeTitle, ...badges.map(badge => badge.label)].join(', '); + const status = describeAgentMergeStatus(summary, collectComments(summary).length); + return localize('chat.agentMerge.summaryLabel', "{0}, {1}", status, agentMergeSource); } /** @@ -119,16 +129,7 @@ export function getAgentMergeRequestLabel(element: IChatRequestViewModel): strin return summary && getAgentMergeSummaryLabel(summary); } -/** - * Renders the machine-facing Agent Merge prompt as a compact disclosure: a - * header naming the pull request work in progress with counts of the review - * feedback and failing checks that triggered it, expanding to the feedback - * itself and a nested disclosure holding the verbatim instructions sent to the - * agent. - * - * Review threads the session mirrored into agent feedback link to their local - * comment, so a click on the file label reveals that comment in the editor. - */ +/** Renders the Agent Merge prompt as a compact disclosure whose header action switches between merge details and the agent message. Mirrored review file labels link to their local comments. */ export class ChatAgentMergeContentPart extends Disposable { readonly domNode: HTMLElement; @@ -140,6 +141,7 @@ export class ChatAgentMergeContentPart extends Disposable { private readonly _summary: IAgentMergePromptSummary, private readonly _sessionResource: URI, private readonly _markdownRenderer: IMarkdownRenderer, + timestamp: number | undefined, @IOpenerService private readonly _openerService: IOpenerService, @IHoverService private readonly _hoverService: IHoverService, @ICommandService private readonly _commandService: ICommandService, @@ -150,34 +152,96 @@ export class ChatAgentMergeContentPart extends Disposable { this._fileLabels = describeAgentMergeFileLabels(this._comments); this.domNode = dom.$('.chat-agent-merge'); - this._createHeader(this.domNode); - - const body = dom.append(this.domNode, dom.$('.chat-agent-merge-body')); - this._createPullRequestRow(body); - this._createCommentsSection(body); - this._createChecksSection(body); - this._createAgentMessageSection(body); + const card = dom.append(this.domNode, dom.$('.chat-agent-merge-card')); + this._createHeader(card); + + const body = dom.append(card, dom.$('.chat-agent-merge-body')); + const details = dom.append(body, dom.$('.chat-agent-merge-details')); + this._createPullRequestRow(details); + this._createCommentsSection(details); + this._createChecksSection(details); + this._createAgentMessage(body); + this._createMetadata(this.domNode, timestamp); } private _createHeader(parent: HTMLElement): void { - const button = this._register(new Button(parent, { ...transparentButtonStyles, title: false })); - button.element.classList.add('chat-agent-merge-header'); + const header = dom.append(parent, dom.$('.chat-agent-merge-header')); + const handlePointerFocus = (button: Button) => { + this._register(dom.addDisposableListener(button.element, dom.EventType.POINTER_DOWN, event => { + if (event.pointerType !== 'mouse') { + this.domNode.classList.add('direct-pointer-input'); + return; + } + this.domNode.classList.remove('direct-pointer-input'); + event.preventDefault(); + button.element.blur(); + })); + }; + const disclosureButton = this._register(new Button(header, { ...transparentButtonStyles, title: false })); + disclosureButton.element.classList.add('chat-agent-merge-header-disclosure'); + + const status = describeAgentMergeStatus(this._summary, this._comments.length); + const content = dom.append(header, dom.$('.chat-agent-merge-header-content', { 'aria-hidden': 'true' })); + dom.append(content, dom.$('span.chat-agent-merge-title', undefined, status)); + this._register(this._hoverService.setupDelayedHover(disclosureButton.element, { content: status })); + + let messageButton: Button | undefined; + const setExpanded = (expanded: boolean) => { + this.domNode.classList.toggle('collapsed', !expanded); + disclosureButton.element.ariaExpanded = String(expanded); + disclosureButton.element.ariaLabel = getAgentMergeSummaryLabel(this._summary); + if (messageButton) { + messageButton.element.tabIndex = expanded ? 0 : -1; + } + }; + setExpanded(false); + handlePointerFocus(disclosureButton); + this._register(disclosureButton.onDidClick(() => setExpanded(this.domNode.classList.contains('collapsed')))); + + if (this._summary.agentMessage) { + const agentMessageLabel = localize('chat.agentMerge.agentMessage', "Agent Message"); + const showMessageLabel = localize('chat.agentMerge.showAgentMessage', "Show Agent Message"); + const showDetailsLabel = localize('chat.agentMerge.showMergeDetails', "Show Merge Details"); + const agentMessageButton = this._register(new Button(header, { ...transparentButtonStyles, title: false })); + messageButton = agentMessageButton; + agentMessageButton.element.classList.add('chat-agent-merge-message-toggle'); + agentMessageButton.icon = Codicon.eye; + agentMessageButton.setAriaLabel(agentMessageLabel); + agentMessageButton.element.tabIndex = -1; + handlePointerFocus(agentMessageButton); + let showingAgentMessage = false; + const updateMessageVisibility = (visible: boolean) => { + showingAgentMessage = visible; + this.domNode.classList.toggle('showing-agent-message', visible); + agentMessageButton.checked = visible; + }; + updateMessageVisibility(false); + this._register(this._hoverService.setupDelayedHover(agentMessageButton.element, () => ({ + content: showingAgentMessage ? showDetailsLabel : showMessageLabel, + }))); + this._register(agentMessageButton.onDidClick(() => { + setExpanded(true); + updateMessageVisibility(!showingAgentMessage); + })); + } - const twistie = dom.append(button.element, dom.$('span.chat-agent-merge-twistie')); + const twistie = dom.append(header, dom.$('span.chat-agent-merge-twistie', { 'aria-hidden': 'true' })); twistie.classList.add(...ThemeIcon.asClassNameArray(Codicon.chevronRightCompact)); - const icon = dom.append(button.element, dom.$('span.chat-agent-merge-icon')); - icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.gitMerge)); - dom.append(button.element, dom.$('span.chat-agent-merge-title', undefined, agentMergeTitle)); - - const badges = dom.append(button.element, dom.$('span.chat-agent-merge-badges')); - for (const badge of describeBadges(this._summary, this._comments.length)) { - const badgeElement = dom.append(badges, dom.$('span.chat-agent-merge-badge')); - const badgeIcon = dom.append(badgeElement, dom.$('span')); - badgeIcon.classList.add(...ThemeIcon.asClassNameArray(badge.icon)); - dom.append(badgeElement, dom.$('span', undefined, badge.label)); - } + } - this._register(toggleDisclosure(button, this.domNode, getAgentMergeSummaryLabel(this._summary))); + private _createMetadata(parent: HTMLElement, timestamp: number | undefined): void { + const metadata = dom.append(parent, dom.$('.chat-agent-merge-metadata')); + const formattedTimestamp = formatChatRequestTimestamp(timestamp); + if (formattedTimestamp) { + const time = dom.append(metadata, dom.$('time.chat-agent-merge-timestamp', { + datetime: formattedTimestamp.dateTime, + 'aria-label': localize('chat.agentMerge.startedAt', "Started {0}", formattedTimestamp.fullText), + tabindex: 0, + }, formattedTimestamp.text)); + this._register(this._hoverService.setupDelayedHover(time, { content: formattedTimestamp.fullText })); + dom.append(metadata, dom.$('span.chat-agent-merge-metadata-separator', { 'aria-hidden': 'true' }, '\u2022')); + } + dom.append(metadata, dom.$('span.chat-agent-merge-participant', undefined, agentMergeSource)); } private _createPullRequestRow(body: HTMLElement): void { @@ -186,12 +250,24 @@ export class ChatAgentMergeContentPart extends Disposable { } const row = dom.append(body, dom.$('.chat-agent-merge-pr')); - const icon = dom.append(row, dom.$('span.chat-agent-merge-row-icon')); - icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.gitPullRequest)); - - const link: HTMLAnchorElement = dom.append(row, dom.$('a.chat-agent-merge-link.chat-agent-merge-pr-title', undefined, this._summary.title || this._summary.pullRequestUrl)); - link.href = this._summary.pullRequestUrl; - this._registerLink(link, this._summary.pullRequestUrl, () => this._openerService.open(URI.parse(this._summary.pullRequestUrl))); + const title = this._summary.title || this._summary.pullRequestUrl; + const pullRequestNumber = /\/pull\/(?<number>\d+)\/?$/.exec(URI.parse(this._summary.pullRequestUrl).path)?.groups?.number; + const label = pullRequestNumber + ? localize('chat.agentMerge.pullRequestPillLabel', "#{0} {1}", pullRequestNumber, title) + : title; + const tooltip = pullRequestNumber + ? localize('chat.agentMerge.openPullRequestWithTitle', "Open Pull Request #{0}: {1}", pullRequestNumber, title) + : localize('chat.agentMerge.openPullRequestWithTitleFallback', "Open Pull Request: {0}", title); + const action = this._register(new Action( + 'chat.agentMerge.openPullRequest', + label, + ThemeIcon.asClassName(Codicon.gitPullRequest), + true, + () => this._openerService.open(URI.parse(this._summary.pullRequestUrl)), + )); + action.tooltip = tooltip; + const viewItem = this._register(new ChatPillActionViewItem(undefined, action, {})); + viewItem.render(row); } private _createCommentsSection(body: HTMLElement): void { @@ -200,7 +276,9 @@ export class ChatAgentMergeContentPart extends Disposable { } const section = dom.append(body, dom.$('.chat-agent-merge-section')); - dom.append(section, dom.$('.chat-agent-merge-section-title', undefined, localize('chat.agentMerge.commentsTitle', "Review Feedback"))); + if (this._summary.failedChecks.length > 0) { + dom.append(section, dom.$('.chat-agent-merge-section-title', undefined, localize('chat.agentMerge.commentsTitle', "Feedback"))); + } const fileElements = new Map<string, HTMLElement>(); this._comments.forEach((comment, index) => { @@ -269,7 +347,9 @@ export class ChatAgentMergeContentPart extends Disposable { } const section = dom.append(body, dom.$('.chat-agent-merge-section')); - dom.append(section, dom.$('.chat-agent-merge-section-title', undefined, localize('chat.agentMerge.checksTitle', "Failing Checks"))); + if (this._comments.length > 0) { + dom.append(section, dom.$('.chat-agent-merge-section-title', undefined, localize('chat.agentMerge.checksTitle', "Checks"))); + } // A check's own run URL is not part of the prompt, so the pull request's // checks tab is the closest target that always resolves. const checksUrl = this._summary.pullRequestUrl ? `${this._summary.pullRequestUrl}/checks` : undefined; @@ -287,23 +367,13 @@ export class ChatAgentMergeContentPart extends Disposable { } } - private _createAgentMessageSection(body: HTMLElement): void { + private _createAgentMessage(body: HTMLElement): void { if (!this._summary.agentMessage) { return; } - const label = localize('chat.agentMerge.agentMessage', "Agent message"); const section = dom.append(body, dom.$('.chat-agent-merge-message')); - const messageBody = dom.$('.chat-agent-merge-message-body', undefined, this._summary.agentMessage); - - const button = this._register(new Button(section, { ...transparentButtonStyles, title: false })); - button.element.classList.add('chat-agent-merge-message-header'); - const twistie = dom.append(button.element, dom.$('span.chat-agent-merge-twistie')); - twistie.classList.add(...ThemeIcon.asClassNameArray(Codicon.chevronRightCompact)); - dom.append(button.element, dom.$('span', undefined, label)); - - section.appendChild(messageBody); - this._register(toggleDisclosure(button, section, label)); + dom.append(section, dom.$('.chat-agent-merge-message-body', undefined, this._summary.agentMessage)); } /** Makes an element activate `run` by mouse or keyboard, with a hover describing it. */ @@ -324,21 +394,6 @@ export class ChatAgentMergeContentPart extends Disposable { } } -/** - * Wires a button to expand and collapse the region its owner holds, keeping the - * collapsed state on the owner so CSS can drive both the twistie and the - * region's visibility from one class. - */ -function toggleDisclosure(button: Button, owner: HTMLElement, ariaLabel: string): IDisposable { - const apply = (expanded: boolean) => { - owner.classList.toggle('collapsed', !expanded); - button.element.ariaExpanded = String(expanded); - button.element.ariaLabel = ariaLabel; - }; - apply(false); - return button.onDidClick(() => apply(owner.classList.contains('collapsed'))); -} - function collectComments(summary: IAgentMergePromptSummary): readonly IAgentMergeCommentItem[] { const comments: IAgentMergeCommentItem[] = []; for (const thread of summary.reviewThreads) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatRequestOriginPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatRequestOriginPart.ts index f57ff8c32cf040..773b4ae9a2eb5c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatRequestOriginPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatRequestOriginPart.ts @@ -53,7 +53,9 @@ export class ChatRequestOriginPart extends Disposable { this._register(hoverService.setupManagedHover( getDefaultHoverDelegate('element'), this.domNode, - localize('chat.requestOrigin.openSource', "Open source chat"), + requestOrigin?.delegationScope === 'session' + ? localize('chat.requestOrigin.openSourceSession', "Open source session") + : localize('chat.requestOrigin.openSource', "Open source chat"), )); for (const eventType of [dom.EventType.CLICK, TouchEventType.Tap]) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatAgentMergeContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatAgentMergeContent.css index dcdc111a8c5fb0..5a99d69fbb87b7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatAgentMergeContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatAgentMergeContent.css @@ -6,109 +6,227 @@ .chat-agent-merge { display: flex; flex-direction: column; + gap: var(--vscode-spacing-size40); margin: var(--vscode-spacing-size40) 0; - border: var(--vscode-strokeThickness) solid var(--vscode-chat-requestBorder); + width: 100%; + max-width: 100%; + font-size: var(--vscode-fontSize-label1); +} + +.chat-agent-merge > .chat-agent-merge-card { + display: flex; + flex-direction: column; + align-self: flex-start; + background-color: var(--vscode-chat-statusBackground); + border: var(--vscode-strokeThickness) solid transparent; border-radius: var(--vscode-cornerRadius-medium); overflow: hidden; - font-size: var(--vscode-fontSize-label1); /* Collapsed, the card hugs its header; expanded, the body grows it to the transcript width. */ width: fit-content; max-width: 100%; } -.chat-agent-merge > .chat-agent-merge-header, -.chat-agent-merge .chat-agent-merge-message-header { +.hc-black .chat-agent-merge > .chat-agent-merge-card, +.hc-light .chat-agent-merge > .chat-agent-merge-card { + border-color: var(--vscode-chat-requestBorder); +} + +.chat-agent-merge > .chat-agent-merge-card > .chat-agent-merge-header, +.chat-agent-merge .chat-agent-merge-header-content { display: flex; align-items: center; - gap: var(--vscode-spacing-size60); +} + +.chat-agent-merge > .chat-agent-merge-card > .chat-agent-merge-header { + position: relative; + gap: var(--vscode-spacing-size40); + padding: var(--vscode-spacing-size60) var(--vscode-spacing-size80) var(--vscode-spacing-size60) var(--vscode-spacing-size120); + border-radius: calc(var(--vscode-cornerRadius-medium) - var(--vscode-strokeThickness)) calc(var(--vscode-cornerRadius-medium) - var(--vscode-strokeThickness)) 0 0; + overflow: hidden; +} + +.chat-agent-merge .chat-agent-merge-header-disclosure { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + border-radius: inherit; color: inherit; text-decoration: none; cursor: pointer; user-select: none; } -.chat-agent-merge > .chat-agent-merge-header { - padding: var(--vscode-spacing-size60) var(--vscode-spacing-size80); +.chat-agent-merge .chat-agent-merge-header-content { + position: relative; + z-index: 1; + flex: 0 1 auto; + min-width: 0; + min-height: var(--vscode-spacing-size200); + pointer-events: none; +} + +.chat-agent-merge.collapsed > .chat-agent-merge-card > .chat-agent-merge-header { + border-radius: calc(var(--vscode-cornerRadius-medium) - var(--vscode-strokeThickness)); } -.chat-agent-merge > .chat-agent-merge-header:hover, -.chat-agent-merge .chat-agent-merge-message-header:hover { +.chat-agent-merge > .chat-agent-merge-card > .chat-agent-merge-header:hover { background-color: var(--vscode-toolbar-hoverBackground); } +.chat-agent-merge .chat-agent-merge-header-disclosure:focus-visible, +.chat-agent-merge .chat-agent-merge-message-toggle:focus-visible { + outline-offset: -1px !important; +} + +.chat-agent-merge .chat-agent-merge-header-disclosure:focus:not(:focus-visible) { + outline: 0 !important; +} + +.chat-agent-merge .chat-agent-merge-message-toggle { + position: relative; + z-index: 1; + box-sizing: border-box; + display: none; + align-items: center; + justify-content: center; + flex: 0 0 auto; + margin-inline-start: auto; + width: var(--vscode-spacing-size200); + height: var(--vscode-spacing-size200); + padding: var(--vscode-spacing-size40); + border-radius: var(--vscode-cornerRadius-small); + color: var(--vscode-descriptionForeground); + opacity: 0; + pointer-events: none; + cursor: pointer; + transition: opacity 100ms ease-out; +} + +.chat-agent-merge:not(.collapsed) .chat-agent-merge-message-toggle { + display: flex; +} + +.chat-agent-merge .chat-agent-merge-message-toggle.codicon { + font-size: var(--vscode-codiconFontSize-compact); +} + +.chat-agent-merge.direct-pointer-input:not(.collapsed) > .chat-agent-merge-card > .chat-agent-merge-header .chat-agent-merge-message-toggle, +.chat-agent-merge:not(.collapsed) > .chat-agent-merge-card > .chat-agent-merge-header:hover .chat-agent-merge-message-toggle, +.chat-agent-merge:not(.collapsed) > .chat-agent-merge-card > .chat-agent-merge-header:focus-within .chat-agent-merge-message-toggle { + opacity: 1; + pointer-events: auto; +} + +.chat-agent-merge .chat-agent-merge-message-toggle:hover, +.chat-agent-merge .chat-agent-merge-message-toggle:focus-visible, +.chat-agent-merge .chat-agent-merge-message-toggle.checked { + color: var(--vscode-foreground); + background-color: var(--vscode-toolbar-activeBackground); +} + /* `.codicon` joins each compound so these sizes outrank the `font` shorthand on the codicon base rule, which would otherwise force every glyph to 16px. */ .chat-agent-merge .chat-agent-merge-twistie.codicon { + position: relative; + z-index: 1; flex: 0 0 auto; font-size: var(--vscode-codiconFontSize-compact); color: var(--vscode-descriptionForeground); - transition: transform 100ms ease-out; + pointer-events: none; + transition: opacity 100ms ease-out, transform 100ms ease-out; +} + +.chat-agent-merge .chat-agent-merge-header-content + .chat-agent-merge-twistie { + margin-inline-start: auto; +} + +.chat-agent-merge .chat-agent-merge-twistie { + opacity: 0; +} + +.chat-agent-merge > .chat-agent-merge-card > .chat-agent-merge-header:hover > .chat-agent-merge-twistie, +.chat-agent-merge > .chat-agent-merge-card > .chat-agent-merge-header:focus-within > .chat-agent-merge-twistie { + opacity: 1; } /* Reduced motion follows the workbench-managed `.monaco-reduce-motion` class, which sits either on an ancestor or on the workbench element itself. */ .monaco-reduce-motion .chat-agent-merge .chat-agent-merge-twistie.codicon, -.monaco-workbench.monaco-reduce-motion .chat-agent-merge .chat-agent-merge-twistie.codicon { +.monaco-workbench.monaco-reduce-motion .chat-agent-merge .chat-agent-merge-twistie.codicon, +.monaco-reduce-motion .chat-agent-merge .chat-agent-merge-message-toggle, +.monaco-workbench.monaco-reduce-motion .chat-agent-merge .chat-agent-merge-message-toggle { transition: none; } -.chat-agent-merge:not(.collapsed) > .chat-agent-merge-header > .chat-agent-merge-twistie, -.chat-agent-merge-message:not(.collapsed) > .chat-agent-merge-message-header > .chat-agent-merge-twistie { +.chat-agent-merge:not(.collapsed) > .chat-agent-merge-card > .chat-agent-merge-header > .chat-agent-merge-twistie { transform: rotate(90deg); } -.chat-agent-merge .chat-agent-merge-icon.codicon { - flex: 0 0 auto; - font-size: var(--vscode-codiconFontSize-compact); - color: var(--vscode-descriptionForeground); -} - .chat-agent-merge .chat-agent-merge-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; font-weight: var(--vscode-fontWeight-semiBold); white-space: nowrap; } -.chat-agent-merge .chat-agent-merge-badges { +.chat-agent-merge > .chat-agent-merge-metadata { + align-self: flex-end; display: flex; align-items: center; - gap: var(--vscode-spacing-size80); + gap: var(--vscode-spacing-size40); min-width: 0; - overflow: hidden; color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-body2); + line-height: var(--vscode-spacing-size160); + opacity: 0; + pointer-events: none; + white-space: nowrap; + transition: opacity 100ms ease-in-out; } -.chat-agent-merge .chat-agent-merge-badge { - display: flex; - align-items: center; - gap: var(--vscode-spacing-size40); - white-space: nowrap; +.chat-agent-merge:hover > .chat-agent-merge-metadata, +.chat-agent-merge:focus-within > .chat-agent-merge-metadata { + opacity: 0.7; + pointer-events: auto; } -.chat-agent-merge .chat-agent-merge-badge::before { - content: ''; - flex: 0 0 auto; - width: 2px; - height: 2px; - border-radius: var(--vscode-cornerRadius-circle); - background-color: currentColor; - opacity: 0.6; - margin-right: var(--vscode-spacing-size40); +.chat-agent-merge .chat-agent-merge-timestamp { + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum"; } -.chat-agent-merge .chat-agent-merge-badge .codicon { - font-size: var(--vscode-codiconFontSize-compact); - color: inherit; +.chat-agent-merge .chat-agent-merge-timestamp:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: var(--vscode-spacing-size20); } -.chat-agent-merge > .chat-agent-merge-body { +.monaco-reduce-motion .chat-agent-merge > .chat-agent-merge-metadata, +.monaco-workbench.monaco-reduce-motion .chat-agent-merge > .chat-agent-merge-metadata { + transition: none; +} + +.chat-agent-merge > .chat-agent-merge-card > .chat-agent-merge-body { display: flex; flex-direction: column; - gap: var(--vscode-spacing-size120); + gap: var(--vscode-spacing-size80); padding: var(--vscode-spacing-size40) var(--vscode-spacing-size120) var(--vscode-spacing-size120); - border-top: var(--vscode-strokeThickness) solid var(--vscode-chat-requestBorder); } -.chat-agent-merge.collapsed > .chat-agent-merge-body { +.chat-agent-merge.collapsed > .chat-agent-merge-card > .chat-agent-merge-body { + display: none; +} + +.chat-agent-merge .chat-agent-merge-details { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size80); + min-width: 0; +} + +.chat-agent-merge.showing-agent-message .chat-agent-merge-details { display: none; } @@ -124,32 +242,27 @@ which sits either on an ancestor or on the workbench element itself. */ color: var(--vscode-errorForeground); } -/* Clickable text stays in its surrounding color and announces itself on hover, -matching the pull request content part's link treatment. */ -.chat-agent-merge .chat-agent-merge-link { - cursor: pointer; - color: inherit; - text-decoration: none; -} - -.chat-agent-merge .chat-agent-merge-link:hover { - text-decoration: underline; -} - .chat-agent-merge .chat-agent-merge-pr { display: flex; align-items: center; - gap: var(--vscode-spacing-size60); - padding-top: var(--vscode-spacing-size80); + padding-top: var(--vscode-spacing-size40); + padding-bottom: var(--vscode-spacing-size40); + margin-inline: calc(var(--vscode-spacing-size60) - var(--vscode-spacing-size120)); min-width: 0; } -.chat-agent-merge .chat-agent-merge-pr-title { +.chat-agent-merge .chat-agent-merge-pr .chat-pill-button, +.chat-agent-merge .chat-agent-merge-pr .chat-pill-label { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.chat-agent-merge .chat-agent-merge-pr .chat-pill-button { + max-width: 100%; +} + .chat-agent-merge .chat-agent-merge-section { display: flex; flex-direction: column; @@ -157,8 +270,12 @@ matching the pull request content part's link treatment. */ min-width: 0; } +.chat-agent-merge .chat-agent-merge-section + .chat-agent-merge-section { + padding-top: var(--vscode-spacing-size80); +} + .chat-agent-merge .chat-agent-merge-section-title { - font-size: var(--vscode-fontSize-label2); + font-size: var(--vscode-fontSize-label1); font-weight: var(--vscode-fontWeight-semiBold); color: var(--vscode-descriptionForeground); } @@ -168,6 +285,7 @@ matching the pull request content part's link treatment. */ display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: var(--vscode-spacing-size60); + row-gap: var(--vscode-spacing-size20); align-items: start; } @@ -177,6 +295,10 @@ matching the pull request content part's link treatment. */ line-height: var(--vscode-spacing-size200); } +.chat-agent-merge .chat-agent-merge-comment-icon.codicon { + transform: translateY(1px); +} + .chat-agent-merge .chat-agent-merge-comment-header { display: flex; align-items: baseline; @@ -211,6 +333,24 @@ matching the pull request content part's link treatment. */ opacity: 0.8; } +.chat-agent-merge .chat-agent-merge-link, +.chat-agent-merge .chat-agent-merge-comment-body a, +.chat-agent-merge .chat-agent-merge-comment-body a code { + cursor: pointer; + color: var(--vscode-textLink-foreground); + text-decoration: none; +} + +.chat-agent-merge .chat-agent-merge-link:hover, +.chat-agent-merge .chat-agent-merge-link:active, +.chat-agent-merge .chat-agent-merge-comment-body a:hover, +.chat-agent-merge .chat-agent-merge-comment-body a:active, +.chat-agent-merge .chat-agent-merge-comment-body a:hover code, +.chat-agent-merge .chat-agent-merge-comment-body a:active code { + color: var(--vscode-textLink-activeForeground); + text-decoration: underline; +} + .chat-agent-merge .chat-agent-merge-comment-body { grid-column: 2; overflow-wrap: anywhere; @@ -239,29 +379,17 @@ matching the pull request content part's link treatment. */ } .chat-agent-merge .chat-agent-merge-message { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: var(--vscode-spacing-size60); + display: none; min-width: 0; } -.chat-agent-merge .chat-agent-merge-message-header { - font-size: var(--vscode-fontSize-label2); - color: var(--vscode-descriptionForeground); - padding: var(--vscode-spacing-size20) var(--vscode-spacing-size40); - margin-left: calc(-1 * var(--vscode-spacing-size40)); - border-radius: var(--vscode-cornerRadius-small); +.chat-agent-merge.showing-agent-message .chat-agent-merge-message { + display: block; + margin-top: var(--vscode-spacing-size80); } .chat-agent-merge .chat-agent-merge-message-body { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--vscode-descriptionForeground); - padding-left: var(--vscode-spacing-size80); - border-left: var(--vscode-strokeThickness) solid var(--vscode-chat-requestBorder); -} - -.chat-agent-merge-message.collapsed > .chat-agent-merge-message-body { - display: none; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatRichLink.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatRichLink.css index da77f9dd6e92f1..6b2dd8e41aeee1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatRichLink.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatRichLink.css @@ -364,7 +364,8 @@ .interactive-item-container .value .rendered-markdown .chat-rich-link:is( [data-chat-rich-link-kind='session'], [data-chat-rich-link-kind='chat'] -) .chat-rich-link-icon { +) .chat-rich-link-icon, +.interactive-item-container .value .rendered-markdown .chat-rich-link .chat-rich-link-status-icon { position: static; top: auto; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts index d2dc4608ea64ad..3f4b5d2ca3685d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts @@ -107,8 +107,9 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS const initialContent = terminalData.presentationOverrides?.commandLine ?? terminalData.confirmation?.commandLine ?? (terminalData.commandLine.toolEdited ?? terminalData.commandLine.original).trimStart(); const cdPrefix = terminalData.confirmation?.cdPrefix ?? ''; // When presentationOverrides is set, the editor should be read-only since the displayed content - // differs from the actual command (e.g., extracted Python code vs full python -c command) - const isReadOnly = !!terminalData.presentationOverrides; + // differs from the actual command (e.g., extracted Python code vs full python -c command). + // A producer that cannot apply an edited command opts out the same way. + const isReadOnly = !!terminalData.presentationOverrides || terminalData.editable === false; const autoApproveEnabled = this.configurationService.getValue(TerminalContribSettingId.EnableAutoApprove) === true; // Custom actions typically come pre-computed from the run in terminal tool, but they can @@ -176,16 +177,18 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS uri: model.uri, chatSessionResource: this.context.element.sessionResource }); - this._register(model.onDidChangeContent(() => { - const currentValue = model.getValue(); - // Only set userEdited if the content actually differs from the initial value - // Prepend cd prefix back if it was extracted for display - if (currentValue !== initialContent) { - terminalData.commandLine.userEdited = cdPrefix + currentValue; - } else { - terminalData.commandLine.userEdited = undefined; - } - })); + if (!isReadOnly) { + this._register(model.onDidChangeContent(() => { + const currentValue = model.getValue(); + // Only set userEdited if the content actually differs from the initial value + // Prepend cd prefix back if it was extracted for display + if (currentValue !== initialContent) { + terminalData.commandLine.userEdited = cdPrefix + currentValue; + } else { + terminalData.commandLine.userEdited = undefined; + } + })); + } const elements = h('.chat-confirmation-message-terminal', [ h('.chat-confirmation-message-terminal-editor@editor'), h('.chat-confirmation-message-terminal-disclaimer@disclaimer'), diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts index 9df90f3857df74..5fb46fc58290f5 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts @@ -189,6 +189,8 @@ export class ToolConfirmationSubPart extends AbstractToolConfirmationSubPart { elements.editor.appendChild(titleEl); const inputData = toolInvocation.toolSpecificData; + // Editable unless a producer opts out, so tools that predate the flag are unchanged. + const isEditable = inputData.editable ?? true; const codeBlockRenderOptions: ICodeBlockRenderOptions = { hideToolbar: true, @@ -197,7 +199,7 @@ export class ToolConfirmationSubPart extends AbstractToolConfirmationSubPart { verticalPadding: 5, editorOptions: { wordWrap: 'off', - readOnly: false, + readOnly: !isEditable, ariaLabel: this.getTitle(), } }; @@ -265,13 +267,15 @@ export class ToolConfirmationSubPart extends AbstractToolConfirmationSubPart { uri: model.uri, chatSessionResource: this.context.element.sessionResource }); - this._register(model.onDidChangeContent(e => { - try { - inputData.rawInput = JSON.parse(model.getValue()); - } catch { - // ignore - } - })); + if (isEditable) { + this._register(model.onDidChangeContent(e => { + try { + inputData.rawInput = JSON.parse(model.getValue()); + } catch { + // ignore + } + })); + } elements.editor.append(editor.object.element); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index b43b6c624c57b7..6f05c72cc6a06d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -338,12 +338,18 @@ export function moveResponseOutcomeToolsAfterFinalResponse(content: ReadonlyArra const responseLinkTargets = outcomeTools.some(part => getSessionCreatedOutcomeLink(part) !== undefined) ? getFinalResponseLinkTargets(content) : undefined; - const uniqueOutcomeTools = responseLinkTargets - ? outcomeTools.filter(part => { - const openLink = getSessionCreatedOutcomeLink(part); - return openLink === undefined || !responseLinkTargets.has(openLink); - }) - : outcomeTools; + const seenSessionLinks = new Set<string>(); + const uniqueOutcomeTools = outcomeTools.filter(part => { + const openLink = getSessionCreatedOutcomeLink(part); + if (openLink === undefined) { + return true; + } + if (responseLinkTargets?.has(openLink) || seenSessionLinks.has(openLink)) { + return false; + } + seenSessionLinks.add(openLink); + return true; + }); const finalResponseStartIndex = getFinalResponseStartIndexAfterMovingResponseOutcomeTools(content); if (finalResponseStartIndex === undefined) { @@ -2539,7 +2545,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch // gets summarized instead of being shown to the user verbatim. const agentMerge = element.systemInitiatedLabel === undefined ? parseAgentMergePrompt(element.messageText) : undefined; if (agentMerge) { - const agentMergePart = this.instantiationService.createInstance(ChatAgentMergeContentPart, agentMerge, element.sessionResource, this.chatContentMarkdownRenderer); + const agentMergePart = this.instantiationService.createInstance(ChatAgentMergeContentPart, agentMerge, element.sessionResource, this.chatContentMarkdownRenderer, element.requestTimestamp); templateData.elementDisposables.add(agentMergePart); templateData.value.appendChild(agentMergePart.domNode); return; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index b8c50f6e8d122c..10014ff6858afb 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -1762,10 +1762,12 @@ export class ChatWidget extends Disposable implements IChatWidget { // This ensures handoffs reflect what the response agent offers, regardless of mode picker state. // Fall back to the current mode picker for old sessions where modeInfo was not persisted. const modeInfo = lastItem.model.request?.modeInfo; - let responseMode: IChatMode | undefined; const modes = this.input.currentChatModesObs.get(); + let responseMode: IChatMode | undefined; if (modeInfo?.modeInstructions?.name) { responseMode = modes.findModeByName(modeInfo.modeInstructions.name); + } else if (modeInfo?.kind) { + responseMode = modes.findModeById(modeInfo.kind); } else { responseMode = this.input.currentModeObs.get(); } diff --git a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts index 9e3aa724b20472..f9c6019aa00910 100644 --- a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts @@ -88,6 +88,11 @@ export interface IAICustomizationWorkspaceService { */ readonly activeProjectRoot: IObservable<URI | undefined>; + /** + * Display label for the active project. + */ + readonly activeProjectLabel: IObservable<string | undefined>; + /** * Returns the current active project root, if any. */ diff --git a/src/vs/workbench/contrib/chat/common/automations/automationsEnabled.ts b/src/vs/workbench/contrib/chat/common/automations/automationsEnabled.ts index 31efd8bd3cc423..f32e6abe866bb4 100644 --- a/src/vs/workbench/contrib/chat/common/automations/automationsEnabled.ts +++ b/src/vs/workbench/contrib/chat/common/automations/automationsEnabled.ts @@ -7,7 +7,7 @@ import { RawContextKey } from '../../../../../platform/contextkey/common/context /** * Gates the entire Automations feature: sidebar entry, editor section, - * session composer option, and scheduled execution. Default `false`. + * session composer option, and scheduled execution. Enabled by default in non-Stable builds. */ export const CHAT_AUTOMATIONS_ENABLED_SETTING = 'chat.automations.enabled'; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 1ad2106bc39e8a..e01b92b77e5632 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -659,6 +659,17 @@ export interface IChatTerminalToolInvocationData { // isSandboxWrapped boolean to run in the terminal (potentially different from original command) isSandboxWrapped?: boolean; }; + /** + * Whether the user may edit the command before confirming. + * + * Omitted means editable, the historical behavior for the built-in terminal + * tool, which runs `commandLine.userEdited` when it is set. A producer whose + * confirmation does not return the edit — an agent-host session, whose edit + * would have to travel back as `chat/toolCallConfirmed.editedToolInput` — + * MUST set `false`. Letting someone edit a command they are approving and + * then running the original is worse than showing it read-only. + */ + editable?: boolean; /** * LM-generated intention describing why the command is being run, shown * above the command in the terminal tool card. Set by the Agent Host; the @@ -802,6 +813,21 @@ export interface IChatToolInputInvocationData { rawInput: any; /** Optional MCP App UI metadata for rendering during and after tool execution */ mcpAppData?: ChatMcpAppData; + /** + * Whether the user may edit {@link rawInput} before confirming. + * + * Omitted means editable, the historical behavior: the confirmation editor + * writes back into `rawInput`, and for an extension-contributed tool + * `ILanguageModelToolsService` then invokes it with that value as its + * parameters. That path always honours an edit, which is why no opt-out + * existed before. + * + * A producer whose confirmation does not run through it — an agent-host + * session, whose edit would have to travel back as + * `chat/toolCallConfirmed.editedToolInput` — MUST set `false`. Inviting an + * edit and then running the original is worse than showing none. + */ + editable?: boolean; } export const enum ToolConfirmKind { diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 2cf5b6e363e4f9..c76aa5f6816c3a 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -53,6 +53,7 @@ export enum ChatConfiguration { AgentSessionProjectionEnabled = 'chat.agentSessionProjection.enabled', MigrateLegacyCopilotCliSessions = 'chat.agentSessions.migrateLegacyCopilotCli', ShowExternalAgentSessions = 'chat.agentSessions.showExternal', + ConsolidatedRemoteWorkspaces = 'chat.agentSessions.consolidatedRemoteWorkspaces', ExtensionToolsEnabled = 'chat.extensionTools.enabled', RepoInfoEnabled = 'chat.repoInfo.enabled', EditRequests = 'chat.editRequests', diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 16fe3468b8d9d8..2399b9c330eba7 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -793,10 +793,13 @@ export class PromptsService extends Disposable implements IPromptsService { const skipReason = isEnabled ? undefined : 'disabled'; return { status, skipReason, promptPath: this.withPromptPathMetadata(promptPath, agent.name, agent.description), agent }; } catch (e) { + if (isCancellationError(e)) { + throw e; + } const error = e instanceof Error ? e : new Error(String(e)); if (error instanceof FileOperationError && error.fileOperationResult === FileOperationResult.FILE_NOT_FOUND) { this.logger.warn(`[computeAgentDiscoveryInfo] Skipping agent file that does not exist: ${uri}`, error.message); - } else if (!isCancellationError(e)) { + } else { this.logger.error(`[computeAgentDiscoveryInfo] Failed to parse agent file: ${uri}`, error); } return { @@ -1631,12 +1634,13 @@ class CachedPromise<T> extends Disposable { throw err; }); // The pool is only meaningful while the computation is in flight. - promise.finally(() => { + const disposePool = () => { if (this.cachedPool === pool) { this.cachedPool = undefined; } pool!.dispose(); - }); + }; + promise.then(disposePool, disposePool); this.cachedPromise = promise; this.cachedPool = pool; } diff --git a/src/vs/workbench/contrib/chat/common/widget/chatColors.ts b/src/vs/workbench/contrib/chat/common/widget/chatColors.ts index 2915b7ddb6ecfb..7130857d8cbc12 100644 --- a/src/vs/workbench/contrib/chat/common/widget/chatColors.ts +++ b/src/vs/workbench/contrib/chat/common/widget/chatColors.ts @@ -33,6 +33,12 @@ export const chatRequestBackground = registerColor( localize('chat.requestBackground', 'The background color of a chat request.') ); +export const chatStatusBackground = registerColor( + 'chat.statusBackground', + { dark: transparent(foreground, 0.08), light: transparent(foreground, 0.08), hcDark: Color.black, hcLight: Color.white }, + localize('chat.statusBackground', 'The background color of status elements in chat.') +); + export const chatSlashCommandBackground = registerColor( 'chat.slashCommandBackground', { dark: '#26477866', light: '#adceff7a', hcDark: Color.white, hcLight: badgeBackground }, diff --git a/src/vs/workbench/contrib/chat/electron-browser/actions/exportAgentHostDebugLogsService.ts b/src/vs/workbench/contrib/chat/electron-browser/actions/exportAgentHostDebugLogsService.ts index 714b0c4bf66331..72c87c37cf17d9 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/actions/exportAgentHostDebugLogsService.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/actions/exportAgentHostDebugLogsService.ts @@ -30,7 +30,7 @@ class NativeAgentHostDebugLogsExportService implements IAgentHostDebugLogsExport @ILogService private readonly logService: ILogService, ) { } - async save(exportName: string, files: readonly IAgentHostDebugLogFile[], hostArtifact: IAgentHostDebugLogsHostArtifact | undefined): Promise<boolean> { + async save(exportName: string, files: readonly IAgentHostDebugLogFile[], hostArtifact: IAgentHostDebugLogsHostArtifact | undefined): Promise<URI | undefined> { const defaultUri = joinPath(await this.fileDialogService.preferredHome(Schemas.file), `${exportName}.zip`); const saveUri = await this.fileDialogService.showSaveDialog({ title: localize('exportDebugLogs.saveDialogTitle', "Export Agent Host Debug Logs"), @@ -40,7 +40,7 @@ class NativeAgentHostDebugLogsExportService implements IAgentHostDebugLogsExport }); if (!saveUri) { - return false; + return undefined; } const zipFiles: INativeZipFile[] = files.map(file => { @@ -93,7 +93,7 @@ class NativeAgentHostDebugLogsExportService implements IAgentHostDebugLogsExport } } } - return true; + return saveUri; } } diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatContext.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatContext.test.ts index cca05f5f901add..cd96b4f889b998 100644 --- a/src/vs/workbench/contrib/chat/test/browser/actions/chatContext.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatContext.test.ts @@ -4,10 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { shouldShowOpenEditorsContext } from '../../../browser/actions/chatContext.js'; +import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { IQuickInputService } from '../../../../../../platform/quickinput/common/quickInput.js'; +import { GitHubContextValuePick, shouldShowOpenEditorsContext } from '../../../browser/actions/chatContext.js'; +import { ChatContextPickService } from '../../../browser/attachments/chatContextPickService.js'; import { IChatWidget } from '../../../browser/chat.js'; +import { IGitRepository, IGitService } from '../../../../git/common/gitService.js'; function widget(overrides: Partial<Pick<IChatWidget, 'viewModel' | 'lockedAgentId'>>): Pick<IChatWidget, 'viewModel' | 'lockedAgentId'> { return { @@ -23,9 +30,48 @@ function widgetWithSession(sessionResource: URI): Pick<IChatWidget, 'viewModel' }); } +function repository(remoteUrl: string): IGitRepository { + return new class extends mock<IGitRepository>() { + override readonly rootUri = URI.file(`/workspace/${remoteUrl.length}`); + override readonly state = observableValue('test', { + remotes: [{ name: 'origin', fetchUrl: remoteUrl, isReadOnly: false }], + mergeChanges: [], + indexChanges: [], + workingTreeChanges: [], + untrackedChanges: [], + }); + }(); +} + +class TestGitService extends mock<IGitService>() { + constructor(override readonly repositories: readonly IGitRepository[]) { + super(); + } +} + +class TestCommandService extends mock<ICommandService>() { + result: object | undefined; + command: { id: string; repoId: string | undefined } | undefined; + + override async executeCommand<T>(id: string, repoId?: string): Promise<T> { + this.command = { id, repoId }; + return this.result as T; + } +} + +class TestGitHubContextValuePick extends GitHubContextValuePick { + repositoryPicks: readonly string[] | undefined; + selectedRepository: string | undefined; + + protected override async pickRepository(repositories: readonly string[]): Promise<string | undefined> { + this.repositoryPicks = repositories; + return this.selectedRepository; + } +} + suite('ChatContext', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); test('shows Open Editors for regular Copilot CLI sessions with eligible editors', () => { assert.strictEqual( @@ -54,4 +100,108 @@ suite('ChatContext', () => { false ); }); + + test('opens global GitHub context search without a GitHub repository', async () => { + const commandService = new TestCommandService(); + const pick = new GitHubContextValuePick( + 'issue', + new TestGitService([repository('https://example.com/owner/repository.git')]), + new class extends mock<IQuickInputService>() { }(), + commandService, + ); + + await pick.asAttachment(); + assert.deepStrictEqual({ + enabled: pick.isEnabled(), + command: commandService.command, + }, { + enabled: true, + command: { + id: 'github.copilot.chat.cloudSessions.openIssue', + repoId: undefined, + }, + }); + }); + + test('opens GitHub context picker directly for one repository', async () => { + const commandService = new TestCommandService(); + commandService.result = { + repoId: 'microsoft/vscode', + url: 'https://github.com/microsoft/vscode/issues/123', + label: 'microsoft/vscode#123', + }; + const pick = new GitHubContextValuePick( + 'issue', + new TestGitService([repository('https://github.com/microsoft/vscode.git')]), + new class extends mock<IQuickInputService>() { }(), + commandService, + ); + + const attachment = await pick.asAttachment(); + assert.deepStrictEqual({ + command: commandService.command, + attachment: attachment && { + id: attachment.id, + name: attachment.name, + value: URI.isUri(attachment.value) ? attachment.value.toString() : undefined, + icon: attachment.icon?.id, + } + }, { + command: { + id: 'github.copilot.chat.cloudSessions.openIssue', + repoId: 'microsoft/vscode', + }, + attachment: { + id: 'https://github.com/microsoft/vscode/issues/123', + name: 'microsoft/vscode#123', + value: 'https://github.com/microsoft/vscode/issues/123', + icon: 'issues', + } + }); + }); + + test('selects a repository before opening GitHub context picker for multiple repositories', async () => { + const commandService = new TestCommandService(); + const pick = new TestGitHubContextValuePick( + 'pullRequest', + new TestGitService([ + repository('git@github.com:microsoft/vscode.git'), + repository('https://github.com/microsoft/typescript.git'), + ]), + new class extends mock<IQuickInputService>() { }(), + commandService, + ); + pick.selectedRepository = 'microsoft/vscode'; + + await pick.asAttachment(); + assert.deepStrictEqual({ + repositoryPicks: pick.repositoryPicks, + commandRepoId: commandService.command?.repoId, + }, { + repositoryPicks: ['microsoft/typescript', 'microsoft/vscode'], + commandRepoId: 'microsoft/vscode', + }); + }); + + test('orders sessions before GitHub context picks', () => { + const service = new ChatContextPickService(); + disposables.add(service.registerChatContextItem(new GitHubContextValuePick( + 'issue', + new TestGitService([]), + new class extends mock<IQuickInputService>() { }(), + new TestCommandService(), + ))); + disposables.add(service.registerChatContextItem({ + type: 'valuePick', + label: 'Sessions...', + icon: Codicon.comment, + ordinal: -400, + asAttachment: async () => undefined, + })); + + assert.deepStrictEqual(Array.from(service.items, item => item.label), [ + 'Sessions...', + 'Issue...', + ]); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentCustomizationItemProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentCustomizationItemProvider.test.ts index 260e3a992d4f4a..0bd35397d5e0e3 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentCustomizationItemProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentCustomizationItemProvider.test.ts @@ -145,6 +145,59 @@ suite('AgentCustomizationItemProvider', () => { }]); }); + test('classifies a directory child by its real file URI when its container is synthetic', async () => { + const skillUri = 'file:///workspace/.agents/skills/launch/SKILL.md'; + const customizations: Customization[] = [{ + type: CustomizationType.Directory, + id: 'codex-repository-skills', + uri: 'codex-skills:/repo', + name: 'Repository', + enabled: true, + contents: CustomizationType.Skill, + writable: false, + children: [{ + type: CustomizationType.Skill, + id: skillUri, + uri: skillUri, + name: 'launch', + description: 'Launch Code OSS.', + }], + }]; + + class TestCustomizationService extends NullAgentHostCustomizationService { + override getWorkingDirectories(): readonly string[] { + return ['file:///workspace']; + } + override getCustomizations(): readonly Customization[] { + return customizations; + } + } + + const provider = disposables.add(new AgentCustomizationItemProvider( + 'local', + undefined, + undefined, + upcastPartial<IFileService>({}), + new NullLogService(), + new TestCustomizationService(), + makePromptsService(), + )); + + const items = await provider.provideChatSessionCustomizations(URI.parse('agent-host-codex:///session'), CancellationToken.None); + + assert.deepStrictEqual(items.map(item => ({ + type: item.type, + name: item.name, + uri: item.uri.toString(), + source: item.source, + })), [{ + type: PromptsType.skill, + name: 'launch', + uri: skillUri, + source: AICustomizationSources.local, + }]); + }); + test('overrides a stale enabled provider row when its built-in skill is user-disabled', async () => { const bundleUri = URI.from({ scheme: SYNCED_CUSTOMIZATION_SCHEME, path: '/bundle' }); const bundledSkillUri = URI.joinPath(bundleUri, 'skills', 'create-pr', 'SKILL.md'); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index faa690690dd41d..1d559b58231c7f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -10193,7 +10193,7 @@ suite('AgentHostChatContribution', () => { await timeout(10); })); - test('local agent contribution advertises image attachments', () => { + test('local agent contribution advertises target-only delegation and image attachments', () => { const { instantiationService, agentHostService, chatSessionContributions, chatSessionItemControllers } = createTestServices(disposables); disposables.add(instantiationService.createInstance(AgentHostContribution)); @@ -10202,8 +10202,18 @@ suite('AgentHostChatContribution', () => { activeSessions: 0, }); - assert.deepStrictEqual(chatSessionContributions.map(c => ({ type: c.type, supportsImageAttachments: c.capabilities?.supportsImageAttachments })), [ - { type: 'agent-host-copilot', supportsImageAttachments: true }, + assert.deepStrictEqual(chatSessionContributions.map(c => ({ + type: c.type, + canDelegate: c.canDelegate, + supportsDelegation: c.supportsDelegation, + supportsImageAttachments: c.capabilities?.supportsImageAttachments + })), [ + { + type: 'agent-host-copilot', + canDelegate: true, + supportsDelegation: false, + supportsImageAttachments: true + }, ]); assert.deepStrictEqual(chatSessionItemControllers.map(c => c.type), []); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts index 91f614e7692f6d..f770220f3c89ce 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts @@ -9,7 +9,7 @@ import { ClaudeSessionConfigKey } from '../../../../../../platform/agentHost/com import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { CodexSessionConfigKey } from '../../../../../../platform/agentHost/common/codexSessionConfigKeys.js'; import type { SessionConfigPropertySchema } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { getAgentHostSandboxSettingId, getConfigPickerItemHover, getConfigPickerListOptions, getConfigPickerTriggerHover, getConfigPickerTriggerLabel, resolveConfigChipValue } from '../../../browser/agentSessions/agentHost/agentHostChatInputPicker.js'; +import { getAgentHostSandboxSettingId, getConfigPickerAccessibleTriggerLabel, getConfigPickerItemHover, getConfigPickerListOptions, getConfigPickerTriggerHover, getConfigPickerTriggerLabel, resolveConfigChipValue } from '../../../browser/agentSessions/agentHost/agentHostChatInputPicker.js'; import { AgentHostSdkSandboxEnabledSettingId, AgentHostSdkSandboxWindowsEnabledSettingId } from '../../../../../../platform/agentHost/common/agentService.js'; import { AgentSandboxSettingId } from '../../../../../../platform/sandbox/common/settings.js'; import { SessionType } from '../../../common/chatSessionsService.js'; @@ -87,23 +87,25 @@ suite('AgentHostChatInputPicker - trigger labels', () => { enumLabels: ['Default permissions', 'Assisted permissions', 'Allow all', 'Autopilot'], } as SessionConfigPropertySchema; - test('appends the sandbox state to every selected permission mode', () => { + test('uses an icon-ready label while preserving the sandbox state for accessibility', () => { assert.deepStrictEqual({ - default: getConfigPickerTriggerLabel(permissionsSchema, ChatPermissionLevel.Default, true), - assisted: getConfigPickerTriggerLabel(permissionsSchema, ChatPermissionLevel.Assisted, true), - allowAll: getConfigPickerTriggerLabel(permissionsSchema, ChatPermissionLevel.AutoApprove, true), - autopilot: getConfigPickerTriggerLabel(permissionsSchema, ChatPermissionLevel.Autopilot, true), + default: getConfigPickerTriggerLabel(permissionsSchema, ChatPermissionLevel.Default), + assisted: getConfigPickerTriggerLabel(permissionsSchema, ChatPermissionLevel.Assisted), + allowAll: getConfigPickerTriggerLabel(permissionsSchema, ChatPermissionLevel.AutoApprove), + autopilot: getConfigPickerTriggerLabel(permissionsSchema, ChatPermissionLevel.Autopilot), + accessible: getConfigPickerAccessibleTriggerLabel('Default permissions', true), }, { - default: 'Default permissions (sandboxed)', - assisted: 'Assisted permissions (sandboxed)', - allowAll: 'Allow all (sandboxed)', - autopilot: 'Autopilot (sandboxed)', + default: 'Default permissions', + assisted: 'Assisted permissions', + allowAll: 'Allow all', + autopilot: 'Autopilot', + accessible: 'Default permissions (sandboxed)', }); }); - test('leaves the selected permission label unchanged when sandboxing is disabled', () => { + test('leaves the accessible permission label unchanged when sandboxing is disabled', () => { assert.strictEqual( - getConfigPickerTriggerLabel(permissionsSchema, ChatPermissionLevel.Assisted, false), + getConfigPickerAccessibleTriggerLabel('Assisted permissions', false), 'Assisted permissions' ); }); @@ -172,10 +174,13 @@ suite('AgentHostChatInputPicker - resolveConfigChipValue', () => { } as SessionConfigPropertySchema; test('explains the selected approval level on the trigger hover', () => { - assert.strictEqual( - getConfigPickerTriggerHover(SessionConfigKey.AutoApprove, approvalsSchema, 'autoApprove', false), - 'Copilot runs all tools without asking for approval.' - ); + assert.deepStrictEqual({ + unsandboxed: getConfigPickerTriggerHover(SessionConfigKey.AutoApprove, approvalsSchema, 'autoApprove', false), + sandboxed: getConfigPickerTriggerHover(SessionConfigKey.AutoApprove, approvalsSchema, 'autoApprove', false, true), + }, { + unsandboxed: 'Copilot runs all tools without asking for approval.', + sandboxed: 'Copilot runs all tools without asking for approval. Terminal commands are sandboxed.', + }); }); test('explains approval choices on item hover', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts index 3f8b2d73c09c0b..825c6011c1fdaf 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts @@ -4,15 +4,25 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Event } from '../../../../../../base/common/event.js'; +import { IReference } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { CustomizationEnablementKind, CustomizationType, McpServerCustomization, McpServerStatus, type Customization, type CustomizationEnablement } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { createAgentHostResourceUriMapper, identityAgentHostResourceUriMapper, IAgentHostResourceUriMapper } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { createSessionState, RootState, SessionState, SessionStatus, StateComponents } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IOutputService } from '../../../../../services/output/common/output.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, ILoggerService, NullLogService, NullLoggerService } from '../../../../../../platform/log/common/log.js'; -import { AbstractAgentHostCustomizationService, IAgentHostCustomizationTarget } from '../../../browser/agentSessions/agentHost/agentHostCustomizationService.js'; +import { AbstractAgentHostCustomizationService, IAgentHostCustomizationTarget, WorkbenchAgentHostCustomizationService } from '../../../browser/agentSessions/agentHost/agentHostCustomizationService.js'; +import { IAgentHostUntitledProvisionalSessionService } from '../../../browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; +import { IChatService } from '../../../common/chatService/chatService.js'; +import { IAgentHostActiveClientService } from '../../../browser/agentSessions/agentHost/agentHostActiveClientService.js'; class FakeTarget implements IAgentHostCustomizationTarget { readonly enablementChanges: { readonly rawId: string; readonly enablement: readonly CustomizationEnablement[] }[] = []; @@ -66,6 +76,29 @@ class TestAgentHostCustomizationService extends AbstractAgentHostCustomizationSe } } +class TestSessionSubscription extends mock<IAgentSubscription<SessionState>>() { + override readonly onDidChange = Event.None; + private current: SessionState | Error | undefined; + private confirmed: SessionState | undefined; + + override get value(): SessionState | Error | undefined { + return this.current; + } + + override get verifiedValue(): SessionState | undefined { + return this.confirmed; + } + + setSnapshot(state: SessionState): void { + this.current = state; + this.confirmed = state; + } + + setError(error: Error): void { + this.current = error; + } +} + suite('AbstractAgentHostCustomizationService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -268,4 +301,93 @@ suite('AbstractAgentHostCustomizationService', () => { disabledReason: { source: 'scope', scope: CustomizationEnablementKind.Session }, }); }); + +}); + +suite('WorkbenchAgentHostCustomizationService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('uses provisional roots only until authoritative session state is available', () => { + const sessionResource = URI.parse('untitled:chat'); + const backendSession = URI.parse('copilot:/session'); + const provisionalRoot = URI.file('/provisional'); + const hydratedRoot = URI.file('/hydrated'); + const retainedRoot = URI.file('/retained'); + const subscription = new TestSessionSubscription(); + const connection = new class extends mock<IAgentConnection>() { + override readonly resourceUris = identityAgentHostResourceUriMapper; + override readonly onDidAction = Event.None; + override readonly rootState = { + value: undefined, + verifiedValue: undefined, + onDidChange: Event.None, + onWillApplyAction: Event.None, + onDidApplyAction: Event.None, + } satisfies IAgentSubscription<RootState>; + + override getSubscription<T>(_kind: StateComponents): IReference<IAgentSubscription<T>> { + return { + object: subscription as unknown as IAgentSubscription<T>, + dispose: () => { }, + }; + } + }(); + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(ILoggerService, store.add(new NullLoggerService())); + instantiationService.stub(IOutputService, { + getChannel: () => undefined, + getChannelDescriptor: () => undefined, + showChannel: async () => { }, + }); + const service = store.add(new WorkbenchAgentHostCustomizationService( + new class extends mock<IAgentHostConnectionsService>() { + override readonly ambientConnection = connection; + }(), + new class extends mock<IAgentHostUntitledProvisionalSessionService>() { + override readonly onDidChange = Event.None; + override get(): URI { + return backendSession; + } + override getProvisionalWorkingDirectories(): readonly URI[] { + return [provisionalRoot]; + } + }(), + instantiationService, + new NullLogService(), + new class extends mock<IChatService>() { + override readonly onDidDisposeSession = Event.None; + }(), + new class extends mock<IAgentHostActiveClientService>() { }(), + )); + const createState = (workingDirectories: readonly URI[]): SessionState => createSessionState({ + resource: backendSession.toString(), + provider: 'copilot', + title: 'Session', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + workingDirectories: workingDirectories.map(uri => uri.toString()), + }); + + const beforeSnapshot = service.getWorkingDirectories(sessionResource); + subscription.setSnapshot(createState([hydratedRoot])); + const afterSnapshot = service.getWorkingDirectories(sessionResource); + subscription.setSnapshot(createState([])); + const afterEmptySnapshot = service.getWorkingDirectories(sessionResource); + subscription.setSnapshot(createState([retainedRoot])); + subscription.setError(new Error('subscription failed')); + const afterError = service.getWorkingDirectories(sessionResource); + + assert.deepStrictEqual({ + beforeSnapshot, + afterSnapshot, + afterEmptySnapshot, + afterError, + }, { + beforeSnapshot: [provisionalRoot.toString()], + afterSnapshot: [hydratedRoot.toString()], + afterEmptySnapshot: [], + afterError: [retainedRoot.toString()], + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts index 268195bf67e644..c33fbaa8886fb7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts @@ -76,9 +76,7 @@ suite('AgentHostLanguageModelProvider', () => { const infos = await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None); assert.deepStrictEqual( Object.fromEntries(Object.entries(infos[0].metadata.configurationSchema?.properties ?? {}).map(([key, property]) => [key, property.group])), - // `contextTier` needs token counts from the catalogue to be worth showing, and there is - // none here; see the context-tier tests below. - { reasoningEffort: 'navigation' } + { reasoningEffort: 'navigation', contextTier: 'tokens' } ); }); @@ -269,9 +267,9 @@ suite('AgentHostLanguageModelProvider', () => { }; } - test('labels the host context tiers with the token counts from the workbench catalogue', async () => { - // The host names its tiers because the SDK gives it no per-model windows. The workbench - // already knows them, so the picker shows the numbers while the wire value stays the tier. + test('publishes the host context tiers with the host labels, not catalogue token counts', async () => { + // The host's tier enum and the catalogue's `contextSize` list are independent catalogues, + // so pairing them by position mislabels the moment either changes. The host names its own. const { catalogue: known } = catalogue([{ id: 'claude-opus-5', contextSizes: [264_000, 1_000_000] }]); const provider = store.add(new AgentHostLanguageModelProvider('agent-host-copilot', 'copilot', known)); provider.updateModels([ @@ -289,13 +287,13 @@ suite('AgentHostLanguageModelProvider', () => { const tier = infos[0].metadata.configurationSchema?.properties?.contextTier; assert.deepStrictEqual( { enum: tier?.enum, labels: tier?.enumItemLabels, group: tier?.group }, - { enum: ['default', 'long_context'], labels: ['264K', '1M'], group: 'tokens' } + { enum: ['default', 'long_context'], labels: ['Default', 'Long context'], group: 'tokens' } ); }); - test('drops the context tier when there is no distinct long-context window to choose', async () => { - // Matches how the GitHub desktop app suppresses the picker: an unknown model, or one whose - // tiers are the same size, offers a choice the user cannot act on. + test('keeps a host config property the catalogue cannot enrich', async () => { + // A host advertises a property because it will honour it, and an unknown model, a staged + // rollout and an unresolved catalogue are indistinguishable from here. const { catalogue: known } = catalogue([{ id: 'known-single-tier', contextSizes: [200_000] }]); const provider = store.add(new AgentHostLanguageModelProvider('agent-host-copilot', 'copilot', known)); const contextTierOnly = { @@ -311,8 +309,8 @@ suite('AgentHostLanguageModelProvider', () => { assert.deepStrictEqual( infos.map(info => ({ id: info.metadata.id, properties: Object.keys(info.metadata.configurationSchema?.properties ?? {}) })), [ - { id: 'known-single-tier', properties: [] }, - { id: 'unknown-to-catalogue', properties: [] }, + { id: 'known-single-tier', properties: ['contextTier'] }, + { id: 'unknown-to-catalogue', properties: ['contextTier'] }, ] ); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts index 9e57d45a24ae4a..67b0e79b5dadf5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts @@ -1494,6 +1494,26 @@ suite('AgentHostUntitledProvisionalSessionService', () => { }); }); + test('retains working directories after rebinding a provisional session', async () => { + const folderA = URI.file('/repoA'); + const folderB = URI.file('/repoB'); + workspaceFolders = [folderA, folderB]; + agentHost.rootStateAgents = [agentInfo('copilot', true)]; + const untitled = untitledChatUri('rebind-roots'); + const real = URI.from({ scheme: 'agent-host-copilot', path: '/real-rebind-roots' }); + + await provisional.getOrCreate(untitled, 'copilot', folderA); + await provisional.tryRebind(untitled, real, 'copilot'); + + assert.deepStrictEqual({ + untitled: provisional.getProvisionalWorkingDirectories(untitled), + real: provisional.getProvisionalWorkingDirectories(real)?.map(directory => directory.toString()), + }, { + untitled: undefined, + real: [folderA.toString(), folderB.toString()], + }); + }); + test('sends only the primary when the provider does not advertise multiple working directories', async () => { const folderA = URI.file('/repoA'); const folderB = URI.file('/repoB'); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 07a0bb98aed8bb..e191bf2d8ec5e9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -18,7 +18,7 @@ import { createAgentHostResourceUriMapper, fromAgentHostUri, toAgentHostContentU import { buildSubagentChatUri, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, createErrorResponsePart, MessageAttachmentKind, MessageKind, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, readUsageInfoMeta, withMessageHiddenFromTranscript, withMessageRequestHiddenFromTranscript, type ActiveTurn, type ICompletedToolCall, type ToolCallPendingConfirmationState, type ToolCallRunningState, type Turn, type ToolCallResponsePart, ToolCallCancellationReason, type Message, type ToolResultContent } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ChatTranscriptContextAttachmentDisplayKind, IChatRequestTranscriptContextVariableEntry, toChatTranscriptContextAttachmentMeta } from '../../../common/attachments/chatVariableEntries.js'; import { ChatRequestOriginKind } from '../../../common/chatRequestOrigin.js'; -import { IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind, type IChatMarkdownContent, type IChatTerminalToolInvocationData, type IChatThinkingPart, type IChatUsage } from '../../../common/chatService/chatService.js'; +import { IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind, type IChatMarkdownContent, type IChatTerminalToolInvocationData, type IChatThinkingPart, type IChatToolInputInvocationData, type IChatUsage } from '../../../common/chatService/chatService.js'; import { isToolResultInputOutputDetails, type IToolResultInputOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; import { turnsToHistory as rawTurnsToHistory, activeTurnToProgress as rawActiveTurnToProgress, completedToolCallToSerialized, containsAutomaticReplyAnswer, createInputRequestCarousel, messageAttachmentsToVariableData, shouldObserveSubagentChat, toolCallStateToInvocation as rawToolCallStateToInvocation, toolCallStateToPreparedInvocation as rawToolCallStateToPreparedInvocation, toolCallStateToStreamingInvocation, finalizeToolInvocation as rawFinalizeToolInvocation, updateRunningToolSpecificData as rawUpdateRunningToolSpecificData, updateStreamingToolInvocation, usageInfoToAutoModeResolution, usageInfoToChatUsage, usageInfoToQuotas, formatTurnResponseDetails, rewriteAgentHostLinkTarget, rewriteMarkdownLinks, type TurnModelLookup } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; import { getQuotaReset } from '../../../../../services/chat/common/chatEntitlementService.js'; @@ -1594,10 +1594,14 @@ suite('stateToProgressAdapter', () => { kind: invocation.toolSpecificData?.kind, command: (invocation.toolSpecificData as IChatTerminalToolInvocationData | undefined)?.commandLine.original, language: (invocation.toolSpecificData as IChatTerminalToolInvocationData | undefined)?.language, + // Read-only for the same reason as a generic input: the edit is never returned, so + // an editable command line would run the one the agent proposed. + editable: (invocation.toolSpecificData as IChatTerminalToolInvocationData | undefined)?.editable, }, { kind: 'terminal', command: 'rg -n "sandbox" --glob "*.ts"', language: 'shellscript', + editable: false, }); }); @@ -1624,6 +1628,31 @@ suite('stateToProgressAdapter', () => { }); }); + test('presents a generic confirmation input read-only, since edits are never sent back', () => { + // The confirmation editor writes into `rawInput`, but this adapter never returns it as + // `editedToolInput` — an editable field would run the command the agent proposed. + const tc: ToolCallPendingConfirmationState = { + toolCallId: 'tc-perm-path', + toolName: 'shell', + displayName: 'Shell', + invocationMessage: 'Access paths', + status: ToolCallStatus.PendingConfirmation, + toolInput: '/a/one.ts, /a/two.ts', + // A host claiming otherwise does not change that: the round trip is what is missing. + editable: true, + _meta: { requestId: 'req-3', promptRequest: { kind: 'path', accessKind: 'shell' } }, + }; + + const invocation = toolCallStateToInvocation(tc); + assert.deepStrictEqual({ + kind: invocation.toolSpecificData?.kind, + editable: (invocation.toolSpecificData as IChatToolInputInvocationData | undefined)?.editable, + }, { + kind: 'input', + editable: false, + }); + }); + test('does not render a path permission as a terminal command', () => { // A path request's subject is a list of paths, not a command line, // even when its `accessKind` is `shell`. @@ -3322,8 +3351,9 @@ suite('stateToProgressAdapter', () => { contributor: { kind: ToolCallContributorKind.MCP, customizationId: 'docs-customization' }, _meta: meta, }); - // Confirmation state carries the raw input but does not mount the App. - assert.deepStrictEqual(invocation.toolSpecificData, { kind: 'input', rawInput: { topic: 'metadata' } }); + // Confirmation state carries the raw input but does not mount the App. It is read-only: + // this adapter never returns an edited input to the host. + assert.deepStrictEqual(invocation.toolSpecificData, { kind: 'input', rawInput: { topic: 'metadata' }, editable: false }); let stateChanged = false; const disposable = autorun(r => { diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts index 2ad3ed2fb964c5..3053dd77d28152 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts @@ -118,6 +118,7 @@ suite('AICustomizationItemsModel', () => { instaService.stub(IAICustomizationWorkspaceService, { activeProjectRoot: observableValue('test', undefined), + activeProjectLabel: observableValue('test', undefined), getActiveProjectRoot: () => undefined, managementSections: [AICustomizationManagementSection.Agents], isSessionsWindow: false, @@ -600,6 +601,7 @@ suite('AICustomizationItemsModel', () => { }); instaService.stub(IAICustomizationWorkspaceService, { activeProjectRoot: observableValue('test', undefined), + activeProjectLabel: observableValue('test', undefined), getActiveProjectRoot: () => undefined, managementSections: [AICustomizationManagementSection.Agents], isSessionsWindow: false, @@ -843,6 +845,7 @@ suite('AICustomizationItemsModel', () => { }); instaService.stub(IAICustomizationWorkspaceService, { activeProjectRoot: observableValue('test', undefined), + activeProjectLabel: observableValue('test', undefined), getActiveProjectRoot: () => undefined, managementSections: [AICustomizationManagementSection.Agents], isSessionsWindow: false, diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts index 677f932b800430..f2dd156e904460 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts @@ -316,6 +316,7 @@ suite('aiCustomizationListWidget', () => { instaService.stub(IAICustomizationWorkspaceService, { activeProjectRoot: observableValue('test', undefined), + activeProjectLabel: observableValue('test', undefined), getActiveProjectRoot: () => undefined, managementSections: [AICustomizationManagementSection.Agents], isSessionsWindow: false, diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts index 22599bf3cde971..e094948f5924fe 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts @@ -30,15 +30,18 @@ suite('aiCustomizationManagementEditor', () => { test('includes the customization target in the modal title', () => { const input = store.add(new AICustomizationManagementEditorInput()); - const names = [input.getName()]; - input.setTargetLabel('Copilot'); - names.push(input.getName()); - input.setTargetLabel(undefined); - names.push(input.getName()); - assert.deepStrictEqual(names, [ - 'Agent Customizations', - 'Agent Customizations - Copilot', - 'Agent Customizations', + const labels = [[input.getName(), input.getDescription()]]; + input.setTargetLabels('Copilot'); + labels.push([input.getName(), input.getDescription()]); + input.setTargetLabels('Copilot', 'vscode'); + labels.push([input.getName(), input.getDescription()]); + input.setTargetLabels(undefined); + labels.push([input.getName(), input.getDescription()]); + assert.deepStrictEqual(labels, [ + ['Agent Customizations', undefined], + ['Agent Customizations', '(Copilot)'], + ['Agent Customizations', '(Copilot · vscode)'], + ['Agent Customizations', undefined], ]); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatRequestText.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatRequestText.test.ts index 15e67cba1fccd9..be8f338764df05 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatRequestText.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatRequestText.test.ts @@ -40,7 +40,7 @@ suite('getChatRequestText', () => { getChatRequestText(request(agentMergePrompt, 'Terminal needs input')), ], [ 'Rename the widget', - 'Agent Merge, 1 comment', + '1 Review Comment, Agent Merge', agentMergePrompt, ]); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/exportAgentHostDebugLogs.test.ts b/src/vs/workbench/contrib/chat/test/browser/exportAgentHostDebugLogs.test.ts index 070903e4a4d87d..5b464ecc9be57a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/exportAgentHostDebugLogs.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/exportAgentHostDebugLogs.test.ts @@ -5,16 +5,20 @@ import assert from 'assert'; import { VSBuffer, streamToBuffer } from '../../../../../base/common/buffer.js'; +import { isDisposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; import { hasKey } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import type { IAgentHostDebugLogsArtifact, IAgentHostDebugLogsChunk } from '../../../../../platform/agentHost/common/agentService.js'; import { buildChatUri, buildDefaultChatUri, getSessionChatResource } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { TestClipboardService } from '../../../../../platform/clipboard/test/common/testClipboardService.js'; import { FileService } from '../../../../../platform/files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../../../platform/files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; -import { collectRotatedLogFiles, createHostArtifactStream, findOutputChannelLogFiles, getAgentHostDebugLogsExportName, resolveAgentHostDebugLogsChat, toActiveAgentHostSession } from '../../browser/actions/exportAgentHostDebugLogsAction.js'; +import { INotification } from '../../../../../platform/notification/common/notification.js'; +import { TestNotificationService } from '../../../../../platform/notification/test/common/testNotificationService.js'; +import { collectRotatedLogFiles, createHostArtifactStream, findOutputChannelLogFiles, getAgentHostDebugLogsExportName, notifyAgentHostDebugLogsExported, resolveAgentHostDebugLogsChat, toActiveAgentHostSession } from '../../browser/actions/exportAgentHostDebugLogsAction.js'; function artifactOfSize(size: number): IAgentHostDebugLogsArtifact { return { @@ -35,6 +39,49 @@ function chunkedReader(contents: VSBuffer, chunkSize: number): (position: number }; } +suite('notifyAgentHostDebugLogsExported', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('copies the exact desktop archive and web export folder paths', async () => { + const notifications: INotification[] = []; + const notificationService = new class extends TestNotificationService { + override notify(notification: INotification) { + notifications.push(notification); + return super.notify(notification); + } + }; + const clipboardService = new TestClipboardService(); + const desktopArchive = URI.file('/exports/ah-logs.zip'); + const webExportFolder = URI.file('/exports/ah-logs'); + + notifyAgentHostDebugLogsExported(notificationService, clipboardService, false, desktopArchive); + const desktopAction = notifications[0].actions?.primary?.[0]; + assert.ok(desktopAction); + if (isDisposable(desktopAction)) { + disposables.add(desktopAction); + } + await desktopAction.run(); + const desktopClipboardText = await clipboardService.readText(); + + notifyAgentHostDebugLogsExported(notificationService, clipboardService, false, webExportFolder); + const webAction = notifications[1].actions?.primary?.[0]; + assert.ok(webAction); + if (isDisposable(webAction)) { + disposables.add(webAction); + } + await webAction.run(); + const webClipboardText = await clipboardService.readText(); + + assert.deepStrictEqual({ + desktopClipboardText, + webClipboardText, + }, { + desktopClipboardText: desktopArchive.fsPath, + webClipboardText: webExportFolder.fsPath, + }); + }); +}); + suite('createHostArtifactStream', () => { ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/workbench/contrib/chat/test/browser/promptTimeline/promptTimelineModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptTimeline/promptTimelineModel.test.ts index fb3e10a802e2ac..0b8ddd42ac9079 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptTimeline/promptTimelineModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptTimeline/promptTimelineModel.test.ts @@ -147,7 +147,7 @@ suite('PromptTimelineModel', () => { assert.deepStrictEqual(model.promptTicks.get().map(tick => tick.text), [ 'First prompt', - 'Agent Merge, 1 comment, 1 check failing', + '1 Review Comment and 1 Failing Check, Agent Merge', ]); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatAgentMergeContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatAgentMergeContentPart.test.ts index fe0f8dd0b1433a..cb3ebcc12c99c8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatAgentMergeContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatAgentMergeContentPart.test.ts @@ -4,11 +4,58 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; +import * as dom from '../../../../../../../base/browser/dom.js'; +import { toDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; -import { describeAgentMergeFileLabels } from '../../../../browser/widget/chatContentParts/chatAgentMergeContentPart.js'; +import { IAgentMergePromptSummary } from '../../../../../../../platform/agentHost/common/agentMergePrompt.js'; +import { ICommandService } from '../../../../../../../platform/commands/common/commands.js'; +import { IHoverService } from '../../../../../../../platform/hover/browser/hover.js'; +import { IMarkdownRenderer } from '../../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; +import { ChatAgentMergeContentPart, describeAgentMergeFileLabels, getAgentMergeSummaryLabel } from '../../../../browser/widget/chatContentParts/chatAgentMergeContentPart.js'; + +function summary(overrides: Partial<IAgentMergePromptSummary> = {}): IAgentMergePromptSummary { + return { + actions: [], + pullRequestUrl: '', + title: '', + headRef: '', + headSha: '', + baseRef: 'main', + reviewThreads: [], + reviewSummaries: [], + newComments: [], + failedChecks: [], + behind: false, + conflicting: false, + agentMessage: '', + ...overrides, + }; +} + +function createPart( + data: IAgentMergePromptSummary, + options: { + readonly hoverService?: IHoverService; + readonly markdownRenderer?: IMarkdownRenderer; + readonly timestamp?: number; + } = {}, +): ChatAgentMergeContentPart { + return new ChatAgentMergeContentPart( + data, + URI.parse('test://session'), + options.markdownRenderer ?? upcastPartial<IMarkdownRenderer>({}), + options.timestamp, + upcastPartial<IOpenerService>({}), + options.hoverService ?? upcastPartial<IHoverService>({ setupDelayedHover: () => toDisposable(() => { }) }), + upcastPartial<ICommandService>({}), + ); +} suite('ChatAgentMergeContentPart file labels', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); test('names a unique file without a disambiguating path', () => { const labels = describeAgentMergeFileLabels([ @@ -49,4 +96,141 @@ suite('ChatAgentMergeContentPart file labels', () => { { name: 'index.ts:9', title: 'src/a/index.ts' }, ]); }); + + test('summarizes encountered events in one status sentence', () => { + assert.deepStrictEqual([ + getAgentMergeSummaryLabel(summary()), + getAgentMergeSummaryLabel(summary({ behind: true })), + getAgentMergeSummaryLabel(summary({ conflicting: true })), + getAgentMergeSummaryLabel(summary({ + reviewSummaries: [ + { author: 'octocat', body: 'Please fix this.' }, + { author: 'hubot', body: 'Please add a test.' }, + ], + failedChecks: ['Compile', 'Unit Tests'], + behind: true, + conflicting: true, + })), + ], [ + 'No Pending Feedback, Agent Merge', + 'Behind Base Branch, Agent Merge', + 'Merge Conflicts, Agent Merge', + '2 Review Comments, 2 Failing Checks, Merge Conflicts, and Behind Base Branch, Agent Merge', + ]); + }); + + test('keeps the Agent Message toggle name stable while reporting its state', () => { + const part = store.add(createPart(summary({ agentMessage: 'Merge agent details.' }))); + const button = part.domNode.querySelector<HTMLElement>('.chat-agent-merge-message-toggle'); + assert.ok(button); + + const getAccessibleState = () => ({ + label: button.getAttribute('aria-label'), + pressed: button.getAttribute('aria-pressed'), + }); + const initial = getAccessibleState(); + button.click(); + const showingMessage = getAccessibleState(); + button.click(); + const showingDetails = getAccessibleState(); + + assert.deepStrictEqual([initial, showingMessage, showingDetails], [ + { label: 'Agent Message', pressed: 'false' }, + { label: 'Agent Message', pressed: 'true' }, + { label: 'Agent Message', pressed: 'false' }, + ]); + }); + + test('reveals secondary actions for touch input while suppressing mouse focus', () => { + const part = store.add(createPart(summary())); + const button = part.domNode.querySelector<HTMLElement>('.chat-agent-merge-header-disclosure'); + assert.ok(button); + dom.getWindow(button).document.body.append(part.domNode); + store.add(toDisposable(() => part.domNode.remove())); + + const pointerDown = (pointerType: string) => { + const event = new PointerEvent(dom.EventType.POINTER_DOWN, { bubbles: true, cancelable: true, pointerType }); + button.dispatchEvent(event); + return event.defaultPrevented; + }; + + button.focus(); + const touchPrevented = pointerDown('touch'); + const touchInput = part.domNode.classList.contains('direct-pointer-input'); + button.focus(); + const mousePrevented = pointerDown('mouse'); + const mouseInput = part.domNode.classList.contains('direct-pointer-input'); + const mouseRetainedFocus = dom.getWindow(button).document.activeElement === button; + + assert.deepStrictEqual({ + touchPrevented, + touchInput, + mousePrevented, + mouseInput, + mouseRetainedFocus, + }, { + touchPrevented: false, + touchInput: true, + mousePrevented: true, + mouseInput: false, + mouseRetainedFocus: false, + }); + }); + + test('attaches the status hover to the interactive disclosure', () => { + let hoverTarget: HTMLElement | undefined; + const part = store.add(createPart(summary(), { + hoverService: upcastPartial<IHoverService>({ + setupDelayedHover: target => { + hoverTarget = target; + return toDisposable(() => { }); + }, + }), + })); + + assert.strictEqual(hoverTarget, part.domNode.querySelector('.chat-agent-merge-header-disclosure')); + }); + + test('renders the request timestamp and participant below the card', () => { + const timestamp = new Date().setHours(15, 33, 0, 0); + const part = store.add(createPart(summary(), { timestamp })); + const metadata = part.domNode.querySelector('.chat-agent-merge-metadata'); + const time = metadata?.querySelector('time'); + + assert.deepStrictEqual({ + cardParent: part.domNode.querySelector('.chat-agent-merge-card')?.parentElement, + metadataParent: metadata?.parentElement, + metadataText: metadata?.textContent, + dateTime: time?.dateTime, + tabIndex: time?.tabIndex, + }, { + cardParent: part.domNode, + metadataParent: part.domNode, + metadataText: '3:33 PM\u2022Agent Merge', + dateTime: new Date(timestamp).toISOString(), + tabIndex: 0, + }); + }); + + test('shows section headings only when comments and checks are both present', () => { + const markdownRenderer = upcastPartial<IMarkdownRenderer>({ + render: () => ({ element: dom.$('div'), dispose: () => { } }), + }); + const reviewSummaries = [{ author: 'octocat', body: 'Please fix this.' }]; + const commentsOnly = store.add(createPart(summary({ reviewSummaries }), { markdownRenderer })); + const checksOnly = store.add(createPart(summary({ failedChecks: ['Compile'] }), { markdownRenderer })); + const mixed = store.add(createPart(summary({ reviewSummaries, failedChecks: ['Compile'] }), { markdownRenderer })); + const sectionTitles = (part: ChatAgentMergeContentPart) => + Array.from(part.domNode.querySelectorAll('.chat-agent-merge-section-title'), element => element.textContent); + + assert.deepStrictEqual([ + sectionTitles(commentsOnly), + sectionTitles(checksOnly), + sectionTitles(mixed), + ], [ + [], + [], + ['Feedback', 'Checks'], + ]); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatRequestOriginPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatRequestOriginPart.test.ts index a3e8512318fbf8..96fad8e749558a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatRequestOriginPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatRequestOriginPart.test.ts @@ -4,12 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { IManagedHover, IManagedHoverContentOrFactory } from '../../../../../../../base/browser/ui/hover/hover.js'; import { DeferredPromise } from '../../../../../../../base/common/async.js'; import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; import { constObservable } from '../../../../../../../base/common/observable.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { mock } from '../../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { IHoverService } from '../../../../../../../platform/hover/browser/hover.js'; import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; import { IChatWidgetService } from '../../../../browser/chat.js'; import { ChatRequestOriginPart } from '../../../../browser/widget/chatContentParts/chatRequestOriginPart.js'; @@ -65,15 +67,27 @@ suite('ChatRequestOriginPart', () => { }); }); - test('distinguishes delegation from another chat in the same session', () => { + test('distinguishes delegation from another chat or session', () => { const disposables = store.add(new DisposableStore()); const instantiationService = workbenchInstantiationService(undefined, disposables); instantiationService.stub(IChatRequestOriginService, disposables.add(new ChatRequestOriginService())); instantiationService.stub(IChatSideChatService, disposables.add(new ChatSideChatService())); instantiationService.stub(IChatService, new class extends mock<IChatService>() { }); instantiationService.stub(IChatWidgetService, new class extends mock<IChatWidgetService>() { }); + const hoverContents: IManagedHoverContentOrFactory[] = []; + instantiationService.stub(IHoverService, new class extends mock<IHoverService>() { + override setupManagedHover(...args: Parameters<IHoverService['setupManagedHover']>): IManagedHover { + hoverContents.push(args[2]); + return { + show: () => { }, + hide: () => { }, + update: () => { }, + dispose: () => { }, + }; + } + }); - const part = disposables.add(instantiationService.createInstance( + const chatPart = disposables.add(instantiationService.createInstance( ChatRequestOriginPart, URI.parse('agent-host-copilot:/session#target'), { @@ -82,13 +96,38 @@ suite('ChatRequestOriginPart', () => { delegationScope: 'chat', }, )); + const sessionPart = disposables.add(instantiationService.createInstance( + ChatRequestOriginPart, + URI.parse('agent-host-copilot:/target-session'), + { + kind: ChatRequestOriginKind.Delegation, + sourceSessionResource: URI.parse('agent-host-session://copilot/source-session?turn=turn-1'), + delegationScope: 'session', + }, + )); assert.deepStrictEqual({ - text: part.domNode.textContent, - ariaLabel: part.domNode.getAttribute('aria-label'), + chat: { + text: chatPart.domNode.textContent, + ariaLabel: chatPart.domNode.getAttribute('aria-label'), + hover: hoverContents[0], + }, + session: { + text: sessionPart.domNode.textContent, + ariaLabel: sessionPart.domNode.getAttribute('aria-label'), + hover: hoverContents[1], + }, }, { - text: 'Sent from another chat', - ariaLabel: 'Sent from another chat. Select to open the source.', + chat: { + text: 'Sent from another chat', + ariaLabel: 'Sent from another chat. Select to open the source.', + hover: 'Open source chat', + }, + session: { + text: 'Sent by another session', + ariaLabel: 'Sent by another session. Select to open the source.', + hover: 'Open source session', + }, }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts index 88976f16e8f936..975acd3b071a77 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts @@ -236,6 +236,48 @@ suite('ChatListRenderer', () => { assert.deepStrictEqual(moveResponseOutcomeToolsAfterFinalResponse([tool, finalResponse]), [finalResponse]); }); + test('deduplicates repeated session outcomes by target', () => { + const tool: IChatToolInvocationSerialized = { + kind: 'toolInvocationSerialized', + toolCallId: 'create-session', + toolId: 'create_session', + invocationMessage: 'Creating session...', + originMessage: undefined, + pastTenseMessage: 'Created session', + isComplete: true, + isConfirmed: { type: ToolConfirmKind.ConfirmationNotNeeded }, + presentation: undefined, + source: ToolDataSource.Internal, + toolSpecificData: { + kind: 'sessionCreated', + openLink: 'agent-host-session://local/session', + label: 'Implement issue', + }, + }; + const repeatedTarget: IChatToolInvocationSerialized = { + ...tool, + toolCallId: 'send-message', + toolId: 'send_message', + invocationMessage: 'Sending message...', + pastTenseMessage: 'Sent message', + }; + const otherTarget: IChatToolInvocationSerialized = { + ...repeatedTarget, + toolCallId: 'send-other-message', + toolSpecificData: { + kind: 'sessionCreated', + openLink: 'agent-host-session://local/other-session', + label: 'Investigate other issue', + }, + }; + const finalResponse = { kind: 'markdownContent', content: new MarkdownString('Done') } as const; + + assert.deepStrictEqual( + moveResponseOutcomeToolsAfterFinalResponse([tool, repeatedTarget, otherTarget, finalResponse]), + [finalResponse, tool, otherTarget], + ); + }); + test('leaves created-session tools in place when there is no final response', () => { const tool: IChatToolInvocationSerialized = { kind: 'toolInvocationSerialized', diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index bf9c544c58861c..bb8a065010bd02 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -999,6 +999,52 @@ suite('PromptsService', () => { ); }); + test('does not cache partially completed canceled agent discovery', async () => { + const rootFolder = '/custom-agents-cancellation'; + const rootFolderUri = URI.file(rootFolder); + const firstAgent = URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'); + const secondAgent = URI.joinPath(rootFolderUri, '.github/agents/agent2.agent.md'); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + await mockFiles(fileService, [ + { path: firstAgent.path, contents: ['---', 'description: First agent.', '---'] }, + { path: secondAgent.path, contents: ['---', 'description: Second agent.', '---'] }, + ]); + + const firstReadCompleted = new DeferredPromise<void>(); + const secondReadStarted = new DeferredPromise<void>(); + const releaseSecondRead = new DeferredPromise<void>(); + const readFile = fileService.readFile.bind(fileService); + const readFileStub = sinon.stub(fileService, 'readFile').callsFake(async (resource: URI, options?: IReadFileOptions, token?: CancellationToken): Promise<IFileContent> => { + if (resource.toString() === firstAgent.toString()) { + const result = await readFile(resource, options, token); + firstReadCompleted.complete(); + return result; + } + if (resource.toString() === secondAgent.toString()) { + secondReadStarted.complete(); + await releaseSecondRead.p; + } + return readFile(resource, options, token); + }); + + const cancellationTokenSource = disposables.add(new CancellationTokenSource()); + const canceledDiscovery = service.getCustomAgents(cancellationTokenSource.token); + await Promise.all([firstReadCompleted.p, secondReadStarted.p]); + cancellationTokenSource.cancel(); + await assert.rejects(canceledDiscovery, CancellationError); + releaseSecondRead.complete(); + await timeout(0); + readFileStub.restore(); + + const agents = await service.getCustomAgents(CancellationToken.None); + + assert.deepStrictEqual( + agents.map(agent => agent.name).sort(), + ['agent1', 'agent2'], + ); + }); + test('header with handOffs', async () => { const rootFolderName = 'custom-agents-with-handoffs'; diff --git a/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts b/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts index 40ced0ea4db9fd..a82ed9620aba8e 100644 --- a/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts +++ b/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts @@ -28,9 +28,10 @@ import { IEditorGroup, IEditorGroupsService } from '../../../services/editor/com import { IEditorService } from '../../../services/editor/common/editorService.js'; import { URI } from '../../../../base/common/uri.js'; import { MultiDiffEditorViewModel } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.js'; -import { IMultiDiffEditorLayoutDebugState, IMultiDiffEditorOptions, IMultiDiffEditorViewState } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; +import { IMultiDiffEditorLayoutDebugState, IMultiDiffEditorViewState } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { IDiffEditor } from '../../../../editor/common/editorCommon.js'; +import { IMultiDiffEditorOptions } from '../../../../editor/common/multiDiffEditor.js'; import { Range } from '../../../../editor/common/core/range.js'; import { MultiDiffEditorItem } from './multiDiffSourceResolverService.js'; import { IEditorProgressService } from '../../../../platform/progress/common/progress.js'; diff --git a/src/vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts b/src/vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts index f3a8c619746bce..0d5f4528f05b13 100644 --- a/src/vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts +++ b/src/vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts @@ -8,7 +8,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { observableFromEvent, ValueWithChangeEventFromObservable, waitForState } from '../../../../base/common/observable.js'; import { basename } from '../../../../base/common/path.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; -import { IMultiDiffEditorOptions } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; +import { IMultiDiffEditorOptions } from '../../../../editor/common/multiDiffEditor.js'; import { localize2 } from '../../../../nls.js'; import { Action2 } from '../../../../platform/actions/common/actions.js'; import { ContextKeyValue } from '../../../../platform/contextkey/common/contextkey.js'; diff --git a/src/vs/workbench/contrib/notebook/browser/diff/notebookMultiDiffEditor.ts b/src/vs/workbench/contrib/notebook/browser/diff/notebookMultiDiffEditor.ts index b4cdbd75e051ca..87ee014e9f7c60 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/notebookMultiDiffEditor.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/notebookMultiDiffEditor.ts @@ -23,12 +23,12 @@ import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { EditorPane } from '../../../../browser/parts/editor/editorPane.js'; import { CellUri, INotebookDiffEditorModel, NOTEBOOK_MULTI_DIFF_EDITOR_ID } from '../../common/notebookCommon.js'; import { FontMeasurements } from '../../../../../editor/browser/config/fontMeasurements.js'; +import { IMultiDiffEditorOptions } from '../../../../../editor/common/multiDiffEditor.js'; import { NotebookOptions } from '../notebookOptions.js'; import { INotebookService } from '../../common/notebookService.js'; import { NotebookMultiDiffEditorInput, NotebookMultiDiffEditorWidgetInput } from './notebookMultiDiffEditorInput.js'; import { MultiDiffEditorWidget } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; import { ResourceLabel } from '../../../../browser/labels.js'; -import type { IMultiDiffEditorOptions } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; import { INotebookDocumentService } from '../../../../services/notebook/common/notebookDocumentService.js'; import { localize } from '../../../../../nls.js'; import { Schemas } from '../../../../../base/common/network.js'; diff --git a/src/vs/workbench/contrib/terminalContrib/telemetry/browser/terminalProfileConfigurationTelemetry.ts b/src/vs/workbench/contrib/terminalContrib/telemetry/browser/terminalProfileConfigurationTelemetry.ts new file mode 100644 index 00000000000000..6da1ed39ca4279 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/telemetry/browser/terminalProfileConfigurationTelemetry.ts @@ -0,0 +1,118 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { IConfigurationService, ConfigurationTargetToString } from '../../../../../platform/configuration/common/configuration.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { TerminalSettingId } from '../../../../../platform/terminal/common/terminal.js'; +import { TerminalChatAgentToolsSettingId } from '../../chatAgentTools/common/terminalChatAgentToolsConfiguration.js'; + +const terminalProfileSettings = [ + { settingId: TerminalChatAgentToolsSettingId.TerminalProfileLinux, profileType: 'chat', os: 'linux' }, + { settingId: TerminalChatAgentToolsSettingId.TerminalProfileMacOs, profileType: 'chat', os: 'osx' }, + { settingId: TerminalChatAgentToolsSettingId.TerminalProfileWindows, profileType: 'chat', os: 'windows' }, + { settingId: TerminalSettingId.AutomationProfileLinux, profileType: 'automation', os: 'linux' }, + { settingId: TerminalSettingId.AutomationProfileMacOs, profileType: 'automation', os: 'osx' }, + { settingId: TerminalSettingId.AutomationProfileWindows, profileType: 'automation', os: 'windows' }, + { settingId: TerminalSettingId.DefaultProfileLinux, profileType: 'default', os: 'linux' }, + { settingId: TerminalSettingId.DefaultProfileMacOs, profileType: 'default', os: 'osx' }, + { settingId: TerminalSettingId.DefaultProfileWindows, profileType: 'default', os: 'windows' }, +] as const; + +type TerminalProfileSetting = typeof terminalProfileSettings[number]; + +export class TerminalProfileConfigurationTelemetry extends Disposable { + private readonly _configuredSettings = new Map<TerminalProfileSetting['settingId'], boolean>(); + + constructor( + @IConfigurationService configurationService: IConfigurationService, + @ITelemetryService telemetryService: ITelemetryService, + ) { + super(); + + for (const setting of terminalProfileSettings) { + const configured = this._isConfigured(configurationService, setting.settingId); + this._configuredSettings.set(setting.settingId, configured); + if (configured) { + this._reportSettingState(telemetryService, setting); + } + } + + this._register(configurationService.onDidChangeConfiguration(event => { + for (const setting of terminalProfileSettings) { + if (!event.affectsConfiguration(setting.settingId, {})) { + continue; + } + + this._reportSettingChanged(configurationService, telemetryService, setting, ConfigurationTargetToString(event.source) ?? 'UNKNOWN'); + } + })); + } + + private _reportSettingState(telemetryService: ITelemetryService, setting: TerminalProfileSetting): void { + type TerminalProfileSettingStateEvent = { + settingId: TerminalProfileSetting['settingId']; + profileType: TerminalProfileSetting['profileType']; + os: TerminalProfileSetting['os']; + configured: true; + }; + type TerminalProfileSettingStateClassification = { + owner: 'anthonykim1'; + comment: 'Tracks terminal profile settings that already have a configured value without collecting profile names, paths, arguments, environment variables, or other profile contents.'; + settingId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The terminal profile setting that has a configured value.' }; + profileType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the configured setting controls chat, automation, or default terminals.' }; + os: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The operating system targeted by the configured setting.' }; + configured: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the setting has a configured value.' }; + }; + + telemetryService.publicLog2<TerminalProfileSettingStateEvent, TerminalProfileSettingStateClassification>('terminal/profileSettingState', { + ...setting, + configured: true, + }); + } + + private _reportSettingChanged( + configurationService: IConfigurationService, + telemetryService: ITelemetryService, + setting: TerminalProfileSetting, + source: string, + ): void { + type TerminalProfileSettingChangedEvent = { + settingId: TerminalProfileSetting['settingId']; + profileType: TerminalProfileSetting['profileType']; + os: TerminalProfileSetting['os']; + configured: boolean; + changeType: 'added' | 'changed' | 'removed'; + source: string; + }; + type TerminalProfileSettingChangedClassification = { + owner: 'anthonykim1'; + comment: 'Tracks changes to terminal profile settings without collecting profile names, paths, arguments, environment variables, or other profile contents.'; + settingId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The terminal profile setting that changed.' }; + profileType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the changed setting controls chat, automation, or default terminals.' }; + os: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The operating system targeted by the changed setting.' }; + configured: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the setting resolves to a configured profile after the change.' }; + changeType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the effective profile setting was added, changed, or removed.' }; + source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The broad configuration change source reported by the configuration service.' }; + }; + + const configured = this._isConfigured(configurationService, setting.settingId); + const wasConfigured = this._configuredSettings.get(setting.settingId) ?? false; + const changeType = configured ? (wasConfigured ? 'changed' : 'added') : (wasConfigured ? 'removed' : 'changed'); + this._configuredSettings.set(setting.settingId, configured); + + telemetryService.publicLog2<TerminalProfileSettingChangedEvent, TerminalProfileSettingChangedClassification>('terminal/profileSettingChanged', { + ...setting, + configured, + changeType, + source, + }); + } + + private _isConfigured(configurationService: IConfigurationService, settingId: TerminalProfileSetting['settingId']): boolean { + const value = configurationService.getValue(settingId); + return value !== null && value !== undefined; + } +} diff --git a/src/vs/workbench/contrib/terminalContrib/telemetry/browser/terminalTelemetry.ts b/src/vs/workbench/contrib/terminalContrib/telemetry/browser/terminalTelemetry.ts index f6570068e6336e..e7df46ada48a1f 100644 --- a/src/vs/workbench/contrib/terminalContrib/telemetry/browser/terminalTelemetry.ts +++ b/src/vs/workbench/contrib/terminalContrib/telemetry/browser/terminalTelemetry.ts @@ -14,9 +14,11 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele import { TelemetryTrustedValue } from '../../../../../platform/telemetry/common/telemetryUtils.js'; import { TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; import { TerminalLocation, type IShellLaunchConfig, type ShellIntegrationInjectionFailureReason } from '../../../../../platform/terminal/common/terminal.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import type { IWorkbenchContribution } from '../../../../common/contributions.js'; import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js'; import { ITerminalEditorService, ITerminalService, type ITerminalInstance } from '../../../terminal/browser/terminal.js'; +import { TerminalProfileConfigurationTelemetry } from './terminalProfileConfigurationTelemetry.js'; export class TerminalTelemetryContribution extends Disposable implements IWorkbenchContribution { static ID = 'terminalTelemetry'; @@ -25,10 +27,13 @@ export class TerminalTelemetryContribution extends Disposable implements IWorkbe @ILifecycleService lifecycleService: ILifecycleService, @ITerminalService terminalService: ITerminalService, @ITerminalEditorService terminalEditorService: ITerminalEditorService, + @IConfigurationService configurationService: IConfigurationService, @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); + this._register(new TerminalProfileConfigurationTelemetry(configurationService, this._telemetryService)); + this._register(terminalService.onDidCreateInstance(async instance => { const store = new DisposableStore(); this._store.add(store); diff --git a/src/vs/workbench/contrib/terminalContrib/telemetry/test/browser/terminalProfileConfigurationTelemetry.test.ts b/src/vs/workbench/contrib/terminalContrib/telemetry/test/browser/terminalProfileConfigurationTelemetry.test.ts new file mode 100644 index 00000000000000..6afb8f633d1fa5 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/telemetry/test/browser/terminalProfileConfigurationTelemetry.test.ts @@ -0,0 +1,132 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { ConfigurationTarget } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { NullTelemetryServiceShape } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; +import { TerminalSettingId } from '../../../../../../platform/terminal/common/terminal.js'; +import { TerminalChatAgentToolsSettingId } from '../../../chatAgentTools/common/terminalChatAgentToolsConfiguration.js'; +import { TerminalProfileConfigurationTelemetry } from '../../browser/terminalProfileConfigurationTelemetry.js'; + +class TestTelemetryService extends NullTelemetryServiceShape { + readonly events: { readonly name: string; readonly data: unknown }[] = []; + + override publicLog2(eventName?: string, data?: unknown): void { + if (eventName) { + this.events.push({ name: eventName, data }); + } + } +} + +const profileSettings = [ + { settingId: TerminalChatAgentToolsSettingId.TerminalProfileLinux, profileType: 'chat', os: 'linux', value: { path: '/bin/bash' } }, + { settingId: TerminalChatAgentToolsSettingId.TerminalProfileMacOs, profileType: 'chat', os: 'osx', value: { path: '/bin/zsh' } }, + { settingId: TerminalChatAgentToolsSettingId.TerminalProfileWindows, profileType: 'chat', os: 'windows', value: { path: 'pwsh.exe' } }, + { settingId: TerminalSettingId.AutomationProfileLinux, profileType: 'automation', os: 'linux', value: { path: '/bin/bash' } }, + { settingId: TerminalSettingId.AutomationProfileMacOs, profileType: 'automation', os: 'osx', value: { path: '/bin/zsh' } }, + { settingId: TerminalSettingId.AutomationProfileWindows, profileType: 'automation', os: 'windows', value: { path: 'pwsh.exe' } }, + { settingId: TerminalSettingId.DefaultProfileLinux, profileType: 'default', os: 'linux', value: 'bash' }, + { settingId: TerminalSettingId.DefaultProfileMacOs, profileType: 'default', os: 'osx', value: 'zsh' }, + { settingId: TerminalSettingId.DefaultProfileWindows, profileType: 'default', os: 'windows', value: 'PowerShell' }, +] as const; + +suite('TerminalProfileConfigurationTelemetry', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let configurationService: TestConfigurationService; + let telemetryService: TestTelemetryService; + + setup(() => { + configurationService = new TestConfigurationService(); + telemetryService = new TestTelemetryService(); + store.add(new TerminalProfileConfigurationTelemetry(configurationService, telemetryService)); + }); + + async function changeSetting(settingId: string, value: unknown, source = ConfigurationTarget.USER): Promise<void> { + await configurationService.setUserConfiguration(settingId, value); + configurationService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration: (key, overrides) => key === settingId && overrides !== undefined, + affectedKeys: new Set([settingId]), + change: { keys: [settingId], overrides: [] }, + source, + }); + } + + test('reports changes for each terminal profile setting without profile contents', async () => { + for (const setting of profileSettings) { + await changeSetting(setting.settingId, setting.value); + } + + assert.deepStrictEqual(telemetryService.events, profileSettings.map(({ value, ...setting }) => ({ + name: 'terminal/profileSettingChanged', + data: { + ...setting, + configured: true, + changeType: 'added', + source: 'USER', + }, + }))); + }); + + test('reports settings that already have configured values', () => { + const configuredSettings = [profileSettings[0], profileSettings[7]]; + const configurationService = new TestConfigurationService(Object.fromEntries(configuredSettings.map(setting => [setting.settingId, setting.value]))); + const telemetryService = new TestTelemetryService(); + store.add(new TerminalProfileConfigurationTelemetry(configurationService, telemetryService)); + + assert.deepStrictEqual(telemetryService.events, configuredSettings.map(({ value, ...setting }) => ({ + name: 'terminal/profileSettingState', + data: { + ...setting, + configured: true, + }, + }))); + }); + + test('reports added, changed, and removed settings and ignores unrelated settings', async () => { + await changeSetting(TerminalSettingId.DefaultProfileLinux, 'bash'); + await changeSetting(TerminalSettingId.DefaultProfileLinux, 'zsh', ConfigurationTarget.WORKSPACE); + await changeSetting(TerminalSettingId.DefaultProfileLinux, null); + await changeSetting(TerminalSettingId.FontSize, 16); + + assert.deepStrictEqual(telemetryService.events, [ + { + name: 'terminal/profileSettingChanged', + data: { + settingId: TerminalSettingId.DefaultProfileLinux, + profileType: 'default', + os: 'linux', + configured: true, + changeType: 'added', + source: 'USER', + }, + }, + { + name: 'terminal/profileSettingChanged', + data: { + settingId: TerminalSettingId.DefaultProfileLinux, + profileType: 'default', + os: 'linux', + configured: true, + changeType: 'changed', + source: 'WORKSPACE', + }, + }, + { + name: 'terminal/profileSettingChanged', + data: { + settingId: TerminalSettingId.DefaultProfileLinux, + profileType: 'default', + os: 'linux', + configured: false, + changeType: 'removed', + source: 'USER', + }, + }, + ]); + }); +}); diff --git a/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts b/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts index 95cabd8ef6bcc1..31f60e6268cee7 100644 --- a/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts +++ b/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts @@ -28,8 +28,9 @@ import { AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticati import { IWorkbenchEnvironmentService } from '../../../environment/common/environmentService.js'; import { IExtensionService } from '../../../extensions/common/extensions.js'; import { IHostService } from '../../../host/browser/host.js'; -import { DefaultAccountProvider } from '../../browser/defaultAccount.js'; +import { DefaultAccountProvider, DefaultAccountService } from '../../browser/defaultAccount.js'; import { TestProductService } from '../../../../test/common/workbenchTestServices.js'; +import { AccountPolicyGateState, AccountPolicyService } from '../../../policies/common/accountPolicyService.js'; suite('DefaultAccountProvider', () => { @@ -575,6 +576,168 @@ suite('DefaultAccountProvider', () => { assert.strictEqual(requestService.requestCount, 2); }); + test('managed settings endpoint outage without a refresh requirement keeps freshness non-blocking', async () => { + let managedSettingsRequestCount = 0; + const requestService = new TestRequestService(async options => { + if (options.url?.endsWith('/copilot_internal/user')) { + return jsonResponse({ chat_enabled: true }); + } + if (options.url?.includes('/copilot_internal/managed_settings')) { + managedSettingsRequestCount++; + if (managedSettingsRequestCount === 1) { + return jsonResponse({ + permissions: { disableBypassPermissionsMode: 'disable' }, + }); + } + throw new Error('managed settings request timed out'); + } + throw new Error(`Unexpected request: ${options.url}`); + }); + const provider = await createProvider( + requestService, + {}, + {}, + 'https://api.github.com/copilot_internal/managed_settings', + { getSessions: async () => sessions } + ); + + await provider.refresh({ forceRefresh: true }); + const accountService = disposables.add(new DefaultAccountService(TestProductService)); + accountService.setDefaultAccountProvider(provider); + await accountService.refresh(); + const gateService = disposables.add(new AccountPolicyService(new NullLogService(), accountService)); + await timeout(0); + + assert.deepStrictEqual({ + managedSettingsRequestCount, + requestTimeouts: requestService.requests + .filter(request => request.url?.includes('/copilot_internal/managed_settings')) + .map(request => request.timeout), + defaultAccount: provider.defaultAccount, + managedSettings: provider.policyData?.managedSettings, + freshness: provider.managedSettingsFreshness, + compatibilityError: provider.managedSettingsCompatibilityError, + gateState: gateService.gateInfo.state, + }, { + managedSettingsRequestCount: 2, + requestTimeouts: [5000, 5000], + defaultAccount: { + authenticationProvider: { id: 'github', name: 'GitHub', enterprise: false }, + accountName: 'octocat', + sessionId: 'session', + enterprise: false, + entitlementsData: { chat_enabled: true }, + }, + managedSettings: { + 'permissions.disableBypassPermissionsMode': 'disable', + }, + freshness: { state: ManagedSettingsFreshnessState.NotRequired }, + compatibilityError: null, + gateState: AccountPolicyGateState.Inactive, + }); + }); + + test('truthy non-boolean server refresh control remains non-blocking after an endpoint outage', async () => { + let managedSettingsRequestCount = 0; + const requestService = new TestRequestService(async options => { + if (options.url?.endsWith('/copilot_internal/user')) { + return jsonResponse({ chat_enabled: true }); + } + if (options.url?.includes('/copilot_internal/managed_settings')) { + managedSettingsRequestCount++; + if (managedSettingsRequestCount === 1) { + return jsonResponse({ + forceRemoteSettingsRefresh: 'true', + permissions: { disableBypassPermissionsMode: 'disable' }, + }); + } + throw new Error('managed settings request timed out'); + } + throw new Error(`Unexpected request: ${options.url}`); + }); + const provider = await createProvider( + requestService, + {}, + {}, + 'https://api.github.com/copilot_internal/managed_settings', + { getSessions: async () => sessions } + ); + + await provider.refresh({ forceRefresh: true }); + + assert.deepStrictEqual({ + managedSettingsRequestCount, + managedSettings: provider.policyData?.managedSettings, + freshness: provider.managedSettingsFreshness, + compatibilityError: provider.managedSettingsCompatibilityError, + }, { + managedSettingsRequestCount: 2, + managedSettings: { + forceRemoteSettingsRefresh: 'true', + 'permissions.disableBypassPermissionsMode': 'disable', + }, + freshness: { state: ManagedSettingsFreshnessState.NotRequired }, + compatibilityError: null, + }); + }); + + test('non-466 managed settings failures remain non-blocking without a refresh requirement', async () => { + const failures: { name: string; response: () => IRequestContext }[] = [ + { name: 'http error', response: () => jsonResponse({}, 500) }, + { + name: 'malformed response', + response: () => ({ + res: { statusCode: 200, headers: {} }, + stream: bufferToStream(VSBuffer.fromString('{')), + }), + }, + { name: 'rate limited', response: () => jsonResponse({}, 429, { 'retry-after': '60' }) }, + ]; + const outcomes = []; + + for (const failure of failures) { + const requestService = new TestRequestService(async () => failure.response()); + const provider = await createProvider(requestService); + const cachedPolicy = createCachedPolicy(false); + const result = await provider['getManagedSettings'](sessions, cachedPolicy, { forceRefresh: true }); + outcomes.push({ + name: failure.name, + status: provider.managedSettingsFetchStatus, + data: result.data, + keptCachedTimestamp: result.fetchedAt === cachedPolicy.managedSettingsFetchedAt, + freshness: provider.managedSettingsFreshness, + compatibilityError: provider.managedSettingsCompatibilityError, + }); + } + + assert.deepStrictEqual(outcomes, [ + { + name: 'http error', + status: 500, + data: createCachedPolicy(false).policyData, + keptCachedTimestamp: true, + freshness: { state: ManagedSettingsFreshnessState.NotRequired }, + compatibilityError: null, + }, + { + name: 'malformed response', + status: 'parse-error', + data: createCachedPolicy(false).policyData, + keptCachedTimestamp: true, + freshness: { state: ManagedSettingsFreshnessState.NotRequired }, + compatibilityError: null, + }, + { + name: 'rate limited', + status: 429, + data: createCachedPolicy(false).policyData, + keptCachedTimestamp: true, + freshness: { state: ManagedSettingsFreshnessState.NotRequired }, + compatibilityError: null, + }, + ]); + }); + test('managed settings source change preserves a prior blocked state', async () => { const requestService = new TestRequestService(async () => { throw new Error('managed settings unavailable'); diff --git a/src/vs/workbench/services/label/common/labelService.ts b/src/vs/workbench/services/label/common/labelService.ts index 3636e338cc5165..5f166578268446 100644 --- a/src/vs/workbench/services/label/common/labelService.ts +++ b/src/vs/workbench/services/label/common/labelService.ts @@ -14,7 +14,7 @@ import { IWorkbenchEnvironmentService } from '../../environment/common/environme import { IWorkspaceContextService, IWorkspace, isWorkspace, ISingleFolderWorkspaceIdentifier, isSingleFolderWorkspaceIdentifier, isWorkspaceIdentifier, IWorkspaceIdentifier, toWorkspaceIdentifier, WORKSPACE_EXTENSION, isUntitledWorkspace, isTemporaryWorkspace } from '../../../../platform/workspace/common/workspace.js'; import { basenameOrAuthority, basename, dirname, isEqualOrParent, joinPath, relativePath } from '../../../../base/common/resources.js'; import { tildify, getPathLabel } from '../../../../base/common/labels.js'; -import { ILabelService, ResourceLabelFormatter, ResourceLabelFormatting, IFormatterChangeEvent, Verbosity } from '../../../../platform/label/common/label.js'; +import { ILabelService, ResourceLabelFormatter, ResourceLabelFormatting, IFormatterChangeEvent, Verbosity, ResourceLabelTemplateFormatter } from '../../../../platform/label/common/label.js'; import { ExtensionsRegistry } from '../../extensions/common/extensionsRegistry.js'; import { match } from '../../../../base/common/glob.js'; import { ILifecycleService, LifecyclePhase } from '../../lifecycle/common/lifecycle.js'; @@ -26,6 +26,7 @@ import { IRemoteAgentService } from '../../remote/common/remoteAgentService.js'; import { Schemas } from '../../../../base/common/network.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { Memento } from '../../../common/memento.js'; +import { escapeRegExpCharacters } from '../../../../base/common/strings.js'; const resourceLabelFormattersExtPoint = ExtensionsRegistry.registerExtensionPoint<ResourceLabelFormatter[]>({ extensionPoint: 'resourceLabelFormatters', @@ -129,11 +130,31 @@ interface IStoredFormatters { i?: number; } +interface IHomeFormatterRegistration { + readonly formatter: ResourceLabelTemplateFormatter; + readonly templateMatcher: RegExp; +} + +interface IResolvedHomeFormatter { + readonly home: URI; + readonly formatting: ResourceLabelFormatting; + readonly literalLabel: boolean; + readonly homeLength: number; + readonly authorityLength: number; +} + +const homeTemplateParameterRegex = /^\$\{(?<name>[a-zA-Z_][\w]*)\}$/; + +function isTemplateFormatter(formatter: ResourceLabelFormatter | ResourceLabelTemplateFormatter): formatter is ResourceLabelTemplateFormatter { + return URI.isUri(formatter.home); +} + export class LabelService extends Disposable implements ILabelService { declare readonly _serviceBrand: undefined; private formatters: ResourceLabelFormatter[]; + private homeFormatters: IHomeFormatterRegistration[] = []; private readonly _onDidChangeFormatters = this._register(new Emitter<IFormatterChangeEvent>({ leakWarningThreshold: 400, leakWarningName: 'LabelService._onDidChangeFormatters' })); readonly onDidChangeFormatters = this._onDidChangeFormatters.event; @@ -180,27 +201,70 @@ export class LabelService extends Disposable implements ILabelService { getUriHome(resource: URI): URI | undefined { const formatter = this.findHomeFormatter(resource); - return formatter?.home ? resource.with({ path: formatter.home, query: null, fragment: null }) : undefined; + return formatter?.home; } - private findHomeFormatter(resource: URI): ResourceLabelFormatter | undefined { - let bestResult: ResourceLabelFormatter | undefined; - for (const formatter of this.formatters) { - if (!formatter.home || formatter.scheme !== resource.scheme || (formatter.authority && !match(formatter.authority, resource.authority, { ignoreCase: true }))) { + private findHomeFormatter(resource: URI): IResolvedHomeFormatter | undefined { + let bestResult = this.findStaticHomeFormatter(resource); + let bestResultIsTemplate = false; + for (const registration of this.homeFormatters) { + const formatter = registration.formatter; + if (formatter.home.scheme !== resource.scheme || + (formatter.home.authority && formatter.home.authority.toLowerCase() !== resource.authority.toLowerCase())) { continue; } - if (!isEqualOrParent(resource, resource.with({ path: formatter.home }))) { + const templateMatch = registration.templateMatcher.exec(resource.path); + if (!templateMatch) { continue; } - const authorityLength = formatter.authority?.length ?? 0; - const bestAuthorityLength = bestResult?.authority?.length ?? 0; - const bestHomeLength = bestResult?.home?.length ?? 0; + const parameters = new Map(Object.entries(templateMatch.groups ?? {})); + const home = resource.with({ path: templateMatch[0], query: null, fragment: null }); + const formatting = formatter.formatting({ resource, home, parameters }); + if (!formatting) { + continue; + } + + const result: IResolvedHomeFormatter = { + home, + formatting, + literalLabel: true, + homeLength: home.path.length, + authorityLength: formatter.home.authority.length, + }; if (!bestResult || - formatter.home.length > bestHomeLength || - (formatter.home.length === bestHomeLength && authorityLength > bestAuthorityLength) || - (formatter.home.length === bestHomeLength && authorityLength === bestAuthorityLength && formatter.priority) + result.homeLength > bestResult.homeLength || + (result.homeLength === bestResult.homeLength && result.authorityLength > bestResult.authorityLength) || + (result.homeLength === bestResult.homeLength && result.authorityLength === bestResult.authorityLength && !bestResultIsTemplate) ) { - bestResult = formatter; + bestResult = result; + bestResultIsTemplate = true; + } + } + return bestResult; + } + + private findStaticHomeFormatter(resource: URI): IResolvedHomeFormatter | undefined { + let bestResult: IResolvedHomeFormatter | undefined; + for (const formatter of this.formatters) { + if (!formatter.home || formatter.scheme !== resource.scheme || + (formatter.authority && !match(formatter.authority, resource.authority, { ignoreCase: true })) || + !isEqualOrParent(resource, resource.with({ path: formatter.home }))) { + continue; + } + + const result: IResolvedHomeFormatter = { + home: resource.with({ path: formatter.home, query: null, fragment: null }), + formatting: formatter.formatting, + literalLabel: false, + homeLength: formatter.home.length, + authorityLength: formatter.authority?.length ?? 0, + }; + if (!bestResult || + result.homeLength > bestResult.homeLength || + (result.homeLength === bestResult.homeLength && result.authorityLength > bestResult.authorityLength) || + (result.homeLength === bestResult.homeLength && result.authorityLength === bestResult.authorityLength && formatter.priority) + ) { + bestResult = result; } } return bestResult; @@ -234,11 +298,10 @@ export class LabelService extends Disposable implements ILabelService { getUriLabel(resource: URI, options: { relative?: boolean; noPrefix?: boolean; separator?: '/' | '\\'; appendWorkspaceSuffix?: boolean } = {}): string { const homeFormatter = options.noPrefix ? undefined : this.findHomeFormatter(resource); - if (homeFormatter?.home) { - const home = resource.with({ path: homeFormatter.home, query: null, fragment: null }); + if (homeFormatter) { const separator = options.separator ?? homeFormatter.formatting.separator; - const path = relativePath(home, resource); - const label = this.formatUri(home, homeFormatter.formatting); + const path = relativePath(homeFormatter.home, resource); + const label = homeFormatter.literalLabel ? homeFormatter.formatting.label : this.formatUri(homeFormatter.home, homeFormatter.formatting); return path ? `${label}${separator}${this.adjustPathSeparators(path, separator)}` : label; } @@ -467,15 +530,55 @@ export class LabelService extends Disposable implements ILabelService { return this.registerFormatter(formatter); } - registerFormatter(formatter: ResourceLabelFormatter): IDisposable { - this.formatters.push(formatter); - this._onDidChangeFormatters.fire({ scheme: formatter.scheme }); + registerFormatter(formatter: ResourceLabelFormatter | ResourceLabelTemplateFormatter): IDisposable { + let homeRegistration: IHomeFormatterRegistration | undefined; + let scheme: string; + if (isTemplateFormatter(formatter)) { + homeRegistration = this.createTemplateFormatterRegistration(formatter); + this.homeFormatters.push(homeRegistration); + scheme = formatter.home.scheme; + } else { + this.formatters.push(formatter); + scheme = formatter.scheme; + } + this._onDidChangeFormatters.fire({ scheme }); + const changeListener = homeRegistration?.formatter.onDidChangeFormatting(() => this._onDidChangeFormatters.fire({ scheme })); return { dispose: () => { + changeListener?.dispose(); this.formatters = this.formatters.filter(f => f !== formatter); - this._onDidChangeFormatters.fire({ scheme: formatter.scheme }); + if (homeRegistration) { + this.homeFormatters = this.homeFormatters.filter(candidate => candidate !== homeRegistration); + } + this._onDidChangeFormatters.fire({ scheme }); + } + }; + } + + private createTemplateFormatterRegistration(formatter: ResourceLabelTemplateFormatter): IHomeFormatterRegistration { + const { home } = formatter; + const homePath = home.path.length > 1 ? home.path.replace(/\/+$/, '') : home.path; + const parameterNames = new Set<string>(); + const matcherPattern = homePath.split('/').map(segment => { + const parameterMatch = homeTemplateParameterRegex.exec(segment); + if (parameterMatch?.groups?.name) { + const parameterName = parameterMatch.groups.name; + if (parameterNames.has(parameterName)) { + throw new Error(`Duplicate resource label home template parameter: ${parameterName}`); + } + parameterNames.add(parameterName); + return `(?<${parameterName}>(?!\\.{1,2}(?:/|$))[^/]+)`; } + if (segment.includes('${')) { + throw new Error(`Resource label home template parameters must occupy an entire path segment: ${segment}`); + } + return escapeRegExpCharacters(segment); + }).join('/'); + const isRootHome = homePath === '' || homePath === '/'; + return { + formatter, + templateMatcher: new RegExp(`^${matcherPattern}${isRootHome ? '' : '(?=/|$)'}`), }; } diff --git a/src/vs/workbench/services/label/common/resourceLabelHomeStore.ts b/src/vs/workbench/services/label/common/resourceLabelHomeStore.ts deleted file mode 100644 index 079e7f683ab66b..00000000000000 --- a/src/vs/workbench/services/label/common/resourceLabelHomeStore.ts +++ /dev/null @@ -1,50 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; -import { getComparisonKey } from '../../../../base/common/resources.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ILabelService } from '../../../../platform/label/common/label.js'; - -export interface IResourceLabelHome { - readonly uri: URI; - readonly label: string; -} - -export class ResourceLabelHomeStore extends Disposable { - - private readonly registrations = this._register(new DisposableMap<string>()); - - constructor( - @ILabelService private readonly labelService: ILabelService, - ) { - super(); - } - - set(homes: readonly IResourceLabelHome[]): void { - const keyFor = (home: IResourceLabelHome) => `${getComparisonKey(home.uri)}\0${home.label}`; - const homeKeys = new Set(homes.map(keyFor)); - for (const home of homes) { - const key = keyFor(home); - if (!this.registrations.has(key)) { - this.registrations.set(key, this.labelService.registerFormatter({ - scheme: home.uri.scheme, - authority: home.uri.authority || undefined, - home: home.uri.path, - priority: true, - formatting: { - label: home.label, - separator: this.labelService.getSeparator(home.uri.scheme, home.uri.authority), - }, - })); - } - } - for (const [key] of this.registrations) { - if (!homeKeys.has(key)) { - this.registrations.deleteAndDispose(key); - } - } - } -} diff --git a/src/vs/workbench/services/label/test/browser/label.test.ts b/src/vs/workbench/services/label/test/browser/label.test.ts index 5e66e2eaa83edd..ec6bd9e50c3e55 100644 --- a/src/vs/workbench/services/label/test/browser/label.test.ts +++ b/src/vs/workbench/services/label/test/browser/label.test.ts @@ -18,7 +18,7 @@ import { ResourceLabelFormatter } from '../../../../../platform/label/common/lab import { sep } from '../../../../../base/common/path.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { ResourceLabelHomeStore } from '../../common/resourceLabelHomeStore.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; suite('URI Label', () => { let labelService: LabelService; @@ -123,15 +123,278 @@ suite('URI Label', () => { mismatchedRegistration.dispose(); }); + test('preserves resource label home authority and priority precedence', () => { + const resource = URI.parse('test://current/sessions/session-id/file.md'); + const withoutAuthority = labelService.registerFormatter({ + scheme: 'test', + home: '/sessions/session-id', + formatting: { label: 'No Authority', separator: '/' }, + }); + const globAuthority = labelService.registerFormatter({ + scheme: 'test', + authority: 'cur*', + home: '/sessions/session-id', + formatting: { label: 'Glob Authority', separator: '/' }, + }); + const exactAuthority = labelService.registerFormatter({ + scheme: 'test', + authority: 'current', + home: '/sessions/session-id', + formatting: { label: 'Exact Authority', separator: '/' }, + }); + const priorityExactAuthority = labelService.registerFormatter({ + scheme: 'test', + authority: 'CURRENT', + home: '/sessions/session-id', + priority: true, + formatting: { label: 'Priority Exact Authority', separator: '/' }, + }); + + assert.strictEqual(labelService.getUriLabel(resource), 'Priority Exact Authority/file.md'); + priorityExactAuthority.dispose(); + assert.strictEqual(labelService.getUriLabel(resource), 'Exact Authority/file.md'); + exactAuthority.dispose(); + assert.strictEqual(labelService.getUriLabel(resource), 'Glob Authority/file.md'); + globAuthority.dispose(); + assert.strictEqual(labelService.getUriLabel(resource), 'No Authority/file.md'); + withoutAuthority.dispose(); + }); + + test('resource label home paths are case-sensitive', () => { + const registration = labelService.registerFormatter({ + scheme: 'test', + authority: 'current', + home: '/Sessions/session-id', + formatting: { label: 'Session', separator: '/' }, + }); + + assert.deepStrictEqual({ + matching: labelService.getUriLabel(URI.parse('test://current/Sessions/session-id/file.md')), + differentCase: labelService.getUriLabel(URI.parse('test://current/sessions/session-id/file.md')), + }, { + matching: 'Session/file.md', + differentCase: `${sep}sessions${sep}session-id${sep}file.md`, + }); + + registration.dispose(); + }); + + test('resolves URI home templates to concrete formatting', () => { + let resolverCalls = 0; + const staticRegistration = labelService.registerFormatter({ + scheme: 'test', + authority: 'current', + home: '/sessions/session-id', + formatting: { label: 'Static Session', separator: '/' }, + }); + const registration = labelService.registerFormatter({ + home: URI.from({ scheme: 'test', authority: 'current', path: '/sessions/${sessionId}' }), + onDidChangeFormatting: Event.None, + formatting: context => { + resolverCalls++; + const sessionId = context.parameters.get('sessionId'); + return sessionId === 'session-id' ? { label: 'Session ${path}', separator: '/' } : undefined; + }, + }); + const resource = URI.parse('test://current/sessions/session-id/files/result.md'); + + assert.deepStrictEqual({ + home: labelService.getUriHome(resource)?.toString(), + label: labelService.getUriLabel(resource), + unrelated: labelService.getUriLabel(URI.parse('test://current/unrelated/file.md')), + resolverCalls, + }, { + home: 'test://current/sessions/session-id', + label: 'Session ${path}/files/result.md', + unrelated: `${sep}unrelated${sep}file.md`, + resolverCalls: 2, + }); + registration.dispose(); + assert.strictEqual(labelService.getUriLabel(resource), 'Static Session/files/result.md'); + staticRegistration.dispose(); + }); + + test('URI home templates without an authority match any authority', () => { + const registration = labelService.registerFormatter({ + home: URI.from({ scheme: 'test', path: '/sessions/${sessionId}' }), + onDidChangeFormatting: Event.None, + formatting: context => ({ label: context.parameters.get('sessionId') ?? '', separator: '/' }), + }); + + assert.strictEqual(labelService.getUriLabel(URI.parse('test://remote/sessions/session-id/file.md')), 'session-id/file.md'); + + registration.dispose(); + }); + + test('URI home formatters support authority roots', () => { + const registration = labelService.registerFormatter({ + home: URI.from({ scheme: 'test', authority: 'current' }), + onDidChangeFormatting: Event.None, + formatting: () => ({ label: 'Root', separator: '/' }), + }); + const resource = URI.parse('test://current/file.md'); + + assert.deepStrictEqual({ + home: labelService.getUriHome(resource)?.toString(), + label: labelService.getUriLabel(resource), + }, { + home: 'test://current', + label: 'Root/file.md', + }); + + registration.dispose(); + }); + + test('URI home templates do not capture dot path segments', () => { + let resolverCalls = 0; + const registration = labelService.registerFormatter({ + home: URI.parse('test://current/sessions/${sessionId}'), + onDidChangeFormatting: Event.None, + formatting: () => { + resolverCalls++; + return { label: 'Session', separator: '/' }; + }, + }); + + const current = URI.parse('test://current/sessions/./file.md'); + const parent = URI.parse('test://current/sessions/../file.md'); + assert.deepStrictEqual({ + currentHome: labelService.getUriHome(current), + current: labelService.getUriLabel(current), + parentHome: labelService.getUriHome(parent), + parent: labelService.getUriLabel(parent), + dotPrefixed: labelService.getUriLabel(URI.parse('test://current/sessions/.session/file.md')), + resolverCalls, + }, { + currentHome: undefined, + current: `${sep}sessions${sep}file.md`, + parentHome: undefined, + parent: `${sep}file.md`, + dotPrefixed: 'Session/file.md', + resolverCalls: 1, + }); + + registration.dispose(); + }); + + test('URI home templates resolve all parameters', () => { + const registration = labelService.registerFormatter({ + home: URI.parse('test://current/orgs/${org}/sessions/${sessionId}'), + onDidChangeFormatting: Event.None, + formatting: context => ({ + label: `${context.parameters.get('org')}/${context.parameters.get('sessionId')}`, + separator: '/', + }), + }); + const resource = URI.parse('test://current/orgs/acme/sessions/session-id/file.md'); + + assert.deepStrictEqual({ + home: labelService.getUriHome(resource)?.path, + label: labelService.getUriLabel(resource), + }, { + home: '/orgs/acme/sessions/session-id', + label: 'acme/session-id/file.md', + }); + + registration.dispose(); + }); + + test('URI home templates support trailing separators', () => { + const registration = labelService.registerFormatter({ + home: URI.parse('test://current/sessions/${sessionId}/'), + onDidChangeFormatting: Event.None, + formatting: () => ({ label: 'Session', separator: '/' }), + }); + + assert.deepStrictEqual({ + exact: labelService.getUriLabel(URI.parse('test://current/sessions/session-id')), + descendant: labelService.getUriLabel(URI.parse('test://current/sessions/session-id/file.md')), + }, { + exact: 'Session', + descendant: 'Session/file.md', + }); + + registration.dispose(); + }); + + test('equally specific URI home templates use registration order', () => { + const formatter = { + home: URI.parse('test://current/sessions/${sessionId}'), + onDidChangeFormatting: Event.None, + formatting: () => ({ label: 'First', separator: '/' as const }), + }; + const first = labelService.registerFormatter(formatter); + const second = labelService.registerFormatter({ + ...formatter, + formatting: () => ({ label: 'Second', separator: '/' }), + }); + const resource = URI.parse('test://current/sessions/session-id/file.md'); + + assert.strictEqual(labelService.getUriLabel(resource), 'First/file.md'); + first.dispose(); + assert.strictEqual(labelService.getUriLabel(resource), 'Second/file.md'); + + second.dispose(); + }); + + test('one URI home template formats all known sessions without resolving unrelated resources', () => { + let formatterChanges = 0; + let resolverCalls = 0; + const listener = labelService.onDidChangeFormatters(() => formatterChanges++); + const onDidChange = new Emitter<void>(); + const labels = new Map(Array.from({ length: 100 }, (_, index) => [`session-${index}`, `Session ${index}`])); + const registration = labelService.registerFormatter({ + home: URI.parse('test://current/sessions/${sessionId}'), + onDidChangeFormatting: onDidChange.event, + formatting: context => { + resolverCalls++; + const label = labels.get(context.parameters.get('sessionId') ?? ''); + return label === undefined ? undefined : { label, separator: '/' }; + }, + }); + + assert.deepStrictEqual({ + formatterChanges, + home: labelService.getUriHome(URI.parse('test://current/sessions/session-42/file.md'))?.toString(), + label: labelService.getUriLabel(URI.parse('test://current/sessions/session-42/file.md')), + unrelated: labelService.getUriLabel(URI.parse('test://current/unrelated/file.md')), + resolverCalls, + }, { + formatterChanges: 1, + home: 'test://current/sessions/session-42', + label: 'Session 42/file.md', + unrelated: `${sep}unrelated${sep}file.md`, + resolverCalls: 2, + }); + + labels.set('session-42', 'Renamed Session'); + onDidChange.fire(); + assert.deepStrictEqual({ + formatterChanges, + label: labelService.getUriLabel(URI.parse('test://current/sessions/session-42/file.md')), + }, { + formatterChanges: 2, + label: 'Renamed Session/file.md', + }); + + registration.dispose(); + onDidChange.fire(); + assert.strictEqual(formatterChanges, 3); + onDidChange.dispose(); + listener.dispose(); + }); + test('resource label homes take precedence over ordinary formatter changes', () => { const resource = URI.file('/home/test/.agent/sessions/session-id/file.md'); - const root = URI.file('/home/test/.agent/sessions/session-id'); const first = labelService.registerFormatter({ scheme: 'file', formatting: { label: 'FIRST${path}', separator: '/' }, }); - const homes = new ResourceLabelHomeStore(labelService); - homes.set([{ uri: root, label: 'Session' }]); + const home = labelService.registerFormatter({ + home: URI.file('/home/test/.agent/sessions/${sessionId}'), + onDidChangeFormatting: Event.None, + formatting: () => ({ label: 'Session', separator: '/' }), + }); assert.strictEqual(labelService.getUriLabel(resource), 'Session/file.md'); @@ -142,18 +405,21 @@ suite('URI Label', () => { }); assert.strictEqual(labelService.getUriLabel(resource), 'Session/file.md'); - homes.dispose(); + home.dispose(); second.dispose(); }); test('noPrefix skips resource label homes', () => { const resource = URI.file('/home/test/.agent/sessions/session-id/file.md'); - const homes = new ResourceLabelHomeStore(labelService); - homes.set([{ uri: URI.file('/home/test/.agent/sessions/session-id'), label: 'Agent/Session' }]); + const home = labelService.registerFormatter({ + home: URI.file('/home/test/.agent/sessions/${sessionId}'), + onDidChangeFormatting: Event.None, + formatting: () => ({ label: 'Agent/Session', separator: '/' }), + }); assert.strictEqual(labelService.getUriLabel(resource, { noPrefix: true }), resource.fsPath); - homes.dispose(); + home.dispose(); }); test('keeps equivalent resource label home registrations until all are disposed', () => { diff --git a/src/vs/workbench/services/label/test/common/mockLabelService.ts b/src/vs/workbench/services/label/test/common/mockLabelService.ts index ab4f76e2e52f77..f9e83694294df0 100644 --- a/src/vs/workbench/services/label/test/common/mockLabelService.ts +++ b/src/vs/workbench/services/label/test/common/mockLabelService.ts @@ -7,13 +7,20 @@ import { Emitter } from '../../../../../base/common/event.js'; import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { basename, normalize } from '../../../../../base/common/path.js'; import { isEqualOrParent } from '../../../../../base/common/resources.js'; +import { escapeRegExpCharacters } from '../../../../../base/common/strings.js'; import { URI } from '../../../../../base/common/uri.js'; -import { IFormatterChangeEvent, ILabelService, ResourceLabelFormatter, Verbosity } from '../../../../../platform/label/common/label.js'; +import { IFormatterChangeEvent, ILabelService, ResourceLabelFormatter, ResourceLabelFormatting, ResourceLabelTemplateFormatter, Verbosity } from '../../../../../platform/label/common/label.js'; import { IWorkspace, IWorkspaceIdentifier } from '../../../../../platform/workspace/common/workspace.js'; +function isTemplateFormatter(formatter: ResourceLabelFormatter | ResourceLabelTemplateFormatter): formatter is ResourceLabelTemplateFormatter { + return URI.isUri(formatter.home); +} + +const homeTemplateParameterRegex = /^\$\{(?<name>[a-zA-Z_][\w]*)\}$/; + export class MockLabelService implements ILabelService { _serviceBrand: undefined; - private formatters: ResourceLabelFormatter[] = []; + private formatters: (ResourceLabelFormatter | ResourceLabelTemplateFormatter)[] = []; private readonly _onDidChangeFormatters = new Emitter<IFormatterChangeEvent>(); readonly onDidChangeFormatters = this._onDidChangeFormatters.event; @@ -22,8 +29,8 @@ export class MockLabelService implements ILabelService { } getUriLabel(resource: URI, options?: { relative?: boolean | undefined; noPrefix?: boolean | undefined }): string { const formatter = this.findHomeFormatter(resource); - if (formatter?.home) { - const relativePath = resource.path.slice(formatter.home.length).replace(/^\//, ''); + if (formatter) { + const relativePath = resource.path.slice(formatter.home.path.length).replace(/^\//, ''); return relativePath ? `${formatter.formatting.label}/${relativePath}` : formatter.formatting.label; } return normalize(resource.fsPath); @@ -43,36 +50,77 @@ export class MockLabelService implements ILabelService { getSeparator(scheme: string, authority?: string): '/' | '\\' { return '/'; } - registerFormatter(formatter: ResourceLabelFormatter): IDisposable { + registerFormatter(formatter: ResourceLabelFormatter | ResourceLabelTemplateFormatter): IDisposable { this.formatters.push(formatter); - this._onDidChangeFormatters.fire({ scheme: formatter.scheme }); + const scheme = isTemplateFormatter(formatter) ? formatter.home.scheme : formatter.scheme; + this._onDidChangeFormatters.fire({ scheme }); + const changeListener = isTemplateFormatter(formatter) ? formatter.onDidChangeFormatting(() => this._onDidChangeFormatters.fire({ scheme })) : undefined; return { dispose: () => { + changeListener?.dispose(); this.formatters = this.formatters.filter(candidate => candidate !== formatter); - this._onDidChangeFormatters.fire({ scheme: formatter.scheme }); + this._onDidChangeFormatters.fire({ scheme }); } }; } + get formatterCount(): number { + return this.formatters.length; + } + getUriHome(resource: URI): URI | undefined { const formatter = this.findHomeFormatter(resource); - return formatter?.home ? resource.with({ path: formatter.home, query: null, fragment: null }) : undefined; + return formatter?.home; } - private findHomeFormatter(resource: URI): ResourceLabelFormatter | undefined { - let result: ResourceLabelFormatter | undefined; + private findHomeFormatter(resource: URI): { readonly home: URI; readonly formatting: ResourceLabelFormatting } | undefined { + let result: { readonly home: URI; readonly formatting: ResourceLabelFormatting } | undefined; for (const formatter of this.formatters) { if (!formatter.home) { continue; } - if (formatter.scheme !== resource.scheme || (formatter.authority && formatter.authority !== resource.authority)) { - continue; - } - if (!isEqualOrParent(resource, resource.with({ path: formatter.home }))) { - continue; + let candidate: { readonly home: URI; readonly formatting: ResourceLabelFormatting } | undefined; + if (isTemplateFormatter(formatter)) { + if (formatter.home.scheme !== resource.scheme || + (formatter.home.authority && formatter.home.authority.toLowerCase() !== resource.authority.toLowerCase())) { + continue; + } + const homePath = formatter.home.path.length > 1 ? formatter.home.path.replace(/\/+$/, '') : formatter.home.path; + const parameterNames = new Set<string>(); + const matcherPattern = homePath.split('/').map(segment => { + const parameterMatch = homeTemplateParameterRegex.exec(segment); + if (parameterMatch?.groups?.name) { + const parameterName = parameterMatch.groups.name; + if (parameterNames.has(parameterName)) { + throw new Error(`Duplicate resource label home template parameter: ${parameterName}`); + } + parameterNames.add(parameterName); + return `(?<${parameterName}>(?!\\.{1,2}(?:/|$))[^/]+)`; + } + if (segment.includes('${')) { + throw new Error(`Resource label home template parameters must occupy an entire path segment: ${segment}`); + } + return escapeRegExpCharacters(segment); + }).join('/'); + const isRootHome = homePath === '' || homePath === '/'; + const templateMatch = new RegExp(`^${matcherPattern}${isRootHome ? '' : '(?=/|$)'}`).exec(resource.path); + if (!templateMatch) { + continue; + } + const home = resource.with({ path: templateMatch[0], query: null, fragment: null }); + const formatting = formatter.formatting({ resource, home, parameters: new Map(Object.entries(templateMatch.groups ?? {})) }); + if (formatting) { + candidate = { home, formatting }; + } + } else if (formatter.scheme === resource.scheme && (!formatter.authority || formatter.authority === resource.authority) && + isEqualOrParent(resource, resource.with({ path: formatter.home }))) { + candidate = { + home: resource.with({ path: formatter.home, query: null, fragment: null }), + formatting: formatter.formatting, + }; } - if (!result || formatter.home.length > (result.home?.length ?? 0)) { - result = formatter; + if (candidate && (!result || candidate.home.path.length > result.home.path.length)) { + result = candidate; } } return result; diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMerge.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMerge.fixture.ts index 28fa7ebc98c9a6..36b4b95372b16d 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMerge.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMerge.fixture.ts @@ -17,6 +17,7 @@ import { ChatContentMarkdownRenderer } from '../../../../contrib/chat/browser/wi import { ChatAgentMergeContentPart } from '../../../../contrib/chat/browser/widget/chatContentParts/chatAgentMergeContentPart.js'; import { AgentFeedbackReviewCommandId, IChatAgentFeedbackPullRequestThreadLink } from '../../../../contrib/chat/common/chatService/chatService.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; +import { renderChatWidget } from './chatWidget.fixture.js'; // ============================================================================ // Sample data @@ -109,6 +110,7 @@ const markdownThreads: AgentMergePromptContext['reviewThreads'] = [ ]; const failedChecks = ['Compile / Compile (ubuntu-latest)', 'Linux Unit Tests (Electron)']; +const fixtureTimestamp = new Date().setHours(15, 33, 0, 0); function createContext(overrides?: Partial<AgentMergePromptContext>): AgentMergePromptContext { return { @@ -148,8 +150,8 @@ interface IRenderAgentMergeOptions { readonly summary: IAgentMergePromptSummary; /** Expands the widget by clicking its header, the way a user would. */ readonly expanded?: boolean; - /** Also expands the nested agent-message disclosure. */ - readonly agentMessageExpanded?: boolean; + /** Shows the agent message instead of the merge details. */ + readonly agentMessageVisible?: boolean; /** Review thread ids the session mirrored into agent feedback. */ readonly mirroredThreadIds?: readonly string[]; } @@ -157,6 +159,7 @@ interface IRenderAgentMergeOptions { function renderAgentMerge({ container, disposableStore, theme }: ComponentFixtureContext, options: IRenderAgentMergeOptions): void { container.style.width = '640px'; container.style.padding = '8px'; + container.style.backgroundColor = 'var(--vscode-sideBar-background, var(--vscode-editor-background))'; // The widget only looks up mirrors where the Agents window registered the // feedback commands, so a fixture with mirrors registers them for real; the @@ -193,14 +196,14 @@ function renderAgentMerge({ container, disposableStore, theme }: ComponentFixtur instantiationService.get(IMarkdownRendererService).setDefaultCodeBlockRenderer(instantiationService.createInstance(EditorMarkdownCodeBlockRenderer)); const markdownRenderer = instantiationService.createInstance(ChatContentMarkdownRenderer); - const part = disposableStore.add(instantiationService.createInstance(ChatAgentMergeContentPart, options.summary, sessionResource, markdownRenderer)); + const part = disposableStore.add(instantiationService.createInstance(ChatAgentMergeContentPart, options.summary, sessionResource, markdownRenderer, fixtureTimestamp)); container.appendChild(part.domNode); - if (options.expanded || options.agentMessageExpanded) { - part.domNode.querySelector<HTMLElement>('.chat-agent-merge-header')?.click(); + if (options.expanded) { + part.domNode.querySelector<HTMLElement>('.chat-agent-merge-header-disclosure')?.click(); } - if (options.agentMessageExpanded) { - part.domNode.querySelector<HTMLElement>('.chat-agent-merge-message-header')?.click(); + if (options.agentMessageVisible) { + part.domNode.querySelector<HTMLElement>('.chat-agent-merge-message-toggle')?.click(); } } @@ -225,6 +228,7 @@ export default defineThemedFixtureGroup({ path: 'chat/' }, { FailingChecks: defineComponentFixture({ labels: { kind: 'screenshot' }, + additionalThemes: ['darkHighContrast'], render: (ctx) => renderAgentMerge(ctx, { summary: createSummary(['fixCI'], { failedChecks }), }), @@ -274,7 +278,7 @@ export default defineThemedFixtureGroup({ path: 'chat/' }, { labels: { kind: 'screenshot' }, render: (ctx) => renderAgentMerge(ctx, { summary: createSummary(['addressReviews', 'fixCI'], { reviewThreads: reviewThreads.slice(0, 1), failedChecks }), - agentMessageExpanded: true, + agentMessageVisible: true, }), }), @@ -302,4 +306,34 @@ export default defineThemedFixtureGroup({ path: 'chat/' }, { mirroredThreadIds: [reviewThreads[0].id, reviewThreads[2].id], }), }), + + InChat: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderChatWidget(ctx, { + width: 720, + height: 600, + inputVisible: false, + messages: [ + { + user: 'Polish the Agent Merge widget', + assistant: [{ + kind: 'markdown', + text: 'I updated the widget to make its status easier to scan and keep secondary controls quiet until they are needed.', + }], + }, + { + user: buildAgentMergePrompt(['addressReviews', 'fixCI'], createContext({ + reviewThreads: reviewThreads.slice(0, 1), + failedChecks, + })), + isSystemInitiated: true, + timestamp: fixtureTimestamp, + assistant: [{ + kind: 'markdown', + text: 'I addressed the review feedback and fixed the failing checks. The branch is ready for another review.', + }], + }, + ], + }), + }), }); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatRichLink.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatRichLink.fixture.ts index 05af1df575d4d9..cb103ad1d2d44b 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatRichLink.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatRichLink.fixture.ts @@ -115,6 +115,8 @@ export default defineThemedFixtureGroup({ path: 'chat/' }, { { authoredLabel: 'Review architecture', presentation: buildAgentSessionLinkPresentation('Review architecture', undefined, 'needsInput') }, { authoredLabel: 'Update fixtures', presentation: buildAgentSessionLinkPresentation('Update fixtures', undefined, 'completed') }, { authoredLabel: 'Run validation', presentation: buildAgentSessionLinkPresentation('Run validation', undefined, 'error') }, + { authoredLabel: 'Investigate tests', presentation: buildAgentSessionLinkPresentation('Investigate tests', undefined, 'inProgress', 'chat') }, + { authoredLabel: 'Resolving chat', presentation: { kind: 'resource', status: { kind: 'pending', label: 'Loading' } } }, ]), }), presentationKinds: defineComponentFixture({ diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts index a239e0019c9171..fddb30d9c0be55 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts @@ -55,6 +55,7 @@ export interface IFixtureFileChange { export interface IFixtureMessage { readonly user: string; // user prompt text + readonly timestamp?: number; readonly assistant?: ReadonlyArray< | { kind: 'markdown'; text: string } | { kind: 'progress'; text: string } @@ -277,7 +278,7 @@ export async function renderChatWidget(context: ComponentFixtureContext, options undefined, undefined, undefined, - undefined, + message.timestamp, undefined, undefined, message.requestHidden, diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index e0cddf34f3fbb7..7ff2424d595c7d 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -888,6 +888,7 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor showGettingStartedBanner: true, }; override readonly activeProjectRoot = observableValue('root', URI.file('/workspace')); + override readonly activeProjectLabel = observableValue('label', 'workspace'); override readonly hasOverrideProjectRoot = observableValue('hasOverride', false); override getActiveProjectRoot() { return URI.file('/workspace'); } override clearOverrideProjectRoot() { } @@ -1277,6 +1278,7 @@ async function renderMcpBrowseMode(ctx: ComponentFixtureContext): Promise<void> showGettingStartedBanner: true, }; override readonly activeProjectRoot = observableValue('root', URI.file('/workspace')); + override readonly activeProjectLabel = observableValue('label', 'workspace'); override readonly hasOverrideProjectRoot = observableValue('hasOverride', false); override getActiveProjectRoot() { return URI.file('/workspace'); } }()); @@ -1393,6 +1395,10 @@ async function renderPluginCatalog(ctx: ComponentFixtureContext, browse: boolean makeInstalledPlugin('Sentry', URI.file('/home/dev/.vscode/agent-plugins/example/sentry-plugin'), true), makeInstalledPlugin('Datadog', URI.file('/home/dev/.vscode/agent-plugins/example/datadog-plugin'), false, true), ]; + const marketplaceInstalledPlugins = noInstalledPlugins ? [] : marketplacePlugins.slice(0, 3).map((plugin, index) => ({ + pluginUri: browseInstalledPlugins[index].uri, + plugin, + })); // Map plugin source descriptors to install URIs, matching installed URIs above const pluginInstallUris = new Map<string, URI>([ @@ -1422,7 +1428,7 @@ async function renderPluginCatalog(ctx: ComponentFixtureContext, browse: boolean override readonly enablementModel = undefined!; }()); reg.defineInstance(IPluginMarketplaceService, new class extends mock<IPluginMarketplaceService>() { - override readonly installedPlugins = constObservable([]); + override readonly installedPlugins = constObservable(marketplaceInstalledPlugins); override readonly recommendedPlugins = constObservable(new Set(['Figma@copilot', 'Stripe@copilot'])); override readonly onDidChangeMarketplaces = Event.None; override async fetchMarketplacePlugins() { return marketplacePlugins; } diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationWelcomePages.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationWelcomePages.fixture.ts index 2755f0fe29418f..c6e18a36128c9c 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationWelcomePages.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationWelcomePages.fixture.ts @@ -38,6 +38,7 @@ function createMockWorkspaceService(): IAICustomizationWorkspaceService { showGettingStartedBanner: true, }; override readonly activeProjectRoot = constObservable(URI.file('/workspace')); + override readonly activeProjectLabel = constObservable('workspace'); override readonly hasOverrideProjectRoot = constObservable(false); override getActiveProjectRoot(): URI { return URI.file('/workspace'); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts index 4600407be5705c..b6e02d641eb3a7 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts @@ -4,21 +4,26 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../../base/common/codicons.js'; -import { Event } from '../../../../../base/common/event.js'; +import * as DOM from '../../../../../base/browser/dom.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; -import { constObservable, IObservable } from '../../../../../base/common/observable.js'; +import { Disposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { ExtUri } from '../../../../../base/common/resources.js'; import { ThemeIcon, themeColorFromId } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; +import { IActionViewItemFactory, IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; import { IListService, ListService } from '../../../../../platform/list/browser/listService.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { IMenu, IMenuService, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; +import { IMenu, IMenuService, MenuId, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { EditorMarkdownCodeBlockRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/editorMarkdownCodeBlockRenderer.js'; import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; // eslint-disable-next-line local/code-import-patterns import { IAgentHostFilterService } from '../../../../../sessions/services/agentHostFilter/common/agentHostFilter.js'; @@ -35,25 +40,64 @@ import { ISessionsService } from '../../../../../sessions/services/sessions/brow // eslint-disable-next-line local/code-import-patterns import { ICustomViewService } from '../../../../../sessions/services/customView/browser/customViewService.js'; // eslint-disable-next-line local/code-import-patterns +import { Menus } from '../../../../../sessions/browser/menus.js'; +// eslint-disable-next-line local/code-import-patterns import { IChat, ISession, ISessionChangesSummary, ISessionFolder, ISessionWorkspace, SessionStatus, ChatInteractivity } from '../../../../../sessions/services/sessions/common/session.js'; // eslint-disable-next-line local/code-import-patterns import { IActiveSession, ISessionsManagementService } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; // eslint-disable-next-line local/code-import-patterns import { SessionsGrouping, SessionsList, SessionsSorting } from '../../../../../sessions/contrib/sessions/browser/views/sessionsList.js'; // eslint-disable-next-line local/code-import-patterns +import { AUTOMATIONS_NEW_BADGE_STYLE_SETTING, type AutomationsNewBadgeStyle } from '../../../../../sessions/contrib/sessions/browser/automationsNewBadge.js'; +// eslint-disable-next-line local/code-import-patterns +import { renderSessionsHeader } from '../../../../../sessions/contrib/sessions/browser/views/sessionsView.js'; +// eslint-disable-next-line local/code-import-patterns +import { NewSessionActionViewItemContribution } from '../../../../../sessions/contrib/sessions/browser/sessionsActions.js'; +// eslint-disable-next-line local/code-import-patterns +import { NEW_SESSION_ACTION_ID } from '../../../../../sessions/contrib/chat/common/constants.js'; +// eslint-disable-next-line local/code-import-patterns import { IsPhoneLayoutContext } from '../../../../../sessions/common/contextkeys.js'; import { AgentSessionApprovalKind, AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../../contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { IAgentSessionsService } from '../../../../contrib/chat/browser/agentSessions/agentSessionsService.js'; import { IAgentSession, IAgentSessionsModel } from '../../../../contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { IAutomationService } from '../../../../contrib/chat/common/automations/automationService.js'; +import type { IAutomationRun } from '../../../../contrib/chat/common/automations/automation.js'; +import { ChatAutomationsEnabledContext } from '../../../../contrib/chat/common/automations/automationsEnabled.js'; import { IChatService } from '../../../../contrib/chat/common/chatService/chatService.js'; import { IChatModel } from '../../../../contrib/chat/common/model/chatModel.js'; import { IVoicePlaybackService } from '../../../../contrib/chat/common/voicePlaybackService.js'; import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; +import { TestProductService } from '../../../common/workbenchTestServices.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; // eslint-disable-next-line local/code-import-patterns import '../../../../../sessions/contrib/sessions/browser/media/sessionsList.css'; +// eslint-disable-next-line local/code-import-patterns +import '../../../../../sessions/contrib/sessions/browser/media/sessionsViewPane.css'; +// eslint-disable-next-line local/code-import-patterns +import '../../../../../sessions/contrib/sessions/browser/media/newSessionActionViewItem.css'; + +class FixtureActionViewItemService extends Disposable implements IActionViewItemService { + declare _serviceBrand: undefined; + + private readonly providers = new Map<string, IActionViewItemFactory>(); + private readonly changeEmitter = this._register(new Emitter<MenuId>()); + readonly onDidChange = this.changeEmitter.event; + + register(menu: MenuId, commandId: string | MenuId, provider: IActionViewItemFactory, event?: Event<unknown>): IDisposable { + const key = `${menu.id}/${commandId instanceof MenuId ? commandId.id : commandId}`; + this.providers.set(key, provider); + const listener = event?.(() => this.changeEmitter.fire(menu)); + return toDisposable(() => { + listener?.dispose(); + this.providers.delete(key); + }); + } + + lookUp(menu: MenuId, commandId: string | MenuId): IActionViewItemFactory | undefined { + return this.providers.get(`${menu.id}/${commandId instanceof MenuId ? commandId.id : commandId}`); + } +} interface IChatSpec { readonly id: string; @@ -172,15 +216,20 @@ interface IRenderOptions { readonly width?: number; readonly phone?: boolean; readonly revealHierarchyGuides?: boolean; + readonly showAutomations?: boolean; + readonly automationRunStatus?: IAutomationRun['status']; + readonly automationBadgeStyle?: AutomationsNewBadgeStyle; readonly showFocusedToolbar?: boolean; } -function renderSessionsList(ctx: ComponentFixtureContext, options: IRenderOptions): void | Promise<void> { +async function renderSessionsList(ctx: ComponentFixtureContext, options: IRenderOptions): Promise<void> { const { container, disposableStore } = ctx; const approvals = new Map<string, IAgentSessionApprovalInfo>(); const sessions = options.sessions.map(spec => createSession(spec, approvals)); const approvalModel = createApprovalModel(approvals); const groups = options.groups ?? []; + const automationRuns = observableValue<readonly IAutomationRun[]>(disposableStore, []); + const actionViewItemService = disposableStore.add(new FixtureActionViewItemService()); const membership = new Map<string, string>(); for (const spec of options.sessions) { if (spec.group) { @@ -192,6 +241,7 @@ function renderSessionsList(ctx: ComponentFixtureContext, options: IRenderOption colorTheme: ctx.theme, additionalServices: reg => { registerWorkbenchServices(reg); + reg.defineInstance(IProductService, TestProductService); if (options.showFocusedToolbar) { const archiveAction = new class extends mock<MenuItemAction>() { override readonly id = 'sessions.fixture.archive'; @@ -279,7 +329,8 @@ function renderSessionsList(ctx: ComponentFixtureContext, options: IRenderOption override hasPendingResponse() { return false; } }()); reg.defineInstance(IAutomationService, new class extends mock<IAutomationService>() { - override readonly runs = constObservable([]); + override readonly automations = constObservable([]); + override readonly runs = automationRuns; }()); reg.defineInstance(IWorkbenchAssignmentService, new class extends mock<IWorkbenchAssignmentService>() { override readonly onDidRefetchAssignments = Event.None; @@ -288,13 +339,40 @@ function renderSessionsList(ctx: ComponentFixtureContext, options: IRenderOption reg.defineInstance(IUriIdentityService, new class extends mock<IUriIdentityService>() { override readonly extUri = new ExtUri(() => true); }()); - reg.defineInstance(ICustomViewService, new class extends mock<ICustomViewService>() { }()); + reg.defineInstance(ICustomViewService, new class extends mock<ICustomViewService>() { + override readonly activeCustomView = constObservable(undefined); + }()); }, }); + if (options.showAutomations) { + const contextKeyService = instantiationService.get(IContextKeyService); + const newSessionAction = new MenuItemAction( + { id: NEW_SESSION_ACTION_ID, title: 'New Session' }, + undefined, + undefined, + undefined, + undefined, + contextKeyService, + instantiationService.get(ICommandService), + ); + instantiationService.stub(IActionViewItemService, actionViewItemService); + instantiationService.stub(IMenuService, new class extends mock<IMenuService>() { + override createMenu(id: MenuId): IMenu { + return { + onDidChange: Event.None, + getActions: () => id === Menus.SidebarSessionsHeader ? [['navigation', [newSessionAction]]] : [], + dispose: () => { }, + }; + } + }()); + } // Render terminal-approval labels as real (monospace) code blocks — otherwise // the markdown renderer emits empty code-block spans and the command is blank. (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration('editor', { fontFamily: 'monospace' }); + if (options.automationBadgeStyle) { + await (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(AUTOMATIONS_NEW_BADGE_STYLE_SETTING, options.automationBadgeStyle); + } instantiationService.get(IMarkdownRendererService).setDefaultCodeBlockRenderer(instantiationService.createInstance(EditorMarkdownCodeBlockRenderer)); // Phone layout is driven by both a CSS class (visual) and a context key (row @@ -303,6 +381,9 @@ function renderSessionsList(ctx: ComponentFixtureContext, options: IRenderOption if (options.phone) { IsPhoneLayoutContext.bindTo(instantiationService.get(IContextKeyService)).set(true); } + if (options.showAutomations) { + ChatAutomationsEnabledContext.bindTo(instantiationService.get(IContextKeyService)).set(true); + } const width = options.width ?? 340; container.style.width = `${width}px`; @@ -312,15 +393,41 @@ function renderSessionsList(ctx: ComponentFixtureContext, options: IRenderOption container.classList.add('agent-sessions-workbench', 'phone-layout'); } - const listHost = container.ownerDocument.createElement('div'); - container.appendChild(listHost); + let listParent = container; + if (options.showAutomations) { + container.classList.add('agent-sessions-viewpane', 'agent-sessions-section'); + const content = DOM.append(container, DOM.$('.agent-sessions-content')); + disposableStore.add(instantiationService.createInstance(NewSessionActionViewItemContribution)); + renderSessionsHeader(content, false, instantiationService, instantiationService.get(IContextKeyService), disposableStore).toolbar?.refresh(); + listParent = content; + } + const listHost = DOM.append(listParent, DOM.$(options.showAutomations ? '.agent-sessions-control-container' : 'div')); const list = disposableStore.add(instantiationService.createInstance(SessionsList, listHost, { grouping: () => options.grouping ?? SessionsGrouping.Workspace, sorting: () => SessionsSorting.Created, onSessionOpen: () => { }, approvalModel, })); - list.layout(options.phone ? 260 : 220, width); + list.layout(options.phone ? 260 : options.showAutomations ? 180 : 220, width); + + if (options.automationRunStatus) { + automationRuns.set([{ + id: 'fixture-run', + automationId: 'fixture-automation', + status: options.automationRunStatus, + trigger: 'schedule', + startedAt: new Date().toISOString(), + leaderWindowId: 1, + }], undefined); + } + await Promise.resolve(); + if (options.showAutomations && !container.querySelector('.agent-sessions-compact-new-button')) { + const menu = instantiationService.get(IMenuService).createMenu(Menus.SidebarSessionsHeader, instantiationService.get(IContextKeyService)); + const actionCount = menu.getActions().flatMap(([, actions]) => actions).length; + menu.dispose(); + const hasProvider = !!instantiationService.get(IActionViewItemService).lookUp(Menus.SidebarSessionsHeader, NEW_SESSION_ACTION_ID); + throw new Error(`Expected the production New Session action; found ${actionCount} menu action(s), provider=${hasProvider}.`); + } if (options.showFocusedToolbar) { return Promise.resolve().then(() => { @@ -413,6 +520,55 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { sessions: [{ id: 'c', title: 'Update onboarding copy', workspace: 'vscode-docs', minutesAgo: 180 }], }), }), + SessionsList_AutomationsNewBadge: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + additionalThemes: ['darkHighContrast'], + expectedVisualDescriptions: ['The Sessions header has an outlined New button. Directly below it, the Automations row has a smaller right-aligned outlined NEW capsule that reads as a non-interactive feature badge rather than a second button.'], + render: ctx => renderSessionsList(ctx, { + sessions: [], + showAutomations: true, + }), + }), + SessionsList_AutomationsNewBadge_Accent: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + additionalThemes: ['darkHighContrast'], + expectedVisualDescriptions: ['The Automations row has a compact right-aligned NEW pill using the prominent activity badge colors, while the larger outlined New button remains visually distinct in the Sessions header.'], + render: ctx => renderSessionsList(ctx, { + sessions: [], + showAutomations: true, + automationBadgeStyle: 'accent', + }), + }), + SessionsList_AutomationsNewBadge_Soft: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + additionalThemes: ['darkHighContrast'], + expectedVisualDescriptions: ['The Automations row has a compact right-aligned NEW pill with a subtle neutral fill, while the larger outlined New button remains visually distinct in the Sessions header.'], + render: ctx => renderSessionsList(ctx, { + sessions: [], + showAutomations: true, + automationBadgeStyle: 'soft', + }), + }), + SessionsList_AutomationsNewBadge_Narrow: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + additionalThemes: ['darkHighContrast'], + expectedVisualDescriptions: ['At the 170px minimum sidebar width, the Automations label remains readable and the outlined NEW capsule stays right-aligned without changing the row height.'], + render: ctx => renderSessionsList(ctx, { + sessions: [], + showAutomations: true, + width: 170, + }), + }), + SessionsList_AutomationsNewBadge_Running: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + additionalThemes: ['darkHighContrast'], + expectedVisualDescriptions: ['The Automations row shows its running status icon and the outlined NEW capsule together without overlap or layout shift.'], + render: ctx => renderSessionsList(ctx, { + sessions: [], + showAutomations: true, + automationRunStatus: 'running', + }), + }), SessionsList_CustomGroup_Phone: defineComponentFixture({ render: ctx => renderSessionsList(ctx, { sessions: GROUPED_SESSIONS, groups: [GROUP], phone: true, width: 340 }), }), diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 5beedb57d1be29..822f3dc323dd4b 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -7,16 +7,16 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/c13f82b9dbe63bd2450bb28f48d66704613bfa02a36b47c6a05a3c9b4d0b81ea) #### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTab/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/e268b39e5d6e416b04ef4d38c0bd5af7d4c2fbd65fccf525cdd39bb4c0a8b677) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/80f87f875ed4c2e57f538d498dfae808fb0fb0599d966dace61b135c467ff794) #### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTab/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/08799bed239d1dda65f4faf6a038c606dba9c4c24946271440f2c931da42da3d) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/9bd3ed05110dbe9c193acf371e2af656926d8f223faba768a8acd7f009bd7485) #### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTabNarrow/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/3a83a9d7e75afeb186484f56b4ac280426db67149a9f4678090ed9b9e2a707ea) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/80d131368e6930bc9135e9d47f57dbcf121588dab5718cfda4b40c8543a3e620) #### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTabNarrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/b84b57c9751c608e2add0c47891dfa443036d3b72e8d8e090b51bd7230f1aaae) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/08e0a7af4d6954232fd0de9008b3c0f4936d31b4a286446f44011f463fe23b41) #### chat/aiCustomizations/aiCustomizationManagementEditor/EmbeddedMcpDetailUninstalled/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/a5c4c329b3df33c748b8cd46ba7585490b58b2d42fa71c25a44a7f327b344b1b) @@ -25,10 +25,10 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/b150bed8ecf034a2ac61bc46081007101d109ed98bc176824e045965dfff94da) #### chat/aiCustomizations/aiCustomizationManagementEditor/HooksEmptyWorkspace/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/bfdfa45f49cf147c63733f42db4f09bc28c5ea17c8f56d39a8e02d1855813be5) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/584a93e3dbed1d8f7ffcff61cb6d9617fc60e09beed1f2f07ed759cf1359d960) #### chat/aiCustomizations/aiCustomizationManagementEditor/HooksEmptyWorkspace/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/35ef986c0f72d6682a4fe7e690aab7e3d5a93409c0f030494b2fc23928b05644) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/cd215308df295015347d508181e37f594152b90c4d29ae8d93c6b76fe3820ad7) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServerDetailNarrow/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/381816517a26c12e647f06d53e86100e1c67445bb76ea60ee3dcd981ffeaa41d) @@ -37,46 +37,46 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/b8c214bd7418237091cedd35cb8e5abb117e9b0741c8c108000939306595e0ec) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServersTab/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/74532b70cb20bf39b0b56f444c4bf5edd86cce9ba7716f023cf90f39e9654dd8) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/f0544aedf46f4e4f082b31fbf9572639656ce6e149a2bacc6114da6b751b6faa) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServersTab/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/ca8da65e09bb1c6a09530522e70dd51d18ca870fa13f25506d0608a5505f04a2) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/109b64f5e81c0da1bd6e00a1bcbb94edb3a3099c95a49bc9578605b055dd5175) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogHome/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/a35248fe00e671dfa1613970c3d5c5c1cd8d898dfee4526f3595367fbb7cca81) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/4e881a199c6371613745f5298984d63de42aac4d0eaabb3c4811c9bcda00205a) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogHome/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/bbeff84bd7f12045e3033d3df8b2f6e2d4feb30f1d02b3f92d38425fe6078b63) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/aca2145ec8c31a48c033dd291119b5c74d4782728215013939d18e21b3889a83) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogHomeNarrow/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/e0b4dd4aafce49b83131fb5fcbc5458052695d5e9382d014edc411464527ca2a) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/df5b59c82075a7531e8ceffd16f7098626863f731bd099970ecda88efa3d9a40) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogHomeNarrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/8ba7ed9a14433d46c8aed5d7a5cc05047d1e9e18c2916be87c02b9a4cd1c73eb) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/8895e63f1e5e0a369181cf60128aa984e258592ff85b6b87e57d0c604360888a) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogSearch/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/a30e32dae4d629b88694f3b482add3269620ad0ffad0c310056390bab3efb604) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/d39e63162436cf09ff71cd6427d6c808ad23889f00bf73240e089d887fba374e) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogSearch/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/4353419eb708c0d7ff8fe8a01a8ecb53c231020e7397995f390bca1f172129a0) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/94ed07ad627037f1c98d1e9767449991e6f4ebac3c25d3dc265c03cb5cb930fc) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginDetail/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/a49fc7f166a304ee372ccca3cd6e9e26688b0c80e1ec69fd734361b0221eee16) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/f9c703b94481f8bd41d4154fe26df01d2a502eae7b1a917ed406fe1f72ac34df) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginDetail/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/9fcc38b4e24fdfa58c30408dbdaf6d0054cc173dcf9e123d23112149be4f6ea1) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/d0b6575b59c8658eb961d0f022cf4943bfc9477b9e1bbf65f9de5a08ab773149) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTab/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/a49fc7f166a304ee372ccca3cd6e9e26688b0c80e1ec69fd734361b0221eee16) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/f9c703b94481f8bd41d4154fe26df01d2a502eae7b1a917ed406fe1f72ac34df) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTab/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/9fcc38b4e24fdfa58c30408dbdaf6d0054cc173dcf9e123d23112149be4f6ea1) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/d0b6575b59c8658eb961d0f022cf4943bfc9477b9e1bbf65f9de5a08ab773149) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTabNarrow/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/498dd59bd46769e3ca7393f202f587789c17a2979b87ca378f20e37f333bdfd4) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/0de50692f3dc96a1396e2a820e6cd45a5be7f6625e7b49565089b8e929927c0e) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTabNarrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/f08f056b5a9843fc413b1105d5ede83a19961aa8d000158779068b94c72135d5) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/4b3c83e0b3b4f3e116e9f38ffe48ad7bf5a9b9b4656e233c8394196eae9133bc) #### chat/aiCustomizations/aiCustomizationManagementEditor/PromptMigration/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/13f0fd52bed5191a9fafe05be00a7757e72a92f05162a2f323a31663b9ddc83c) @@ -187,16 +187,16 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/fe4b95bf8348637bba9f8c0dda791924e6c67fd7b5d173398f9b2c0bfc9f7071) #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/b37045f2e22c3d9d6a396f113cfbf92c879f32d3612e1fb8b60bd0b769fb266c) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/c03e1bc3f517347607d0422462dd55a50c95d01f03b5203b27829aa296f9b54e) #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/46d3951ed96515724efb23c0754e07f935e8f0cad534ca955a9e606d2abe84ae) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/b646e331c1ecbc6859e19e42179cd7413780f139c71aad5e4ab009aed35d6f04) #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/7228d732b817fa0201537ab73726c055a32e82ec4744abf94122e1b5b9f6054f) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/e1d5b98be6a615e7e30e3f08e1b1e80219743fbe0b478ff68a0e7b08e234cf56) #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/a4875725e56c9aea1ebd8aebdc8106fe955b9d89b3dc7115cadf290792590f0a) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/e6b7c571945d3c311ab89529478340018496b9d4c75e13095b672c913cee52ee) #### sessions/chat/newWidget/newChatWidget/NewSessionRemoteWorkspace/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/34408877e1ac8af8668377237c835da520bfc4a1301ebd55f6dab32084f88042) @@ -205,10 +205,55 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/ef4a895e36499772c251ee00d72d765d084b5082430e430c99398b7a12761c7a) #### sessions/chat/newWidget/newChatWidget/NewSessionWorkspacePicker/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/eaf71f4c733c6a7dc5c7bf9c48578243c6eb271769bb40593e8df5b977eb6111) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/c6c33dcf1ba01a1cf19d97289c2699e2e0571aac0e220b6c2d71282a3dd4cf3a) #### sessions/chat/newWidget/newChatWidget/NewSessionWorkspacePicker/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/79d4608ac44ba3bf43cb4555a8e02fca4165866a2fb6b518d3c3994b9228e37c) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/695c9069e5791b7d24b24c1f9db4561755ff5694ad38370a7706426b6d38210a) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Accent/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/7d1ed985c2b64d4cb7e9ef6c56df06761e308d0f4c8c49370bfaaf289d11121f) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Accent/DarkHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/dc2d1502e0902545279c33075ad656aff4206049872b18e9cbe87ddd171729f7) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Accent/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/b338d37441f73f6548b2cc9988d3875e351b914aa87269d06f74f4e844a51681) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Narrow/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/be3b3a64f1f39466e2239ab7d884befc5540d6a42253e77ba96408e4957c1404) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Narrow/DarkHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/761d4a6a30b41df0f542aa11ba02fb10896fecdd32fd3723e63b0b9062552d6f) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Narrow/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/22cf5e117dce9936a9d7189741fbea102c57dea6e5ff9b04f6d9e9532f261ec5) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Running/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/8d11fedea833e441d29331a2e51b034f6aeb3de94bb9eb4aa313428007748c2f) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Running/DarkHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/5c1f4114f8de4b3fcd390d0a952440e0107b0b2ecd98c39cf8a24ba54499f23e) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Running/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/40ebe91ecf151cf2dfaa702cb260805da15d3a9cc89465d353a9d2ea37f61405) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Soft/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/806a587cfc90c9fa9b69728137b2a26f0e3827278541f74e50dde685816fae40) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Soft/DarkHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/34b08a9593a152f108dfa5a005d968a2e709a8ae12fcdc2f57fd0a15280a5b72) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Soft/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/55b9eb610b82aa126043592d1ecc5458ce8f0f4a37b3a9c2db557c60ed39d353) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/11b9c20d4100af6684519045dbef07052202c37041363c888a6cbc4be276b4d4) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge/DarkHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/dc2d1502e0902545279c33075ad656aff4206049872b18e9cbe87ddd171729f7) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/5db359cb5c1f424f4f5153ad59e633efda657332d2e19049e472f27bd38df64f) #### sessions/sessionsList/SessionsList_NarrowHoverToolbar/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/8af0c707c9c8e321ac7c8fd792b3c242a0d394cdaf68c3fe1c61804095395030)