From 43f74c456fae43f78ccd51c23333bd80aeb7b27b Mon Sep 17 00:00:00 2001 From: vritant24 Date: Fri, 21 Aug 2026 15:40:24 -0700 Subject: [PATCH 01/10] chat: preserve conversation ID for BYOK Responses Forward the conversation identifier through extension-contributed model options so Responses endpoints can generate prompt_cache_key.\n\nFixes #332031\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/customEndpointProvider.spec.ts | 97 +++++++++++++++++++ .../vscode-node/languageModelAccess.ts | 1 + .../endpoint/vscode-node/extChatEndpoint.ts | 5 +- 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/extensions/copilot/src/extension/byok/vscode-node/test/customEndpointProvider.spec.ts b/extensions/copilot/src/extension/byok/vscode-node/test/customEndpointProvider.spec.ts index 0602c8ce23bf60..ff91152c72fe4a 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/test/customEndpointProvider.spec.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/test/customEndpointProvider.spec.ts @@ -10,8 +10,10 @@ import { BlockedExtensionService, IBlockedExtensionService } from '../../../../p import { IChatMLFetcher, type IFetchMLOptions } from '../../../../platform/chat/common/chatMLFetcher'; import { ChatLocation, type ChatResponse, type ChatResponses } from '../../../../platform/chat/common/commonTypes'; import { MockChatMLFetcher } from '../../../../platform/chat/test/common/mockChatMLFetcher'; +import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService'; import { IChatModelInformation, ModelSupportedEndpoint } from '../../../../platform/endpoint/common/endpointProvider'; import { CustomDataPartMimeTypes } from '../../../../platform/endpoint/common/endpointTypes'; +import { ExtensionContributedChatEndpoint } from '../../../../platform/endpoint/vscode-node/extChatEndpoint'; import type { IChatEndpoint, IEndpointBody } from '../../../../platform/networking/common/networking'; import { ITestingServicesAccessor } from '../../../../platform/test/node/services'; import { TokenizerType } from '../../../../util/common/tokenizer'; @@ -473,6 +475,101 @@ describe('CustomEndpointBYOKModelProvider', () => { expect(chatMLFetcher.requests.map(request => request.modelCapabilities?.enableThinking)).toEqual([true, false]); }); + it('issue #332031: preserves the conversation ID through a BYOK Responses request', async () => { + const provider = instaService.createInstance(TestCustomEndpointBYOKModelProvider, createStorageService()); + const tokenSource = disposables.add(new vscode.CancellationTokenSource()); + const [model] = await provider.provideLanguageModelChatInformation({ + silent: true, + configuration: { + apiKey: 'test-api-key', + models: [{ + id: customResponsesModelId, + name: 'Custom Responses Model', + url: 'https://api.example.com', + apiType: 'responses', + maxInputTokens: 128000, + maxOutputTokens: 16000, + toolCalling: true, + vision: false, + }], + } + }, tokenSource.token); + const languageModel = { + ...model, + sendRequest: async ( + messages: readonly (vscode.LanguageModelChatMessage | vscode.LanguageModelChatMessage2)[], + options: vscode.LanguageModelChatRequestOptions, + token: vscode.CancellationToken, + ) => { + const responseParts: vscode.LanguageModelResponsePart2[] = []; + await provider.provideLanguageModelChatResponse(model, [...messages], { + requestInitiator: 'core', + tools: options.tools ?? [], + toolMode: options.toolMode ?? vscode.LanguageModelChatToolMode.Auto, + modelOptions: options.modelOptions, + }, { report: part => responseParts.push(part) }, token); + return { + stream: (async function* () { + yield* responseParts; + })() + }; + } + } as unknown as vscode.LanguageModelChat; + const extensionEndpoint = instaService.createInstance(ExtensionContributedChatEndpoint, languageModel); + const configurationService = accessor.get(IConfigurationService); + const conversationId = 'conversation-332031'; + const messages: Raw.ChatMessage[] = [{ + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'hello' }] + }]; + + await configurationService.setConfig(ConfigKey.ResponsesApiPromptCacheKeyEnabled, true); + const directEndpoint = await provider.createEndpoint(model); + const directPromptCacheKey = directEndpoint.createRequestBody({ + debugName: 'test-direct', + messages, + conversationId, + requestId: 'test-request-direct', + postOptions: {}, + finishedCb: undefined, + location: ChatLocation.Agent, + }).prompt_cache_key; + + const capturePromptCacheKey = async (enabled: boolean, requestConversationId: string | undefined) => { + await configurationService.setConfig(ConfigKey.ResponsesApiPromptCacheKeyEnabled, enabled); + const requestIndex = chatMLFetcher.requests.length; + await extensionEndpoint.makeChatRequest2({ + debugName: 'test', + messages, + conversationId: requestConversationId, + finishedCb: undefined, + location: ChatLocation.Agent, + requestOptions: {}, + }, tokenSource.token); + const request = chatMLFetcher.requests[requestIndex]; + if (!request) { + throw new Error('Expected the BYOK endpoint to receive a request'); + } + return request.endpoint.createRequestBody({ + ...request, + requestId: `test-request-${requestIndex}`, + postOptions: request.requestOptions, + }).prompt_cache_key; + }; + + expect({ + direct: directPromptCacheKey, + bridgedEnabled: await capturePromptCacheKey(true, conversationId), + disabled: await capturePromptCacheKey(false, conversationId), + missingConversationId: await capturePromptCacheKey(true, undefined), + }).toEqual({ + direct: `${conversationId}:${model.family}`, + bridgedEnabled: `${conversationId}:${model.family}`, + disabled: undefined, + missingConversationId: undefined, + }); + }); + it('sends Authorization: Bearer for Chat Completions endpoints', () => { const endpoint = instaService.createInstance(CustomEndpointOAIEndpoint, makeMetadata(undefined), diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts index 8b037c1cbcc448..52d7a75c20bc27 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts @@ -811,6 +811,7 @@ export class CopilotLanguageModelWrapper extends Disposable { source: { extensionId }, requestOptions: options, userInitiatedRequest: !!extensionId, + conversationId: internalModelOptions?._conversationId, telemetryProperties, modelCapabilities: { enableThinking: internalModelOptions?._enableThinking, diff --git a/extensions/copilot/src/platform/endpoint/vscode-node/extChatEndpoint.ts b/extensions/copilot/src/platform/endpoint/vscode-node/extChatEndpoint.ts index 5052fe2c8fe7a9..75105da06d61ed 100644 --- a/extensions/copilot/src/platform/endpoint/vscode-node/extChatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/vscode-node/extChatEndpoint.ts @@ -34,6 +34,7 @@ import { ExtensionContributedChatTokenizer } from './extChatTokenizer'; */ export interface ExtensionLanguageModelRequestOptions extends OTelModelOptions { readonly _enableThinking?: boolean; + readonly _conversationId?: string; } enum ChatImageMimeType { @@ -178,6 +179,7 @@ export class ExtensionContributedChatEndpoint implements IChatEndpoint { source, telemetryProperties, modelCapabilities, + conversationId, }: IMakeChatRequestOptions, token: CancellationToken): Promise { const vscodeMessages = convertToApiChatMessage(messages, { ignoreStatefulMarker, @@ -200,12 +202,13 @@ export class ExtensionContributedChatEndpoint implements IChatEndpoint { description: tool.function.description, inputSchema: tool.function.parameters, })), - // Pass correlation ID and OTel trace context through modelOptions for cross-IPC restoration. + // Pass internal request context through modelOptions for cross-IPC restoration. modelOptions: { _capturingTokenCorrelationId: ourRequestId, _otelTraceContext: activeTraceCtx ?? null, ...(telemetryTurn !== undefined ? { _telemetryTurn: telemetryTurn } : {}), ...(modelCapabilities?.enableThinking !== undefined ? { _enableThinking: modelCapabilities.enableThinking } : {}), + ...(conversationId !== undefined ? { _conversationId: conversationId } : {}), } satisfies ExtensionLanguageModelRequestOptions }; From c66d8b3e450ba014f90eb2041bd21c20f52ce8d3 Mon Sep 17 00:00:00 2001 From: TylerLeonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:56:55 -0700 Subject: [PATCH 02/10] launch skill: fix Windows launches always starting signed out (#332042) * Fix launch.ps1 to discover Node.js from fnm Add fnm directory fallback to Get-UsableNode so PowerShell launcher can find Node.js installed via fnm when it's not on PATH. Tries PATH first (original behavior), then checks AppData\Local\fnm_multishells for fnm-managed versions. Co-Authored-By: Claude Haiku 4.5 * launch skill: copy the shared-data-dir so Windows stays signed in On Windows the GitHub session is stored at StorageScope.APPLICATION_SHARED rather than APPLICATION - see useSharedStorage and CROSS_APP_SHARED_SECRET_KEYS in src/vs/platform/secrets/common/secrets.ts. That puts the encrypted session blob in /sharedStorage/state.vscdb while the DPAPI-wrapped key stays in /Local State. The launcher copied the profile but handed Code OSS a brand-new empty --shared-data-dir, so every Windows launch silently started signed out. Signing in again did not help, because the new session was written to a shared dir that the next launch discarded. macOS and Linux are unaffected: isWindows is false there, so the same token lands inside the copied profile. Seed the run's shared-data-dir from ~/ (overridable with CODE_OSS_DEV_AUTHED_SHARED_DATA_DIR), and teach the auth preflight to probe the shared database as well as globalStorage so its warning is accurate. Also correct SKILL.md, which documented the macOS-only model and recommended a remedy that cannot fix this on Windows. Verified end to end: the launched Agents window authenticates against the real service and completes a chat request without prompting for sign-in. Co-Authored-By: Claude Opus 5 * launch skill: address review feedback on shared-data seeding - Mirror the VSCODE_PORTABLE branch of IEnvironmentService.appSharedDataHome when resolving the source shared-data-dir, so a session created in portable mode is found instead of silently falling through to the product folder. - Stop claiming a missing shared-data-dir means the launch will prompt for sign-in. ApplicationSharedStorageMain registers application storage as a read fallback, so profiles predating the APPLICATION_SHARED migration authenticate from globalStorage with no shared dir at all. Report the missing directory as a fact and let the combined preflight decide. - Rewrite the SKILL.md callout that read as contradicting the remedy printed directly beneath it. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Haiku 4.5 --- .agents/skills/launch/SKILL.md | 21 +++- .agents/skills/launch/scripts/launch.ps1 | 122 +++++++++++++++++++---- 2 files changed, 120 insertions(+), 23 deletions(-) diff --git a/.agents/skills/launch/SKILL.md b/.agents/skills/launch/SKILL.md index 72606d1aab6f52..93fb1f58f784d8 100644 --- a/.agents/skills/launch/SKILL.md +++ b/.agents/skills/launch/SKILL.md @@ -14,7 +14,7 @@ You're working on VS Code itself and you want to: This skill provides a launcher that clones an authenticated user-data-dir to a throwaway temp folder, picks free ports for every debug surface, and prints them as JSON so you can pick them up programmatically. -The clone is **slim**: workspace storage, browser caches, file history, cached VSIX backups, and old logs are excluded by default. On macOS, auth tokens live in the OS keychain plus small files inside `User/globalStorage` - both of which *are* preserved. +The clone is **slim**: workspace storage, browser caches, file history, cached VSIX backups, and old logs are excluded by default. On macOS, auth tokens live in the OS keychain plus small files inside `User/globalStorage` - both of which *are* preserved. On Windows the GitHub session lives in the **shared-data-dir** instead, which the launcher seeds separately (see [Windows authentication](#windows-authentication)). ## Prerequisites @@ -74,7 +74,20 @@ The exclude list mirrors the one used by VS Code's own perf-test skill (`.github #### Windows authentication -Windows has no shared per-app keychain for these secrets. They live in the copied profile, notably `User/globalStorage/state.vscdb` and root `Local State`, so the launcher verifies that they (plus `machineid` and `Network`) survived the copy. If a launched instance prompts for sign-in, launch `.\scripts\code.bat --user-data-dir=` directly, sign in once, and close it; every later launch copies that source profile and inherits the session. +Windows has no shared per-app keychain for these secrets, so they live in files on disk - but **not all in the user-data-dir**. The GitHub session is stored at `StorageScope.APPLICATION_SHARED` *only on Windows* (see `useSharedStorage` and `CROSS_APP_SHARED_SECRET_KEYS` in `src/vs/platform/secrets/common/secrets.ts`), which puts the two halves of the credential in **different directories**: + +| Piece | Location | +|---|---| +| Encrypted GitHub session blob | `/sharedStorage/state.vscdb` | +| DPAPI-wrapped decryption key (`os_crypt.encrypted_key`) | `/Local State` | + +The launcher therefore seeds **both**: it copies the source profile *and* copies the source shared-data-dir into the run's throwaway `shared-data` dir. The source resolves the same way `IEnvironmentService.appSharedDataHome` does - `$env:CODE_OSS_DEV_AUTHED_SHARED_DATA_DIR` if set, else `$env:VSCODE_PORTABLE\shared-data` when running portable, else `~/` (i.e. `%USERPROFILE%\.vscode-oss-shared`). It also verifies `Local State`, `machineid`, and `Network` survived the profile copy, and warns on stderr if neither database holds a GitHub session. + +> This asymmetry is invisible on macOS/Linux, where the same token lands inside the profile. A Windows-only "always signed out" symptom is a shared-data-dir problem, **not** a profile problem: signing in against the source profile writes a perfectly good session, but before this seeding existed every launch handed Code OSS an empty shared dir and threw it away. + +To (re)establish the source session: run `.\scripts\code.bat --user-data-dir=$env:USERPROFILE\.vscode-oss-dev` directly, sign in once, and close it. That writes the blob to `%USERPROFILE%\.vscode-oss-shared` and the key to the profile's `Local State`; later launches copy both and inherit the session. + +> Profiles that predate the `APPLICATION_SHARED` migration can still hold the secret in `User/globalStorage/state.vscdb`. `ApplicationSharedStorageMain` registers application storage as a read fallback, so those profiles authenticate even with no shared-data-dir present - which is why a missing shared dir is reported as a fact rather than assumed fatal. Excluded (transient, regenerable, or known-not-needed): - `User/workspaceStorage/` - per-workspace state, **including stored chat sessions** (often multi-GB) @@ -325,7 +338,7 @@ You can run `@playwright/cli` and `dap-cli` against the **same window simultaneo Every launch picks fresh ports and a fresh temp `runDir`, so you can run as many concurrent Code OSS windows as your machine can handle. Each one's ports come back in its own JSON blob - keep them separate. -The launcher also passes `--shared-data-dir=/shared-data`. This is **required** for multi-instance isolation: Code OSS keeps a fixed-path SQLite DB at `~/.-shared/sharedStorage/state.vscdb` that is *not* covered by `--user-data-dir`. Without overriding it, two concurrent instances would fight over the same file and one would die with "shared background process terminated unexpectedly". Each launch gets its own `shared-data` dir. +The launcher also passes `--shared-data-dir=/shared-data`. This is **required** for multi-instance isolation: Code OSS keeps a fixed-path SQLite DB at `~/.-shared/sharedStorage/state.vscdb` that is *not* covered by `--user-data-dir`. Without overriding it, two concurrent instances would fight over the same file and one would die with "shared background process terminated unexpectedly". Each launch gets its own `shared-data` dir, **seeded from the source shared-data-dir** so the Windows GitHub session survives - see [Windows authentication](#windows-authentication) for why that copy matters. ## Restart after source changes @@ -374,4 +387,4 @@ Code OSS is a full Electron app and easily eats 1-4 GB. Always clean up. - **`launch.sh` exits non-zero with a log tail** - either pre-launch failed, `code.sh` died before CDP came up, or CDP never opened within 90s. The tail printed to stderr is from `runDir/code.log` - read it to diagnose. - **Snapshot shows the wrong page or no expected controls** - use `tab-list`, switch with `tab-select ` if needed, then re-snapshot before interacting. - **CLI typing commands complete but the input stays empty** - focus chat with the platform shortcut, use `press` or clipboard paste rather than `fill` / `type`, then verify the input state before sending. -- **Auth missing in the launched window** - confirm the source profile is actually authed (`ls "$SOURCE_UDD"` should contain `User/`, and `ls "$SOURCE_UDD/User/globalStorage"` should show persisted extension state). On Windows, sign in directly against the source profile once so its copied `state.vscdb` and `Local State` contain the session. +- **Auth missing in the launched window** - confirm the source profile is actually authed (`ls "$SOURCE_UDD"` should contain `User/`, and `ls "$SOURCE_UDD/User/globalStorage"` should show persisted extension state). **On Windows, check the shared-data-dir first**: the GitHub session blob lives in `%USERPROFILE%\.vscode-oss-shared\sharedStorage\state.vscdb`, not in the profile. The launcher logs `copying shared data: -> ` on stderr when it finds it, and warns `no shared-data-dir at ` when it doesn't. A missing or empty source shared-data-dir means signing in again against the source profile is what you need - see [Windows authentication](#windows-authentication). diff --git a/.agents/skills/launch/scripts/launch.ps1 b/.agents/skills/launch/scripts/launch.ps1 index cc3105ec1bea5b..ab34e92c633bbc 100644 --- a/.agents/skills/launch/scripts/launch.ps1 +++ b/.agents/skills/launch/scripts/launch.ps1 @@ -39,22 +39,65 @@ function Get-UsableNode([string]$repoPath) { } $setupMessage = "Run in PowerShell from $repoPath`: fnm env --use-on-cd --shell powershell | Out-String | Invoke-Expression; fnm use" - if ($null -eq $command) { - throw "Node.js $requiredVersion or newer is required on PATH. $setupMessage" + if ($null -ne $command) { + try { + $version = & $command.Source --version 2>$null + if ($LASTEXITCODE -ne 0 -or $version -notmatch '^v(?\d+\.\d+\.\d+)') { + throw 'could not determine its version' + } + if ([version]$Matches.version -lt [version]$requiredVersion) { + throw "found $version" + } + return $command.Source + } catch { + # Fall through to fnm fallback + } } - try { - $version = & $command.Source --version 2>$null - if ($LASTEXITCODE -ne 0 -or $version -notmatch '^v(?\d+\.\d+\.\d+)') { - throw 'could not determine its version' + # Fallback: Check fnm directories (most recent first) + $fnmBase = Join-Path $env:USERPROFILE 'AppData\Local\fnm_multishells' + if (Test-Path $fnmBase) { + $fnmDirs = Get-ChildItem $fnmBase -Directory -ErrorAction SilentlyContinue | Sort-Object -Property CreationTime -Descending + foreach ($dir in $fnmDirs) { + $nodePath = Join-Path $dir.FullName 'node.exe' + if (Test-Path $nodePath) { + try { + $version = & $nodePath --version 2>$null + if ($LASTEXITCODE -eq 0 -and $version -match '^v(?\d+\.\d+\.\d+)') { + if ([version]$Matches.version -ge [version]$requiredVersion) { + return $nodePath + } + } + } catch { } + } } - if ([version]$Matches.version -lt [version]$requiredVersion) { - throw "found $version" + } + + throw "Node.js $requiredVersion or newer is required on PATH. $setupMessage" +} + +function Get-SourceSharedDataDir([string]$repoPath) { + if ($env:CODE_OSS_DEV_AUTHED_SHARED_DATA_DIR) { + return $env:CODE_OSS_DEV_AUTHED_SHARED_DATA_DIR + } + + # Mirrors IEnvironmentService.appSharedDataHome, minus the --shared-data-dir + # branch (that one names the *destination*, not the source we copy from): + # VSCODE_PORTABLE\shared-data, else ~/. + if ($env:VSCODE_PORTABLE) { + return Join-Path $env:VSCODE_PORTABLE 'shared-data' + } + + $folderName = '.vscode-oss-shared' + $productJson = Join-Path $repoPath 'product.json' + if (Test-Path -LiteralPath $productJson -PathType Leaf) { + $product = Get-Content -LiteralPath $productJson -Raw | ConvertFrom-Json + if ($product.PSObject.Properties['sharedDataFolderName']) { + $folderName = $product.sharedDataFolderName } - return $command.Source - } catch { - throw "Node.js $requiredVersion or newer is required on PATH ($($_.Exception.Message)). $setupMessage" } + + return Join-Path $env:USERPROFILE $folderName } function Get-FreePort { @@ -171,14 +214,13 @@ function Assert-AuthCriticalProfileFiles([string]$destination) { } } -function Test-SourceHasGitHubAuthenticationSecret([string]$node, [string]$source, [string]$temporaryDb) { - $sourceDb = Join-Path $source 'User\globalStorage\state.vscdb' - if (-not (Test-Path -LiteralPath $sourceDb -PathType Leaf)) { - return $null +function Test-DbHasGitHubAuthenticationSecret([string]$node, [string]$db, [string]$temporaryDb) { + if (-not (Test-Path -LiteralPath $db -PathType Leaf)) { + return $false } try { - [IO.File]::Copy($sourceDb, $temporaryDb, $true) + [IO.File]::Copy($db, $temporaryDb, $true) $script = @' import { DatabaseSync } from 'node:sqlite'; @@ -206,6 +248,33 @@ try { } } +function Test-SourceHasGitHubAuthenticationSecret([string]$node, [string]$source, [string]$sharedSource, [string]$temporaryDb) { + # On Windows the GitHub session is APPLICATION_SHARED scoped, so it lives in + # the shared-data-dir rather than the profile - see useSharedStorage in + # src/vs/platform/secrets/common/secrets.ts. Older profiles may still hold it + # in globalStorage, and both directories get copied, so either one counts. + $databases = @( + (Join-Path $sharedSource 'sharedStorage\state.vscdb'), + (Join-Path $source 'User\globalStorage\state.vscdb') + ) + + $undetermined = $false + foreach ($db in $databases) { + $result = Test-DbHasGitHubAuthenticationSecret $node $db $temporaryDb + if ($result -eq $true) { + return $true + } + if ($null -eq $result) { + $undetermined = $true + } + } + + if ($undetermined) { + return $null + } + 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 @@ -287,11 +356,11 @@ function Ensure-SimpleDialogSetting([string]$settingsFile) { $lastBrace = $maskedText.LastIndexOf('}') if ($lastBrace -eq -1) { - throw "settings.json has no closing brace โ€” refusing to clobber it: $settingsFile" + 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" + 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 @@ -452,7 +521,22 @@ try { $logFile = Join-Path $runDir 'code.log' New-Item -ItemType Directory -Force -Path $runDir, $sharedDataDir | Out-Null [IO.File]::WriteAllText($logFile, '', [Text.UTF8Encoding]::new($false)) - $hasGitHubAuthenticationSecret = Test-SourceHasGitHubAuthenticationSecret $node $sourceUserDataDir (Join-Path $runDir 'auth-preflight.vscdb') + $sourceSharedDataDir = Get-SourceSharedDataDir $repo + if (Test-Path -LiteralPath $sourceSharedDataDir -PathType Container) { + # On Windows the GitHub session is APPLICATION_SHARED scoped, so it lives here + # and not in the profile - see useSharedStorage in + # src/vs/platform/secrets/common/secrets.ts. Without this copy the launched + # instance always prompts for sign-in. + Write-LaunchError "[launch.ps1] copying shared data: $sourceSharedDataDir -> $sharedDataDir" + Copy-ProfileDirectory $sourceSharedDataDir $sharedDataDir $false + } else { + # Not necessarily fatal: profiles predating the APPLICATION_SHARED migration + # still hold the secret in globalStorage, and ApplicationSharedStorageMain + # falls back to application storage. State the fact and let the preflight + # below decide whether a sign-in is actually coming. + Write-LaunchError "[launch.ps1] no shared-data-dir at $sourceSharedDataDir; nothing to seed" + } + $hasGitHubAuthenticationSecret = Test-SourceHasGitHubAuthenticationSecret $node $sourceUserDataDir $sourceSharedDataDir (Join-Path $runDir 'auth-preflight.vscdb') if ($hasGitHubAuthenticationSecret -eq $false) { Write-LaunchError "[launch.ps1] WARNING: source profile $sourceUserDataDir has no stored GitHub session; the launched instance will prompt you to sign in." Write-LaunchError 'To fix once and for all, launch Code OSS directly against the source profile (no copy), sign in, then close it:' From 86d7ed0c3d4372ea17ae6b67b5b495281bdf9992 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:31:37 +0200 Subject: [PATCH 03/10] Agent Merge: recover from a checks fragment the host refuses (#332003) * Agent Merge: recover from a checks fragment the host refuses Agent Merge could hang indefinitely on a pull request whose checks fragment was refused by GitHub, showing the user nothing at all. When an organization enforces SAML SSO and the signed-in token is not authorized for it, the checks GraphQL query is refused with HTTP 200 and a FORBIDDEN error in the body. `CheckRun.checkSuite` is non-nullable, so the refusal on the GitHub Actions data behind the workflow-name subselection null-propagates and fails the whole fragment rather than that one field. Checks then never load, the gate is permanently indeterminate, and nothing surfaces: - checks never loaded reads as pending, so the fragment held the fast poll cadence and re-requested a permanently refused query roughly once a minute, forever; - indeterminate was the only gate outcome with neither an action nor a budget, so the session stayed resident with nothing to show; - only `authentication` raised an auth requirement, so an `authorization` refusal never prompted the user to re-authorize. Recover the fragment and make the failure legible: - gate the workflow-name subselection and drop it for a repository whose host refuses it, keeping the checks themselves. Only the rollup request is retried, and an expected-check-suites refusal degrades to absent and incomplete, so neither is mistaken for the other; - raise an auth requirement for `authorization` too, naming the organization and calling out SSO when GitHub reports it; - give indeterminate a budget over continuously observed time, so a pull request that can never be read stops instead of idling while a turn or a sleeping host cannot exhaust it; - name the fragment and its error in the indeterminate reason instead of one string shared by five fragments; - back off persistent authorization failures, and stop an errored checks fragment from holding the fast cadence. Partial check data is still never accepted, so a refused fragment continues to fail closed rather than reporting checks it could not read. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Request a credential when the snapshot records a refused fragment Only the first refresh of a subscription reports a failure by throwing, so raising the auth requirement from the evaluation catch missed the case it was meant to cover: every later refusal is recorded on the snapshot and read as an ordinary indeterminate gate, leaving the session waiting on a credential the user was never asked for. Detect a refused gate fragment on the snapshot and request a credential there, once per distinct failure so a persistent refusal does not nag and a failure after recovery can prompt again. Share one path with the throw site, and keep the fragment list with the gate that defines it. Also shorten the comments added with this change to the limits in the coding guidelines. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../platform/agentHost/common/agentMerge.ts | 52 ++++++-- .../agentHost/node/agentMergeController.ts | 102 +++++++++++++++- .../agentHost/test/common/agentMerge.test.ts | 21 ++++ .../test/node/agentMergeController.test.ts | 30 ++++- .../platform/github/common/githubService.ts | 2 +- .../github/common/pullRequestQueryService.ts | 105 +++++++++++++---- .../common/pullRequestResourceService.ts | 9 +- .../test/node/pullRequestQueryService.test.ts | 111 ++++++++++++++++++ 8 files changed, 387 insertions(+), 45 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentMerge.ts b/src/vs/platform/agentHost/common/agentMerge.ts index 171eb71d0b6cee..6470396fbe21ee 100644 --- a/src/vs/platform/agentHost/common/agentMerge.ts +++ b/src/vs/platform/agentHost/common/agentMerge.ts @@ -155,7 +155,7 @@ export const defaultAgentMergeConfiguration: AgentMergeConfiguration = { }; export type AgentMergeGateResult = - | { readonly kind: 'indeterminate'; readonly reason: string } + | { readonly kind: 'indeterminate'; readonly reason: string; readonly cause: string } | { readonly kind: 'terminal' } | { readonly kind: 'noWork'; readonly waitingOnChecks: boolean; readonly fingerprint: string } | { readonly kind: 'prompt'; readonly actions: readonly AgentMergeRepairAction[]; readonly fingerprint: string; readonly context: AgentMergePromptContext } @@ -293,22 +293,19 @@ class FeedbackBudget { export function evaluateAgentMerge(snapshot: PullRequestSnapshot, configuration: AgentMergeConfiguration, commentWatermark: string): AgentMergeGateResult { const core = snapshot.core; if (core.status !== 'ready' || !core.complete || !core.value) { - return { kind: 'indeterminate', reason: 'Pull request core state is incomplete' }; + return { kind: 'indeterminate', reason: 'Pull request core state is incomplete', cause: 'core:incomplete' }; } if (core.value.state !== 'open') { return { kind: 'terminal' }; } - if (!isCompleteFragment(snapshot, 'topLevelComments') - || !isCompleteFragment(snapshot, 'submittedReviews') - || !isCompleteFragment(snapshot, 'reviewThreads') - || !isCompleteHeadFragment(snapshot, 'checks', core.value.headSha) - || !isCompleteHeadFragment(snapshot, 'mergeability', core.value.headSha)) { - return { kind: 'indeterminate', reason: 'Pull request state is incomplete or stale' }; + const incomplete = firstIncompleteFragment(snapshot, core.value.headSha); + if (incomplete) { + return { kind: 'indeterminate', ...describeIncompleteFragment(snapshot, incomplete) }; } const checks = classifyAgentMergeRequiredChecks(snapshot.checks.value!); if (checks.kind === 'indeterminate') { - return { kind: 'indeterminate', reason: checks.reason }; + return { kind: 'indeterminate', reason: checks.reason, cause: `checks:${checks.reason}` }; } const reviewThreads = snapshot.reviewThreads.value! @@ -393,6 +390,43 @@ function isCompleteHeadFragment(snapshot: PullRequestSnapshot, fragment: 'checks return state.status === 'ready' && state.complete && state.value !== undefined && state.headSha === headSha; } +const conversationFragments = ['topLevelComments', 'submittedReviews', 'reviewThreads'] as const; +const headFragments = ['checks', 'mergeability'] as const; + +type EvaluatedFragment = typeof conversationFragments[number] | typeof headFragments[number]; + +/** Fragments the gate must be able to read before it can decide anything. */ +export const agentMergeGateFragments = ['core', ...conversationFragments, ...headFragments] as const; + +function firstIncompleteFragment(snapshot: PullRequestSnapshot, headSha: string): EvaluatedFragment | undefined { + for (const fragment of conversationFragments) { + if (!isCompleteFragment(snapshot, fragment)) { + return fragment; + } + } + for (const fragment of headFragments) { + if (!isCompleteHeadFragment(snapshot, fragment, headSha)) { + return fragment; + } + } + return undefined; +} + +/** Describes why a fragment blocks evaluation, with a `cause` that stays stable while the condition lasts. */ +function describeIncompleteFragment(snapshot: PullRequestSnapshot, fragment: EvaluatedFragment): { readonly reason: string; readonly cause: string } { + const state = snapshot[fragment]; + if (state.error) { + return { + reason: `Pull request ${fragment} could not be loaded (${state.error.kind}): ${state.error.message}`, + cause: `${fragment}:${state.error.kind}`, + }; + } + return { + reason: `Pull request ${fragment} state is incomplete or stale (status=${state.status}, complete=${state.complete})`, + cause: `${fragment}:incomplete`, + }; +} + function latestReviewsByAuthor(reviews: PullRequestSnapshot['submittedReviews']['value']): NonNullable { const latest = new Map[number]>(); for (const review of reviews ?? []) { diff --git a/src/vs/platform/agentHost/node/agentMergeController.ts b/src/vs/platform/agentHost/node/agentMergeController.ts index 3aa6995c59474c..e6779a093d73a5 100644 --- a/src/vs/platform/agentHost/node/agentMergeController.ts +++ b/src/vs/platform/agentHost/node/agentMergeController.ts @@ -15,7 +15,7 @@ import { IGitHubService } from '../../github/common/githubService.js'; import { PullRequestRef, PullRequestSnapshot, PullRequestSubscription } from '../../github/common/githubPullRequestService.js'; import { GitHubRequestError } from '../../github/common/githubTransport.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentMergeConfigKey, AgentMergeConfiguration, AgentMergePromptContext, AgentMergeRepairAction, AgentMergeSessionState, AgentMergeTarget, agentMergeRootConfigSchema, defaultAgentMergeConfiguration, evaluateAgentMerge, readAgentMergeSessionState, resolveAgentMergeConfiguration } from '../common/agentMerge.js'; +import { AgentMergeConfigKey, AgentMergeConfiguration, AgentMergePromptContext, AgentMergeRepairAction, AgentMergeSessionState, AgentMergeTarget, agentMergeGateFragments, agentMergeRootConfigSchema, defaultAgentMergeConfiguration, evaluateAgentMerge, readAgentMergeSessionState, resolveAgentMergeConfiguration } from '../common/agentMerge.js'; import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js'; import { deriveGitHubEndpoints } from '../common/githubEndpoints.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; @@ -31,6 +31,10 @@ const snapshotDebounce = 30_000; const backstopInterval = 10 * 60_000; const maximumRepeatedPromptCount = 3; const maximumTotalPromptCount = 6; +/** How long one unchanged indeterminate cause may persist before Agent Merge gives up. */ +const maximumIndeterminateDuration = 30 * 60_000; +/** How long a gap between indeterminate observations may be before the budget window restarts. */ +const indeterminateObservationGap = 2 * backstopInterval; export interface IAgentMergeControllerOptions { readonly startTurn: (session: string, turnId: string, prompt: string) => boolean; @@ -54,6 +58,10 @@ class AgentMergeRuntime extends Disposable { * backstop. */ didRefreshForMissingBranch = false; + /** The unchanged indeterminate cause being timed out, if any. */ + indeterminate: { readonly cause: string; readonly since: number; observedAt: number } | undefined; + /** The refused fragment a credential was last requested for, if any. */ + reportedCredentialFailure: string | undefined; constructor( readonly session: string, @@ -325,11 +333,8 @@ export class AgentMergeController extends Disposable { this._logService.trace(`[AgentMergeController] Evaluation stopped with disposed runtime: session=${session}`); return; } - if (error instanceof GitHubRequestError && error.kind === 'authentication') { - this._stateManager.emitAuthRequired({ - resource: this._gitHubEndpointService.getRepoResource(), - reason: AuthRequiredReason.Required, - }); + if (error instanceof GitHubRequestError && (error.kind === 'authentication' || error.kind === 'authorization')) { + this._requestGitHubAuthorization(session, error.kind, error.message); } this._logService.error(error, `[AgentMergeController] Evaluation failed: session=${session}, kind=${githubErrorKind(error)}`); this._runtimes.get(session)?.backstopScheduler.schedule(); @@ -414,8 +419,17 @@ export class AgentMergeController extends Disposable { const configuration = this._getConfiguration(agentMerge); const gate = evaluateAgentMerge(snapshot, configuration, target.commentWatermark); this._logGateResult(session, gate); + if (gate.kind !== 'indeterminate') { + runtime.indeterminate = undefined; + runtime.reportedCredentialFailure = undefined; + } switch (gate.kind) { case 'indeterminate': + this._reportBlockedCredential(session, runtime, snapshot); + if (this._isIndeterminateBudgetExhausted(session, runtime, gate.cause)) { + this._disable(session, agentMerge, `the pull request state could not be evaluated for ${Math.round(maximumIndeterminateDuration / 60_000)} minutes: ${gate.reason}`); + return; + } runtime.backstopScheduler.schedule(); return; case 'terminal': @@ -774,6 +788,66 @@ export class AgentMergeController extends Disposable { return readSessionGitState(state?._meta)?.branchName === branchName; } + /** Resolves the organization owning the bound pull request, for diagnostics. */ + private _organizationForSession(session: string): string | undefined { + const state = this._stateManager.getSessionState(session); + const pullRequestUrl = readAgentMergeSessionState(state?.config?.values)?.target?.pullRequestUrl; + return pullRequestUrl ? parsePullRequestUrl(pullRequestUrl)?.owner : undefined; + } + + /** + * Asks the client for a credential that can read the bound pull request, + * naming the organization to authorize when GitHub reports SAML enforcement. + */ + private _requestGitHubAuthorization(session: string, kind: 'authentication' | 'authorization', message: string): void { + this._stateManager.emitAuthRequired({ + resource: this._gitHubEndpointService.getRepoResource(), + reason: AuthRequiredReason.Required, + }); + const organization = this._organizationForSession(session); + const remedy = isSamlEnforcementError(message) && organization + ? `; the credential must be SSO-authorized for ${organization}` + : ''; + this._logService.warn(`[AgentMergeController] GitHub refused the credential (${kind})${remedy}: session=${session}`); + } + + /** + * Requests a credential when a fragment the gate needs was refused by + * GitHub, which only the first refresh of a subscription reports by throwing. + */ + private _reportBlockedCredential(session: string, runtime: AgentMergeRuntime, snapshot: PullRequestSnapshot): void { + const blocked = firstCredentialFailure(snapshot); + if (!blocked) { + runtime.reportedCredentialFailure = undefined; + return; + } + if (runtime.reportedCredentialFailure === blocked.id) { + return; + } + runtime.reportedCredentialFailure = blocked.id; + this._requestGitHubAuthorization(session, blocked.kind, blocked.message); + } + + /** + * Reports whether one unchanged indeterminate cause has persisted past its + * budget, measured over continuously observed time so a turn or a sleeping + * host cannot exhaust it. + */ + private _isIndeterminateBudgetExhausted(session: string, runtime: AgentMergeRuntime, cause: string): boolean { + const now = Date.now(); + const current = runtime.indeterminate; + if (current?.cause !== cause || now - current.observedAt > indeterminateObservationGap) { + runtime.indeterminate = { cause, since: now, observedAt: now }; + return false; + } + current.observedAt = now; + if (now - current.since < maximumIndeterminateDuration) { + return false; + } + this._logService.warn(`[AgentMergeController] Indeterminate budget exhausted: session=${session}, cause=${cause}`); + return true; + } + private _logGateResult(session: string, gate: ReturnType): void { switch (gate.kind) { case 'prompt': @@ -928,3 +1002,19 @@ function githubErrorKind(error: unknown): string { ? `${error.kind}${error.statusCode === undefined ? '' : `:${error.statusCode}`}` : error instanceof Error ? error.name : typeof error; } + +/** Detects the SAML single sign-on refusal GitHub returns for organizations that enforce it. */ +export function isSamlEnforcementError(message: string): boolean { + return message.toLowerCase().includes('saml enforcement'); +} + +/** Finds the first fragment the gate needs that GitHub refused to serve. */ +export function firstCredentialFailure(snapshot: PullRequestSnapshot): { readonly id: string; readonly kind: 'authentication' | 'authorization'; readonly message: string } | undefined { + for (const fragment of agentMergeGateFragments) { + const error = snapshot[fragment].error; + if (error?.kind === 'authentication' || error?.kind === 'authorization') { + return { id: `${fragment}:${error.kind}`, kind: error.kind, message: error.message }; + } + } + return undefined; +} diff --git a/src/vs/platform/agentHost/test/common/agentMerge.test.ts b/src/vs/platform/agentHost/test/common/agentMerge.test.ts index bda060b221b311..3fbeec053b2ab1 100644 --- a/src/vs/platform/agentHost/test/common/agentMerge.test.ts +++ b/src/vs/platform/agentHost/test/common/agentMerge.test.ts @@ -146,6 +146,27 @@ suite('Agent Merge gate', () => { }); }); + test('names the fragment holding evaluation back and why', () => { + const failing = readySnapshot(); + assert.deepStrictEqual([ + evaluateAgentMerge({ + ...failing, + checks: { + status: 'error', + complete: false, + error: { kind: 'authorization', statusCode: 200, message: 'Resource protected by organization SAML enforcement.' }, + }, + }, configuration, '2026-08-02T00:00:00.000Z'), + evaluateAgentMerge({ + ...failing, + mergeability: { status: 'loading', complete: false }, + }, configuration, '2026-08-02T00:00:00.000Z'), + ], [ + { kind: 'indeterminate', reason: 'Pull request checks could not be loaded (authorization): Resource protected by organization SAML enforcement.', cause: 'checks:authorization' }, + { kind: 'indeterminate', reason: 'Pull request mergeability state is incomplete or stale (status=loading, complete=false)', cause: 'mergeability:incomplete' }, + ]); + }); + test('keeps client and controller state in separate config values', () => { assert.deepStrictEqual(readAgentMergeSessionState({ [SessionConfigKey.AgentMerge]: { enabled: true, overrides: { fixCI: false } }, diff --git a/src/vs/platform/agentHost/test/node/agentMergeController.test.ts b/src/vs/platform/agentHost/test/node/agentMergeController.test.ts index d49b0e0c00d9cd..acb92d3d0e8790 100644 --- a/src/vs/platform/agentHost/test/node/agentMergeController.test.ts +++ b/src/vs/platform/agentHost/test/node/agentMergeController.test.ts @@ -16,9 +16,10 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; import { SessionStatus, buildDefaultChatUri, MessageKind, withSessionGitState, type SessionSummary } from '../../common/state/sessionState.js'; import { IGitHubService } from '../../../github/common/githubService.js'; +import { PullRequestSnapshot } from '../../../github/common/githubPullRequestService.js'; import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostGitHubEndpointService } from '../../node/agentHostGitHubEndpointService.js'; -import { AgentMergeController, parsePullRequestUrl } from '../../node/agentMergeController.js'; +import { AgentMergeController, firstCredentialFailure, isSamlEnforcementError, parsePullRequestUrl } from '../../node/agentMergeController.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; let sessionCounter = 0; @@ -411,6 +412,33 @@ suite('AgentMergeController', () => { notAUrl: undefined, }); }); + + test('detects a refused gate fragment so a credential can be requested from the snapshot', () => { + const saml = 'GitHub GraphQL request failed: Resource protected by organization SAML enforcement. You must grant your OAuth token access to this organization.'; + const ready = { status: 'ready', complete: true, value: {} }; + const snapshot = (overrides: object) => ({ core: ready, topLevelComments: ready, submittedReviews: ready, reviewThreads: ready, checks: ready, mergeability: ready, ...overrides }) as unknown as PullRequestSnapshot; + + assert.deepStrictEqual({ + // Only the first refresh of a subscription throws; every later failure is + // recorded here, which is the state the SAML scenario actually reaches. + refused: firstCredentialFailure(snapshot({ checks: { status: 'error', complete: false, error: { kind: 'authorization', statusCode: 200, message: saml } } })), + signedOut: firstCredentialFailure(snapshot({ core: { status: 'error', complete: false, error: { kind: 'authentication', message: 'Bad credentials' } } }))?.id, + // A failure the user cannot fix by authorizing must not prompt. + serverError: firstCredentialFailure(snapshot({ checks: { status: 'error', complete: false, error: { kind: 'server', message: 'boom' } } })), + stillLoading: firstCredentialFailure(snapshot({ mergeability: { status: 'loading', complete: false } })), + healthy: firstCredentialFailure(snapshot({})), + saml: isSamlEnforcementError(saml), + notSaml: isSamlEnforcementError('Bad credentials'), + }, { + refused: { id: 'checks:authorization', kind: 'authorization', message: saml }, + signedOut: 'core:authentication', + serverError: undefined, + stillLoading: undefined, + healthy: undefined, + saml: true, + notSaml: false, + }); + }); }); function summary(resource: string): SessionSummary { diff --git a/src/vs/platform/github/common/githubService.ts b/src/vs/platform/github/common/githubService.ts index 7714e0d1b9bb54..ec49f4907b4cae 100644 --- a/src/vs/platform/github/common/githubService.ts +++ b/src/vs/platform/github/common/githubService.ts @@ -52,7 +52,7 @@ export class GitHubService extends Disposable implements IGitHubService { this.credentials = this._register(new GitHubCredentialService(this.transport, options.tokenProvider, options.endpoint, this._logService)); this.capabilities = this._register(new GitHubHostCapabilitiesService(this.transport, options.endpoint, this._logService)); - const pullRequestQuery = new PullRequestQueryService(this.transport, this.capabilities, options.endpoint); + const pullRequestQuery = new PullRequestQueryService(this.transport, this.capabilities, options.endpoint, this._logService); this.pullRequests = this._register(new PullRequestResourceService( undefined, undefined, diff --git a/src/vs/platform/github/common/pullRequestQueryService.ts b/src/vs/platform/github/common/pullRequestQueryService.ts index e52da91cb37dc0..36fde39a6aa9d1 100644 --- a/src/vs/platform/github/common/pullRequestQueryService.ts +++ b/src/vs/platform/github/common/pullRequestQueryService.ts @@ -23,6 +23,7 @@ import { GitHubHostCapabilities, IGitHubEndpointProvider } from './githubTypes.j import { GitHubCredential } from './githubCredentialService.js'; import { IGitHubCapabilities } from './githubHostCapabilitiesService.js'; import { GitHubGraphQLError, GitHubRequestError, IGitHubTransport } from './githubTransport.js'; +import { ILogService } from '../../log/common/log.js'; import { PullRequestRequestPlanner } from './pullRequestRequestPlanner.js'; export type PullRequestFragmentResult = @@ -79,7 +80,7 @@ const reviewThreadCommentsQuery = `query AgentHostPullRequestReviewThreadComment rateLimit { limit remaining used resetAt } }`; -const checksQuery = (includeRequiredness: boolean) => `query AgentHostPullRequestChecks($owner: String!, $repo: String!, $number: Int!, $after: String) { +const checksQuery = (includeRequiredness: boolean, includeWorkflowNames: boolean) => `query AgentHostPullRequestChecks($owner: String!, $repo: String!, $number: Int!, $after: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $number) { headRefOid @@ -92,7 +93,7 @@ const checksQuery = (includeRequiredness: boolean) => `query AgentHostPullReques __typename ... on CheckRun { databaseId name status conclusion detailsUrl - checkSuite { workflowRun { workflow { name } } } + ${includeWorkflowNames ? 'checkSuite { workflowRun { workflow { name } } }' : ''} ${includeRequiredness ? 'isRequired(pullRequestNumber: $number)' : ''} } ... on StatusContext { @@ -144,10 +145,14 @@ export class PullRequestQueryService implements IPullRequestQuery { private readonly _planner = new PullRequestRequestPlanner(); + /** Repositories whose host refused the workflow-name subselection, keyed by `owner/repo`. */ + private readonly _workflowNamesUnavailable = new Set(); + constructor( private readonly _transport: IGitHubTransport, private readonly _capabilities: IGitHubCapabilities, private readonly _endpoint: IGitHubEndpointProvider, + private readonly _logService?: ILogService, ) { } async fetch( @@ -387,22 +392,90 @@ export class PullRequestQueryService implements IPullRequestQuery { loadExpectedSuites: boolean, includeOptional: boolean, ): Promise { + const rollup = await this._fetchCheckRollupWithWorkflowNames(ref, core, credential, signal, priority, includeRequiredness, includeOptional); + const expected = loadExpectedSuites + ? await this._fetchExpectedCheckSuitesWhenPermitted(ref, core.headSha, credential, signal, priority) + : { suites: [], complete: false }; + return { + headSha: rollup.headSha, + checks: rollup.checks, + requirednessComplete: includeRequiredness, + expectedSuites: expected.suites, + expectedSuitesComplete: expected.complete, + }; + } + + /** + * Loads the check rollup, dropping the workflow-name subselection for a + * repository whose host refuses it so the rest of the checks stay readable. + */ + private async _fetchCheckRollupWithWorkflowNames( + ref: PullRequestRef, + core: PullRequestCore, + credential: GitHubCredential, + signal: AbortSignal, + priority: import('./githubTypes.js').GitHubRequestPriority, + includeRequiredness: boolean, + includeOptional: boolean, + ): Promise<{ readonly headSha: string; readonly checks: readonly PullRequestCheck[] }> { + const repositoryKey = `${ref.owner}/${ref.repo}`.toLowerCase(); + const includeWorkflowNames = !this._workflowNamesUnavailable.has(repositoryKey); + try { + return await this._fetchCheckRollup(ref, core, credential, signal, priority, includeRequiredness, includeOptional, includeWorkflowNames); + } catch (error) { + if (!includeWorkflowNames || !(error instanceof GitHubRequestError) || error.kind !== 'authorization') { + throw error; + } + this._workflowNamesUnavailable.add(repositoryKey); + this._logService?.warn(`[PullRequestQueryService] Retrying checks for ${ref.owner}/${ref.repo}#${ref.number} without workflow names because GitHub refused them: ${error.message}`); + return await this._fetchCheckRollup(ref, core, credential, signal, priority, includeRequiredness, includeOptional, false); + } + } + + /** Loads the expected check suites, reporting them absent and incomplete when the host refuses them. */ + private async _fetchExpectedCheckSuitesWhenPermitted( + ref: PullRequestRef, + headSha: string, + credential: GitHubCredential, + signal: AbortSignal, + priority: import('./githubTypes.js').GitHubRequestPriority, + ): Promise<{ readonly suites: readonly PullRequestCheckSuite[]; readonly complete: boolean }> { + try { + return { suites: await this._fetchExpectedCheckSuites(ref, headSha, credential, signal, priority), complete: true }; + } catch (error) { + if (!(error instanceof GitHubRequestError) || error.kind !== 'authorization') { + throw error; + } + this._logService?.warn(`[PullRequestQueryService] Reporting expected check suites for ${ref.owner}/${ref.repo}#${ref.number} as unavailable because GitHub refused them: ${error.message}`); + return { suites: [], complete: false }; + } + } + + private async _fetchCheckRollup( + ref: PullRequestRef, + core: PullRequestCore, + credential: GitHubCredential, + signal: AbortSignal, + priority: import('./githubTypes.js').GitHubRequestPriority, + includeRequiredness: boolean, + includeOptional: boolean, + includeWorkflowNames: boolean, + ): Promise<{ readonly headSha: string; readonly checks: readonly PullRequestCheck[] }> { const checks: PullRequestCheck[] = []; let after: string | undefined; - let observedHead: string | undefined; for (let page = 0; page < maximumPaginationPages; page++) { const response = await this._transport.graphql( credential.account, credential.token, this._endpoint.getGraphQlUri(), - checksQuery(includeRequiredness), + checksQuery(includeRequiredness, includeWorkflowNames), { owner: ref.owner, repo: ref.repo, number: ref.number, after }, signal, priority, ); throwGraphQLErrors(response.errors); const pullRequest = objectAt(response.data, 'repository', 'pullRequest'); - observedHead = requiredString(pullRequest, 'headRefOid'); + const observedHead = requiredString(pullRequest, 'headRefOid'); if (observedHead !== core.headSha) { throw new GitHubRequestError('GitHub checks response was for an old pull request head', 'unknown'); } @@ -411,31 +484,13 @@ export class PullRequestQueryService implements IPullRequestQuery { const commit = objectProperty(commitNode, 'commit'); const rollup = optionalObjectProperty(commit, 'statusCheckRollup'); if (!rollup) { - const expectedSuites = loadExpectedSuites - ? await this._fetchExpectedCheckSuites(ref, core.headSha, credential, signal, priority) - : []; - return { - headSha: observedHead, - checks: [], - requirednessComplete: includeRequiredness, - expectedSuites, - expectedSuitesComplete: loadExpectedSuites, - }; + return { headSha: observedHead, checks: [] }; } const contexts = objectProperty(rollup, 'contexts'); checks.push(...arrayProperty(contexts, 'nodes').map(toCheck)); const pageInfo = pageInfoFrom(contexts); if (!pageInfo.hasNextPage) { - const expectedSuites = loadExpectedSuites - ? await this._fetchExpectedCheckSuites(ref, core.headSha, credential, signal, priority) - : []; - return { - headSha: observedHead, - checks: filterChecks(checks, includeRequiredness, includeOptional), - requirednessComplete: includeRequiredness, - expectedSuites, - expectedSuitesComplete: loadExpectedSuites, - }; + return { headSha: observedHead, checks: filterChecks(checks, includeRequiredness, includeOptional) }; } after = requiredCursor(pageInfo.endCursor); } diff --git a/src/vs/platform/github/common/pullRequestResourceService.ts b/src/vs/platform/github/common/pullRequestResourceService.ts index 85929799f9e37b..ba626566835511 100644 --- a/src/vs/platform/github/common/pullRequestResourceService.ts +++ b/src/vs/platform/github/common/pullRequestResourceService.ts @@ -667,7 +667,7 @@ export class PullRequestResourceService extends Disposable implements IPullReque return; } if (error instanceof GitHubRequestError - && (error.kind === 'authorization' || error.kind === 'notFound' || error.kind === 'validation' || error.kind === 'schema' || error.kind === 'rateLimit')) { + && (error.kind === 'notFound' || error.kind === 'validation' || error.kind === 'schema' || error.kind === 'rateLimit')) { this._scheduleNext(entry, fragment, interest); return; } @@ -690,10 +690,13 @@ export class PullRequestResourceService extends Disposable implements IPullReque case 'inlineComments': case 'reviewThreads': return visible ? this._policy.conversationVisible : this._policy.conversationBackground; - case 'checks': - return checksPending(entry.snapshot.get().checks.value) + case 'checks': { + // An errored fragment carries no trustworthy pending signal. + const checks = entry.snapshot.get().checks; + return checks.status !== 'error' && checksPending(checks.value) ? visible ? this._policy.checksPendingVisible : this._policy.checksPendingBackground : this._policy.checksBackstop; + } case 'mergeability': return visible ? this._policy.mergeabilityVisible : this._policy.mergeabilityBackground; case 'participants': diff --git a/src/vs/platform/github/test/node/pullRequestQueryService.test.ts b/src/vs/platform/github/test/node/pullRequestQueryService.test.ts index 3e95deb4d9aad0..b080dc4bfac609 100644 --- a/src/vs/platform/github/test/node/pullRequestQueryService.test.ts +++ b/src/vs/platform/github/test/node/pullRequestQueryService.test.ts @@ -305,6 +305,117 @@ suite('PullRequestQueryService', () => { }); }); + test('drops workflow names when the host refuses them and keeps the fallback for later polls', async () => { + await withServer(async server => { + const checkRun = { __typename: 'CheckRun', databaseId: 1, name: 'CI', status: 'COMPLETED', conclusion: 'SUCCESS', isRequired: true }; + const expectedSuitesResponse = gitHubGraphQLResponse({ + repository: { + object: { + oid: 'head-1', + checkSuites: { + nodes: [{ id: 'CS1', status: 'COMPLETED', conclusion: 'SUCCESS', app: { name: 'Build' }, checkRuns: { totalCount: 1 } }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }); + server.enqueue( + gitHubGraphQLStep({ + queryIncludes: ['AgentHostPullRequestChecks', 'workflowRun'], + response: gitHubGraphQLResponse(undefined, [{ + type: 'FORBIDDEN', + message: 'Resource protected by organization SAML enforcement. You must grant your OAuth token access to this organization.', + }]), + }), + gitHubGraphQLStep({ + queryIncludes: 'AgentHostPullRequestChecks', + assert: request => assert.ok(!request.graphQl?.query?.includes('workflowRun'), 'retry must omit the refused workflow-name subselection'), + response: gitHubGraphQLResponse(checksPage('head-1', [checkRun], false)), + }), + gitHubGraphQLStep({ queryIncludes: 'AgentHostPullRequestExpectedCheckSuites', response: expectedSuitesResponse }), + gitHubGraphQLStep({ + queryIncludes: 'AgentHostPullRequestChecks', + assert: request => assert.ok(!request.graphQl?.query?.includes('workflowRun'), 'later polls must not retry the refused subselection'), + response: gitHubGraphQLResponse(checksPage('head-1', [checkRun], false)), + }), + gitHubGraphQLStep({ queryIncludes: 'AgentHostPullRequestExpectedCheckSuites', response: expectedSuitesResponse }), + ); + const { query, ref, credential } = setup(server); + const signal = new AbortController().signal; + // The Agent Merge subscription shape, which always loads expected suites. + const options = { priority: 'interactive', checks: { required: true } } as const; + const first = await query.fetch('checks', ref, core('head-1'), options, credential, signal); + const second = await query.fetch('checks', ref, core('head-1'), options, credential, signal); + + const expected = { + fragment: 'checks', + value: { + headSha: 'head-1', + checks: [{ id: '1', type: 'checkRun', name: 'CI', status: 'COMPLETED', conclusion: 'SUCCESS', required: true, detailsUrl: undefined, workflowName: undefined }], + requirednessComplete: true, + expectedSuites: [{ id: 'CS1', name: 'Build', status: 'COMPLETED', conclusion: 'SUCCESS', checkRunsReported: true }], + expectedSuitesComplete: true, + }, + complete: true, + headSha: 'head-1', + }; + assert.deepStrictEqual([first, second], [expected, expected]); + server.assertSatisfied(); + }); + }); + + test('keeps checks usable when only the expected check suites are refused', async () => { + await withServer(async server => { + const checkRun = { + __typename: 'CheckRun', + databaseId: 1, + name: 'CI', + status: 'COMPLETED', + conclusion: 'SUCCESS', + isRequired: true, + checkSuite: { workflowRun: { workflow: { name: 'Code OSS' } } }, + }; + const refusal = gitHubGraphQLResponse(undefined, [{ + type: 'FORBIDDEN', + message: 'Resource protected by organization SAML enforcement. You must grant your OAuth token access to this organization.', + }]); + server.enqueue( + gitHubGraphQLStep({ + queryIncludes: ['AgentHostPullRequestChecks', 'workflowRun'], + response: gitHubGraphQLResponse(checksPage('head-1', [checkRun], false)), + }), + gitHubGraphQLStep({ queryIncludes: 'AgentHostPullRequestExpectedCheckSuites', response: refusal }), + gitHubGraphQLStep({ + queryIncludes: 'AgentHostPullRequestChecks', + assert: request => assert.ok(request.graphQl?.query?.includes('workflowRun'), 'a refused expected-suites request must not disable workflow names'), + response: gitHubGraphQLResponse(checksPage('head-1', [checkRun], false)), + }), + gitHubGraphQLStep({ queryIncludes: 'AgentHostPullRequestExpectedCheckSuites', response: refusal }), + ); + const { query, ref, credential } = setup(server); + const signal = new AbortController().signal; + const options = { priority: 'interactive', checks: { required: true } } as const; + const first = await query.fetch('checks', ref, core('head-1'), options, credential, signal); + const second = await query.fetch('checks', ref, core('head-1'), options, credential, signal); + + // Checks stay readable, and the missing suites are reported incomplete. + const expected = { + fragment: 'checks', + value: { + headSha: 'head-1', + checks: [{ id: '1', type: 'checkRun', name: 'CI', status: 'COMPLETED', conclusion: 'SUCCESS', required: true, detailsUrl: undefined, workflowName: 'Code OSS' }], + requirednessComplete: true, + expectedSuites: [], + expectedSuitesComplete: false, + }, + complete: true, + headSha: 'head-1', + }; + assert.deepStrictEqual([first, second], [expected, expected]); + server.assertSatisfied(); + }); + }); + test('fully paginates current-head checks and normalizes mergeability', async () => { await withServer(async server => { server.enqueue( From 45b04d5212d9d00a1d034395fe716f2ba30e29c2 Mon Sep 17 00:00:00 2001 From: roblourens Date: Fri, 21 Aug 2026 16:41:21 -0700 Subject: [PATCH 04/10] Disable Kerberos proxy smoke test on GitHub Actions (#332064) (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-darwin-test.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/pr-darwin-test.yml b/.github/workflows/pr-darwin-test.yml index c8d2ff95d9b36c..0a6f018cf7b538 100644 --- a/.github/workflows/pr-darwin-test.yml +++ b/.github/workflows/pr-darwin-test.yml @@ -215,11 +215,6 @@ jobs: timeout-minutes: 40 run: bash test/smoke/scripts/run-agents-window-network-proxy.sh - - name: ๐Ÿงช Run Agents Window smoke tests through Kerberos-authenticated macOS PAC proxy - if: ${{ inputs.electron_tests && inputs.smoke_tests }} - timeout-minutes: 40 - run: bash test/smoke/scripts/run-agents-window-network-proxy.sh --kerberos - - name: ๐Ÿงช Run smoke tests (Browser, Chromium) if: ${{ inputs.browser_tests && inputs.smoke_tests }} timeout-minutes: 20 From e6ac244843f58feee09438368ff670b1f01fdbce Mon Sep 17 00:00:00 2001 From: roblourens Date: Fri, 21 Aug 2026 17:02:02 -0700 Subject: [PATCH 05/10] Add Agent Host hung turn lifecycle diagnostics (#332045) * Agent Host: add hung turn lifecycle diagnostics (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Agent Host: address telemetry review feedback (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/common/agent.ts | 14 ++ .../agentHost/node/agentHostBootstrap.ts | 2 + .../node/agentHostClientConnectionService.ts | 87 +++++++++ .../agentHostClientConnectionTelemetry.ts | 82 --------- .../platform/agentHost/node/agentHostMain.ts | 7 - .../agentHost/node/agentHostServerMain.ts | 4 - .../node/agentHostTelemetryReporter.ts | 24 ++- .../agentHost/node/agentHostTurnTracker.ts | 59 +++++- .../agentHost/node/agentSideEffects.ts | 12 +- .../agentHost/node/copilot/copilotAgent.ts | 10 +- .../node/copilot/copilotAgentSession.ts | 39 +++- .../node/copilot/copilotSessionWrapper.ts | 29 ++- .../agentHost/node/protocolServerHandler.ts | 77 +++++--- .../node/agentHostTelemetryReporter.test.ts | 18 ++ .../node/agentHostToolCallTelemetry.test.ts | 2 + .../node/agentHostTurnHangTelemetry.test.ts | 116 +++++++++++- .../test/node/agentHostTurnTelemetry.test.ts | 2 + .../test/node/agentServiceTestUtils.ts | 5 +- .../test/node/agentSideEffects.test.ts | 2 + .../test/node/copilotAgentSession.test.ts | 109 +++++++++++ .../platform/agentHost/test/node/mockAgent.ts | 1 + .../test/node/protocolServerHandler.test.ts | 172 ++++++++++++++++-- 22 files changed, 717 insertions(+), 156 deletions(-) create mode 100644 src/vs/platform/agentHost/node/agentHostClientConnectionService.ts delete mode 100644 src/vs/platform/agentHost/node/agentHostClientConnectionTelemetry.ts diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index 91e548bed76f45..f6d4cc6afc3d58 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -208,6 +208,17 @@ export interface IAgentMaterializeChatEvent { } export type AgentProvider = string; +export type AgentTurnProviderCallState = 'notStarted' | 'pending' | 'resolved' | 'rejected'; +export type AgentTurnProviderSessionState = 'active' | 'disconnecting' | 'disconnected' | 'shutdown'; + +export type IAgentTurnDiagnosticSnapshot = { + readonly state: 'available'; + readonly providerCallState: AgentTurnProviderCallState; + readonly providerTurnStarted: boolean; + readonly providerSessionState: AgentTurnProviderSessionState; +} | { + readonly state: 'missingChat' | 'missingTurn'; +}; /** Well-known agent provider id for the Claude agent-host backend. */ export const CLAUDE_AGENT_PROVIDER_ID = 'claude' as const; @@ -1095,6 +1106,9 @@ export interface IAgent { /** Optional history mutation for providers with a native truncation operation. */ truncateChat?(chat: URI, turnId: string | undefined, context?: URI | IAgentChatContext): Promise; + /** Return bounded diagnostics for an in-flight turn when supported. */ + getTurnDiagnosticSnapshot?(chat: URI, turnId: string): IAgentTurnDiagnosticSnapshot | undefined; + // ---- Active clients and interaction ------------------------------------ /** Get or create one client's contribution handle for an exact chat. */ diff --git a/src/vs/platform/agentHost/node/agentHostBootstrap.ts b/src/vs/platform/agentHost/node/agentHostBootstrap.ts index 5a25a0938989da..10be5bad66b7fc 100644 --- a/src/vs/platform/agentHost/node/agentHostBootstrap.ts +++ b/src/vs/platform/agentHost/node/agentHostBootstrap.ts @@ -59,6 +59,7 @@ import { registerPendingEditContentProvider } from './copilot/pendingEditContent import { SessionDataService } from './sessionDataService.js'; import { IAgentCustomizationSettingsRegistration } from '../common/agentCustomizationSettings.js'; import { AgentHostLaunchKind } from '../common/agentHostTelemetry.js'; +import { AgentHostClientConnectionService, IAgentHostClientConnectionService } from './agentHostClientConnectionService.js'; export interface IAgentHostNetworkServices { readonly proxyResolver: IAgentHostProxyResolver; @@ -136,6 +137,7 @@ export async function createAgentHostRuntime(options: ICreateAgentHostRuntimeOpt [ISessionDataService, sessionDataService], [IProductService, productService], ); + services.set(IAgentHostClientConnectionService, disposables.add(new AgentHostClientConnectionService())); const networkServices = registerAgentHostNetworkServices(services, logService, disposables); const proxyResolver = networkServices.proxyResolver; const fetchFn = proxyResolver.fetch.bind(proxyResolver); diff --git a/src/vs/platform/agentHost/node/agentHostClientConnectionService.ts b/src/vs/platform/agentHost/node/agentHostClientConnectionService.ts new file mode 100644 index 00000000000000..973cfed7b3990c --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostClientConnectionService.ts @@ -0,0 +1,87 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, toDisposable, type IDisposable } from '../../../base/common/lifecycle.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; + +export const AGENT_HOST_CLIENT_CONNECTION_HISTORY_RETENTION = 30_000 * 10; + +export interface IAgentHostClientConnectionCounts { + readonly connectedClientCount: number; + readonly connectedTransportCount: number; + readonly clientTransportCount: number; +} + +export interface IAgentHostClientConnectionSource { + hasSeenClient(clientId: string): boolean; + isClientConnected(clientId: string): boolean; + getConnectedClientTransportCounts(): ReadonlyMap; +} + +export const IAgentHostClientConnectionService = createDecorator('agentHostClientConnectionService'); + +export interface IAgentHostClientConnectionService { + readonly _serviceBrand: undefined; + registerSource(source: IAgentHostClientConnectionSource): IDisposable; + hasSeenClient(clientId: string): boolean; + isClientConnected(clientId: string): boolean; + getConnectionCounts(clientId: string): IAgentHostClientConnectionCounts; +} + +export class AgentHostClientConnectionService extends Disposable implements IAgentHostClientConnectionService { + declare readonly _serviceBrand: undefined; + private readonly _sources = new Set(); + + constructor() { + super(); + this._register(toDisposable(() => this._sources.clear())); + } + + registerSource(source: IAgentHostClientConnectionSource): IDisposable { + if (this._sources.has(source)) { + throw new Error('Agent Host client connection source is already registered'); + } + this._sources.add(source); + return toDisposable(() => this._sources.delete(source)); + } + + hasSeenClient(clientId: string): boolean { + for (const source of this._sources) { + if (source.hasSeenClient(clientId)) { + return true; + } + } + return false; + } + + isClientConnected(clientId: string): boolean { + for (const source of this._sources) { + if (source.isClientConnected(clientId)) { + return true; + } + } + return false; + } + + getConnectionCounts(clientId: string): IAgentHostClientConnectionCounts { + const connectedClients = new Set(); + let connectedTransportCount = 0; + let clientTransportCount = 0; + for (const source of this._sources) { + for (const [connectedClientId, transportCount] of source.getConnectedClientTransportCounts()) { + connectedClients.add(connectedClientId); + connectedTransportCount += transportCount; + if (connectedClientId === clientId) { + clientTransportCount += transportCount; + } + } + } + return { + connectedClientCount: connectedClients.size, + connectedTransportCount, + clientTransportCount, + }; + } +} diff --git a/src/vs/platform/agentHost/node/agentHostClientConnectionTelemetry.ts b/src/vs/platform/agentHost/node/agentHostClientConnectionTelemetry.ts deleted file mode 100644 index be272c4aef37e6..00000000000000 --- a/src/vs/platform/agentHost/node/agentHostClientConnectionTelemetry.ts +++ /dev/null @@ -1,82 +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 } from '../../../base/common/lifecycle.js'; - -export const AGENT_HOST_CLIENT_CONNECTION_HISTORY_RETENTION = 30_000 * 10; - -export interface IAgentHostClientConnectionCounts { - readonly connectedClientCount: number; - readonly connectedTransportCount: number; - readonly clientTransportCount: number; -} - -export interface IAgentHostClientConnectedResult extends IAgentHostClientConnectionCounts { - readonly isReconnect: boolean; -} - -export class AgentHostClientConnectionTelemetryTracker extends Disposable { - private readonly _recentlyDisconnectedClients = new Map(); - private readonly _activeTransports = new Map>(); - - constructor(private readonly _historyRetentionMs = AGENT_HOST_CLIENT_CONNECTION_HISTORY_RETENTION) { - super(); - } - - hasSeenClient(clientId: string): boolean { - this._pruneDisconnectedClientHistory(); - return this._activeTransports.has(clientId) || this._recentlyDisconnectedClients.has(clientId); - } - - connect(clientId: string, transportToken: object): IAgentHostClientConnectedResult { - const isReconnect = this.hasSeenClient(clientId); - this._recentlyDisconnectedClients.delete(clientId); - let transports = this._activeTransports.get(clientId); - if (!transports) { - transports = new Set(); - this._activeTransports.set(clientId, transports); - } - transports.add(transportToken); - return { isReconnect, ...this._counts(clientId) }; - } - - disconnect(clientId: string, transportToken: object): IAgentHostClientConnectionCounts { - const transports = this._activeTransports.get(clientId); - transports?.delete(transportToken); - if (transports?.size === 0) { - this._activeTransports.delete(clientId); - this._recentlyDisconnectedClients.set(clientId, Date.now()); - } - this._pruneDisconnectedClientHistory(); - return this._counts(clientId); - } - - override dispose(): void { - this._recentlyDisconnectedClients.clear(); - this._activeTransports.clear(); - super.dispose(); - } - - private _pruneDisconnectedClientHistory(): void { - const cutoff = Date.now() - this._historyRetentionMs; - for (const [clientId, disconnectedAt] of this._recentlyDisconnectedClients) { - if (disconnectedAt <= cutoff) { - this._recentlyDisconnectedClients.delete(clientId); - } - } - } - - private _counts(clientId: string): IAgentHostClientConnectionCounts { - let connectedTransportCount = 0; - for (const transports of this._activeTransports.values()) { - connectedTransportCount += transports.size; - } - return { - connectedClientCount: this._activeTransports.size, - connectedTransportCount, - clientTransportCount: this._activeTransports.get(clientId)?.size ?? 0, - }; - } -} diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 4cc4df6171b123..e212ec73f5ed31 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -29,7 +29,6 @@ import { ByokLmBridgeRegistry } from './byokLmBridgeRegistry.js'; import { IAgentHostProxyResolver } from './agentHostProxyResolver.js'; import { type IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; import { ProtocolServerHandler } from './protocolServerHandler.js'; -import { AgentHostClientConnectionTelemetryTracker } from './agentHostClientConnectionTelemetry.js'; import { WebSocketProtocolServer } from './webSocketTransport.js'; import { MessagePortProtocolServer } from './messagePortProtocolServer.js'; import { cleanupLocalAgentHostEndpointMetadataSync, cleanupLocalAgentHostEndpointSocketSync, createLocalAgentHostEndpointMetadata, prepareLocalAgentHostEndpointMetadataDirectory, prepareLocalAgentHostEndpointSocketDirectory, publishLocalAgentHostEndpointMetadata, type ILocalAgentHostEndpointMetadata } from './localAgentHostMetadata.js'; @@ -112,7 +111,6 @@ async function startAgentHost(): Promise { let byokLmBridgeRegistry: ByokLmBridgeRegistry; let proxyResolver!: IAgentHostProxyResolver; const hostLaunchKind = readAgentHostLaunchKind(process.env[AgentHostLaunchKindEnvVar]); - const connectionTelemetryTracker = disposables.add(new AgentHostClientConnectionTelemetryTracker()); try { byokLmBridgeRegistry = new ByokLmBridgeRegistry(); runtime = await createAgentHostRuntime({ @@ -222,7 +220,6 @@ async function startAgentHost(): Promise { // MessagePort + the external endpoint, which each get their own handler). const localProtocolHandlerConfig = { hostLaunchKind, - connectionTelemetryTracker, defaultDirectory: URI.file(os.homedir()).toString(), completionTriggerCharacters: runtime.completions.triggerCharacters, terminalCommandPrefix: BANG_COMMAND_PREFIX, @@ -368,7 +365,6 @@ async function startAgentHost(): Promise { wsServer, { hostLaunchKind, - connectionTelemetryTracker, defaultDirectory: URI.file(os.homedir()).toString(), completionTriggerCharacters: runtime.completions.triggerCharacters, terminalCommandPrefix: BANG_COMMAND_PREFIX, @@ -456,7 +452,6 @@ async function startAgentHost(): Promise { otlpLogEmitter, protocolIngressDisposables, hostLaunchKind, - connectionTelemetryTracker, count => connectionCountEmitter.fire(count), handler => protocolHandlers.push(handler), ); @@ -551,7 +546,6 @@ async function startWebSocketServer( otlpLogEmitter: OtlpLogEmitter, disposables: DisposableStore, hostLaunchKind: AgentHostLaunchKind, - connectionTelemetryTracker: AgentHostClientConnectionTelemetryTracker, onConnectionCountChanged: (count: number) => void, onProtocolHandlerCreated: (handler: ProtocolServerHandler) => void, ): Promise { @@ -596,7 +590,6 @@ async function startWebSocketServer( wsServer, { hostLaunchKind, - connectionTelemetryTracker, defaultDirectory: URI.file(os.homedir()).toString(), completionTriggerCharacters, terminalCommandPrefix: BANG_COMMAND_PREFIX, diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index bd1cfa766acfbe..ff94a43f519c1c 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -42,7 +42,6 @@ import { AgentModelRefreshScheduler, MODEL_REFRESH_INTERVAL_MS } from './agentMo import { AgentHostClaudeAgentEnabledEnvVar, AgentHostClaudeSdkRootEnvVar, AgentHostCodexAgentEnabledEnvVar, AgentHostCodexAgentSdkRootEnvVar, isAgentEnabled } from '../common/agentService.js'; import { WebSocketProtocolServer } from './webSocketTransport.js'; import { ProtocolServerHandler } from './protocolServerHandler.js'; -import { AgentHostClientConnectionTelemetryTracker } from './agentHostClientConnectionTelemetry.js'; import { AgentHostClientFileSystemProvider } from '../common/agentHostClientFileSystemProvider.js'; import { AGENT_CLIENT_SCHEME } from '../common/agentClientUri.js'; import { resolveServerUrls } from './serverUrls.js'; @@ -294,8 +293,6 @@ async function main(): Promise { const clientFileSystemProvider = disposables.add(new AgentHostClientFileSystemProvider()); disposables.add(fileService.registerProvider(AGENT_CLIENT_SCHEME, clientFileSystemProvider)); - const connectionTelemetryTracker = disposables.add(new AgentHostClientConnectionTelemetryTracker()); - // Wire up protocol handler disposables.add(instantiationService.createInstance( ProtocolServerHandler, @@ -304,7 +301,6 @@ async function main(): Promise { wsServer, { hostLaunchKind: AgentHostLaunchKind.VSCodeCLI, - connectionTelemetryTracker, defaultDirectory: URI.file(os.homedir()).toString(), completionTriggerCharacters: runtime.completions.triggerCharacters, terminalCommandPrefix: BANG_COMMAND_PREFIX, diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts index bb42ed8fe11db3..0106d75c10968e 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts @@ -7,7 +7,7 @@ import type { LanguageModelToolInvokedClassification, LanguageModelToolInvokedEv import type { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { TelemetryTrustedValue } from '../../telemetry/common/telemetryUtils.js'; import { hash } from '../../../base/common/hash.js'; -import { AgentSession } from '../common/agent.js'; +import { AgentSession, type AgentTurnProviderCallState, type AgentTurnProviderSessionState, type IAgentTurnDiagnosticSnapshot } from '../common/agent.js'; import type { SessionMode } from '../common/agentHostSchema.js'; import { getTelemetryChatSessionId } from '../common/agentTelemetryCorrelation.js'; import { readAgentErrorTelemetryMeta } from '../common/meta/agentErrorMeta.js'; @@ -162,6 +162,8 @@ export type AgentHostTurnResult = 'success' | 'error' | 'cancelled'; export type AgentHostModelTelemetryKind = 'trusted' | 'byok' | 'unknown'; type AgentHostModelSelectionKind = 'default' | 'auto' | 'explicit'; export type AgentHostTurnFailureStage = 'validation' | 'workingDirectory' | 'modelSelection' | 'sendMessage' | 'provider'; +export type AgentHostInitiatorClientConnectionState = 'connected' | 'disconnected' | 'unknown'; +export type AgentHostProviderDiagnosticState = 'available' | 'error' | 'missingChat' | 'missingTurn' | 'unavailable' | 'unsupported'; interface IAgentHostTurnAttributedReport { clientContext?: IAgentHostClientTelemetryContext; @@ -353,6 +355,11 @@ export interface IAgentHostTurnHungEvent extends IAgentHostInitiatorTelemetry { hadAnyProgress: boolean; lastActivityKind: AgentHostTurnActivityTelemetryKind; currentStage: AgentHostTurnFailureStage; + providerDiagnosticState: AgentHostProviderDiagnosticState; + providerCallState?: AgentTurnProviderCallState; + providerTurnStarted?: boolean; + providerSessionState?: AgentTurnProviderSessionState; + initiatorClientConnectionState: AgentHostInitiatorClientConnectionState; blockedOn: SessionInputRequestKind | undefined; toolId: string | undefined; toolSourceKind: string | undefined; @@ -375,6 +382,11 @@ export type IAgentHostTurnHungClassification = IAgentHostInitiatorClassification hadAnyProgress: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether any turn activity at all was observed before the watchdog fired.' }; lastActivityKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'A bounded category for the last observed turn activity, preserving the AHP action namespace and action name without slash-like syntax. Values are none, other, or categories such as chat.delta and chat.toolCallReady.' }; currentStage: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The bounded turn stage active when the hang watchdog fired.' }; + providerDiagnosticState: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether bounded provider diagnostics were available, unsupported, unavailable, failed, or missing the expected chat or turn.' }; + providerCallState?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the provider call had not started, was pending, resolved, or rejected when the hang watchdog fired.' }; + providerTurnStarted?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the provider reported that its turn started before the hang watchdog fired.' }; + providerSessionState?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The bounded provider session state when the hang watchdog fired.' }; + initiatorClientConnectionState: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the client that initiated the turn was still connected when the hang watchdog fired, or unknown when the client could not be identified.' }; blockedOn: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The kind of outstanding user-blocking session input request, when there is one. Client tool execution is not counted, since it is delegated work rather than a prompt.' }; toolId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The identifier of the tool the turn appears to be stuck on. When hangReason is waitingOnUser this is the tool gated by the blocking request, which is exact; when it is runningTool this is the longest-running in-flight tool call, which is a best guess when several are running. Undefined when no tool explains the hang.' }; toolSourceKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the stuck tool is provided by the agent host, an MCP server, or a client.' }; @@ -396,6 +408,9 @@ export interface IAgentHostTurnHungReport extends IAgentHostTurnAttributedReport hadAnyProgress: boolean; lastActivityKind: string; currentStage: AgentHostTurnFailureStage; + providerDiagnosticState: AgentHostProviderDiagnosticState; + providerDiagnosticSnapshot: IAgentTurnDiagnosticSnapshot | undefined; + initiatorClientConnectionState: AgentHostInitiatorClientConnectionState; blockedOn: SessionInputRequestKind | undefined; toolId: string | undefined; toolSourceKind: string | undefined; @@ -1191,6 +1206,13 @@ export class AgentHostTelemetryReporter { hadAnyProgress: report.hadAnyProgress, lastActivityKind: normalizeTurnActivityKind(report.lastActivityKind), currentStage: report.currentStage, + providerDiagnosticState: report.providerDiagnosticState, + ...(report.providerDiagnosticSnapshot?.state === 'available' ? { + providerCallState: report.providerDiagnosticSnapshot.providerCallState, + providerTurnStarted: report.providerDiagnosticSnapshot.providerTurnStarted, + providerSessionState: report.providerDiagnosticSnapshot.providerSessionState, + } : {}), + initiatorClientConnectionState: report.initiatorClientConnectionState, blockedOn: report.blockedOn, toolId: report.toolId, toolSourceKind: report.toolSourceKind, diff --git a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts index 1c66fc71c6f989..66febde0d5fbf1 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts @@ -4,16 +4,21 @@ *--------------------------------------------------------------------------------------------*/ import { disposableTimeout } from '../../../base/common/async.js'; +import { getErrorMessage } from '../../../base/common/errors.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap, toDisposable } from '../../../base/common/lifecycle.js'; import { StopWatch } from '../../../base/common/stopwatch.js'; +import { URI } from '../../../base/common/uri.js'; +import type { IAgent, IAgentTurnDiagnosticSnapshot } from '../common/agent.js'; import type { SessionMode } from '../common/agentHostSchema.js'; import { createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; import { AgentHostClientType } from '../common/agentHostClientInfo.js'; +import { IAgentHostClientConnectionService } from './agentHostClientConnectionService.js'; +import { ILogService } from '../../log/common/log.js'; import { canRefineContributor, toolSourceKindFromContributor } from './agentHostToolCallTracker.js'; import { SessionInputRequestKind } from '../common/state/protocol/state.js'; import type { ToolCallContributor } from '../common/state/sessionState.js'; -import type { AgentHostModelTelemetryKind, AgentHostTelemetryReporter, AgentHostTurnFailureStage, AgentHostTurnHangReason, AgentHostTurnResult, IAgentHostTurnFailure } from './agentHostTelemetryReporter.js'; +import type { AgentHostInitiatorClientConnectionState, AgentHostModelTelemetryKind, AgentHostProviderDiagnosticState, AgentHostTelemetryReporter, AgentHostTurnFailureStage, AgentHostTurnHangReason, AgentHostTurnResult, IAgentHostTurnFailure } from './agentHostTelemetryReporter.js'; /** * How long a turn must go without any observed activity before the watchdog @@ -51,7 +56,7 @@ interface ITurnBlocker { /** Per-turn timing state, keyed by `session:turnId`. */ interface ITurnTiming { readonly stopWatch: StopWatch; - readonly provider: string; + readonly agent: IAgent; readonly session: string; readonly turnId: string; model: string | undefined; @@ -60,6 +65,7 @@ interface ITurnTiming { readonly permissionLevel: string | undefined; readonly interactionMode: SessionMode | undefined; readonly clientContext: IAgentHostClientTelemetryContext; + readonly initiatorClientId: string | undefined; readonly completedModelCallIds: Set; firstProgressMs: number | undefined; currentStage: AgentHostTurnFailureStage; @@ -134,7 +140,11 @@ export class AgentHostTurnTracker extends Disposable { private readonly _onDidStartTurn = this._register(new Emitter()); readonly onDidStartTurn: Event = this._onDidStartTurn.event; - constructor(private readonly _reporter: AgentHostTelemetryReporter) { + constructor( + private readonly _reporter: AgentHostTelemetryReporter, + @IAgentHostClientConnectionService private readonly _clientConnections: IAgentHostClientConnectionService, + @ILogService private readonly _logService: ILogService, + ) { super(); this._register(toDisposable(() => { this._turnTimings.clear(); @@ -143,11 +153,11 @@ export class AgentHostTurnTracker extends Disposable { })); } - turnStarted(provider: string, session: string, turnId: string, model: string | undefined, modelTelemetryKind: AgentHostModelTelemetryKind | undefined, modelSelectionKind: 'default' | 'auto' | 'explicit', permissionLevel: string | undefined, interactionMode: SessionMode | undefined, clientContext = createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown)): void { + turnStarted(agent: IAgent, session: string, turnId: string, model: string | undefined, modelTelemetryKind: AgentHostModelTelemetryKind | undefined, modelSelectionKind: 'default' | 'auto' | 'explicit', permissionLevel: string | undefined, interactionMode: SessionMode | undefined, clientContext = createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown), initiatorClientId?: string): void { const key = this._key(session, turnId); this._turnTimings.set(key, { stopWatch: StopWatch.create(false), - provider, + agent, session, turnId, model, @@ -156,6 +166,7 @@ export class AgentHostTurnTracker extends Disposable { permissionLevel, interactionMode, clientContext, + initiatorClientId, completedModelCallIds: new Set(), firstProgressMs: undefined, currentStage: 'validation', @@ -171,7 +182,7 @@ export class AgentHostTurnTracker extends Disposable { }); this._turnUsages.set(key, {}); this._armHangWatchdog(key); - this._onDidStartTurn.fire(provider); + this._onDidStartTurn.fire(agent.id); } markFirstProgress(session: string, turnId: string): void { @@ -328,6 +339,10 @@ export class AgentHostTurnTracker extends Disposable { return this._turnTimings.get(this._key(session, turnId))?.clientContext; } + getInitiatorClientId(session: string, turnId: string): string | undefined { + return this._turnTimings.get(this._key(session, turnId))?.initiatorClientId; + } + turnCompleted(session: string, turnId: string, result: AgentHostTurnResult, failure?: IAgentHostTurnFailure, workspace?: { readonly isMultiRoot: boolean; readonly folderCount: number }): void { const key = this._key(session, turnId); const timing = this._turnTimings.get(key); @@ -339,7 +354,7 @@ export class AgentHostTurnTracker extends Disposable { this._reporter.turnCompleted({ clientContext: timing.clientContext, - provider: timing.provider, + provider: timing.agent.id, session: timing.session, turnId, timeToFirstProgress: timing.firstProgressMs, @@ -362,7 +377,7 @@ export class AgentHostTurnTracker extends Disposable { if (timing.lastHangReason !== undefined) { this._reporter.hungTurnCompleted({ clientContext: timing.clientContext, - provider: timing.provider, + provider: timing.agent.id, session: timing.session, turnId, hangReason: timing.lastHangReason, @@ -439,15 +454,18 @@ export class AgentHostTurnTracker extends Disposable { timing.lastHangStopWatch = StopWatch.create(true); const userBlocker = this._firstUserBlocker(timing); const stuckTool = this._resolveStuckTool(timing, hangReason); + const providerDiagnostics = this._getProviderDiagnostics(timing); this._reporter.turnHung({ clientContext: timing.clientContext, - provider: timing.provider, + provider: timing.agent.id, session: timing.session, turnId: timing.turnId, hangReason, hadAnyProgress: timing.lastActivityKind !== TURN_ACTIVITY_NONE, lastActivityKind: timing.lastActivityKind, currentStage: timing.currentStage, + ...providerDiagnostics, + initiatorClientConnectionState: this._getInitiatorClientConnectionState(timing.initiatorClientId), blockedOn: userBlocker?.kind, toolId: stuckTool?.toolId, toolSourceKind: stuckTool?.toolSourceKind, @@ -466,6 +484,29 @@ export class AgentHostTurnTracker extends Disposable { } } + private _getProviderDiagnostics(timing: ITurnTiming): { providerDiagnosticState: AgentHostProviderDiagnosticState; providerDiagnosticSnapshot: IAgentTurnDiagnosticSnapshot | undefined } { + const getSnapshot = timing.agent.getTurnDiagnosticSnapshot; + if (!getSnapshot) { + return { providerDiagnosticState: 'unsupported', providerDiagnosticSnapshot: undefined }; + } + try { + const snapshot = getSnapshot.call(timing.agent, URI.parse(timing.session), timing.turnId); + return snapshot + ? { providerDiagnosticState: snapshot.state, providerDiagnosticSnapshot: snapshot } + : { providerDiagnosticState: 'unavailable', providerDiagnosticSnapshot: undefined }; + } catch (error) { + this._logService.error(`[AgentHostTurnTracker] Failed to collect provider diagnostics for provider=${timing.agent.id}: ${getErrorMessage(error)}`, error); + return { providerDiagnosticState: 'error', providerDiagnosticSnapshot: undefined }; + } + } + + private _getInitiatorClientConnectionState(clientId: string | undefined): AgentHostInitiatorClientConnectionState { + if (!clientId) { + return 'unknown'; + } + return this._clientConnections.isClientConnected(clientId) ? 'connected' : 'disconnected'; + } + /** * The first outstanding request that blocks on the user, or `undefined` * when none does. See {@link turnBlocked} for why client tool execution is diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index ca2f65b70fde5e..0c27cf18493428 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -290,7 +290,7 @@ export class AgentSideEffects extends Disposable { ) { super(); this._telemetryReporter = new AgentHostTelemetryReporter(this._telemetryService); - this._turnTracker = this._register(new AgentHostTurnTracker(this._telemetryReporter)); + this._turnTracker = this._register(instantiationService.createInstance(AgentHostTurnTracker, this._telemetryReporter)); this.onDidStartTurn = this._turnTracker.onDidStartTurn; this._toolCallTracker = this._register(new AgentHostToolCallTracker(this._telemetryReporter, (session, turnId) => this._turnTracker.getClientTelemetryContext(session, turnId))); this._inputRequestTracker = new AgentHostInputRequestTracker(this._telemetryReporter, undefined, (session, turnId) => this._turnTracker.getClientTelemetryContext(session, turnId)); @@ -1275,6 +1275,7 @@ export class AgentSideEffects extends Disposable { const turnId = generateUuid(); const parentTurnId = this._stateManager.getActiveTurnId(contentChatUri); const parentClientContext = parentTurnId ? this._turnTracker.getClientTelemetryContext(contentChatUri, parentTurnId) : undefined; + const parentClientId = parentTurnId ? this._turnTracker.getInitiatorClientId(contentChatUri, parentTurnId) : undefined; this._stateManager.dispatchServerAction(subagentChatUri, { type: ActionType.ChatTurnStarted, turnId, @@ -1283,7 +1284,7 @@ export class AgentSideEffects extends Disposable { }); const agent = this._options.getAgent(parentSessionUri); if (agent) { - this._turnTracker.turnStarted(agent.id, subagentChatUri, turnId, undefined, undefined, 'default', undefined, undefined, parentClientContext); + this._turnTracker.turnStarted(agent, subagentChatUri, turnId, undefined, undefined, 'default', undefined, undefined, parentClientContext, parentClientId); this._turnTracker.setCurrentStage(subagentChatUri, turnId, 'provider'); } @@ -1348,6 +1349,7 @@ export class AgentSideEffects extends Disposable { const turnId = generateUuid(); const parentTurnId = this._stateManager.getActiveTurnId(parentChatURI); const parentClientContext = parentTurnId ? this._turnTracker.getClientTelemetryContext(parentChatURI, parentTurnId) : undefined; + const parentClientId = parentTurnId ? this._turnTracker.getInitiatorClientId(parentChatURI, parentTurnId) : undefined; this._logService.info(`[AgentSideEffects] Resuming subagent turn: ${subagent.chatUri} (parent=${parentChatURI}, toolCallId=${toolCallId})`); this._stateManager.dispatchServerAction(subagent.chatUri, { type: ActionType.ChatTurnStarted, @@ -1357,7 +1359,7 @@ export class AgentSideEffects extends Disposable { }); const agent = this._options.getAgent(subagent.sessionUri); if (agent) { - this._turnTracker.turnStarted(agent.id, subagent.chatUri, turnId, undefined, undefined, 'default', undefined, undefined, parentClientContext); + this._turnTracker.turnStarted(agent, subagent.chatUri, turnId, undefined, undefined, 'default', undefined, undefined, parentClientContext, parentClientId); this._turnTracker.setCurrentStage(subagent.chatUri, turnId, 'provider'); } this._subagentChats.set({ ...subagent, turnStopWatch: StopWatch.create(false) }, parentChatURI, toolCallId); @@ -1641,7 +1643,7 @@ export class AgentSideEffects extends Disposable { const attachments = action.message.attachments; this._telemetryReporter.userMessageSent(agent.id, clientId, clientContext, channel, action.turnId, state, 'direct', attachments); const { model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode } = this._getTurnTelemetryContext(agent, channel, this._chatContext(sessionChannel, channel), state, action.message.model?.id); - this._turnTracker.turnStarted(agent.id, channel, action.turnId, model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode, clientContext); + this._turnTracker.turnStarted(agent, channel, action.turnId, model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode, clientContext, clientId); void this._sendTurnMessage({ agent, sessionChannel, @@ -2126,7 +2128,7 @@ export class AgentSideEffects extends Disposable { const queuedState = this._stateManager.getSessionState(session); this._telemetryReporter.userMessageSent(agent.id, sender.clientId, sender.clientContext, session, turnId, queuedState, 'queued', attachments); const { model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode } = this._getTurnTelemetryContext(agent, session, this._chatContext(sessionChannel, session), queuedState, msg.message.model?.id); - this._turnTracker.turnStarted(agent.id, session, turnId, model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode, sender.clientContext); + this._turnTracker.turnStarted(agent, session, turnId, model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode, sender.clientContext, sender.clientId); // Selection travels on the queued message; it is applied before sending. void this._sendTurnMessage({ agent, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 23b0748d65d984..9ef193a9022c38 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -43,7 +43,7 @@ import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabl import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { decodeProviderData, encodeProviderData, type IPersistedChat } from '../agentChatBackings.js'; import { prepareSideChatPrompt, sliceSideChatTurns } from '../agentPeerChats.js'; -import { AgentChatOperationContext, AgentSession, AgentSignal, AuthenticateParams, IActiveClient, IAgent, IAgentChatAdoptionResult, type IAgentAdoptedWorktree, IAgentChatConfigCompletionsParams, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentChats, IAgentLegacyChat, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentDescriptor, IAgentDiscoveredChat, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentKnownSessionsFilter, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveChatConfigParams, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, SubagentChatSignal, resolveAgentChatContext, resolveAgentHostCustomizations, resolveAgentHostInstructions, resolveSubagentChatParent } from '../../common/agent.js'; +import { AgentChatOperationContext, AgentSession, AgentSignal, AuthenticateParams, IActiveClient, IAgent, IAgentChatAdoptionResult, type IAgentAdoptedWorktree, IAgentChatConfigCompletionsParams, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentChats, IAgentLegacyChat, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentDescriptor, IAgentDiscoveredChat, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentKnownSessionsFilter, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveChatConfigParams, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, SubagentChatSignal, resolveAgentChatContext, resolveAgentHostCustomizations, resolveAgentHostInstructions, resolveSubagentChatParent, type IAgentTurnDiagnosticSnapshot } from '../../common/agent.js'; import { getReasoningEffortDescription, getReasoningEffortLabel, resolveDefaultReasoningEffort } from '../../common/reasoningEffort.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; @@ -2859,6 +2859,14 @@ export class CopilotAgent extends Disposable implements IAgent { getMessages: (chat: URI, context: URI | IAgentChatContext): Promise => this._getChatMessages(chat, context), }; + getTurnDiagnosticSnapshot(chat: URI, turnId: string): IAgentTurnDiagnosticSnapshot { + const session = this._findChatByUri(chat); + if (!session) { + return { state: 'missingChat' }; + } + return session.getTurnDiagnosticSnapshot(turnId) ?? { state: 'missingTurn' }; + } + /** Creates one exact chat backing: fresh, deferred, imported, forked, or side-chat. */ private async _createChat(chat: URI, context: IAgentChatContext, options: IAgentCreateChatOptions = {}): Promise { const scope = context.configurationResource; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index d217da37335309..3e82d8a575dff8 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -36,7 +36,7 @@ import { gitHubMcpServerUrl } from '../../common/githubEndpoints.js'; import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; import { AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyAnswer, AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js'; import { createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; -import { AgentSession, AgentSignal, AuthenticateParams, IMcpNotification, type IAgentToolPendingConfirmationSignal } from '../../common/agent.js'; +import { AgentSession, AgentSignal, AuthenticateParams, IMcpNotification, type AgentTurnProviderCallState, type IAgentToolPendingConfirmationSignal, type IAgentTurnDiagnosticSnapshot } from '../../common/agent.js'; import { META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js'; import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { toToolCallMeta, type IToolCallMeta, type IToolCallUiMeta, type IToolSearchCandidate } from '../../common/meta/agentToolCallMeta.js'; @@ -507,6 +507,8 @@ interface IMcpLifecycleLogInfo { class CopilotTurn extends Disposable { private _state: CopilotTurnState = 'pending'; + private _providerCallState: AgentTurnProviderCallState = 'notStarted'; + private _providerTurnStarted = false; private readonly _stopWatch = StopWatch.create(false); /** @@ -632,6 +634,13 @@ class CopilotTurn extends Disposable { get isPending(): boolean { return this._state === 'pending'; } get isRunning(): boolean { return this._state === 'running'; } get duration(): number { return Math.max(0, this._stopWatch.elapsed()); } + get providerCallState(): AgentTurnProviderCallState { return this._providerCallState; } + get providerTurnStarted(): boolean { return this._providerTurnStarted; } + + markProviderCallPending(): void { this._providerCallState = 'pending'; } + markProviderCallResolved(): void { this._providerCallState = 'resolved'; } + markProviderCallRejected(): void { this._providerCallState = 'rejected'; } + markProviderTurnStarted(): void { this._providerTurnStarted = true; } /** Transition `pending โ†’ running` on the first SDK event. No-op once running/finished. */ markRunning(): void { @@ -790,6 +799,20 @@ export class CopilotAgentSession extends Disposable { get hasActiveTurn(): boolean { return this._currentTurn.value !== undefined; } get chatUri(): URI { return this._chatChannelUri; } get currentTurnId(): string | undefined { return this._currentTurn.value?.id; } + + getTurnDiagnosticSnapshot(turnId: string): IAgentTurnDiagnosticSnapshot | undefined { + const currentTurn = this._currentTurn.value; + const turn = currentTurn?.id === turnId ? currentTurn : undefined; + if (!turn) { + return undefined; + } + return { + state: 'available', + providerCallState: turn.providerCallState, + providerTurnStarted: turn.providerTurnStarted, + providerSessionState: this._wrapper.lifecycleState, + }; + } get currentTurnClientType(): AgentHostClientType { return this._currentTurn.value?.clientType ?? AgentHostClientType.Unknown; } get currentTurnClientContext(): IAgentHostClientTelemetryContext | undefined { return this._currentTurn.value?.clientContext; } @@ -2377,7 +2400,15 @@ export class CopilotAgentSession extends Disposable { await this._prepareSdkTurn(mode); const traceContext = this._otelService.getSessionTraceContext(this.sessionId, this.resourceUri.toString()); - await this._otelService.withTraceContext(traceContext, () => this._wrapper.session.send({ prompt, attachments: sdkAttachments?.length ? sdkAttachments : undefined })); + const sendingTurn = this._currentTurn.value; + sendingTurn?.markProviderCallPending(); + try { + await this._otelService.withTraceContext(traceContext, () => this._wrapper.session.send({ prompt, attachments: sdkAttachments?.length ? sdkAttachments : undefined })); + sendingTurn?.markProviderCallResolved(); + } catch (error) { + sendingTurn?.markProviderCallRejected(); + throw error; + } this._logService.info(`[Copilot:${this.sessionId}] session.send() returned`); } @@ -2424,9 +2455,12 @@ export class CopilotAgentSession extends Disposable { } const traceContext = this._otelService.getSessionTraceContext(this.sessionId, this.resourceUri.toString()); let result: { started: boolean }; + startingTurn.markProviderCallPending(); try { result = await this._otelService.withTraceContext(traceContext, () => this._wrapper.session.rpc.fleet.start(rest ? { prompt: rest } : {})); + startingTurn.markProviderCallResolved(); } catch (err) { + startingTurn.markProviderCallRejected(); // A terminal `session.idle` already ended this turn while the RPC was in // flight โ€” idle is authoritative, so never emit a second terminal action. if (!startingTurn || this._currentTurn.value !== startingTurn) { @@ -5534,6 +5568,7 @@ export class CopilotAgentSession extends Disposable { })); this._register(wrapper.onTurnStart(e => { + this._currentTurn.value?.markProviderTurnStarted(); this._currentTurn.value?.markRunning(); this._logService.trace(`[Copilot:${sessionId}] Turn started: ${e.data.turnId}`); if (!e.agentId) { diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts index 777fcb0edeef2f..03abbed91a2e9e 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts @@ -7,6 +7,7 @@ import type { CopilotSession, SessionEvent, SessionEventPayload, SessionEventTyp import { DeferredPromise } from '../../../../base/common/async.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import type { AgentTurnProviderSessionState } from '../../common/agent.js'; /** * Thin wrapper around {@link CopilotSession} that exposes each SDK event as a @@ -20,6 +21,7 @@ export class CopilotSessionWrapper extends Disposable { readonly onUnhandledEvent = this._onUnhandledEvent.event; private readonly _shutdown = new DeferredPromise(); private _disconnectPromise: Promise | undefined; + private _disconnectCompleted = false; constructor(readonly session: CopilotSession) { super(); @@ -38,17 +40,34 @@ export class CopilotSessionWrapper extends Disposable { } get sessionId(): string { return this.session.sessionId; } + get lifecycleState(): AgentTurnProviderSessionState { + return this._shutdown.isSettled + ? 'shutdown' + : this._disconnectCompleted + ? 'disconnected' + : this._disconnectPromise + ? 'disconnecting' + : 'active'; + } /** Disconnects once the request completes or the SDK reports session shutdown. */ disconnect(): Promise { if (this._shutdown.isSettled) { return this._shutdown.p; } - this._disconnectPromise ??= this.session.disconnect().catch(error => { - if (!this._shutdown.isSettled) { - throw error; - } - }); + if (!this._disconnectPromise) { + const disconnectPromise = this.session.disconnect() + .then(() => { this._disconnectCompleted = true; }) + .catch(error => { + if (!this._shutdown.isSettled) { + if (this._disconnectPromise === disconnectPromise) { + this._disconnectPromise = undefined; + } + throw error; + } + }); + this._disconnectPromise = disconnectPromise; + } return Promise.race([this._disconnectPromise, this._shutdown.p]); } diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index fa2c0501d4ad05..cbe77ad7a22b7c 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -66,7 +66,7 @@ import { } from '../common/otlp/otlpLogEmitter.js'; import { isFileResourceRead } from '../common/resourceReadLogging.js'; import type { Implementation } from '../common/state/protocol/common/commands.js'; -import { AGENT_HOST_CLIENT_CONNECTION_HISTORY_RETENTION, AgentHostClientConnectionTelemetryTracker } from './agentHostClientConnectionTelemetry.js'; +import { AGENT_HOST_CLIENT_CONNECTION_HISTORY_RETENTION, IAgentHostClientConnectionService, type IAgentHostClientConnectionSource } from './agentHostClientConnectionService.js'; import { AgentHostTelemetryReporter } from './agentHostTelemetryReporter.js'; import { isAgentHostTelemetryService } from './agentHostTelemetryService.js'; @@ -208,7 +208,6 @@ interface IConnectedClient { readonly protocolVersion: string; readonly transport: IProtocolTransport; readonly connectionStopWatch: StopWatch; - readonly telemetryTransportToken: object; readonly isReconnect: boolean; telemetryConnectionActive: boolean; /** @@ -257,6 +256,7 @@ interface IActiveClientRecord { interface IGraceClientRecord { readonly state: 'grace'; + readonly seenConnection: boolean; readonly clientInfo: Implementation | undefined; readonly telemetryContext: IAgentHostClientTelemetryContext | undefined; readonly protocolVersion: string | undefined; @@ -310,8 +310,6 @@ function classifyChannel(channel: string): ChannelSubscription | undefined { export interface IProtocolServerConfig { /** Process launcher that owns this agent host. */ readonly hostLaunchKind?: AgentHostLaunchKind; - /** Process-wide client count tracker shared by every listener in this host. */ - readonly connectionTelemetryTracker?: AgentHostClientConnectionTelemetryTracker; /** Default directory returned to clients during the initialize handshake. */ readonly defaultDirectory?: string; @@ -348,7 +346,7 @@ export interface IProtocolServerConfig { * messages to the agent service, and broadcasts actions/notifications * to subscribed clients. */ -export class ProtocolServerHandler extends Disposable { +export class ProtocolServerHandler extends Disposable implements IAgentHostClientConnectionSource { /** * Per-client records keyed by clientId. Holds both connected clients @@ -359,7 +357,6 @@ export class ProtocolServerHandler extends Disposable { private readonly _clients = new Map(); private readonly _replayBuffer: ActionEnvelope[] = []; private readonly _telemetryReporter: AgentHostTelemetryReporter; - private readonly _connectionTelemetryTracker: AgentHostClientConnectionTelemetryTracker; private readonly _managedSettingsOwnerId = generateUuid(); private readonly _onDidChangeConnectionCount = this._register(new Emitter()); @@ -376,10 +373,11 @@ export class ProtocolServerHandler extends Disposable { @ILogService private readonly _logService: ILogService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @IAgentHostManagedSettingsService private readonly _managedSettingsService: IAgentHostManagedSettingsService, + @IAgentHostClientConnectionService private readonly _clientConnections: IAgentHostClientConnectionService, ) { super(); this._telemetryReporter = new AgentHostTelemetryReporter(this._telemetryService); - this._connectionTelemetryTracker = this._config.connectionTelemetryTracker ?? this._register(new AgentHostClientConnectionTelemetryTracker()); + this._register(this._clientConnections.registerSource(this)); this._register(this._server.onConnection(transport => { this._handleNewConnection(transport); @@ -550,6 +548,7 @@ export class ProtocolServerHandler extends Disposable { this._logService.info(`[ProtocolServer] Client disconnected: ${client.clientId}, subscriptions=${subscriptionCount}`); this._clients.set(client.clientId, { state: 'grace', + seenConnection: true, clientInfo: record.clientInfo, telemetryContext: client.telemetryContext, protocolVersion: client.protocolVersion, @@ -600,7 +599,6 @@ export class ProtocolServerHandler extends Disposable { const previousRecord = this._clients.get(params.clientId); this._applyClientTelemetryLevel(params._meta); - const telemetryTransportToken = {}; const initializationDisposables = disposables.add(new DisposableStore()); const telemetryContext = this._createClientTelemetryContext(params.clientInfo, params._meta, transport); const client: IConnectedClient = { @@ -610,8 +608,7 @@ export class ProtocolServerHandler extends Disposable { protocolVersion: negotiated, transport, connectionStopWatch: StopWatch.create(true), - telemetryTransportToken, - isReconnect: this._connectionTelemetryTracker.hasSeenClient(params.clientId), + isReconnect: this._clientConnections.hasSeenClient(params.clientId), telemetryConnectionActive: false, subscriptions: new Map(), disposables, @@ -631,12 +628,8 @@ export class ProtocolServerHandler extends Disposable { } } - const counts = this._connectionTelemetryTracker.connect(params.clientId, telemetryTransportToken); client.telemetryConnectionActive = true; - if (previousRecord?.state === 'grace') { - previousRecord.disconnectTimeouts.dispose(); - } - this._onDidChangeConnectionCount.fire(this._connectedClientCount); + const counts = this._clientConnections.getConnectionCounts(params.clientId); this._telemetryReporter.clientConnection({ action: 'connected', context: telemetryContext, @@ -644,8 +637,13 @@ export class ProtocolServerHandler extends Disposable { clientImplementationName: client.clientInfo?.name, clientImplementationVersion: client.clientInfo?.version, protocolVersion: client.protocolVersion, + isReconnect: client.isReconnect, ...counts, }); + if (previousRecord?.state === 'grace') { + previousRecord.disconnectTimeouts.dispose(); + } + this._onDidChangeConnectionCount.fire(this._connectedClientCount); return { client, @@ -751,7 +749,7 @@ export class ProtocolServerHandler extends Disposable { const priorProtocolVersion = existingRecord.state === 'active' ? existingRecord.connections.at(-1)?.protocolVersion : existingRecord.protocolVersion; - const telemetryTransportToken = {}; + const isReconnect = this._clientConnections.hasSeenClient(params.clientId); const initializationDisposables = disposables.add(new DisposableStore()); const client: IConnectedClient = { clientId: params.clientId, @@ -760,8 +758,7 @@ export class ProtocolServerHandler extends Disposable { protocolVersion: priorProtocolVersion ?? PROTOCOL_VERSION, transport, connectionStopWatch: StopWatch.create(true), - telemetryTransportToken, - isReconnect: true, + isReconnect, telemetryConnectionActive: false, subscriptions: new Map(), disposables, @@ -780,12 +777,8 @@ export class ProtocolServerHandler extends Disposable { const canReplay = params.lastSeenServerSeq >= oldestBuffered; const responsePromise = this._restoreReconnectSubscriptions(client, params, canReplay); - const counts = this._connectionTelemetryTracker.connect(params.clientId, telemetryTransportToken); client.telemetryConnectionActive = true; - if (existingRecord.state === 'grace') { - existingRecord.disconnectTimeouts.dispose(); - } - this._onDidChangeConnectionCount.fire(this._connectedClientCount); + const counts = this._clientConnections.getConnectionCounts(params.clientId); this._telemetryReporter.clientConnection({ action: 'connected', context: client.telemetryContext, @@ -793,8 +786,13 @@ export class ProtocolServerHandler extends Disposable { clientImplementationName: client.clientInfo?.name, clientImplementationVersion: client.clientInfo?.version, protocolVersion: client.protocolVersion, + isReconnect: client.isReconnect, ...counts, }); + if (existingRecord.state === 'grace') { + existingRecord.disconnectTimeouts.dispose(); + } + this._onDidChangeConnectionCount.fire(this._connectedClientCount); return { client, responsePromise }; } catch (error) { @@ -1092,6 +1090,7 @@ export class ProtocolServerHandler extends Disposable { } private _rollbackFailedInitialization(client: IConnectedClient, previousRecord: IClientRecord | undefined): void { + client.telemetryConnectionActive = false; const record = this._clients.get(client.clientId); if (record?.state === 'active') { const connectionIndex = record.connections.indexOf(client); @@ -1127,6 +1126,7 @@ export class ProtocolServerHandler extends Disposable { } const created: IGraceClientRecord = { state: 'grace', + seenConnection: false, clientInfo: undefined, telemetryContext: undefined, protocolVersion: undefined, @@ -1174,6 +1174,34 @@ export class ProtocolServerHandler extends Disposable { return false; } + hasSeenClient(clientId: string): boolean { + const record = this._clients.get(clientId); + return record?.state === 'active' + ? record.connections.some(connection => connection.telemetryConnectionActive) + : record?.seenConnection === true + && record.lastSeenAt >= Date.now() - AGENT_HOST_CLIENT_CONNECTION_HISTORY_RETENTION; + } + + isClientConnected(clientId: string): boolean { + const record = this._clients.get(clientId); + return record?.state === 'active' + && record.connections.some(connection => connection.telemetryConnectionActive); + } + + getConnectedClientTransportCounts(): ReadonlyMap { + const result = new Map(); + for (const [clientId, record] of this._clients) { + if (record.state !== 'active') { + continue; + } + const count = record.connections.filter(connection => connection.telemetryConnectionActive).length; + if (count > 0) { + result.set(clientId, count); + } + } + return result; + } + /** Number of clients that currently have a live connection. */ private get _connectedClientCount(): number { let count = 0; @@ -1211,7 +1239,7 @@ export class ProtocolServerHandler extends Disposable { return; } client.telemetryConnectionActive = false; - const counts = this._connectionTelemetryTracker.disconnect(client.clientId, client.telemetryTransportToken); + const counts = this._clientConnections.getConnectionCounts(client.clientId); this._telemetryReporter.clientConnection({ action: 'disconnected', context: client.telemetryContext, @@ -1241,6 +1269,7 @@ export class ProtocolServerHandler extends Disposable { if (record.state === 'grace' && record.disconnectTimeouts.size === 0 && record.lastSeenAt < cutoff) { + record.disconnectTimeouts.dispose(); this._clients.delete(clientId); } } diff --git a/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts b/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts index 5308b4a56606e0..f97d69c6bc13d7 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts @@ -405,6 +405,14 @@ suite('AgentHostTelemetryReporter', () => { hadAnyProgress: true, lastActivityKind: ActionType.ChatToolCallDelta, currentStage: 'provider', + providerDiagnosticState: 'available', + providerDiagnosticSnapshot: { + state: 'available', + providerCallState: 'resolved', + providerTurnStarted: true, + providerSessionState: 'active', + }, + initiatorClientConnectionState: 'connected', blockedOn: undefined, toolId: undefined, toolSourceKind: undefined, @@ -424,6 +432,9 @@ suite('AgentHostTelemetryReporter', () => { hadAnyProgress: true, lastActivityKind: 'custom/path/value', currentStage: 'provider', + providerDiagnosticState: 'unsupported', + providerDiagnosticSnapshot: undefined, + initiatorClientConnectionState: 'unknown', blockedOn: undefined, toolId: undefined, toolSourceKind: undefined, @@ -449,6 +460,11 @@ suite('AgentHostTelemetryReporter', () => { hadAnyProgress: true, lastActivityKind: 'chat.toolCallDelta', currentStage: 'provider', + providerDiagnosticState: 'available', + providerCallState: 'resolved', + providerTurnStarted: true, + providerSessionState: 'active', + initiatorClientConnectionState: 'connected', blockedOn: undefined, toolId: undefined, toolSourceKind: undefined, @@ -472,6 +488,8 @@ suite('AgentHostTelemetryReporter', () => { hadAnyProgress: true, lastActivityKind: 'other', currentStage: 'provider', + providerDiagnosticState: 'unsupported', + initiatorClientConnectionState: 'unknown', blockedOn: undefined, toolId: undefined, toolSourceKind: undefined, diff --git a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts index 348db62d58efc3..47be4c046c7373 100644 --- a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts @@ -26,6 +26,7 @@ import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../comm import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; import { AgentHostLocalTurns } from '../../node/agentHostLocalTurns.js'; import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; +import { AgentHostClientConnectionService, IAgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { IAgentHostChangesetService } from '../../common/agentHostChangesetService.js'; import { AgentSideEffects } from '../../node/agentSideEffects.js'; @@ -216,6 +217,7 @@ suite('AgentSideEffects โ€” tool call telemetry', () => { [ITelemetryService, telemetryService], [IAgentHostTerminalManager, disposables.add(new TestAgentHostTerminalManager())], [ISessionDataService, sessionDataService], + [IAgentHostClientConnectionService, disposables.add(new AgentHostClientConnectionService())], ), /*strict*/ true)); sideEffects = disposables.add(instantiationService.createInstance(AgentSideEffects, stateManager, customizationEnablementService, { getAgent: () => agent, diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts index 9ad80f1698c7e0..7bf4524efb08d3 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts @@ -17,6 +17,8 @@ import { ILogService, NullLogService } from '../../../log/common/log.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelation.js'; import { AgentSession, IAgent } from '../../common/agent.js'; +import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; +import { createUnknownAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { SessionInputRequestKind } from '../../common/state/protocol/state.js'; import { ActionType, type ChatAction } from '../../common/state/sessionActions.js'; import { buildDefaultChatUri, buildSubagentChatUri, ChatInputQuestionKind, MessageKind, ResponsePartKind, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallContributorKind } from '../../common/state/sessionState.js'; @@ -31,6 +33,7 @@ import { AgentSideEffects } from '../../node/agentSideEffects.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentHostTurnTracker, TURN_ACTIVITY_NONE, TURN_HANG_THRESHOLD_MS } from '../../node/agentHostTurnTracker.js'; import { AgentHostTelemetryReporter } from '../../node/agentHostTelemetryReporter.js'; +import { AgentHostClientConnectionService, IAgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; import { createNullSessionDataService } from '../common/sessionTestHelpers.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { MockAgent } from './mockAgent.js'; @@ -122,7 +125,7 @@ suite('AgentSideEffects โ€” turn hang telemetry', () => { message: { text: 'hello', origin: { kind: MessageKind.User }, ...(modelId ? { model: { id: modelId } } : {}) }, }; stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(defaultChatUri, action); + sideEffects.handleAction(defaultChatUri, action, 'test'); } function fire(action: ChatAction): void { @@ -190,6 +193,12 @@ suite('AgentSideEffects โ€” turn hang telemetry', () => { await checkpointGate?.p; }, }; + const clientConnections = disposables.add(new AgentHostClientConnectionService()); + disposables.add(clientConnections.registerSource({ + hasSeenClient: clientId => clientId === 'test', + isClientConnected: clientId => clientId === 'test', + getConnectedClientTransportCounts: () => new Map([['test', 1]]), + })); const instantiationService = disposables.add(new InstantiationService(new ServiceCollection( [ILogService, logService], [IAgentConfigurationService, configService], @@ -198,6 +207,7 @@ suite('AgentSideEffects โ€” turn hang telemetry', () => { [ITelemetryService, telemetryService], [IAgentHostTerminalManager, disposables.add(new TestAgentHostTerminalManager())], [ISessionDataService, sessionDataService], + [IAgentHostClientConnectionService, clientConnections], ), /*strict*/ true)); sideEffects = disposables.add(instantiationService.createInstance(AgentSideEffects, stateManager, customizationEnablementService, { getAgent: () => agent, @@ -235,6 +245,8 @@ suite('AgentSideEffects โ€” turn hang telemetry', () => { hadAnyProgress: false, lastActivityKind: TURN_ACTIVITY_NONE, currentStage: 'provider', + providerDiagnosticState: 'unsupported', + initiatorClientConnectionState: 'connected', blockedOn: undefined, toolId: undefined, toolSourceKind: undefined, @@ -413,7 +425,11 @@ suite('AgentSideEffects โ€” turn hang telemetry', () => { }); test('reports the active stage', async () => { - const tracker = disposables.add(new AgentHostTurnTracker(new AgentHostTelemetryReporter(telemetry))); + const tracker = disposables.add(new AgentHostTurnTracker( + new AgentHostTelemetryReporter(telemetry), + disposables.add(new AgentHostClientConnectionService()), + new NullLogService(), + )); const cases = [ { session: AgentSession.uri('mock', 'validation').toString(), stage: 'validation' }, { session: AgentSession.uri('mock', 'working-directory').toString(), stage: 'workingDirectory' }, @@ -424,7 +440,7 @@ suite('AgentSideEffects โ€” turn hang telemetry', () => { await runWithFakedTimers({}, async () => { for (const item of cases) { - tracker.turnStarted('mock', item.session, 'turn', undefined, undefined, 'default', undefined, undefined); + tracker.turnStarted(agent, item.session, 'turn', undefined, undefined, 'default', undefined, undefined); tracker.setCurrentStage(item.session, 'turn', item.stage); } await timeout(TURN_HANG_THRESHOLD_MS); @@ -450,6 +466,100 @@ suite('AgentSideEffects โ€” turn hang telemetry', () => { }); }); + test('reports provider lifecycle and initiating client connection snapshots', async () => { + const clientConnections = disposables.add(new AgentHostClientConnectionService()); + disposables.add(clientConnections.registerSource({ + hasSeenClient: clientId => clientId === 'connected-client', + isClientConnected: clientId => clientId === 'connected-client', + getConnectedClientTransportCounts: () => new Map([['connected-client', 1]]), + })); + const diagnosticAgent = disposables.add(new MockAgent('copilotcli')); + diagnosticAgent.getTurnDiagnosticSnapshot = () => ({ + state: 'available', + providerCallState: 'pending', + providerTurnStarted: false, + providerSessionState: 'active', + }); + const tracker = disposables.add(new AgentHostTurnTracker( + new AgentHostTelemetryReporter(telemetry), + clientConnections, + new NullLogService(), + )); + const session = AgentSession.uri('copilotcli', 'lifecycle').toString(); + + await runWithFakedTimers({}, async () => { + tracker.turnStarted( + diagnosticAgent, + session, + 'turn', + undefined, + undefined, + 'default', + undefined, + undefined, + createUnknownAgentHostClientTelemetryContext(AgentHostClientType.AgentsWindow), + 'connected-client', + ); + tracker.setCurrentStage(session, 'turn', 'provider'); + await timeout(TURN_HANG_THRESHOLD_MS); + }); + + assert.deepStrictEqual(hangEvents().at(-1)?.data, { + provider: 'copilotcli', + agentSessionId: 'lifecycle', + chatSessionId: getTelemetryChatSessionId(session), + isSubagentSession: false, + turnId: 'turn', + hangReason: 'noProgress', + isExpected: false, + hadAnyProgress: false, + lastActivityKind: TURN_ACTIVITY_NONE, + currentStage: 'provider', + providerDiagnosticState: 'available', + providerCallState: 'pending', + providerTurnStarted: false, + providerSessionState: 'active', + initiatorClientType: 'agents_window', + initiatorClientConnectionState: 'connected', + blockedOn: undefined, + toolId: undefined, + toolSourceKind: undefined, + inFlightToolCallCount: 0, + quietTimeMs: true, + turnElapsedMs: true, + model: undefined, + modelSelectionKind: 'default', + permissionLevel: undefined, + }); + }); + + test('reports diagnostic errors without losing the hang event', async () => { + const diagnosticAgent = disposables.add(new MockAgent('copilotcli')); + diagnosticAgent.getTurnDiagnosticSnapshot = () => { + throw new Error('diagnostic failed'); + }; + const tracker = disposables.add(new AgentHostTurnTracker( + new AgentHostTelemetryReporter(telemetry), + disposables.add(new AgentHostClientConnectionService()), + new NullLogService(), + )); + const session = AgentSession.uri('copilotcli', 'diagnostic-error').toString(); + + await runWithFakedTimers({}, async () => { + tracker.turnStarted(diagnosticAgent, session, 'turn', undefined, undefined, 'default', undefined, undefined); + tracker.setCurrentStage(session, 'turn', 'provider'); + await timeout(TURN_HANG_THRESHOLD_MS); + }); + + assert.deepStrictEqual({ + providerDiagnosticState: hangEvents().at(-1)?.data.providerDiagnosticState, + hangReason: hangEvents().at(-1)?.data.hangReason, + }, { + providerDiagnosticState: 'error', + hangReason: 'noProgress', + }); + }); + test('tracks pre-provider stages through the send pipeline', async () => { await runWithFakedTimers({}, async () => { setupSession(); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index 332de78f134e09..019c4818599d41 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -27,6 +27,7 @@ import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../comm import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; import { AgentHostLocalTurns } from '../../node/agentHostLocalTurns.js'; import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; +import { AgentHostClientConnectionService, IAgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { IAgentHostChangesetService } from '../../common/agentHostChangesetService.js'; import { AgentSideEffects } from '../../node/agentSideEffects.js'; @@ -206,6 +207,7 @@ suite('AgentSideEffects โ€” turn tracker telemetry', () => { [ITelemetryService, telemetryService], [IAgentHostTerminalManager, disposables.add(new TestAgentHostTerminalManager())], [ISessionDataService, sessionDataService], + [IAgentHostClientConnectionService, disposables.add(new AgentHostClientConnectionService())], ), /*strict*/ true)); sideEffects = disposables.add(instantiationService.createInstance(AgentSideEffects, stateManager, customizationEnablementService, { getAgent: () => agent, diff --git a/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts b/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts index c54ddb226f769f..637cc092c87a76 100644 --- a/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts +++ b/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts @@ -23,6 +23,7 @@ import { IAgentHostProxyResolver } from '../../node/agentHostProxyResolver.js'; import { AgentService } from '../../node/agentService.js'; import { createAgentServiceComposition, type IAgentServiceComposition } from '../../node/agentServiceComposition.js'; import { ICopilotApiService } from '../../node/shared/copilotApiService.js'; +import { AgentHostClientConnectionService, IAgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; const compositions = new WeakMap(); @@ -51,6 +52,7 @@ export function createTestAgentService( orchestratorDatabase?: IAgentHostDatabase, ): AgentService { const effectiveFileMonitorService = fileMonitorService ?? new AgentHostFileMonitorService(fileService, logService); + const clientConnectionService = new AgentHostClientConnectionService(); const proxyResolver: IAgentHostProxyResolver = { _serviceBrand: undefined, onDidRegisterConnection: Event.None, @@ -70,6 +72,7 @@ export function createTestAgentService( [ITelemetryService, telemetryService], [IAgentHostFileMonitorService, effectiveFileMonitorService], [IAgentHostProxyResolver, proxyResolver], + [IAgentHostClientConnectionService, clientConnectionService], ); const instantiationService = new InstantiationService(services, /*strict*/ true); const options = { @@ -88,7 +91,7 @@ export function createTestAgentService( logService, productService, sessionDataService, - fileMonitorService ? [instantiationService] : [effectiveFileMonitorService, instantiationService], + fileMonitorService ? [clientConnectionService, instantiationService] : [effectiveFileMonitorService, clientConnectionService, instantiationService], ); compositions.set(composition.agentService, composition); return composition.agentService; diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 663af6eb74a750..827505b09eec04 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -35,6 +35,7 @@ import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.j import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostMarkdownPlanRichLinksEnabledConfigKey, AgentHostTelemetryLevelConfigKey, platformSessionSchema, telemetryLevelToAgentHostConfigValue } from '../../common/agentHostSchema.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; +import { AgentHostClientConnectionService, IAgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind } from '../../common/agentHostTelemetry.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; @@ -144,6 +145,7 @@ function createTestSideEffects( [ITelemetryService, telemetryService], [IAgentHostTerminalManager, terminalManager], [ISessionDataService, options.sessionDataService], + [IAgentHostClientConnectionService, disposables.add(new AgentHostClientConnectionService())], ), /*strict*/ true)); const resolvedOptions: IAgentSideEffectsOptions = { ...options, diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 772723af116d61..3ed044a5a6c393 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -83,6 +83,7 @@ import { createTestGitHubEndpointService } from './testGitHubEndpointService.js' class MockCopilotSession { readonly sessionId = 'test-session-1'; readonly sendRequests: unknown[] = []; + sendGate: Promise | undefined; readonly modeSetCalls: Array<{ mode: 'interactive' | 'plan' | 'autopilot' }> = []; readonly permissionModeSetCalls: PermissionAllowAllMode[] = []; permissionModeSetSuccess = true; @@ -153,6 +154,7 @@ class MockCopilotSession { disconnectCalls = 0; disconnectGate: Promise | undefined; disconnectHook: (() => void) | undefined; + disconnectError: Error | undefined; /** * Per-call gates, consumed in call order, for holding individual reads in flight. * Lets a test make an earlier-issued read resolve after a later one. @@ -242,6 +244,7 @@ class MockCopilotSession { async send(request: unknown) { this.operationLog.push('send'); this.sendRequests.push(request); + await this.sendGate; return `message-${this.sendRequests.length}`; } async abort() { @@ -254,6 +257,9 @@ class MockCopilotSession { this.disconnectCalls++; this.disconnectHook?.(); await this.disconnectGate; + if (this.disconnectError) { + throw this.disconnectError; + } } readonly rpc = { @@ -1081,6 +1087,47 @@ suite('CopilotAgentSession', () => { assert.deepStrictEqual(events, ['session.compaction_start']); }); + + test('reports a completed disconnect separately from a pending disconnect', async () => { + const disconnectGate = new DeferredPromise(); + const mockSession = new MockCopilotSession(); + mockSession.disconnectGate = disconnectGate.p; + const wrapper = disposables.add(new CopilotSessionWrapper(mockSession as unknown as CopilotSession)); + + const disconnect = wrapper.disconnect(); + const pendingState = wrapper.lifecycleState; + disconnectGate.complete(); + await disconnect; + + assert.deepStrictEqual({ + pendingState, + completedState: wrapper.lifecycleState, + }, { + pendingState: 'disconnecting', + completedState: 'disconnected', + }); + }); + + test('returns to active and permits retry after disconnect rejects', async () => { + const mockSession = new MockCopilotSession(); + mockSession.disconnectError = new Error('disconnect failed'); + const wrapper = disposables.add(new CopilotSessionWrapper(mockSession as unknown as CopilotSession)); + + await assert.rejects(wrapper.disconnect(), /disconnect failed/); + const rejectedState = wrapper.lifecycleState; + mockSession.disconnectError = undefined; + await wrapper.disconnect(); + + assert.deepStrictEqual({ + rejectedState, + completedState: wrapper.lifecycleState, + disconnectCalls: mockSession.disconnectCalls, + }, { + rejectedState: 'active', + completedState: 'disconnected', + disconnectCalls: 2, + }); + }); }); test('destroySession completes when shutdown arrives before the response', async () => { @@ -1092,6 +1139,7 @@ suite('CopilotAgentSession', () => { mockSession.disconnectHook = () => { void disconnectStarted.complete(); }; }, }); + const destroy = session.destroySession(); await disconnectStarted.p; @@ -1109,6 +1157,60 @@ suite('CopilotAgentSession', () => { } }); + test('reports bounded provider lifecycle state for the active turn', async () => { + const sendGate = new DeferredPromise(); + const { session, mockSession } = await createAgentSession(disposables, { + configureMockSession: mockSession => { + mockSession.sendGate = sendGate.p; + }, + }); + session.resetTurnState('turn-1'); + + assert.deepStrictEqual(session.getTurnDiagnosticSnapshot('turn-1'), { + state: 'available', + providerCallState: 'notStarted', + providerTurnStarted: false, + providerSessionState: 'active', + }); + + const send = session.send('hello', undefined, 'turn-1'); + while (mockSession.sendRequests.length === 0) { + await timeout(0); + } + assert.deepStrictEqual(session.getTurnDiagnosticSnapshot('turn-1'), { + state: 'available', + providerCallState: 'pending', + providerTurnStarted: false, + providerSessionState: 'active', + }); + + mockSession.fire('assistant.turn_start', { turnId: 'sdk-turn-1' }); + assert.deepStrictEqual(session.getTurnDiagnosticSnapshot('turn-1'), { + state: 'available', + providerCallState: 'pending', + providerTurnStarted: true, + providerSessionState: 'active', + }); + + sendGate.complete(); + await send; + mockSession.fire('session.shutdown', { + codeChanges: { filesModified: [], linesAdded: 0, linesRemoved: 0 }, + modelMetrics: {}, + sessionStartTime: 0, + shutdownType: 'routine', + totalApiDurationMs: 0, + }); + + assert.deepStrictEqual(session.getTurnDiagnosticSnapshot('turn-1'), { + state: 'available', + providerCallState: 'resolved', + providerTurnStarted: true, + providerSessionState: 'shutdown', + }); + assert.strictEqual(session.getTurnDiagnosticSnapshot('other-turn'), undefined); + }); + test('logs SDK events without wrapped handlers', async () => { const logService = new CapturingLogService(); const { mockSession } = await createAgentSession(disposables, { logService }); @@ -2435,12 +2537,19 @@ suite('CopilotAgentSession', () => { sendRequests: mockSession.sendRequests, turnCompleteBeforeIdle: getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete).length, hasActiveTurn: session.hasActiveTurn, + diagnostics: session.getTurnDiagnosticSnapshot('turn-fleet'), }, { fleetStartCalls: [{ prompt: 'the full analysis' }], commandInvokeCalls: [], sendRequests: [], turnCompleteBeforeIdle: 0, hasActiveTurn: true, + diagnostics: { + state: 'available', + providerCallState: 'resolved', + providerTurnStarted: false, + providerSessionState: 'active', + }, }); mockSession.fire('assistant.message_delta', { deltaContent: 'Deploying fleet' } as SessionEventPayload<'assistant.message_delta'>['data']); diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 00835e39c023fb..0fefd0ab8326d5 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -59,6 +59,7 @@ export class MockAgent implements IAgent { readonly onDidMaterializeChat = Event.None; readonly onDidChangeChatData = Event.None; readonly onDidSpawnChat = Event.None; + getTurnDiagnosticSnapshot?: IAgent['getTurnDiagnosticSnapshot']; private readonly _onDidSendMessage = new Emitter(); readonly onDidSendMessage = this._onDidSendMessage.event; private readonly _models = observableValue(this, []); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index de853adb602d2c..d468255901946f 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -33,7 +33,7 @@ import { agentsWindowAgentHostClientInfo, editorWindowAgentHostClientInfo, Agent import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, type IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { iterateOtlpLogRecords, OtlpLogEmitter } from '../../common/otlp/otlpLogEmitter.js'; import { MessagePortProtocolServer } from '../../node/messagePortProtocolServer.js'; -import { AgentHostClientConnectionTelemetryTracker } from '../../node/agentHostClientConnectionTelemetry.js'; +import { AGENT_HOST_CLIENT_CONNECTION_HISTORY_RETENTION, AgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; import { AgentHostManagedSettingsService } from '../../node/agentHostManagedSettingsService.js'; import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; @@ -121,9 +121,13 @@ class TestTelemetryService implements ITelemetryService { readonly devDeviceId = 'device'; readonly firstSessionDate = 'first-session'; readonly events: { eventName: string; data: unknown }[] = []; + throwOnClientConnection = false; publicLog(): void { } publicLog2(eventName?: string, data?: unknown): void { + if (this.throwOnClientConnection && eventName === 'agentHost.clientConnection') { + throw new Error('client connection telemetry failed'); + } if (eventName) { this.events.push({ eventName, data }); } @@ -322,6 +326,7 @@ suite('ProtocolServerHandler', () => { let logService: CountingLogService; let telemetryService: TestTelemetryService; let agentHostTelemetryService: AgentHostTelemetryService; + let clientConnections: AgentHostClientConnectionService; const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString(); const defaultChatUri = buildDefaultChatUri(sessionUri); @@ -364,6 +369,7 @@ suite('ProtocolServerHandler', () => { logService = new CountingLogService(); telemetryService = new TestTelemetryService(); agentHostTelemetryService = disposables.add(new AgentHostTelemetryService(telemetryService)); + clientConnections = disposables.add(new AgentHostClientConnectionService()); disposables.add(agentService); disposables.add(handler = new ProtocolServerHandler( agentService, @@ -374,6 +380,7 @@ suite('ProtocolServerHandler', () => { logService, agentHostTelemetryService, managedSettingsService, + clientConnections, )); }); @@ -780,6 +787,7 @@ suite('ProtocolServerHandler', () => { logService, NullTelemetryService, managedSettingsService, + clientConnections, )); const transport = new MockProtocolTransport(); localServer.simulateConnection(transport); @@ -1926,7 +1934,7 @@ suite('ProtocolServerHandler', () => { test('reports process-wide client counts across protocol listeners', () => { const localDisposables = disposables.add(new DisposableStore()); - const tracker = localDisposables.add(new AgentHostClientConnectionTelemetryTracker()); + const tracker = localDisposables.add(new AgentHostClientConnectionService()); const firstServer = localDisposables.add(new MockProtocolServer()); const secondServer = localDisposables.add(new MockProtocolServer()); const handlers: ProtocolServerHandler[] = []; @@ -1935,11 +1943,12 @@ suite('ProtocolServerHandler', () => { agentService, stateManager, listener, - { hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, connectionTelemetryTracker: tracker }, + { hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess }, localDisposables.add(new AgentHostFileSystemProvider()), logService, telemetryService, managedSettingsService, + tracker, ))); } @@ -1967,22 +1976,54 @@ suite('ProtocolServerHandler', () => { ]); }); + test('deduplicates one client connected through multiple protocol listeners', () => { + const localDisposables = disposables.add(new DisposableStore()); + const tracker = localDisposables.add(new AgentHostClientConnectionService()); + const listeners = [ + localDisposables.add(new MockProtocolServer()), + localDisposables.add(new MockProtocolServer()), + ]; + for (const listener of listeners) { + localDisposables.add(new ProtocolServerHandler( + agentService, + stateManager, + listener, + { hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess }, + localDisposables.add(new AgentHostFileSystemProvider()), + logService, + telemetryService, + managedSettingsService, + tracker, + )); + const transport = new MockProtocolTransport(); + listener.simulateConnection(transport); + transport.simulateMessage(request(1, 'initialize', { + protocolVersions: [PROTOCOL_VERSION], + clientId: 'shared-client', + })); + } + + assert.deepStrictEqual(tracker.getConnectionCounts('shared-client'), { + connectedClientCount: 1, + connectedTransportCount: 2, + clientTransportCount: 2, + }); + }); + test('expires disconnected client reconnect history', () => { return runWithFakedTimers({ useFakeTimers: true }, async () => { - const tracker = disposables.add(new AgentHostClientConnectionTelemetryTracker(100)); - const firstTransport = {}; - assert.strictEqual(tracker.connect('client', firstTransport).isReconnect, false); - tracker.disconnect('client', firstTransport); - assert.strictEqual(tracker.hasSeenClient('client'), true); + const firstTransport = connectClient('client'); + firstTransport.simulateClose(); + assert.strictEqual(clientConnections.hasSeenClient('client'), true); - await new Promise(resolve => setTimeout(resolve, 101)); + await new Promise(resolve => setTimeout(resolve, AGENT_HOST_CLIENT_CONNECTION_HISTORY_RETENTION + 1)); assert.deepStrictEqual({ - hasSeenClient: tracker.hasSeenClient('client'), - isReconnect: tracker.connect('client', {}).isReconnect, + hasSeenClient: clientConnections.hasSeenClient('client'), + isClientConnected: clientConnections.isClientConnected('client'), }, { hasSeenClient: false, - isReconnect: false, + isClientConnected: false, }); }); }); @@ -2000,6 +2041,7 @@ suite('ProtocolServerHandler', () => { logService, localTelemetry, managedSettingsService, + clientConnections, )); const counts: number[] = []; localDisposables.add(localHandler.onDidChangeConnectionCount(count => counts.push(count))); @@ -2017,10 +2059,111 @@ suite('ProtocolServerHandler', () => { counts, events: localTelemetry.events, responseCode, + hasSeenClient: clientConnections.hasSeenClient('failed-client'), + isClientConnected: clientConnections.isClientConnected('failed-client'), + connectionCounts: clientConnections.getConnectionCounts('failed-client'), }, { counts: [], events: [], responseCode: JSON_RPC_INTERNAL_ERROR, + hasSeenClient: false, + isClientConnected: false, + connectionCounts: { + connectedClientCount: 0, + connectedTransportCount: 0, + clientTransportCount: 0, + }, + }); + }); + + test('rolls back authoritative connection state when connected telemetry throws', () => { + const localDisposables = disposables.add(new DisposableStore()); + const localServer = localDisposables.add(new MockProtocolServer()); + const localTelemetry = new TestTelemetryService(); + localTelemetry.throwOnClientConnection = true; + const localHandler = localDisposables.add(new ProtocolServerHandler( + agentService, + stateManager, + localServer, + { hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess }, + localDisposables.add(new AgentHostFileSystemProvider()), + logService, + localTelemetry, + managedSettingsService, + clientConnections, + )); + const countEvents: number[] = []; + localDisposables.add(localHandler.onDidChangeConnectionCount(count => countEvents.push(count))); + const transport = new MockProtocolTransport(AgentHostTransportKind.WebSocket); + localServer.simulateConnection(transport); + + transport.simulateMessage(request(1, 'initialize', { + protocolVersions: [PROTOCOL_VERSION], + clientId: 'telemetry-failed-client', + })); + transport.simulateClose(); + + assert.deepStrictEqual({ + countEvents, + isClientConnected: clientConnections.isClientConnected('telemetry-failed-client'), + counts: clientConnections.getConnectionCounts('telemetry-failed-client'), + }, { + countEvents: [], + isClientConnected: false, + counts: { + connectedClientCount: 0, + connectedTransportCount: 0, + clientTransportCount: 0, + }, + }); + }); + + test('does not notify connection observers when reconnect telemetry throws', () => { + const localDisposables = disposables.add(new DisposableStore()); + const localServer = localDisposables.add(new MockProtocolServer()); + const localTelemetry = new TestTelemetryService(); + const localHandler = localDisposables.add(new ProtocolServerHandler( + agentService, + stateManager, + localServer, + { hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess }, + localDisposables.add(new AgentHostFileSystemProvider()), + logService, + localTelemetry, + managedSettingsService, + clientConnections, + )); + const countEvents: number[] = []; + localDisposables.add(localHandler.onDidChangeConnectionCount(count => countEvents.push(count))); + + const initialTransport = new MockProtocolTransport(); + localServer.simulateConnection(initialTransport); + initialTransport.simulateMessage(request(1, 'initialize', { + protocolVersions: [PROTOCOL_VERSION], + clientId: 'reconnect-telemetry-failed-client', + })); + localTelemetry.throwOnClientConnection = true; + + const failedTransport = new MockProtocolTransport(); + localServer.simulateConnection(failedTransport); + failedTransport.simulateMessage(request(2, 'reconnect', { + clientId: 'reconnect-telemetry-failed-client', + lastSeenServerSeq: 0, + subscriptions: [], + })); + failedTransport.simulateClose(); + localTelemetry.throwOnClientConnection = false; + + assert.deepStrictEqual({ + countEvents, + counts: clientConnections.getConnectionCounts('reconnect-telemetry-failed-client'), + }, { + countEvents: [1], + counts: { + connectedClientCount: 1, + connectedTransportCount: 1, + clientTransportCount: 1, + }, }); }); @@ -2037,6 +2180,7 @@ suite('ProtocolServerHandler', () => { logService, localTelemetry, managedSettingsService, + clientConnections, )); const counts: number[] = []; localDisposables.add(localHandler.onDidChangeConnectionCount(count => counts.push(count))); @@ -3144,6 +3288,7 @@ suite('ProtocolServerHandler', () => { logService, NullTelemetryService, managedSettingsService, + clientConnections, )); const secondTransport = new MockProtocolTransport(); secondServer.simulateConnection(secondTransport); @@ -3248,6 +3393,7 @@ suite('ProtocolServerHandler', () => { logService, NullTelemetryService, managedSettingsService, + clientConnections, )); const counts: number[] = []; localDisposables.add(combinedHandler.onDidChangeConnectionCount(count => counts.push(count))); @@ -3386,6 +3532,7 @@ suite('ProtocolServerHandler', () => { new NullLogService(), NullTelemetryService, managedSettingsService, + clientConnections, )); }); @@ -3557,6 +3704,7 @@ suite('ProtocolServerHandler', () => { new NullLogService(), NullTelemetryService, managedSettingsService, + clientConnections, )); }); From 2454591a4837ab249eebd7300be373370c4d5a45 Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:04:55 -0700 Subject: [PATCH 06/10] Browser: never show query parameters in tab descriptions (#332058) --- .../browserView/common/browserEditorInput.ts | 46 +++++++------------ .../browserEditorInput.test.ts | 37 ++++++++++++++- 2 files changed, 52 insertions(+), 31 deletions(-) diff --git a/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts b/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts index 19856c6983a93a..e4ba744cfb2072 100644 --- a/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts +++ b/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts @@ -58,24 +58,13 @@ export interface IBeforeDisposeBrowserEditorEvent { veto(): void; } -/** - * Slice the fragment off a raw URL. A literal `#` always starts the fragment, - * so a plain substring keeps the rest of the URL byte-for-byte intact (no - * re-encoding), matching what the navbar displays. - */ -function stripUrlFragment(url: string): string { - const hash = url.indexOf('#'); - return hash === -1 ? url : url.slice(0, hash); -} - /** * Slice both the query and fragment off a raw URL, preserving the exact * encoding of the remaining scheme/authority/path. */ function stripUrlQueryAndFragment(url: string): string { - const stripped = stripUrlFragment(url); - const query = stripped.indexOf('?'); - return query === -1 ? stripped : stripped.slice(0, query); + const suffix = url.search(/[?#]/); + return suffix === -1 ? url : url.slice(0, suffix); } export class BrowserEditorInput extends EditorInput { @@ -256,12 +245,12 @@ export class BrowserEditorInput extends EditorInput { return truncate(this.title!, MAX_TITLE_LENGTH); } - const name = this._associatedResource ? basename(this._associatedResource) : this.getDescription(Verbosity.SHORT) || BrowserEditorInput.DEFAULT_LABEL; + const name = this._associatedResource ? basename(this._associatedResource) : this.url && this.getURLTitles.get(this.url)[Verbosity.SHORT] || BrowserEditorInput.DEFAULT_LABEL; return truncate(name, MAX_TITLE_LENGTH); } override getTitle(verbosity = Verbosity.MEDIUM): string { - const description = this.getDescription(verbosity); + const description = this.url && this.getURLTitles.get(this.url)[verbosity]; const title = this.title ? `${this.title} (${description})` : description; return title || BrowserEditorInput.DEFAULT_LABEL; } @@ -272,15 +261,19 @@ export class BrowserEditorInput extends EditorInput { private readonly getURLTitles = new LRUCachedFunction((url: string) => { let _short: string | undefined = undefined; - let _medium: string | undefined = undefined; - let _long: string | undefined = undefined; + let _mediumlong: string | undefined = undefined; + const mediumlong = () => { + if (_mediumlong === undefined) { + _mediumlong = stripUrlQueryAndFragment(url); + } + return _mediumlong; + }; return { - // Host only. Derived via the WHATWG URL parser so it matches the - // host shown by the navbar's raw URL (e.g. punycode for IDNs). + // Host only for network URLs, path only for file URLs. get [Verbosity.SHORT]() { if (_short === undefined) { const parsed = URL.parse(url); - _short = parsed ? parsed.host : stripUrlQueryAndFragment(url); + _short = parsed ? parsed.protocol === 'file:' ? parsed.pathname : parsed.host : stripUrlQueryAndFragment(url); } return _short; }, @@ -288,18 +281,11 @@ export class BrowserEditorInput extends EditorInput { // (not a URI round-trip) so the displayed text stays byte-for-byte // consistent with the canonical URL shown in the navbar. get [Verbosity.MEDIUM]() { - if (_medium === undefined) { - _medium = stripUrlQueryAndFragment(url); - } - return _medium; + return mediumlong(); }, - // Raw URL without the fragment, sliced from the canonical string for - // the same consistency reason as the medium form. + // Raw URL without the query/fragment. get [Verbosity.LONG]() { - if (_long === undefined) { - _long = stripUrlFragment(url); - } - return _long; + return mediumlong(); } }; }); diff --git a/src/vs/workbench/contrib/browserView/test/electron-browser/browserEditorInput.test.ts b/src/vs/workbench/contrib/browserView/test/electron-browser/browserEditorInput.test.ts index 19a1915e59980c..a24254da9ea5e7 100644 --- a/src/vs/workbench/contrib/browserView/test/electron-browser/browserEditorInput.test.ts +++ b/src/vs/workbench/contrib/browserView/test/electron-browser/browserEditorInput.test.ts @@ -17,7 +17,7 @@ import { IContextKeyService, RawContextKey } from '../../../../../platform/conte import { ITunnelProxyInfo } from '../../../../../platform/tunnel/common/tunnelProxy.js'; import { BrowserEditorInput, BrowserEditorSerializer, IBrowserEditorInputData } from '../../common/browserEditorInput.js'; import { IBrowserViewContextualFilter, IBrowserViewFilterContext, IBrowserViewModel, IBrowserViewOpenHandler, IBrowserViewWorkbenchCreateOptions, IBrowserViewWorkbenchService } from '../../common/browserView.js'; -import { IUntypedEditorInput } from '../../../../common/editor.js'; +import { IUntypedEditorInput, Verbosity } from '../../../../common/editor.js'; import { applyAvailableEditorIds } from '../../../../common/contextkeys.js'; import { IEditorResolverService, RegisteredEditorPriority } from '../../../../services/editor/common/editorResolverService.js'; import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; @@ -172,6 +172,41 @@ suite('BrowserEditorInput', () => { }); }); + test('formats browser URL descriptions by verbosity', () => { + const httpInput = createInput({ + id: 'http-browser', + url: 'https://example.com/path?query=value#fragment' + }); + const fileInput = createInput({ + id: 'file-browser', + url: 'file:///workspace/path%20name.html?query=value#fragment' + }); + + assert.deepStrictEqual({ + http: { + short: httpInput.getDescription(Verbosity.SHORT), + medium: httpInput.getDescription(Verbosity.MEDIUM), + long: httpInput.getDescription(Verbosity.LONG) + }, + file: { + short: fileInput.getDescription(Verbosity.SHORT), + medium: fileInput.getDescription(Verbosity.MEDIUM), + long: fileInput.getDescription(Verbosity.LONG) + } + }, { + http: { + short: 'example.com', + medium: 'https://example.com/path', + long: 'https://example.com/path' + }, + file: { + short: '/workspace/path%20name.html', + medium: 'file:///workspace/path%20name.html', + long: 'file:///workspace/path%20name.html' + } + }); + }); + test('uses restored presentation until the browser reports navigation', () => { let url = ''; let title = ''; From 150cf9472dfd6133c80d0d9a0c4f28bd91fc2b61 Mon Sep 17 00:00:00 2001 From: roblourens Date: Fri, 21 Aug 2026 17:21:55 -0700 Subject: [PATCH 07/10] Agent Host: Make debug log export best-effort (#332062) Agent Host: make debug log export best-effort Skip unavailable supplementary log sources and remove the debug-log byte limit while retaining manifest and entry-count validation.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/base/node/zip.ts | 24 ++++++--- src/vs/base/test/node/zip/zip.test.ts | 23 +++++++- .../browser/agentHostProtocolClient.ts | 6 +-- .../platform/agentHost/common/agentService.ts | 1 - .../agentHost/node/agentHostDebugLogs.ts | 9 +--- .../agentHostProtocolClient.test.ts | 6 +-- .../test/node/agentHostDebugLogs.test.ts | 35 +++++++----- src/vs/platform/native/common/native.ts | 10 +++- .../electron-main/nativeHostMainService.ts | 53 ++++++++++++------- .../exportAgentHostDebugLogsService.ts | 5 +- 10 files changed, 111 insertions(+), 61 deletions(-) diff --git a/src/vs/base/node/zip.ts b/src/vs/base/node/zip.ts index 09d86cd2fb78cc..c94ecdfc998164 100644 --- a/src/vs/base/node/zip.ts +++ b/src/vs/base/node/zip.ts @@ -199,11 +199,15 @@ export interface IFile { * still produces a valid entry. */ localPathSize?: number; + /** + * Skip the entry when its local source cannot be opened or inspected. + */ + skipSourceErrors?: boolean; } export interface IZipValidationOptions { readonly maxEntries: number; - readonly maxUncompressedSize: number; + readonly maxUncompressedSize?: number; } export async function validateZip(zipPath: string, options: IZipValidationOptions): Promise { @@ -234,7 +238,7 @@ export async function validateZip(zipPath: string, options: IZipValidationOption fail(new Error(`ZIP contains too many entries (${entries}; limit ${options.maxEntries})`)); return; } - if (uncompressedSize > options.maxUncompressedSize) { + if (options.maxUncompressedSize !== undefined && uncompressedSize > options.maxUncompressedSize) { fail(new Error(`ZIP expands beyond the allowed size (${uncompressedSize} bytes; limit ${options.maxUncompressedSize} bytes)`)); return; } @@ -263,7 +267,7 @@ export async function zip(zipPath: string, files: IFile[]): Promise { if (f.contents !== undefined) { zip.addBuffer(typeof f.contents === 'string' ? Buffer.from(f.contents, 'utf8') : f.contents, f.path); } else if (f.localPath) { - if (f.localPathSize === undefined) { + if (f.localPathSize === undefined && !f.skipSourceErrors) { zip.addFile(f.localPath, f.path); } else { // yazl aborts the archive unless the streamed byte count matches the @@ -274,14 +278,23 @@ export async function zip(zipPath: string, files: IFile[]): Promise { try { handle = await promises.open(f.localPath, 'r'); } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + if (f.skipSourceErrors) { continue; } throw error; } let streamOwnsHandle = false; try { - const size = Math.min(f.localPathSize, (await handle.stat()).size); + let size: number; + try { + const currentSize = (await handle.stat()).size; + size = f.localPathSize === undefined ? currentSize : Math.min(f.localPathSize, currentSize); + } catch (error) { + if (f.skipSourceErrors) { + continue; + } + throw error; + } if (size === 0) { zip.addBuffer(Buffer.alloc(0), f.path); } else { @@ -303,7 +316,6 @@ export async function zip(zipPath: string, files: IFile[]): Promise { return result; } - export function extract(zipPath: string, targetPath: string, options: IExtractOptions = {}, token: CancellationToken): Promise { const sourcePathRegex = new RegExp(options.sourcePath ? `^${options.sourcePath}` : ''); diff --git a/src/vs/base/test/node/zip/zip.test.ts b/src/vs/base/test/node/zip/zip.test.ts index 63671eff1b52aa..5f0cfa61dccdaa 100644 --- a/src/vs/base/test/node/zip/zip.test.ts +++ b/src/vs/base/test/node/zip/zip.test.ts @@ -72,7 +72,7 @@ suite('Zip', () => { await Promises.rm(testDir); }); - test('zip should skip a vanished streamed source without failing the archive', async () => { + test('zip should skip an unavailable best-effort bounded source without failing the archive', async () => { const testDir = getRandomTestPath(tmpdir(), 'vsctests', 'zip'); const presentPath = path.join(testDir, 'present.txt'); const missingPath = path.join(testDir, 'missing.txt'); @@ -80,7 +80,7 @@ suite('Zip', () => { await fs.promises.mkdir(testDir, { recursive: true }); await fs.promises.writeFile(presentPath, 'present-contents'); await zip(zipPath, [ - { path: 'missing.txt', localPath: missingPath, localPathSize: 8 }, + { path: 'missing.txt', localPath: missingPath, localPathSize: 8, skipSourceErrors: true }, { path: 'present.txt', localPath: presentPath, localPathSize: 7 }, ]); @@ -90,6 +90,24 @@ suite('Zip', () => { await Promises.rm(testDir); }); + test('zip should skip an unavailable best-effort source without a size limit', async () => { + const testDir = getRandomTestPath(tmpdir(), 'vsctests', 'zip'); + const presentPath = path.join(testDir, 'present.txt'); + const missingPath = path.join(testDir, 'missing.txt'); + const zipPath = path.join(testDir, 'logs.zip'); + await fs.promises.mkdir(testDir, { recursive: true }); + await fs.promises.writeFile(presentPath, 'present-contents'); + await zip(zipPath, [ + { path: 'missing.txt', localPath: missingPath, skipSourceErrors: true }, + { path: 'present.txt', localPath: presentPath }, + ]); + + assert.strictEqual((await buffer(zipPath, 'present.txt')).toString(), 'present-contents'); + await assert.rejects(buffer(zipPath, 'missing.txt')); + + await Promises.rm(testDir); + }); + test('validateZip enforces entry and expanded-size limits', async () => { const testDir = getRandomTestPath(tmpdir(), 'vsctests', 'zip-validation'); const zipPath = path.join(testDir, 'logs.zip'); @@ -99,6 +117,7 @@ suite('Zip', () => { { path: 'two.txt', contents: '5678' }, ]); + await validateZip(zipPath, { maxEntries: 10 }); await assert.rejects(validateZip(zipPath, { maxEntries: 1, maxUncompressedSize: 100 }), /too many entries/); await assert.rejects(validateZip(zipPath, { maxEntries: 10, maxUncompressedSize: 7 }), /expands beyond the allowed size/); await Promises.rm(testDir); diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index 8c9652ab8a179f..5a2da82606256b 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -17,7 +17,7 @@ import { ILogService } from '../../log/common/log.js'; import { FileSystemProviderErrorCode, toFileSystemProviderErrorCode } from '../../files/common/files.js'; import { ConfigurationTargetToString, IConfigurationService } from '../../configuration/common/configuration.js'; import { AgentSession, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../common/agent.js'; -import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, IAgentConnection, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; +import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, IAgentConnection, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; import { CollectAgentHostDebugLogsExtensionMethod, GetAgentHostSessionStateFileExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, type IAgentHostExtensionCommandMap } from '../common/agentHostExtensionProtocol.js'; import { AMBIENT_AGENT_HOST_AUTHORITY } from '../common/agentHostConnectionsService.js'; import { createRemoteWatchHandle, type IRemoteWatchHandle } from '../common/agentHostFileSystemProvider.js'; @@ -1152,8 +1152,8 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect if (resource.scheme !== Schemas.file) { throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Agent Host returned a non-file debug log resource: ${resource.toString()}`); } - if (!Number.isSafeInteger(result.size) || result.size < 0 || result.size > AGENT_HOST_DEBUG_LOGS_MAX_BYTES - || !Number.isSafeInteger(result.uncompressedSize) || result.uncompressedSize < 0 || result.uncompressedSize > AGENT_HOST_DEBUG_LOGS_MAX_BYTES) { + if (!Number.isSafeInteger(result.size) || result.size < 0 + || !Number.isSafeInteger(result.uncompressedSize) || result.uncompressedSize < 0) { throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Agent Host returned invalid debug log artifact sizes'); } if (!Array.isArray(result.entries) || result.entries.length > AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES) { diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 68b3618ba3988b..8e6f4d8ba7c13e 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -71,7 +71,6 @@ export const enum AgentHostIpcChannels { export const AgentHostAhpJsonlLoggingSettingId = 'chat.agentHost.ahpJsonlLoggingEnabled'; export type AgentHostDebugLogsArtifactKind = 'archive' | 'directory'; -export const AGENT_HOST_DEBUG_LOGS_MAX_BYTES = 256 * 1024 * 1024; /** Maximum number of files in one Agent Host debug-log artifact. */ export const AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES = 1000; /** diff --git a/src/vs/platform/agentHost/node/agentHostDebugLogs.ts b/src/vs/platform/agentHost/node/agentHostDebugLogs.ts index c65140993fc95f..7cceb2c63800db 100644 --- a/src/vs/platform/agentHost/node/agentHostDebugLogs.ts +++ b/src/vs/platform/agentHost/node/agentHostDebugLogs.ts @@ -14,7 +14,7 @@ import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import type { ILogService } from '../../log/common/log.js'; import type { IAgent } from '../common/agent.js'; -import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; +import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; type DebugLogsProvider = Pick; type LocalZipFile = IFile & { readonly localPath: string }; @@ -67,10 +67,6 @@ export class AgentHostDebugLogsCollector extends Disposable { uncompressedSize += size; artifactEntries.push({ path: file.path, size }); } - if (uncompressedSize > AGENT_HOST_DEBUG_LOGS_MAX_BYTES) { - throw new Error(`Agent Host debug logs are too large (${uncompressedSize} bytes; limit ${AGENT_HOST_DEBUG_LOGS_MAX_BYTES} bytes)`); - } - if (kind === 'directory') { retainStaging = true; this._scheduleCleanup(staging, true, files.map(file => file.localPath)); @@ -79,9 +75,6 @@ export class AgentHostDebugLogsCollector extends Disposable { await zip(archive, files); const archiveSize = (await stat(archive)).size; - if (archiveSize > AGENT_HOST_DEBUG_LOGS_MAX_BYTES) { - throw new Error(`Agent Host debug log archive is too large (${archiveSize} bytes; limit ${AGENT_HOST_DEBUG_LOGS_MAX_BYTES} bytes)`); - } this._scheduleCleanup(archive, false, [archive]); return { kind, resource: URI.file(archive), providerLogsIncluded, size: archiveSize, uncompressedSize, entries: artifactEntries }; } catch (error) { 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 626226f39519fb..231557b224432c 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -1472,10 +1472,10 @@ suite('AgentHostProtocolClient', () => { assert.strictEqual((await resultPromise).uncompressedSize, entrySize * 2); }); - test('collectDebugLogs accepts a directory containing 30 MiB of rotated logs', async () => { + test('collectDebugLogs accepts a directory larger than the previous 256 MiB limit', async () => { const { client, transport } = createClient(); const resultPromise = client.collectDebugLogs(URI.parse('copilotcli:/session-1'), 'directory'); - const entrySize = 5 * 1024 * 1024; + const entrySize = 50 * 1024 * 1024; const entries = Array.from({ length: 6 }, (_, index) => ({ path: index === 0 ? 'agenthost.log' : `agenthost.${index}.log`, size: entrySize, @@ -1488,7 +1488,7 @@ suite('AgentHostProtocolClient', () => { }, }); - assert.strictEqual((await resultPromise).uncompressedSize, 30 * 1024 * 1024); + assert.strictEqual((await resultPromise).uncompressedSize, 300 * 1024 * 1024); }); test('collectDebugLogs rejects an unsafe or inconsistent artifact manifest', async () => { diff --git a/src/vs/platform/agentHost/test/node/agentHostDebugLogs.test.ts b/src/vs/platform/agentHost/test/node/agentHostDebugLogs.test.ts index ae5b866956d2ed..9a8339c9182170 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDebugLogs.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDebugLogs.test.ts @@ -14,7 +14,7 @@ import { buffer } from '../../../../base/node/zip.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { AgentHostDebugLogsCollector } from '../../node/agentHostDebugLogs.js'; -import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES } from '../../common/agentService.js'; +import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES } from '../../common/agentService.js'; suite('AgentHostDebugLogsCollector', () => { const emptyProvider = { id: 'test', collectDebugLogs: async () => false }; @@ -62,23 +62,22 @@ suite('AgentHostDebugLogsCollector', () => { assert.deepStrictEqual({ kind: result.kind, providerLogsIncluded: result.providerLogsIncluded, - sizeIsBounded: result.size > 0 && result.uncompressedSize > 0 - && result.size <= AGENT_HOST_DEBUG_LOGS_MAX_BYTES - && result.uncompressedSize <= AGENT_HOST_DEBUG_LOGS_MAX_BYTES, + sizesArePositive: result.size > 0 && result.uncompressedSize > 0, events: (await buffer(result.resource.fsPath, 'events.jsonl')).toString(), agentHost: (await buffer(result.resource.fsPath, 'agenthost.log')).toString(), }, { kind: 'archive', providerLogsIncluded: true, - sizeIsBounded: true, + sizesArePositive: true, events: 'event', agentHost: 'agent host', }); }); - test('rejects and cleans an oversized directory artifact', async () => { + test('collects a directory artifact larger than the previous 256 MiB limit', async () => { const logsHome = join(testRoot, 'logs'); const outputRoot = join(testRoot, 'tmp'); + const largeLogSize = 300 * 1024 * 1024; await mkdir(logsHome, { recursive: true }); await mkdir(outputRoot, { recursive: true }); const collector = disposables.add(new AgentHostDebugLogsCollector({ @@ -86,18 +85,26 @@ suite('AgentHostDebugLogsCollector', () => { tmpDir: URI.file(outputRoot), }, new NullLogService())); - await assert.rejects(collector.collect([{ + const result = await collector.collect([{ id: 'test', collectDebugLogs: async (_session, outputDirectory) => { - for (let i = 0; i < 3; i++) { - const largeLog = join(outputDirectory.fsPath, `large-${i}.log`); - await writeFile(largeLog, ''); - await truncate(largeLog, Math.floor(AGENT_HOST_DEBUG_LOGS_MAX_BYTES / 2)); - } + const largeLog = join(outputDirectory.fsPath, 'large.log'); + await writeFile(largeLog, ''); + await truncate(largeLog, largeLogSize); return true; }, - }], URI.parse('test:/session-1'), 'directory'), /Agent Host debug logs are too large/); - assert.deepStrictEqual(await readdir(outputRoot), []); + }], URI.parse('test:/session-1'), 'directory'); + + assert.deepStrictEqual({ + size: result.size, + uncompressedSize: result.uncompressedSize, + entries: result.entries, + }, { + size: largeLogSize, + uncompressedSize: largeLogSize, + entries: [{ path: 'large.log', size: largeLogSize }], + }); + await collector.cleanup(); }); test('rejects and cleans an artifact with too many files', async () => { diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index fd5c10d5fd7551..f148a678200c52 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -38,11 +38,17 @@ export interface IToastResult { */ export type INativeZipFile = | { readonly path: string; readonly contents: string } - | { readonly path: string; readonly source: URI; readonly size: number } + | { + readonly path: string; + readonly source: URI; + readonly size: number; + /** Skip this entry when its source cannot be opened or inspected. */ + readonly skipSourceErrors?: boolean; + } | { readonly sourceArchive: URI }; export interface INativeZipOptions { - readonly maxSize: number; + readonly maxSize?: number; readonly maxEntries: number; } diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index cb912063fe68cf..db3a6aa9994343 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -1424,6 +1424,7 @@ export class NativeHostMainService extends Disposable implements INativeHostMain async createZipFile(windowId: number | undefined, zipPath: URI, files: INativeZipFile[], options?: INativeZipOptions): Promise { const zipFiles: IFile[] = []; const temporaryDirectories: string[] = []; + const maxSize = options?.maxSize; try { for (const file of files) { if (hasKey(file, { contents: true })) { @@ -1438,13 +1439,13 @@ export class NativeHostMainService extends Disposable implements INativeHostMain const temporaryDirectory = join(this.environmentMainService.tmpDir.fsPath, `vscode-zip-merge-${randomPath()}`); temporaryDirectories.push(temporaryDirectory); const archiveSize = (await fs.promises.stat(sourceArchive.fsPath)).size; - if (options && archiveSize > options.maxSize) { - throw new Error(`ZIP is too large to merge (${archiveSize} bytes; limit ${options.maxSize} bytes)`); + if (maxSize !== undefined && archiveSize > maxSize) { + throw new Error(`ZIP is too large to merge (${archiveSize} bytes; limit ${maxSize} bytes)`); } if (options) { await validateZip(sourceArchive.fsPath, { maxEntries: options.maxEntries, - maxUncompressedSize: options.maxSize, + maxUncompressedSize: maxSize, }); } await extract(sourceArchive.fsPath, temporaryDirectory, {}, CancellationToken.None); @@ -1455,34 +1456,48 @@ export class NativeHostMainService extends Disposable implements INativeHostMain if (source.scheme !== Schemas.file) { throw new Error(`Cannot add non-local resource '${source.toString()}' to a zip file`); } - zipFiles.push({ path: file.path, localPath: source.fsPath, localPathSize: file.size }); + zipFiles.push({ path: file.path, localPath: source.fsPath, localPathSize: file.size, skipSourceErrors: file.skipSourceErrors }); } const paths = new Set(); let uncompressedSize = 0; + const availableZipFiles: IFile[] = []; for (const file of zipFiles) { + let fileSize = 0; + if (file.contents !== undefined) { + fileSize = typeof file.contents === 'string' ? Buffer.byteLength(file.contents) : file.contents.byteLength; + } else if (file.localPath) { + try { + const size = (await fs.promises.stat(file.localPath)).size; + fileSize = file.localPathSize === undefined ? size : Math.min(size, file.localPathSize); + } catch (error) { + if (file.skipSourceErrors) { + this.logService.warn(`[NativeHostMainService] Skipping ZIP entry '${file.path}' because its source could not be read: ${error instanceof Error ? error.message : String(error)}`); + continue; + } + throw error; + } + } if (paths.has(file.path)) { throw new Error(`Duplicate ZIP entry '${file.path}'`); } paths.add(file.path); - if (file.contents !== undefined) { - uncompressedSize += typeof file.contents === 'string' ? Buffer.byteLength(file.contents) : file.contents.byteLength; - } else if (file.localPath) { - const size = (await fs.promises.stat(file.localPath)).size; - uncompressedSize += file.localPathSize === undefined ? size : Math.min(size, file.localPathSize); - } - if (options && uncompressedSize > options.maxSize) { - throw new Error(`ZIP expands beyond the allowed size (${uncompressedSize} bytes; limit ${options.maxSize} bytes)`); + availableZipFiles.push(file); + uncompressedSize += fileSize; + if (maxSize !== undefined && uncompressedSize > maxSize) { + throw new Error(`ZIP expands beyond the allowed size (${uncompressedSize} bytes; limit ${maxSize} bytes)`); } } - if (options && zipFiles.length > options.maxEntries) { - throw new Error(`ZIP contains too many entries (${zipFiles.length}; limit ${options.maxEntries})`); + if (options && availableZipFiles.length > options.maxEntries) { + throw new Error(`ZIP contains too many entries (${availableZipFiles.length}; limit ${options.maxEntries})`); } - await zip(zipPath.fsPath, zipFiles); - const zipSize = (await fs.promises.stat(zipPath.fsPath)).size; - if (options && zipSize > options.maxSize) { - await fs.promises.rm(zipPath.fsPath, { force: true }); - throw new Error(`ZIP is too large (${zipSize} bytes; limit ${options.maxSize} bytes)`); + await zip(zipPath.fsPath, availableZipFiles); + if (maxSize !== undefined) { + const zipSize = (await fs.promises.stat(zipPath.fsPath)).size; + if (zipSize > maxSize) { + await fs.promises.rm(zipPath.fsPath, { force: true }); + throw new Error(`ZIP is too large (${zipSize} bytes; limit ${maxSize} bytes)`); + } } } finally { await Promise.all(temporaryDirectories.map(directory => Promises.rm(directory))); 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 bf32959b2313c5..d1bb5528c624e4 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/actions/exportAgentHostDebugLogsService.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/actions/exportAgentHostDebugLogsService.ts @@ -9,7 +9,7 @@ import { hasKey } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { localize } from '../../../../../nls.js'; -import { AGENT_HOST_DEBUG_LOGS_MAX_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES } from '../../../../../platform/agentHost/common/agentService.js'; +import { AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES } from '../../../../../platform/agentHost/common/agentService.js'; import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { INativeEnvironmentService } from '../../../../../platform/environment/common/environment.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; @@ -46,7 +46,7 @@ class NativeAgentHostDebugLogsExportService implements IAgentHostDebugLogsExport const zipFiles: INativeZipFile[] = files.map(file => { return hasKey(file, { contents: true }) ? file - : { path: file.path, source: file.resource.scheme === Schemas.vscodeUserData ? file.resource.with({ scheme: Schemas.file }) : file.resource, size: file.size }; + : { path: file.path, source: file.resource.scheme === Schemas.vscodeUserData ? file.resource.with({ scheme: Schemas.file }) : file.resource, size: file.size, skipSourceErrors: true }; }); let temporaryHostArchive: URI | undefined; try { @@ -65,7 +65,6 @@ class NativeAgentHostDebugLogsExportService implements IAgentHostDebugLogsExport } zipFiles.push({ sourceArchive: localHostArchive }); await this.nativeHostService.createZipFile(saveUri, zipFiles, { - maxSize: AGENT_HOST_DEBUG_LOGS_MAX_BYTES, maxEntries: AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, }); } finally { From 00cc2df8db2d5c4aee55d4ad14f0835da2e4729a Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Fri, 21 Aug 2026 18:03:05 -0700 Subject: [PATCH 08/10] Fix hover on command status bar items (#332068) --- .../workbench/browser/parts/statusbar/media/statusbarpart.css | 4 ++++ src/vs/workbench/browser/parts/statusbar/statusbarItem.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/src/vs/workbench/browser/parts/statusbar/media/statusbarpart.css b/src/vs/workbench/browser/parts/statusbar/media/statusbarpart.css index 1f3b102ebb13eb..6fc44ed872a26c 100644 --- a/src/vs/workbench/browser/parts/statusbar/media/statusbarpart.css +++ b/src/vs/workbench/browser/parts/statusbar/media/statusbarpart.css @@ -183,6 +183,10 @@ background-color: var(--vscode-statusBarItem-warningBackground); } +.monaco-workbench .part.statusbar > .items-container > .statusbar-item.warning-kind.has-command:hover { + background-color: var(--vscode-statusBarItem-warningHoverBackground); +} + .monaco-workbench .part.statusbar > .items-container > .statusbar-item.warning-kind a:hover:not(.disabled) { color: var(--vscode-statusBarItem-warningHoverForeground); background-color: var(--vscode-statusBarItem-warningHoverBackground) !important; diff --git a/src/vs/workbench/browser/parts/statusbar/statusbarItem.ts b/src/vs/workbench/browser/parts/statusbar/statusbarItem.ts index 5c827aa15d11ef..bbee47f5a3acad 100644 --- a/src/vs/workbench/browser/parts/statusbar/statusbarItem.ts +++ b/src/vs/workbench/browser/parts/statusbar/statusbarItem.ts @@ -165,8 +165,10 @@ export class StatusbarEntryItem extends Disposable { } }); + this.container.classList.add('has-command'); this.labelContainer.classList.remove('disabled'); } else { + this.container.classList.remove('has-command'); this.labelContainer.classList.add('disabled'); } } From 62ecd558f23ec5e30d1ffa7eb673313abebf65b0 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:40:36 -0700 Subject: [PATCH 09/10] agentHost: don't report a session as missing while its catalog migration is in flight (#332080) * agentHost: don't report a session as missing while its catalog migration is in flight * feedback update --- .../platform/agentHost/node/agentService.ts | 14 ++++-- .../agentHost/test/node/agentService.test.ts | 47 +++++++++++++++++-- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 9336c79e51c54e..8759e5e52a360c 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -4660,8 +4660,8 @@ export class AgentService extends Disposable implements IAgentService { } return readable; })(); - const registeredSession = (await this._listRegisteredSessions()).find(entry => entry.session.toString() === sessionStr); - const external = registeredSession?.external ?? false; + let registeredSession = (await this._listRegisteredSessions()).find(entry => entry.session.toString() === sessionStr); + let external = registeredSession?.external ?? false; this._logService.trace(`[AgentService] restore: catalog and registry resolved for ${sessionStr} (registered=${!!registeredSession}, external=${external})`); // Adopt-on-open for a surfaced un-adopted legacy Copilot CLI session, strictly gated on the live migrate setting (a no-op for native / already-adopted sessions). @@ -4687,8 +4687,14 @@ export class AgentService extends Disposable implements IAgentService { // created, hidden while `showExternalSessions` is `none`) would be // materialized here and thereby claimed away from the extension host's list. if (!registeredSession && migrateLegacyEnabled && agent.ensureChatAdopted && !adoption.eligible && !adoption.native) { - this._logService.info(`[AgentService] restore refused for unregistered ${sessionStr}: not an adoptable legacy chat (reason=${adoption.reason ?? 'unknown'})`); - throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session is not an adoptable legacy chat: ${sessionStr}`); + // The registry was read before the deferred catalog wait, so absence is only authoritative once that catalog is readable (#331721). + await awaitCatalogReadable(); + registeredSession = (await this._listRegisteredSessions()).find(entry => entry.session.toString() === sessionStr); + external = registeredSession?.external ?? external; + if (!registeredSession) { + this._logService.info(`[AgentService] restore refused for unregistered ${sessionStr}: not an adoptable legacy chat (reason=${adoption.reason ?? 'unknown'})`); + throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session is not an adoptable legacy chat: ${sessionStr}`); + } } // From here the whole restore is wrapped so `migrated` is reported only diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index b3234280e9272a..5a04c0400dd51a 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -6559,17 +6559,18 @@ suite('AgentService (node dispatcher)', () => { const restore = svc.restoreSession(session); await timeout(0); - const metadataCallsBeforeMigration = agent.metadataCalls; + // Metadata reads are now made before the catalog wait, so counting them here would only track scheduling. + const hydratedBeforeMigration = !!svc.stateManager.getSessionState(session.toString()); agent.migrationGate.complete(); await restore; assert.deepStrictEqual({ - metadataCallsBeforeMigration, + hydratedBeforeMigration, metadataReadAfterMigration: agent.metadataCalls > 0, registeredSessions: (await svc.getRegisteredSessions()).map(resource => resource.toString()), restored: !!svc.stateManager.getSessionState(session.toString()), }, { - metadataCallsBeforeMigration: 0, + hydratedBeforeMigration: false, metadataReadAfterMigration: true, registeredSessions: [session.toString()], restored: true, @@ -6595,6 +6596,46 @@ suite('AgentService (node dispatcher)', () => { }); suite('initial provider migration race (#331648)', () => { + /** Provider whose catalog migration registers the session, and which is describable throughout. */ + class BackfillRegistersAgent extends MockAgent { + override readonly onDidDiscoverChats = Event.None; + readonly migrationGate = new DeferredPromise(); + /** Settles once restore has read the registry and reached the adoption probe. */ + readonly adoptionProbed = new DeferredPromise(); + constructor(readonly backfilled: URI) { super('copilot'); } + + override async listChatsToMigrate(): Promise { + await this.migrationGate.p; + return [{ chat: URI.parse(buildDefaultChatUri(this.backfilled)), startTime: Date.now(), modifiedTime: Date.now() }]; + } + + // Not a legacy Copilot CLI chat, e.g. an external chat the GitHub app created. + async ensureChatAdopted(): Promise { + if (!this.adoptionProbed.isSettled) { + this.adoptionProbed.complete(); + } + return { adopted: false, eligible: false }; + } + } + + test('a session the catalog migration will register is not reported missing while that migration is in flight', async () => { + const svc = makeService(); + const session = AgentSession.uri('copilot', 'registered-by-backfill'); + const agent = disposables.add(new BackfillRegistersAgent(session)); + seedSession(agent, session); + svc.registerProvider(agent); + getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + + const restore = svc.restoreSession(session); + // Restore must reach the unregistered-session guard while the backfill that + // would register it is still gated. + await agent.adoptionProbed.p; + agent.migrationGate.complete(); + + await restore; + assert.strictEqual(!!svc.stateManager.getSessionState(session.toString()), true); + }); + /** Provider whose catalog migration is gated; per-session metadata is unavailable until it completes. */ class StartupRaceAgent extends MockAgent { override readonly onDidDiscoverChats = Event.None; From 9657ef29dc4f6eaa6ac7fbae628ced90e44f9f22 Mon Sep 17 00:00:00 2001 From: roblourens Date: Fri, 21 Aug 2026 21:11:04 -0700 Subject: [PATCH 10/10] Adopt a sealed Agent Host service graph (#332036) * Simplify AgentService composition Narrow internal service dependencies, move runtime collaborators out of AgentService, and replace two-phase initialization with a constructor-complete composition. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Define Agent Host service ownership Give the complete Agent Host service graph one disposable runtime owner and document workbench-style placement rules for bootstrap instances, shared orchestration services, and runtime activation. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Document Agent Host service construction Add the target service-placement model and a tested sealable process-local service collection without enabling the seal in production yet. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Create Agent Host service foundation Build callback, state, configuration, authentication, endpoint, proxy, and request foundations before telemetry and share the synchronous path with AgentService tests. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Register Agent Host bootstrap services Migrate bootstrap-owned core and host services to local descriptors, eagerly resolve them under strict DI, and preserve typed test overrides. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Seal the Agent Host service graph Migrate composition-owned and Copilot-dependent services atomically to local descriptors, resolve the complete graph eagerly, and reject late registrations. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Inject AgentService runtime dependencies Make network diagnostics and edit attribution immutable AgentService dependencies and order test-graph teardown after composition-owned listeners. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Activate Agent Host contributions separately Move changeset and completion registrations into an order-preserving post-graph activation phase with explicit disposable ownership. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Finalize the Agent Host service runtime Resolve runtime services through DI, reduce the runtime facade, harden proxy and disposal invariants, and mark the service construction guide current. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clarify Agent Host child service scopes Define one primary runtime graph while allowing explicitly owned child instantiation services and scoped service collections when isolation requires them. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Document Agent Host service model debt Separate stable service-graph contracts from accepted callback, worktree, foundation, concrete-type, and test-seam warts with explicit exit conditions. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Focus Agent Host bootstrap documentation Rename the service guide, add maintenance rules, explain eager resolution as migration-risk control, and distinguish one primary graph from valid scoped child graphs. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Type customization worktree binding Expose worktree binding as a narrow customization-enablement capability and remove the composition root's concrete implementation assertion. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove AgentService state manager escape hatch Route tests through the composition-owned state manager and fix bootstrap cleanup ordering found during review. Clarify descriptor rules for trailing defaulted parameters. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Dispose partial Agent Host contributions Ensure activation failures clean up registrations created earlier in the contribution phase and cover the failure path with a focused test. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Tighten sealed service resolution Allow a sealed descriptor to be replaced only by an instance of its registered constructor, and reject unrelated implementations before descriptor resolution. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostTesting.instructions.md | 7 + src/vs/platform/agentHost/AGENTS.md | 3 + .../agentHost/node/agentHostBootstrap.ts | 250 +++--- .../agentHost/node/agentHostContributions.ts | 50 ++ ...agentHostCustomizationEnablementService.ts | 13 +- .../platform/agentHost/node/agentHostMain.ts | 50 +- .../agentHost/node/agentHostProxyResolver.ts | 26 +- .../agentHost/node/agentHostServerMain.ts | 43 +- .../agentHost/node/agentHostServices.ts | 165 ++++ .../platform/agentHost/node/agentService.ts | 52 +- .../agentHost/node/agentServiceComposition.ts | 222 ++--- .../agentHost/node/agentServiceFoundation.ts | 152 ++++ .../agentHost/node/serviceBootstrapping.md | 232 +++++ .../test/node/agentHostBootstrap.test.ts | 81 +- .../test/node/agentHostContributions.test.ts | 71 ++ .../test/node/agentHostRequestService.test.ts | 28 +- .../test/node/agentHostServices.test.ts | 206 +++++ .../agentHost/test/node/agentService.test.ts | 791 +++++++++--------- .../test/node/agentServiceTestUtils.ts | 53 +- .../test/node/agentSideEffects.test.ts | 6 +- .../agentHost/test/node/claudeAgent.test.ts | 4 +- .../agentHost/test/node/copilotAgent.test.ts | 2 - 22 files changed, 1656 insertions(+), 851 deletions(-) create mode 100644 src/vs/platform/agentHost/node/agentHostContributions.ts create mode 100644 src/vs/platform/agentHost/node/agentHostServices.ts create mode 100644 src/vs/platform/agentHost/node/agentServiceFoundation.ts create mode 100644 src/vs/platform/agentHost/node/serviceBootstrapping.md create mode 100644 src/vs/platform/agentHost/test/node/agentHostContributions.test.ts create mode 100644 src/vs/platform/agentHost/test/node/agentHostServices.test.ts diff --git a/.github/instructions/agentHostTesting.instructions.md b/.github/instructions/agentHostTesting.instructions.md index 7f916384e189b0..d068e1a1516b6f 100644 --- a/.github/instructions/agentHostTesting.instructions.md +++ b/.github/instructions/agentHostTesting.instructions.md @@ -20,6 +20,13 @@ The sessions process is a portable, standalone server that multiple clients can See the agent host protocol documentation for more details. +## Service Construction + +Read `src/vs/platform/agentHost/node/serviceBootstrapping.md` before adding or +moving a node Agent Host service. It is the canonical guide for service +placement, static constructor arguments, activation, test overrides, and +disposal ownership. + ## End to End Testing You can run `node ./scripts/code-agent-host.js` to start an agent host. If you pass `--enable-mock-agent`, then the `ScriptedMockAgent` will be used. diff --git a/src/vs/platform/agentHost/AGENTS.md b/src/vs/platform/agentHost/AGENTS.md index 8546d1fe00f70d..74d1c44cfef6f9 100644 --- a/src/vs/platform/agentHost/AGENTS.md +++ b/src/vs/platform/agentHost/AGENTS.md @@ -10,6 +10,9 @@ # Multi-Chat Architecture +> Node runtime service construction is documented separately in +> [`node/serviceBootstrapping.md`](node/serviceBootstrapping.md). + > **Status: COMPLETE** (2026-07-01) > All waves Aโ€“D and gates G-B1, G-C1, G-C2, G-D1 are done. Codex, Claude, and > Copilot all use the unified orchestrator path. diff --git a/src/vs/platform/agentHost/node/agentHostBootstrap.ts b/src/vs/platform/agentHost/node/agentHostBootstrap.ts index 10be5bad66b7fc..66d8e40c62c515 100644 --- a/src/vs/platform/agentHost/node/agentHostBootstrap.ts +++ b/src/vs/platform/agentHost/node/agentHostBootstrap.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { DisposableStore } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, type IDisposable } from '../../../base/common/lifecycle.js'; import type { Event } from '../../../base/common/event.js'; import type { IObservable } from '../../../base/common/observable.js'; import { joinPath } from '../../../base/common/resources.js'; @@ -15,63 +15,31 @@ import { FileService } from '../../files/common/fileService.js'; import { DiskFileSystemProvider } from '../../files/node/diskFileSystemProvider.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { InstantiationService } from '../../instantiation/common/instantiationService.js'; -import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; import { ILoggerService, ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; -import { IRequestService } from '../../request/common/request.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; -import { ISandboxHelperService } from '../../sandbox/common/sandboxHelperService.js'; -import { SandboxHelperService } from '../../sandbox/node/sandboxHelper.js'; -import { IWindowsMxcTerminalSandboxRuntime, WindowsMxcTerminalSandboxRuntime } from '../../sandbox/common/terminalSandboxMxcRuntime.js'; -import { IAgentPluginManager } from '../common/agentPluginManager.js'; -import { IDiffComputeService } from '../common/diffComputeService.js'; -import { IAgentEditAttributionService } from '../common/fileEditAttribution.js'; -import { IAgentHostGitService } from '../common/agentHostGitService.js'; -import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js'; import { ISessionDataService } from '../common/sessionDataService.js'; import type { IAgent } from '../common/agent.js'; -import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js'; -import { AgentHostGitService } from './agentHostGitService.js'; -import { AgentHostOTelService } from './otel/agentHostOTelService.js'; -import { AgentHostProxyResolver, IAgentHostProxyResolver } from './agentHostProxyResolver.js'; -import { AgentHostRequestService } from './agentHostRequestService.js'; -import { createAgentHostTelemetryService, IAgentHostTelemetryService } from './agentHostTelemetryService.js'; -import { IAgentConfigurationService } from './agentConfigurationService.js'; -import { IAgentHostCompletions } from './agentHostCompletions.js'; -import { IAgentHostCustomizationEnablementService } from './agentHostCustomizationEnablementService.js'; -import { AgentHostStateManager } from './agentHostStateManager.js'; +import { createAgentHostTelemetryService } from './agentHostTelemetryService.js'; import { AgentService, IAgentServiceOptions } from './agentService.js'; import { createAgentServiceComposition } from './agentServiceComposition.js'; -import { INetworkDiagnosticsService, NetworkDiagnosticsService } from './networkDiagnosticsService.js'; -import { AgentPluginManager } from './agentPluginManager.js'; -import { NodeWorkerDiffComputeService } from './diffComputeService.js'; -import { AgentEditAttributionService } from './shared/agentEditAttributionService.js'; -import { EditArcReporterService, IEditArcReporterService } from './shared/editArcReporter.js'; -import { EditSurvivalReporterFactory, IEditSurvivalReporterFactory } from './shared/editSurvivalReporter.js'; +import { activateAgentHostContributions } from './agentHostContributions.js'; +import { createAgentServiceFoundation } from './agentServiceFoundation.js'; +import { AgentHostServiceCollection, instantiateAgentHostServices, registerAgentHostCoreServices, registerAgentHostHostServices } from './agentHostServices.js'; import { IAgentHostWorktreeIsolation, WorktreeIsolation } from './shared/worktreeIsolation.js'; -import { AgentSdkDownloader, IAgentSdkDownloader, type IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; -import { IClaudeAgentSdkService, ClaudeAgentSdkService } from './claude/claudeAgentSdkService.js'; -import { ClaudeProxyService, IClaudeProxyService } from './claude/claudeProxyService.js'; -import { CodexProxyService, ICodexProxyService } from './codex/codexProxyService.js'; +import { IAgentSdkDownloader, type IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; import { IByokLmBridgeRegistry, NullByokLmBridgeRegistry } from './byokLmBridgeRegistry.js'; -import { ByokLmProxyService, IByokLmProxyService, NullByokLmProxyService } from './copilot/byokLmProxyService.js'; import { registerPendingEditContentProvider } from './copilot/pendingEditContentStore.js'; import { SessionDataService } from './sessionDataService.js'; import { IAgentCustomizationSettingsRegistration } from '../common/agentCustomizationSettings.js'; import { AgentHostLaunchKind } from '../common/agentHostTelemetry.js'; import { AgentHostClientConnectionService, IAgentHostClientConnectionService } from './agentHostClientConnectionService.js'; -export interface IAgentHostNetworkServices { - readonly proxyResolver: IAgentHostProxyResolver; - readonly requestService: IRequestService; -} - export interface ICreateAgentHostRuntimeOptions { readonly environmentService: INativeEnvironmentService; readonly productService: IProductService; readonly logService: ILogService; readonly loggerService: ILoggerService | undefined; - readonly disposables: DisposableStore; readonly disableTelemetry?: boolean; readonly transientProxyConfiguration: boolean; readonly hostLaunchKind: AgentHostLaunchKind; @@ -83,84 +51,65 @@ export interface ICreateAgentHostRuntimeOptions { readonly byok: { readonly kind: 'renderer'; readonly bridgeRegistry: IByokLmBridgeRegistry } | { readonly kind: 'unavailable' }; } -export interface IAgentHostRuntime { +export interface IAgentHostRuntime extends IDisposable { + readonly instantiationService: IInstantiationService; + readonly agentService: AgentService; + readonly agents: IObservable; + readonly onDidStartTurn: Event; + readonly sdkDownloadProgress: Event; +} + +class AgentHostRuntime extends Disposable implements IAgentHostRuntime { readonly instantiationService: IInstantiationService; readonly agentService: AgentService; - readonly configurationService: IAgentConfigurationService; - readonly stateManager: AgentHostStateManager; - readonly customizationEnablementService: IAgentHostCustomizationEnablementService; - readonly completions: IAgentHostCompletions; readonly agents: IObservable; readonly onDidStartTurn: Event; - readonly fileService: IFileService; - readonly sessionDataService: ISessionDataService; - readonly proxyResolver: IAgentHostProxyResolver; - readonly telemetryService: IAgentHostTelemetryService; - readonly agentSdkDownloader: AgentSdkDownloader; readonly sdkDownloadProgress: Event; + + constructor( + runtime: Omit, + infrastructure: DisposableStore, + ) { + super(); + this.instantiationService = runtime.instantiationService; + this.agentService = runtime.agentService; + this.agents = runtime.agents; + this.onDidStartTurn = runtime.onDidStartTurn; + this.sdkDownloadProgress = runtime.sdkDownloadProgress; + this._register(runtime.agentService); + this._register(runtime.instantiationService); + this._register(infrastructure); + } } /** - * Register `IAgentHostProxyResolver` and `IRequestService` into the agent host's - * DI container โ€” the services that `IAgentSdkDownloader` (and proxy-aware - * network diagnostics) depend on. + * Creates the complete Agent Host runtime. * - * Used by both entry points (`agentHostMain.ts` and `agentHostServerMain.ts`) - * to avoid drift between them. The order of registration matters because - * Consumers (the downloader itself, and through it `ClaudeAgentSdkService` / - * `CodexAgent`) must be constructed AFTER this call. The resolver is bound to - * `IAgentConfigurationService` after `AgentService` creates the host-owned - * configuration service. + * Add services directly to this bootstrap only when they require runtime or + * environment values, asynchronous construction, an entry-point-selected + * implementation, or must exist before the instantiation service. Shared + * synchronous services belong in `agentHostServices.ts`; callback-bound roots + * belong in {@link createAgentServiceComposition}; process listeners, + * transports, providers, and schedulers belong in the activating entry point. */ -export function registerAgentHostNetworkServices( - services: ServiceCollection, - logService: ILogService, - disposables: DisposableStore, -): IAgentHostNetworkServices { - const proxyResolver = disposables.add(new AgentHostProxyResolver(logService)); - services.set(IAgentHostProxyResolver, proxyResolver); - const requestService = disposables.add(new AgentHostRequestService(logService, proxyResolver)); - services.set(IRequestService, requestService); - return { proxyResolver, requestService }; -} - export async function createAgentHostRuntime(options: ICreateAgentHostRuntimeOptions): Promise { - const { environmentService, productService, logService, loggerService, disposables } = options; - const fileService = disposables.add(new FileService(logService)); - disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new DiskFileSystemProvider(logService)))); - disposables.add(registerPendingEditContentProvider(fileService)); - const sessionDataService = new SessionDataService(URI.file(environmentService.userDataPath), fileService, logService); - const services = new ServiceCollection( - [INativeEnvironmentService, environmentService], - [ILogService, logService], - [IFileService, fileService], - [ISessionDataService, sessionDataService], - [IProductService, productService], - ); - services.set(IAgentHostClientConnectionService, disposables.add(new AgentHostClientConnectionService())); - const networkServices = registerAgentHostNetworkServices(services, logService, disposables); - const proxyResolver = networkServices.proxyResolver; - const fetchFn = proxyResolver.fetch.bind(proxyResolver); - const telemetryService = await createAgentHostTelemetryService({ - environmentService, - productService, - fileService, - loggerService, - logService, - disposables, - disableTelemetry: options.disableTelemetry, - fetchFn, - requestService: networkServices.requestService, - }); - services.set(ITelemetryService, telemetryService); - const instantiationService = new InstantiationService(services, /*strict*/ true); + const { environmentService, productService, logService, loggerService } = options; + const infrastructure = new DisposableStore(); + let instantiationService: InstantiationService | undefined; let agentService: AgentService | undefined; try { - const fileMonitorService = disposables.add(instantiationService.createInstance(AgentHostFileMonitorService)); - services.set(IAgentHostFileMonitorService, fileMonitorService); - services.set(IWindowsMxcTerminalSandboxRuntime, instantiationService.createInstance(WindowsMxcTerminalSandboxRuntime)); - services.set(ISandboxHelperService, new SandboxHelperService()); - services.set(IAgentHostGitService, instantiationService.createInstance(AgentHostGitService)); + const fileService = infrastructure.add(new FileService(logService)); + infrastructure.add(fileService.registerProvider(Schemas.file, infrastructure.add(new DiskFileSystemProvider(logService)))); + infrastructure.add(registerPendingEditContentProvider(fileService)); + const sessionDataService = new SessionDataService(URI.file(environmentService.userDataPath), fileService, logService); + const services = new AgentHostServiceCollection( + [INativeEnvironmentService, environmentService], + [ILogService, logService], + [IFileService, fileService], + [ISessionDataService, sessionDataService], + [IProductService, productService], + ); + services.set(IAgentHostClientConnectionService, infrastructure.add(new AgentHostClientConnectionService())); const agentServiceOptions: IAgentServiceOptions = { rootConfigResource: joinPath(environmentService.appSettingsHome, 'globalStorage', 'agent-host-config.json'), providerConfigurations: options.providerConfigurations, @@ -171,57 +120,72 @@ export async function createAgentHostRuntime(options: ICreateAgentHostRuntimeOpt tmpDir: environmentService.tmpDir, }, }; - const agentServiceComposition = createAgentServiceComposition(agentServiceOptions, services, instantiationService, fetchFn, logService, productService, sessionDataService); + const foundation = createAgentServiceFoundation({ + services, + owned: infrastructure, + logService, + productService, + rootConfigResource: agentServiceOptions.rootConfigResource, + providerConfigurations: agentServiceOptions.providerConfigurations, + transientProxyConfiguration: options.transientProxyConfiguration, + }); + const { fetchFn } = foundation; + const telemetryService = await createAgentHostTelemetryService({ + environmentService, + productService, + fileService, + loggerService, + logService, + disposables: infrastructure, + disableTelemetry: options.disableTelemetry, + fetchFn, + requestService: foundation.requestService, + }); + services.set(ITelemetryService, telemetryService); + const byokBridgeRegistry = options.byok.kind === 'renderer' ? options.byok.bridgeRegistry : new NullByokLmBridgeRegistry(); + services.set(IByokLmBridgeRegistry, byokBridgeRegistry); + const coreServiceIds = registerAgentHostCoreServices(services, { + storageResource: agentServiceOptions.storageResource, + fetchFn, + gitHubServiceOptions: foundation.gitHubServiceOptions, + }); + const hostServiceIds = registerAgentHostHostServices(services, { + userDataPath: URI.file(environmentService.userDataPath), + fetchFn, + byok: options.byok, + }); + instantiationService = new InstantiationService(services, /*strict*/ true); + services.seal(); + instantiateAgentHostServices(instantiationService, [...coreServiceIds, ...hostServiceIds]); + const agentServiceComposition = instantiationService.invokeFunction(accessor => createAgentServiceComposition( + agentServiceOptions, + accessor, + instantiationService!, + logService, + sessionDataService, + foundation, + )); agentService = agentServiceComposition.agentService; - const { configurationService } = agentServiceComposition; - proxyResolver.bindConfigurationService(configurationService, options.transientProxyConfiguration); - const networkDiagnosticsService = instantiationService.createInstance(NetworkDiagnosticsService); - services.set(INetworkDiagnosticsService, networkDiagnosticsService); - agentService.setNetworkDiagnosticsService(networkDiagnosticsService); - services.set(IAgentPluginManager, new AgentPluginManager(URI.file(environmentService.userDataPath), fileService, logService)); - services.set(IDiffComputeService, disposables.add(instantiationService.createInstance(NodeWorkerDiffComputeService))); - const editAttributionService = disposables.add(instantiationService.createInstance(AgentEditAttributionService, undefined, undefined)); - services.set(IAgentEditAttributionService, editAttributionService); - agentService.setEditAttributionService(editAttributionService); - services.set(IEditSurvivalReporterFactory, instantiationService.createInstance(EditSurvivalReporterFactory)); - services.set(IEditArcReporterService, disposables.add(instantiationService.createInstance(EditArcReporterService, undefined))); - - const worktreeIsolation = disposables.add(instantiationService.createInstance(WorktreeIsolation, undefined)); - services.set(IAgentHostWorktreeIsolation, worktreeIsolation); + agentServiceComposition.setContributions(instantiationService.invokeFunction(accessor => activateAgentHostContributions(accessor, instantiationService!))); + const worktreeIsolation = instantiationService.invokeFunction(accessor => accessor.get(IAgentHostWorktreeIsolation)); + if (!(worktreeIsolation instanceof WorktreeIsolation)) { + throw new Error('The production Agent Host requires the concrete WorktreeIsolation service'); + } agentService.setWorktreeIsolation(worktreeIsolation); - const agentSdkDownloader = disposables.add(instantiationService.createInstance(AgentSdkDownloader)); - services.set(IAgentSdkDownloader, agentSdkDownloader); - services.set(IClaudeProxyService, disposables.add(instantiationService.createInstance(ClaudeProxyService))); - services.set(IClaudeAgentSdkService, instantiationService.createInstance(ClaudeAgentSdkService)); - services.set(ICodexProxyService, disposables.add(instantiationService.createInstance(CodexProxyService))); - services.set(IAgentHostOTelService, disposables.add(instantiationService.createInstance(AgentHostOTelService, fetchFn))); - const byokBridgeRegistry = options.byok.kind === 'renderer' ? options.byok.bridgeRegistry : new NullByokLmBridgeRegistry(); - services.set(IByokLmBridgeRegistry, byokBridgeRegistry); - const byokLmProxyService: IByokLmProxyService = options.byok.kind === 'renderer' - ? disposables.add(instantiationService.createInstance(ByokLmProxyService)) - : new NullByokLmProxyService(); - services.set(IByokLmProxyService, byokLmProxyService); + const agentSdkDownloader = instantiationService.invokeFunction(accessor => accessor.get(IAgentSdkDownloader)); - return { + return new AgentHostRuntime({ instantiationService, agentService, - configurationService, - stateManager: agentServiceComposition.stateManager, - customizationEnablementService: agentServiceComposition.customizationEnablementService, - completions: agentServiceComposition.completions, agents: agentServiceComposition.agents, onDidStartTurn: agentServiceComposition.onDidStartTurn, - fileService, - sessionDataService, - proxyResolver, - telemetryService, - agentSdkDownloader, sdkDownloadProgress: agentSdkDownloader.onDidDownloadProgress, - }; + }, infrastructure); } catch (error) { agentService?.dispose(); - instantiationService.dispose(); + instantiationService?.dispose(); + infrastructure.dispose(); throw error; } } diff --git a/src/vs/platform/agentHost/node/agentHostContributions.ts b/src/vs/platform/agentHost/node/agentHostContributions.ts new file mode 100644 index 00000000000000..4cdbcc3600717a --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostContributions.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableStore } from '../../../base/common/lifecycle.js'; +import { IInstantiationService, ServicesAccessor } from '../../instantiation/common/instantiation.js'; +import { ILogService } from '../../log/common/log.js'; +import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; +import { IAgentHostStateManager } from './agentHostStateManager.js'; +import { AgentHostCommitOperationContribution } from './agentHostCommitOperationProvider.js'; +import { IAgentHostCompletions } from './agentHostCompletions.js'; +import { AgentHostDiscardChangesOperationContribution } from './agentHostDiscardChangesOperationProvider.js'; +import { AgentHostFileCompletionProvider } from './agentHostFileCompletionProvider.js'; +import { AgentHostMergeOperationContribution } from './agentHostMergeOperationProvider.js'; +import { AgentHostPullRequestOperationContribution } from './agentHostPullRequestOperationProvider.js'; +import { AgentHostRenameCompletionProvider } from './agentHostRenameCommand.js'; +import { AgentHostSyncOperationContribution } from './agentHostSyncOperationProvider.js'; +import { AgentHostWorkspaceFiles } from './agentHostWorkspaceFiles.js'; +import { AgentHostChatCompletionProvider } from './agentHostChatCompletionProvider.js'; +import { CodexCompactCompletionProvider } from './codexCompactCommand.js'; + +export function activateAgentHostContributions(accessor: ServicesAccessor, instantiationService: IInstantiationService): DisposableStore { + const store = new DisposableStore(); + try { + const changesetOperationService = accessor.get(IAgentHostChangesetOperationService); + store.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostCommitOperationContribution))); + store.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostPullRequestOperationContribution))); + store.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostMergeOperationContribution))); + store.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostSyncOperationContribution))); + store.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostDiscardChangesOperationContribution))); + + const completions = accessor.get(IAgentHostCompletions); + const stateManager = accessor.get(IAgentHostStateManager); + const logService = accessor.get(ILogService); + const workspaceFiles = store.add(instantiationService.createInstance(AgentHostWorkspaceFiles)); + store.add(completions.registerProvider(new AgentHostFileCompletionProvider(stateManager, workspaceFiles, logService))); + store.add(completions.registerProvider(new AgentHostChatCompletionProvider(stateManager))); + store.add(completions.registerProvider(new AgentHostRenameCompletionProvider( + session => (stateManager.getSessionState(session)?.turns.length ?? 0) > 0, + ))); + store.add(completions.registerProvider(new CodexCompactCompletionProvider( + session => (stateManager.getSessionState(session)?.turns.length ?? 0) > 0, + ))); + return store; + } catch (error) { + store.dispose(); + throw error; + } +} diff --git a/src/vs/platform/agentHost/node/agentHostCustomizationEnablementService.ts b/src/vs/platform/agentHost/node/agentHostCustomizationEnablementService.ts index f4daa0e1726b6a..ea9095c9d9b676 100644 --- a/src/vs/platform/agentHost/node/agentHostCustomizationEnablementService.ts +++ b/src/vs/platform/agentHost/node/agentHostCustomizationEnablementService.ts @@ -92,9 +92,14 @@ export interface ICustomizationEnablementChangeEvent { export const IAgentHostCustomizationEnablementService = createDecorator('agentHostCustomizationEnablementService'); +export interface IAgentHostCustomizationEnablementWorktreeBinding { + setWorktreeIsolation(worktree: IAgentHostWorktreeIsolation): void; +} + export interface IAgentHostCustomizationEnablementService { readonly _serviceBrand: undefined; readonly onDidChange: Event; + readonly setWorktreeIsolation?: IAgentHostCustomizationEnablementWorktreeBinding['setWorktreeIsolation']; initializeSession(session: string): Promise; getWorkingDirectoryState(session: string): WorkingDirectoryState; resolve(session: string, target: ICustomizationEnablementTarget): CustomizationEnablementResolution; @@ -104,6 +109,12 @@ export interface IAgentHostCustomizationEnablementService { whenIdle(): Promise; } +export function supportsCustomizationEnablementWorktreeBinding( + service: IAgentHostCustomizationEnablementService, +): service is IAgentHostCustomizationEnablementService & IAgentHostCustomizationEnablementWorktreeBinding { + return typeof service.setWorktreeIsolation === 'function'; +} + /** * Returns the scope-appropriate identity for a customization decision. * @@ -191,7 +202,7 @@ export class AgentHostCustomizationEnablementService extends Disposable implemen })); } - /** Bound after AgentService construction because WorktreeIsolation depends on ICopilotApiService and AgentService's endpoint service. */ + /** Bound after orchestration composition because WorktreeIsolation depends on its ICopilotApiService registration. */ setWorktreeIsolation(worktree: IAgentHostWorktreeIsolation): void { this._worktree = worktree; const onDidChangeWorkingDirectoryPending = worktree.onDidChangeWorkingDirectoryPending; diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index e212ec73f5ed31..f27bde7a150b31 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -19,7 +19,9 @@ import { AgentHostClaudeAgentEnabledEnvVar, AgentHostCodexAgentEnabledEnvVar, Ag import { AgentHostCodexEnabledConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; import { AgentModelRefreshScheduler, MODEL_REFRESH_INTERVAL_MS } from './agentModelRefreshScheduler.js'; import { AgentService } from './agentService.js'; -import { AgentHostStateManager } from './agentHostStateManager.js'; +import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; +import { IAgentConfigurationService } from './agentConfigurationService.js'; +import { IAgentHostCompletions } from './agentHostCompletions.js'; import { CopilotAgent } from './copilot/copilotAgent.js'; import { ClaudeAgent } from './claude/claudeAgent.js'; import { ClaudeSdkPackage } from './claude/claudeAgentSdkService.js'; @@ -27,7 +29,7 @@ import { CodexAgent, CodexSdkPackage } from './codex/codexAgent.js'; import { createCodexProviderConfiguration } from './codex/codexProviderConfiguration.js'; import { ByokLmBridgeRegistry } from './byokLmBridgeRegistry.js'; import { IAgentHostProxyResolver } from './agentHostProxyResolver.js'; -import { type IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; +import { IAgentSdkDownloader, type IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; import { ProtocolServerHandler } from './protocolServerHandler.js'; import { WebSocketProtocolServer } from './webSocketTransport.js'; import { MessagePortProtocolServer } from './messagePortProtocolServer.js'; @@ -54,6 +56,7 @@ import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, createAgentHostClientByokLmConnectio import { AGENT_HOST_CLIENT_PROXY_CHANNEL, createAgentHostClientProxyConnection } from '../common/agentHostClientProxyChannel.js'; import { join } from '../../../base/common/path.js'; import ErrorTelemetry from '../../telemetry/node/errorTelemetry.js'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AgentHostLaunchKindEnvVar, readAgentHostLaunchKind, type AgentHostLaunchKind } from '../common/agentHostTelemetry.js'; // Entry point for the agent host utility process. @@ -105,6 +108,8 @@ async function startAgentHost(): Promise { let agentService: AgentService; let instantiationService!: IInstantiationService; let fileService!: IFileService; + let stateManager!: AgentHostStateManager; + let completionTriggerCharacters!: readonly string[]; // Hoisted out of the `try` below so the protocol handlers (constructed // after the block) can forward agent-SDK download progress to clients. let sdkDownloadProgress: Event | undefined; @@ -118,19 +123,30 @@ async function startAgentHost(): Promise { productService, logService, loggerService, - disposables, transientProxyConfiguration: true, hostLaunchKind, providerConfigurations: [createCodexProviderConfiguration(environmentService.userHome)], byok: { kind: 'renderer', bridgeRegistry: byokLmBridgeRegistry }, }); + disposables.add(runtime); agentService = runtime.agentService; - const agentConfigurationService = runtime.configurationService; instantiationService = runtime.instantiationService; - fileService = runtime.fileService; - proxyResolver = runtime.proxyResolver; - errorTelemetry.value = new ErrorTelemetry(runtime.telemetryService); - const agentSdkDownloader = runtime.agentSdkDownloader; + const runtimeServices = instantiationService.invokeFunction(accessor => ({ + configurationService: accessor.get(IAgentConfigurationService), + fileService: accessor.get(IFileService), + proxyResolver: accessor.get(IAgentHostProxyResolver), + telemetryService: accessor.get(ITelemetryService), + agentSdkDownloader: accessor.get(IAgentSdkDownloader), + stateManager: accessor.get(IAgentHostStateManager), + completions: accessor.get(IAgentHostCompletions), + })); + const agentConfigurationService = runtimeServices.configurationService; + fileService = runtimeServices.fileService; + proxyResolver = runtimeServices.proxyResolver; + stateManager = runtimeServices.stateManager; + completionTriggerCharacters = runtimeServices.completions.triggerCharacters; + errorTelemetry.value = new ErrorTelemetry(runtimeServices.telemetryService); + const agentSdkDownloader = runtimeServices.agentSdkDownloader; sdkDownloadProgress = runtime.sdkDownloadProgress; agentService.registerProvider(instantiationService.createInstance(CopilotAgent)); // Claude and Codex providers are gated on two things: @@ -169,8 +185,8 @@ async function startAgentHost(): Promise { disposables.add(agentConfigurationService.onDidRootConfigChange(registerCodexIfEnabled)); } } catch (err) { - instantiationService?.dispose(); logService.error('Failed to create AgentService', err); + disposables.dispose(); throw err; } @@ -221,7 +237,7 @@ async function startAgentHost(): Promise { const localProtocolHandlerConfig = { hostLaunchKind, defaultDirectory: URI.file(os.homedir()).toString(), - completionTriggerCharacters: runtime.completions.triggerCharacters, + completionTriggerCharacters, terminalCommandPrefix: BANG_COMMAND_PREFIX, otlpLogEmitter, allowExtensionMethods: false, @@ -231,7 +247,7 @@ async function startAgentHost(): Promise { const messagePortProtocolHandler = localDataPlaneDisposables.add(instantiationService.createInstance( ProtocolServerHandler, agentService, - runtime.stateManager, + stateManager, messagePortProtocolServer, localProtocolHandlerConfig, clientFileSystemProvider, @@ -304,7 +320,7 @@ async function startAgentHost(): Promise { const localEndpointProtocolHandler = localDataPlaneDisposables.add(instantiationService.createInstance( ProtocolServerHandler, agentService, - runtime.stateManager, + stateManager, localEndpoint.server, localProtocolHandlerConfig, clientFileSystemProvider, @@ -361,12 +377,12 @@ async function startAgentHost(): Promise { const protocolHandler = protocolIngressDisposables.add(instantiationService.createInstance( ProtocolServerHandler, agentService, - runtime.stateManager, + stateManager, wsServer, { hostLaunchKind, defaultDirectory: URI.file(os.homedir()).toString(), - completionTriggerCharacters: runtime.completions.triggerCharacters, + completionTriggerCharacters, terminalCommandPrefix: BANG_COMMAND_PREFIX, otlpLogEmitter, }, @@ -443,8 +459,8 @@ async function startAgentHost(): Promise { // raw WebSocket streams and cannot carry the local endpoint's bearer token. const configuredWebSocketServerStart = startWebSocketServer( agentService, - runtime.stateManager, - runtime.completions.triggerCharacters, + stateManager, + completionTriggerCharacters, clientFileSystemProvider, instantiationService, environmentService.logsHome, @@ -461,10 +477,8 @@ async function startAgentHost(): Promise { }); process.once('exit', () => { - agentService.dispose(); logService.dispose(); disposables.dispose(); - instantiationService.dispose(); }); } diff --git a/src/vs/platform/agentHost/node/agentHostProxyResolver.ts b/src/vs/platform/agentHost/node/agentHostProxyResolver.ts index c0607fec515b0e..2e835e48fb65e9 100644 --- a/src/vs/platform/agentHost/node/agentHostProxyResolver.ts +++ b/src/vs/platform/agentHost/node/agentHostProxyResolver.ts @@ -38,12 +38,6 @@ export interface IAgentHostProxyResolver { /** Register a renderer connection. Disposing the result removes it. */ register(clientId: string, connection: IAgentHostClientProxyConnection): IDisposable; - /** - * Binds the Agent Host configuration after the orchestrator has initialized it. - * Local hosts mark mirrored values transient; remote hosts persist manual values. - */ - bindConfigurationService(configurationService: IAgentConfigurationService, transient: boolean): void; - getConfigurationValue(key: AgentHostProxyConfigurationKey): T | undefined; /** @@ -70,25 +64,18 @@ export class AgentHostProxyResolver extends Disposable implements IAgentHostProx private readonly _configurationListener = this._register(new MutableDisposable()); private readonly _connections = new Map(); - private _configurationService: IAgentConfigurationService | undefined; private _configurationValues: Record = {}; private _proxyResolver: ReturnType | undefined; private _proxyAgentParams: ProxyAgentParams | undefined; private _fetch: typeof globalThis.fetch | undefined; - constructor(@ILogService private readonly _logService: ILogService) { + constructor( + @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, + @ILogService private readonly _logService: ILogService, + ) { super(); - } - - bindConfigurationService(configurationService: IAgentConfigurationService, transient: boolean): void { - this._configurationService = configurationService; - if (transient) { - configurationService.publishRootTransientValues?.(Object.fromEntries( - Object.values(AgentHostProxyConfigKey).map(key => [key, undefined]) - )); - } this._configurationValues = this._readConfigurationValues(); - this._configurationListener.value = configurationService.onDidRootConfigChange(() => { + this._configurationListener.value = this._configurationService.onDidRootConfigChange(() => { const values = this._readConfigurationValues(); if (!equals(this._configurationValues, values)) { this._configurationValues = values; @@ -98,9 +85,6 @@ export class AgentHostProxyResolver extends Disposable implements IAgentHostProx } getConfigurationValue(key: AgentHostProxyConfigurationKey): T | undefined { - if (!this._configurationService) { - return undefined; - } return this._configurationService.getRootValue(agentHostProxyConfigSchema, key) as T | undefined; } diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index ff94a43f519c1c..8e35d5add58a2f 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -23,20 +23,26 @@ import { generateUuid } from '../../../base/common/uuid.js'; import { localize } from '../../../nls.js'; import { NativeEnvironmentService } from '../../environment/node/environmentService.js'; import { parseArgs, OPTIONS } from '../../environment/node/argv.js'; +import { IFileService } from '../../files/common/files.js'; import { getLogLevel, ILogService } from '../../log/common/log.js'; import { LogService } from '../../log/common/logService.js'; 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 { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { createAgentHostRuntime } from './agentHostBootstrap.js'; +import { IAgentConfigurationService } from './agentConfigurationService.js'; +import { IAgentHostCompletions } from './agentHostCompletions.js'; +import { IAgentHostCustomizationEnablementService } from './agentHostCustomizationEnablementService.js'; +import { IAgentHostStateManager } from './agentHostStateManager.js'; import { BANG_COMMAND_PREFIX } from './agentHostBangCommand.js'; import { CopilotAgent } from './copilot/copilotAgent.js'; import { ClaudeAgent } from './claude/claudeAgent.js'; import { ClaudeSdkPackage } from './claude/claudeAgentSdkService.js'; import { CodexAgent, CodexSdkPackage } from './codex/codexAgent.js'; import { createCodexProviderConfiguration } from './codex/codexProviderConfiguration.js'; -import { type IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; +import { IAgentSdkDownloader, type IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; import { AgentHostCodexEnabledConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; import { AgentModelRefreshScheduler, MODEL_REFRESH_INTERVAL_MS } from './agentModelRefreshScheduler.js'; import { AgentHostClaudeAgentEnabledEnvVar, AgentHostClaudeSdkRootEnvVar, AgentHostCodexAgentEnabledEnvVar, AgentHostCodexAgentSdkRootEnvVar, isAgentEnabled } from '../common/agentService.js'; @@ -47,6 +53,7 @@ import { AGENT_CLIENT_SCHEME } from '../common/agentClientUri.js'; import { resolveServerUrls } from './serverUrls.js'; import ErrorTelemetry from '../../telemetry/node/errorTelemetry.js'; import { AgentHostLaunchKind } from '../common/agentHostTelemetry.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; /** Log to stderr so messages appear in the terminal alongside the process. */ function log(msg: string): void { @@ -188,21 +195,38 @@ async function main(): Promise { productService, logService, loggerService, - disposables, disableTelemetry: options.quiet, transientProxyConfiguration: false, hostLaunchKind: AgentHostLaunchKind.VSCodeCLI, providerConfigurations: [createCodexProviderConfiguration(environmentService.userHome)], byok: { kind: 'unavailable' }, }); - const { agentService, configurationService: agentConfigurationService, instantiationService, fileService, sessionDataService } = runtime; - disposables.add(agentService); - errorTelemetry.value = new ErrorTelemetry(runtime.telemetryService); + disposables.add(runtime); + const { agentService, instantiationService } = runtime; + const runtimeServices = instantiationService.invokeFunction(accessor => ({ + configurationService: accessor.get(IAgentConfigurationService), + fileService: accessor.get(IFileService), + sessionDataService: accessor.get(ISessionDataService), + telemetryService: accessor.get(ITelemetryService), + agentSdkDownloader: accessor.get(IAgentSdkDownloader), + stateManager: accessor.get(IAgentHostStateManager), + completions: accessor.get(IAgentHostCompletions), + customizationEnablementService: accessor.get(IAgentHostCustomizationEnablementService), + })); + const { + configurationService: agentConfigurationService, + fileService, + sessionDataService, + agentSdkDownloader, + stateManager, + completions, + customizationEnablementService, + } = runtimeServices; + errorTelemetry.value = new ErrorTelemetry(runtimeServices.telemetryService); // Register agents let sdkDownloadProgress: Event | undefined; if (!options.quiet) { - const agentSdkDownloader = runtime.agentSdkDownloader; sdkDownloadProgress = runtime.sdkDownloadProgress; const copilotAgent = disposables.add(instantiationService.createInstance(CopilotAgent)); agentService.registerProvider(copilotAgent); @@ -297,12 +321,12 @@ async function main(): Promise { disposables.add(instantiationService.createInstance( ProtocolServerHandler, agentService, - runtime.stateManager, + stateManager, wsServer, { hostLaunchKind: AgentHostLaunchKind.VSCodeCLI, defaultDirectory: URI.file(os.homedir()).toString(), - completionTriggerCharacters: runtime.completions.triggerCharacters, + completionTriggerCharacters: completions.triggerCharacters, terminalCommandPrefix: BANG_COMMAND_PREFIX, otlpLogEmitter, }, @@ -364,11 +388,10 @@ async function main(): Promise { // 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 raceTimeout(Promise.all([sessionDataService.whenIdle(), runtime.customizationEnablementService.whenIdle()]), 3000, () => { + await raceTimeout(Promise.all([sessionDataService.whenIdle(), customizationEnablementService.whenIdle()]), 3000, () => { logService.warn('[AgentHostServer] Timed out waiting for persistence writes to flush; exiting anyway.'); }); disposables.dispose(); - instantiationService.dispose(); loggerService?.dispose(); process.exit(0); } diff --git a/src/vs/platform/agentHost/node/agentHostServices.ts b/src/vs/platform/agentHost/node/agentHostServices.ts new file mode 100644 index 00000000000000..22f35bd174396f --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostServices.ts @@ -0,0 +1,165 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SyncDescriptor } from '../../instantiation/common/descriptors.js'; +import { IInstantiationService, ServiceIdentifier } from '../../instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; +import { GitHubService, IGitHubService } from '../../github/common/githubService.js'; +import type { GitHubServiceOptions } from '../../github/common/githubTypes.js'; +import { ISandboxHelperService } from '../../sandbox/common/sandboxHelperService.js'; +import { SandboxHelperService } from '../../sandbox/node/sandboxHelper.js'; +import { IWindowsMxcTerminalSandboxRuntime, WindowsMxcTerminalSandboxRuntime } from '../../sandbox/common/terminalSandboxMxcRuntime.js'; +import { URI } from '../../../base/common/uri.js'; +import { IAgentPluginManager } from '../common/agentPluginManager.js'; +import { IDiffComputeService } from '../common/diffComputeService.js'; +import { IAgentEditAttributionService } from '../common/fileEditAttribution.js'; +import { IAgentHostGitService } from '../common/agentHostGitService.js'; +import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js'; +import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; +import { IAgentHostChangesetService } from '../common/agentHostChangesetService.js'; +import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js'; +import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js'; +import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js'; +import { IAgentHostReviewService } from '../common/agentHostReviewService.js'; +import { CopilotApiService, ICopilotApiService } from './shared/copilotApiService.js'; +import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js'; +import { AgentHostGitService } from './agentHostGitService.js'; +import { AgentPluginManager } from './agentPluginManager.js'; +import { AgentSdkDownloader, IAgentSdkDownloader } from './agentSdkDownloader.js'; +import { IByokLmBridgeRegistry } from './byokLmBridgeRegistry.js'; +import { ClaudeAgentSdkService, IClaudeAgentSdkService } from './claude/claudeAgentSdkService.js'; +import { ClaudeProxyService, IClaudeProxyService } from './claude/claudeProxyService.js'; +import { ByokLmProxyService, IByokLmProxyService, NullByokLmProxyService } from './copilot/byokLmProxyService.js'; +import { CodexProxyService, ICodexProxyService } from './codex/codexProxyService.js'; +import { NodeWorkerDiffComputeService } from './diffComputeService.js'; +import { NetworkDiagnosticsService, INetworkDiagnosticsService } from './networkDiagnosticsService.js'; +import { AgentHostOTelService } from './otel/agentHostOTelService.js'; +import { AgentHostChangesetOperationService } from './agentHostChangesetOperationService.js'; +import { AgentHostChangesetService } from './agentHostChangesetService.js'; +import { AgentHostChangesetSubscriptionService } from './agentHostChangesetSubscriptionService.js'; +import { AgentHostCheckpointService } from './agentHostCheckpointService.js'; +import { AgentHostCompletions, IAgentHostCompletions } from './agentHostCompletions.js'; +import { AgentHostCustomizationEnablementService, IAgentHostCustomizationEnablementService } from './agentHostCustomizationEnablementService.js'; +import { AgentHostGitStateService } from './agentHostGitStateService.js'; +import { AgentHostManagedSettingsService, IAgentHostManagedSettingsService } from './agentHostManagedSettingsService.js'; +import { AgentHostPromptCache, IAgentHostPromptCache } from './agentHostPromptCache.js'; +import { AgentHostReviewService } from './agentHostReviewService.js'; +import { AgentHostSessionTitleSignal, IAgentHostSessionTitleSignal } from './agentHostSessionTitleSignal.js'; +import { AgentHostStorageService, IAgentHostStorageService } from './agentHostStorageService.js'; +import { AgentHostTerminalManager, IAgentHostTerminalManager } from './agentHostTerminalManager.js'; +import { AgentEditAttributionService } from './shared/agentEditAttributionService.js'; +import { AgentHostOctoKitService, IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js'; +import { EditArcReporterService, IEditArcReporterService } from './shared/editArcReporter.js'; +import { EditSurvivalReporterFactory, IEditSurvivalReporterFactory } from './shared/editSurvivalReporter.js'; +import { IAgentHostWorktreeIsolation, WorktreeIsolation } from './shared/worktreeIsolation.js'; + +/** + * The process-local Agent Host service collection. Sealing is opt-in while the + * existing imperative registrations migrate to descriptors. + */ +export class AgentHostServiceCollection extends ServiceCollection { + private sealed = false; + + seal(): void { + this.sealed = true; + } + + override set(id: ServiceIdentifier, instanceOrDescriptor: T | SyncDescriptor): T | SyncDescriptor { + if (this.sealed) { + const current = this.get(id); + const isDescriptorResolution = current instanceof SyncDescriptor && instanceOrDescriptor instanceof current.ctor; + if (!isDescriptorResolution) { + throw new Error(`Agent Host service collection is sealed: ${id}`); + } + } + return super.set(id, instanceOrDescriptor); + } +} + +/** + * Registers shared Agent Host services. This starts empty so descriptor + * registrations can migrate atomically with their imperative construction. + */ +function registerService( + services: AgentHostServiceCollection, + ids: ServiceIdentifier[], + id: ServiceIdentifier, + value: T | SyncDescriptor, +): void { + if (services.has(id)) { + return; + } + services.set(id, value); + ids.push(id); +} + +export interface IAgentHostCoreServiceInputs { + readonly storageResource: URI | undefined; + readonly fetchFn: typeof globalThis.fetch; + readonly gitHubServiceOptions: GitHubServiceOptions; + readonly copilotApiService?: ICopilotApiService; +} + +export function registerAgentHostCoreServices(services: AgentHostServiceCollection, inputs: IAgentHostCoreServiceInputs): readonly ServiceIdentifier[] { + const ids: ServiceIdentifier[] = []; + registerService(services, ids, IAgentHostFileMonitorService, new SyncDescriptor(AgentHostFileMonitorService)); + registerService(services, ids, INetworkDiagnosticsService, new SyncDescriptor(NetworkDiagnosticsService)); + registerService(services, ids, IDiffComputeService, new SyncDescriptor(NodeWorkerDiffComputeService)); + registerService(services, ids, IAgentEditAttributionService, new SyncDescriptor(AgentEditAttributionService, [undefined, undefined])); + registerService(services, ids, IEditSurvivalReporterFactory, new SyncDescriptor(EditSurvivalReporterFactory)); + registerService(services, ids, IEditArcReporterService, new SyncDescriptor(EditArcReporterService, [undefined])); + registerService(services, ids, IAgentHostStorageService, new SyncDescriptor(AgentHostStorageService, [inputs.storageResource])); + registerService(services, ids, IAgentHostManagedSettingsService, new SyncDescriptor(AgentHostManagedSettingsService)); + registerService(services, ids, IAgentHostOctoKitService, new SyncDescriptor(AgentHostOctoKitService, [inputs.fetchFn])); + registerService(services, ids, IGitHubService, new SyncDescriptor(GitHubService, [inputs.gitHubServiceOptions])); + registerService(services, ids, ICopilotApiService, inputs.copilotApiService ?? new SyncDescriptor(CopilotApiService, [inputs.fetchFn])); + registerService(services, ids, IAgentHostCustomizationEnablementService, new SyncDescriptor(AgentHostCustomizationEnablementService)); + registerService(services, ids, IAgentHostGitStateService, new SyncDescriptor(AgentHostGitStateService)); + registerService(services, ids, IAgentHostCheckpointService, new SyncDescriptor(AgentHostCheckpointService)); + registerService(services, ids, IAgentHostPromptCache, new SyncDescriptor(AgentHostPromptCache)); + registerService(services, ids, IAgentHostSessionTitleSignal, new SyncDescriptor(AgentHostSessionTitleSignal)); + registerService(services, ids, IAgentHostChangesetSubscriptionService, new SyncDescriptor(AgentHostChangesetSubscriptionService)); + registerService(services, ids, IAgentHostChangesetOperationService, new SyncDescriptor(AgentHostChangesetOperationService)); + registerService(services, ids, IAgentHostReviewService, new SyncDescriptor(AgentHostReviewService)); + registerService(services, ids, IAgentHostChangesetService, new SyncDescriptor(AgentHostChangesetService)); + registerService(services, ids, IAgentHostCompletions, new SyncDescriptor(AgentHostCompletions)); + registerService(services, ids, IAgentHostTerminalManager, new SyncDescriptor(AgentHostTerminalManager)); + return ids; +} + +export interface IAgentHostHostServiceInputs { + readonly userDataPath: URI; + readonly fetchFn: typeof globalThis.fetch; + readonly byok: { readonly kind: 'renderer'; readonly bridgeRegistry: IByokLmBridgeRegistry } | { readonly kind: 'unavailable' }; +} + +export function registerAgentHostHostServices(services: AgentHostServiceCollection, inputs: IAgentHostHostServiceInputs): readonly ServiceIdentifier[] { + const ids: ServiceIdentifier[] = []; + registerService(services, ids, IWindowsMxcTerminalSandboxRuntime, new SyncDescriptor(WindowsMxcTerminalSandboxRuntime)); + registerService(services, ids, ISandboxHelperService, new SyncDescriptor(SandboxHelperService)); + registerService(services, ids, IAgentHostGitService, new SyncDescriptor(AgentHostGitService)); + registerService(services, ids, IAgentPluginManager, new SyncDescriptor(AgentPluginManager, [inputs.userDataPath])); + registerService(services, ids, IAgentSdkDownloader, new SyncDescriptor(AgentSdkDownloader)); + registerService(services, ids, IClaudeAgentSdkService, new SyncDescriptor(ClaudeAgentSdkService)); + registerService(services, ids, IClaudeProxyService, new SyncDescriptor(ClaudeProxyService)); + registerService(services, ids, ICodexProxyService, new SyncDescriptor(CodexProxyService)); + registerService(services, ids, IAgentHostOTelService, new SyncDescriptor(AgentHostOTelService, [inputs.fetchFn])); + registerService(services, ids, IAgentHostWorktreeIsolation, new SyncDescriptor(WorktreeIsolation, [undefined])); + registerService( + services, + ids, + IByokLmProxyService, + inputs.byok.kind === 'renderer' ? new SyncDescriptor(ByokLmProxyService) : new NullByokLmProxyService(), + ); + return ids; +} + +export function instantiateAgentHostServices(instantiationService: IInstantiationService, ids: readonly ServiceIdentifier[]): void { + instantiationService.invokeFunction(accessor => { + for (const id of ids) { + accessor.get(id); + } + }); +} diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 8759e5e52a360c..c996057d7b05aa 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -42,7 +42,7 @@ import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../common/me import { readChatSurfaceMeta, withChatSurfaceMeta } from '../common/meta/agentChatSurfaceMeta.js'; import { buildBoundedSideChatSourceContext, getSideChatPartialResponse } from './agentPeerChats.js'; import { AgentConfigurationService, getEffectiveWorkingDirectories } from './agentConfigurationService.js'; -import { AgentHostTerminalManager } from './agentHostTerminalManager.js'; +import { IAgentHostTerminalManager } from './agentHostTerminalManager.js'; import { ISessionDbUriFields, parseSessionDbUri } from '../common/sessionDbUri.js'; import { IGitBlobUriFields, parseGitBlobUri } from './gitDiffContent.js'; import { resolveSessionRepositories } from './agentHostSessionRepositories.js'; @@ -81,7 +81,7 @@ import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostArtifactToolsConfigKey, AgentHostEditTelemetryEnabledConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostShowExternalSessionsConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; -import { AgentHostCustomizationEnablementService } from './agentHostCustomizationEnablementService.js'; +import { IAgentHostCustomizationEnablementService, IAgentHostCustomizationEnablementWorktreeBinding } from './agentHostCustomizationEnablementService.js'; import { SessionCoordinationService } from './sessionCoordination.js'; import { IAgentHostChangesetService, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js'; import { GIT_DB_METADATA_KEYS, IAgentHostGitStateService, META_GIT_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../common/agentHostGitStateService.js'; @@ -381,7 +381,7 @@ export interface IAgentServiceCallbackBinder { export interface IAgentServiceCollaborators { readonly gitHubEndpointService: IAgentHostGitHubEndpointService; - readonly customizationEnablementService: AgentHostCustomizationEnablementService; + readonly customizationEnablementService: IAgentHostCustomizationEnablementService & IAgentHostCustomizationEnablementWorktreeBinding; readonly gitStateService: IAgentHostGitStateService; readonly agentMergeController: AgentMergeController; readonly checkpointService: IAgentHostCheckpointService; @@ -390,7 +390,7 @@ export interface IAgentServiceCollaborators { readonly changesets: IAgentHostChangesetService; readonly changesetCoordinator: AgentHostChangesetCoordinator; readonly completions: IAgentHostCompletions; - readonly terminalManager: AgentHostTerminalManager; + readonly terminalManager: IAgentHostTerminalManager; readonly localTurns: AgentHostLocalTurns; readonly sideEffects: AgentSideEffects; readonly sessionCoordination: SessionCoordinationService; @@ -460,8 +460,6 @@ export class AgentService extends Disposable implements IAgentService { */ private readonly _unpersistedChatBackings = new Set(); - get stateManager(): AgentHostStateManager { return this._stateManager; } - /** Registered providers keyed by their {@link AgentProvider} id. */ private readonly _providers = new Map(); /** Maps each active session URI (toString) to its owning provider. */ @@ -509,14 +507,14 @@ export class AgentService extends Disposable implements IAgentService { /** Owns session git-state probing and git-backed catalogue decoration. */ private readonly _gitStateService: IAgentHostGitStateService; /** Manages PTY-backed terminals for the agent host protocol. */ - private readonly _terminalManager: AgentHostTerminalManager; + private readonly _terminalManager: IAgentHostTerminalManager; /** Persists host-injected `/rename` / `!command` turns for restore & fork/truncate. */ private readonly _localTurns: AgentHostLocalTurns; /** Server-side host for the agent host's server tools. */ private readonly _serverToolHost: AgentServerToolHost; private readonly _debugLogsCollector: AgentHostDebugLogsCollector | undefined; private readonly _configurationService: AgentConfigurationService; - private readonly _customizationEnablementService: AgentHostCustomizationEnablementService; + private readonly _customizationEnablementService: IAgentHostCustomizationEnablementService & IAgentHostCustomizationEnablementWorktreeBinding; /** Captures baseline / per-turn git checkpoints backing the changeset pipeline. */ private readonly _checkpointService: IAgentHostCheckpointService; /** @@ -533,10 +531,6 @@ export class AgentService extends Disposable implements IAgentService { /** Pluggable completion item providers (e.g. workspace file completions, agent-specific @-mentions). */ private readonly _completions: IAgentHostCompletions; private _skillCompletionProviderRegistered = false; - /** Backs {@link getNetworkDiagnosticsInfo} / {@link diagnosticsFetch}; wired via {@link setNetworkDiagnosticsService}. */ - private _networkDiagnostics: INetworkDiagnosticsService | undefined; - private _editAttributionService: IAgentEditAttributionService | undefined; - /** * Authoritative server-side per-resource subscription refcount, keyed by * resource URI string and valued by the set of subscribed protocol @@ -613,9 +607,10 @@ export class AgentService extends Disposable implements IAgentService { @ISessionDataService private readonly _sessionDataService: ISessionDataService, @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @ITelemetryService private readonly _telemetryService: ITelemetryService, + @INetworkDiagnosticsService private readonly _networkDiagnostics: INetworkDiagnosticsService, + @IAgentEditAttributionService private readonly _editAttributionService: IAgentEditAttributionService, ) { super(); - this._register(core.disposables); this._authService = core.authenticationService; this._orchestratorDatabase = core.orchestratorDatabase; this._debugLogsCollector = core.debugLogsCollector; @@ -714,7 +709,9 @@ export class AgentService extends Disposable implements IAgentService { reason: AuthRequiredReason.Required, }); })); + this._editAttributionService.setEnabled(this._stateManager.rootState.config?.values[AgentHostEditTelemetryEnabledConfigKey] !== false); this._scheduleExternalSessionPrune(); + this._register(core.disposables); } private _scheduleExternalSessionPrune(): void { @@ -4322,7 +4319,7 @@ export class AgentService extends Disposable implements IAgentService { this._configurationService.persistRootConfig(); const editTelemetryEnabled = action.config[AgentHostEditTelemetryEnabledConfigKey]; if (typeof editTelemetryEnabled === 'boolean') { - this._editAttributionService?.setEnabled(editTelemetryEnabled); + this._editAttributionService.setEnabled(editTelemetryEnabled); } } this._sideEffects.handleAction(channel, action, clientId, clientContext); @@ -5779,15 +5776,15 @@ export class AgentService extends Disposable implements IAgentService { } prepareEditAttributionFlush(params: IPrepareEditAttributionFlushParams): Promise { - return this._editAttributionService?.prepareFlush(params) ?? Promise.resolve(undefined); + return this._editAttributionService.prepareFlush(params); } commitEditAttributionFlush(params: ICommitEditAttributionFlushParams): Promise { - return this._editAttributionService?.commitFlush(params) ?? Promise.resolve({ outcome: 'missing', agentModifiedCount: 0 }); + return this._editAttributionService.commitFlush(params); } cancelEditAttributionFlush(params: ICancelEditAttributionFlushParams): Promise { - return this._editAttributionService?.cancelFlush(params) ?? Promise.resolve({ outcome: 'missing', agentModifiedCount: 0 }); + return this._editAttributionService.cancelFlush(params); } async resourceWrite(params: ResourceWriteParams): Promise { @@ -6211,25 +6208,7 @@ export class AgentService extends Disposable implements IAgentService { } } - /** - * Wire the network diagnostics service backing {@link getNetworkDiagnosticsInfo} - * and {@link diagnosticsFetch}. A setter rather than a constructor argument - * because the service depends on the agent-host proxy resolver, which the - * remote server constructs lazily โ€” after this service. - */ - setNetworkDiagnosticsService(service: INetworkDiagnosticsService): void { - this._networkDiagnostics = service; - } - - setEditAttributionService(service: IAgentEditAttributionService): void { - this._editAttributionService = service; - service.setEnabled(this._stateManager.rootState.config?.values[AgentHostEditTelemetryEnabledConfigKey] !== false); - } - async getNetworkDiagnosticsInfo(): Promise { - if (!this._networkDiagnostics) { - throw new Error('Network diagnostics unavailable: service not wired'); - } const providers = [...this._providers.values()]; const contributions = await Promise.all(providers.map(async provider => { try { @@ -6276,9 +6255,6 @@ export class AgentService extends Disposable implements IAgentService { } async diagnosticsFetch(url: string): Promise { - if (!this._networkDiagnostics) { - throw new Error('Network diagnostics unavailable: service not wired'); - } return this._networkDiagnostics.fetch(url); } diff --git a/src/vs/platform/agentHost/node/agentServiceComposition.ts b/src/vs/platform/agentHost/node/agentServiceComposition.ts index 756ed692021f8d..82336855bb057c 100644 --- a/src/vs/platform/agentHost/node/agentServiceComposition.ts +++ b/src/vs/platform/agentHost/node/agentServiceComposition.ts @@ -4,68 +4,41 @@ *--------------------------------------------------------------------------------------------*/ import type { Event } from '../../../base/common/event.js'; -import { DisposableStore, type IDisposable } from '../../../base/common/lifecycle.js'; -import { observableValue, type IObservable } from '../../../base/common/observable.js'; +import { DisposableStore, type IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; +import type { IObservable } from '../../../base/common/observable.js'; import { dirname, joinPath } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; -import { GitHubService, IGitHubService } from '../../github/common/githubService.js'; -import { IInstantiationService } from '../../instantiation/common/instantiation.js'; -import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; +import { IInstantiationService, ServicesAccessor } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; -import { IProductService } from '../../product/common/productService.js'; import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; import { IAgentHostChangesetService } from '../common/agentHostChangesetService.js'; -import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js'; import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js'; import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js'; import { IAgentHostReviewService } from '../common/agentHostReviewService.js'; import { AgentHostLaunchKind } from '../common/agentHostTelemetry.js'; import type { IAgent } from '../common/agent.js'; import { ISessionDataService } from '../common/sessionDataService.js'; -import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js'; -import { AgentHostAuthenticationService, IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; +import { IAgentConfigurationService } from './agentConfigurationService.js'; +import { IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { AgentHostChangesetCoordinator } from './agentHostChangesetCoordinator.js'; -import { AgentHostChangesetOperationService } from './agentHostChangesetOperationService.js'; -import { AgentHostChangesetService } from './agentHostChangesetService.js'; -import { AgentHostChangesetSubscriptionService } from './agentHostChangesetSubscriptionService.js'; -import { AgentHostChatCompletionProvider } from './agentHostChatCompletionProvider.js'; -import { AgentHostCheckpointService } from './agentHostCheckpointService.js'; -import { AgentHostCommitOperationContribution } from './agentHostCommitOperationProvider.js'; -import { AgentHostCompletions, IAgentHostCompletions } from './agentHostCompletions.js'; -import { AgentHostCustomizationEnablementService, IAgentHostCustomizationEnablementService } from './agentHostCustomizationEnablementService.js'; -import { AgentHostDiscardChangesOperationContribution } from './agentHostDiscardChangesOperationProvider.js'; +import { IAgentHostCompletions } from './agentHostCompletions.js'; +import { IAgentHostCustomizationEnablementService, supportsCustomizationEnablementWorktreeBinding } from './agentHostCustomizationEnablementService.js'; import { AgentHostDebugLogsCollector } from './agentHostDebugLogs.js'; import { AgentHostDatabase } from './agentHostDatabase.js'; -import { AgentHostFileCompletionProvider } from './agentHostFileCompletionProvider.js'; -import { AgentHostGitStateService } from './agentHostGitStateService.js'; -import { AgentHostGitHubEndpointService, IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; import { AgentHostLocalTurns } from './agentHostLocalTurns.js'; -import { AgentHostManagedSettingsService, IAgentHostManagedSettingsService } from './agentHostManagedSettingsService.js'; -import { AgentHostMergeOperationContribution } from './agentHostMergeOperationProvider.js'; -import { AgentHostPromptCache, IAgentHostPromptCache } from './agentHostPromptCache.js'; -import { AgentHostPullRequestOperationContribution } from './agentHostPullRequestOperationProvider.js'; -import { AgentHostRenameCompletionProvider } from './agentHostRenameCommand.js'; -import { AgentHostReviewService } from './agentHostReviewService.js'; -import { AgentHostSessionTitleSignal, IAgentHostSessionTitleSignal } from './agentHostSessionTitleSignal.js'; -import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; -import { AgentHostStorageService, IAgentHostStorageService } from './agentHostStorageService.js'; -import { AgentHostSyncOperationContribution } from './agentHostSyncOperationProvider.js'; -import { AgentHostTerminalManager, IAgentHostTerminalManager } from './agentHostTerminalManager.js'; -import { AgentHostWorkspaceFiles } from './agentHostWorkspaceFiles.js'; +import { AgentHostStateManager } from './agentHostStateManager.js'; +import { IAgentHostTerminalManager } from './agentHostTerminalManager.js'; import { AgentMergeController } from './agentMergeController.js'; import { AgentMergeTools } from './agentMergeTools.js'; -import { AgentService, type IAgentServiceCallbacks, type IAgentServiceCollaborators, type IAgentServiceCore, type IAgentServiceOptions } from './agentService.js'; +import { AgentService, type IAgentServiceCollaborators, type IAgentServiceCore, type IAgentServiceOptions } from './agentService.js'; import { AgentSessionRegistry } from './agentSessionRegistry.js'; import { AgentSideEffects } from './agentSideEffects.js'; -import { CodexCompactCompletionProvider } from './codexCompactCommand.js'; import { SessionCoordinationService } from './sessionCoordination.js'; import { AgentServerToolHost } from './shared/agentServerToolHost.js'; import { buildServerToolGroups } from './shared/serverToolGroups.js'; -import type { ISessionServerToolAccessor } from './shared/sessionServerTools.js'; -import type { IArtifactServerToolAccessor } from './shared/artifactServerTools.js'; -import { AgentHostOctoKitService, IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js'; -import { CopilotApiService, ICopilotApiService } from './shared/copilotApiService.js'; -import { hostBuildInfoFromProduct } from '../common/state/sessionState.js'; +import { type IAgentServiceFoundation } from './agentServiceFoundation.js'; +import { IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js'; +import { ICopilotApiService } from './shared/copilotApiService.js'; export interface IAgentServiceComposition { readonly agentService: AgentService; @@ -77,70 +50,32 @@ export interface IAgentServiceComposition { readonly completions: IAgentHostCompletions; readonly agents: IObservable; readonly onDidStartTurn: Event; + setContributions(contributions: IDisposable): void; } -class AgentServiceCallbackAdapter { - private callbacks: IAgentServiceCallbacks | undefined; - - readonly sessionServerToolAccessor: ISessionServerToolAccessor = { - isActiveAgentTitleGenerationEnabled: () => this.value.sessionServerToolAccessor.isActiveAgentTitleGenerationEnabled(), - listSessions: () => this.value.sessionServerToolAccessor.listSessions(), - getSession: session => this.value.sessionServerToolAccessor.getSession(session), - createSession: config => this.value.sessionServerToolAccessor.createSession(config), - getModels: () => this.value.sessionServerToolAccessor.getModels(), - getCreationDefaults: source => this.value.sessionServerToolAccessor.getCreationDefaults(source), - startPrompt: (session, chat, prompt) => this.value.sessionServerToolAccessor.startPrompt(session, chat, prompt), - createChat: (session, chat, options) => this.value.sessionServerToolAccessor.createChat(session, chat, options), - renameChat: (session, chat, title) => this.value.sessionServerToolAccessor.renameChat(session, chat, title), - reportToolError: (toolName, error) => this.value.sessionServerToolAccessor.reportToolError(toolName, error), - deleteSession: session => this.value.sessionServerToolAccessor.deleteSession(session), - getChatContext: (session, chatId) => this.value.sessionServerToolAccessor.getChatContext(session, chatId), - getSessionSpawnDepth: session => this.value.sessionServerToolAccessor.getSessionSpawnDepth(session), - setSessionSpawnDepth: (session, depth) => this.value.sessionServerToolAccessor.setSessionSpawnDepth(session, depth), - setSessionOrchestration: (session, orchestration) => this.value.sessionServerToolAccessor.setSessionOrchestration(session, orchestration), - }; - - readonly artifactServerToolAccessor: IArtifactServerToolAccessor = { - isEnabled: () => this.value.artifactServerToolAccessor.isEnabled(), - persist: (session, artifacts) => this.value.artifactServerToolAccessor.persist(session, artifacts), - }; - - bind(callbacks: IAgentServiceCallbacks): void { - if (this.callbacks) { - throw new Error('AgentService callbacks have already been bound'); - } - this.callbacks = callbacks; - } - - canEvictChangeset(changeset: string): boolean { - return this.callbacks?.canEvictChangeset(changeset) ?? false; - } - - get value(): IAgentServiceCallbacks { - if (!this.callbacks) { - throw new Error('AgentService callbacks have not been bound'); - } - return this.callbacks; - } -} - -/** Constructs and registers the complete {@link AgentService} collaborator graph. */ +/** + * Constructs and registers the shared, synchronous session-orchestration graph. + * + * A service belongs here when every Agent Host entry point uses the same + * implementation, its dependencies are already registered, and construction + * does not start process-level behavior. Services requiring runtime options, + * async initialization, or an entry-point-selected implementation belong in + * `agentHostBootstrap.ts`; transports, providers, schedulers, and process + * listeners belong in the activating entry point. + */ export function createAgentServiceComposition( options: IAgentServiceOptions, - services: ServiceCollection, + accessor: ServicesAccessor, instantiationService: IInstantiationService, - fetchFn: typeof globalThis.fetch, logService: ILogService, - productService: IProductService, sessionDataService: ISessionDataService, + foundation: IAgentServiceFoundation, additionalDisposables: readonly IDisposable[] = [], ): IAgentServiceComposition { const owned = new DisposableStore(); + const contributions = owned.add(new MutableDisposable()); let agentService: AgentService | undefined; try { - for (const disposable of additionalDisposables) { - owned.add(disposable); - } const databasePath = options.rootConfigResource ? joinPath(dirname(options.rootConfigResource), 'agent-host.db').fsPath : ':memory:'; @@ -148,26 +83,11 @@ export function createAgentServiceComposition( const debugLogsCollector = options.debugLogsEnvironment ? owned.add(new AgentHostDebugLogsCollector(options.debugLogsEnvironment, logService)) : undefined; - const callbackAdapter = new AgentServiceCallbackAdapter(); - const agents = observableValue(callbackAdapter, []); + const { callbackAdapter, agents, stateManager, configurationService, authenticationService, gitHubEndpointService } = foundation; const sessionRegistry = owned.add(new AgentSessionRegistry(orchestratorDatabase)); - const stateManager = owned.add(new AgentHostStateManager(logService, { - hostBuildInfo: hostBuildInfoFromProduct(productService), - changesetStateRetention: { - canEvict: changeset => callbackAdapter.canEvictChangeset(changeset), - }, - })); - const configurationService = owned.add(new AgentConfigurationService( - stateManager, - logService, - options.rootConfigResource, - options.providerConfigurations ?? [], - )); - const storageService = owned.add(new AgentHostStorageService(options.storageResource, logService)); - const managedSettingsService = owned.add(new AgentHostManagedSettingsService()); const core: IAgentServiceCore = { disposables: owned, - authenticationService: owned.add(new AgentHostAuthenticationService(logService)), + authenticationService, orchestratorDatabase, debugLogsCollector, sessionRegistry, @@ -176,75 +96,29 @@ export function createAgentServiceComposition( agents, callbackBinder: callbackAdapter, }; - services.set(IAgentHostAuthenticationService, core.authenticationService); - services.set(IAgentConfigurationService, configurationService); - services.set(IAgentHostStateManager, stateManager); - services.set(IAgentHostStorageService, storageService); - services.set(IAgentHostManagedSettingsService, managedSettingsService); - // AgentService subscribes after this graph is complete, so collaborator constructors must not emit state-manager events. - const gitHubEndpointService = owned.add(instantiationService.createInstance(AgentHostGitHubEndpointService)); - services.set(IAgentHostGitHubEndpointService, gitHubEndpointService); - const octoKitService = instantiationService.createInstance(AgentHostOctoKitService, fetchFn); - services.set(IAgentHostOctoKitService, octoKitService); - const gitHubService = owned.add(instantiationService.createInstance(GitHubService, { - endpoint: gitHubEndpointService, - tokenProvider: { - getToken: () => { - const resource = gitHubEndpointService.getRepoResource(); - return core.authenticationService.getAuthToken({ resource: resource.resource, scopes: resource.scopes_supported }); - }, - }, - fetch: fetchFn, - })); - services.set(IGitHubService, gitHubService); - const copilotApiService = options.copilotApiService ?? instantiationService.createInstance(CopilotApiService, fetchFn); - services.set(ICopilotApiService, copilotApiService); - const customizationEnablementService = owned.add(instantiationService.createInstance(AgentHostCustomizationEnablementService)); - services.set(IAgentHostCustomizationEnablementService, customizationEnablementService); - const gitStateService = owned.add(instantiationService.createInstance(AgentHostGitStateService)); - services.set(IAgentHostGitStateService, gitStateService); + const octoKitService = accessor.get(IAgentHostOctoKitService); + const copilotApiService = accessor.get(ICopilotApiService); + const customizationEnablementService = accessor.get(IAgentHostCustomizationEnablementService); + if (!supportsCustomizationEnablementWorktreeBinding(customizationEnablementService)) { + throw new Error('AgentService requires customization enablement worktree binding support'); + } + const gitStateService = accessor.get(IAgentHostGitStateService); const agentMergeController = owned.add(instantiationService.createInstance(AgentMergeController, { startTurn: (session, turnId, prompt) => callbackAdapter.value.startAgentMergeTurn(session, turnId, prompt), cancelTurn: (session, turnId) => callbackAdapter.value.cancelAgentMergeTurn(session, turnId), getAutonomousSessionConfig: (session, config) => callbackAdapter.value.getAutonomousSessionConfig(session, config), })); - const checkpointService = owned.add(instantiationService.createInstance(AgentHostCheckpointService)); - services.set(IAgentHostCheckpointService, checkpointService); - const promptCache = instantiationService.createInstance(AgentHostPromptCache); - services.set(IAgentHostPromptCache, promptCache); - const sessionTitleSignal = owned.add(instantiationService.createInstance(AgentHostSessionTitleSignal)); - services.set(IAgentHostSessionTitleSignal, sessionTitleSignal); - const changesetSubscriptions = instantiationService.createInstance(AgentHostChangesetSubscriptionService); - services.set(IAgentHostChangesetSubscriptionService, changesetSubscriptions); - const changesetOperationService = owned.add(instantiationService.createInstance(AgentHostChangesetOperationService)); - services.set(IAgentHostChangesetOperationService, changesetOperationService); - const reviewService = owned.add(instantiationService.createInstance(AgentHostReviewService)); - services.set(IAgentHostReviewService, reviewService); - const changesets = owned.add(instantiationService.createInstance(AgentHostChangesetService)); - services.set(IAgentHostChangesetService, changesets); + const checkpointService = accessor.get(IAgentHostCheckpointService); + const changesetOperationService = accessor.get(IAgentHostChangesetOperationService); + const reviewService = accessor.get(IAgentHostReviewService); + const changesets = accessor.get(IAgentHostChangesetService); const changesetCoordinator = owned.add(instantiationService.createInstance(AgentHostChangesetCoordinator)); owned.add(stateManager.onDidChangeSessionActiveTurn(event => changesetCoordinator.onSessionTurnActiveChanged(event.session, event.active))); - owned.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostCommitOperationContribution))); - owned.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostPullRequestOperationContribution))); - owned.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostMergeOperationContribution))); - owned.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostSyncOperationContribution))); - owned.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostDiscardChangesOperationContribution))); - const completions = owned.add(instantiationService.createInstance(AgentHostCompletions)); - services.set(IAgentHostCompletions, completions); - const workspaceFiles = owned.add(instantiationService.createInstance(AgentHostWorkspaceFiles)); - owned.add(completions.registerProvider(new AgentHostFileCompletionProvider(stateManager, workspaceFiles, logService))); - owned.add(completions.registerProvider(new AgentHostChatCompletionProvider(stateManager))); - owned.add(completions.registerProvider(new AgentHostRenameCompletionProvider( - session => (stateManager.getSessionState(session)?.turns.length ?? 0) > 0, - ))); - owned.add(completions.registerProvider(new CodexCompactCompletionProvider( - session => (stateManager.getSessionState(session)?.turns.length ?? 0) > 0, - ))); + const completions = accessor.get(IAgentHostCompletions); - const terminalManager = owned.add(instantiationService.createInstance(AgentHostTerminalManager)); - services.set(IAgentHostTerminalManager, terminalManager); + const terminalManager = accessor.get(IAgentHostTerminalManager); const localTurns = new AgentHostLocalTurns(sessionDataService, logService); const sideEffects = owned.add(instantiationService.createInstance( AgentSideEffects, @@ -316,6 +190,9 @@ export function createAgentServiceComposition( serverToolHost, }; agentService = instantiationService.createInstance(AgentService, core, collaborators); + for (const disposable of additionalDisposables) { + owned.add(disposable); + } return { agentService, authenticationService: core.authenticationService, @@ -326,12 +203,21 @@ export function createAgentServiceComposition( completions, agents, onDidStartTurn: sideEffects.onDidStartTurn, + setContributions: value => { + if (contributions.value) { + throw new Error('Agent Host contributions have already been set'); + } + contributions.value = value; + }, }; } catch (error) { if (agentService) { agentService.dispose(); } else { owned.dispose(); + for (const disposable of additionalDisposables) { + disposable.dispose(); + } } throw error; } diff --git a/src/vs/platform/agentHost/node/agentServiceFoundation.ts b/src/vs/platform/agentHost/node/agentServiceFoundation.ts new file mode 100644 index 00000000000000..07489e41626ce6 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentServiceFoundation.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 { DisposableStore } from '../../../base/common/lifecycle.js'; +import { observableValue, type ISettableObservable } from '../../../base/common/observable.js'; +import { URI } from '../../../base/common/uri.js'; +import type { GitHubServiceOptions } from '../../github/common/githubTypes.js'; +import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; +import { ILogService } from '../../log/common/log.js'; +import { IProductService } from '../../product/common/productService.js'; +import { IRequestService } from '../../request/common/request.js'; +import type { IAgent } from '../common/agent.js'; +import type { IAgentCustomizationSettingsRegistration } from '../common/agentCustomizationSettings.js'; +import { AgentHostProxyConfigKey } from '../common/agentHostSchema.js'; +import type { IAgentServiceCallbacks, IAgentServiceCallbackBinder } from './agentService.js'; +import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js'; +import { AgentHostAuthenticationService, IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; +import { AgentHostGitHubEndpointService, IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; +import { AgentHostProxyResolver, IAgentHostProxyResolver } from './agentHostProxyResolver.js'; +import { AgentHostRequestService } from './agentHostRequestService.js'; +import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; +import type { IArtifactServerToolAccessor } from './shared/artifactServerTools.js'; +import type { ISessionServerToolAccessor } from './shared/sessionServerTools.js'; +import { hostBuildInfoFromProduct } from '../common/state/sessionState.js'; + +export class AgentServiceCallbackAdapter implements IAgentServiceCallbackBinder { + private callbacks: IAgentServiceCallbacks | undefined; + + readonly sessionServerToolAccessor: ISessionServerToolAccessor = { + isActiveAgentTitleGenerationEnabled: () => this.value.sessionServerToolAccessor.isActiveAgentTitleGenerationEnabled(), + listSessions: () => this.value.sessionServerToolAccessor.listSessions(), + getSession: session => this.value.sessionServerToolAccessor.getSession(session), + createSession: config => this.value.sessionServerToolAccessor.createSession(config), + getModels: () => this.value.sessionServerToolAccessor.getModels(), + getCreationDefaults: source => this.value.sessionServerToolAccessor.getCreationDefaults(source), + startPrompt: (session, chat, prompt) => this.value.sessionServerToolAccessor.startPrompt(session, chat, prompt), + createChat: (session, chat, options) => this.value.sessionServerToolAccessor.createChat(session, chat, options), + renameChat: (session, chat, title) => this.value.sessionServerToolAccessor.renameChat(session, chat, title), + reportToolError: (toolName, error) => this.value.sessionServerToolAccessor.reportToolError(toolName, error), + deleteSession: session => this.value.sessionServerToolAccessor.deleteSession(session), + getChatContext: (session, chatId) => this.value.sessionServerToolAccessor.getChatContext(session, chatId), + getSessionSpawnDepth: session => this.value.sessionServerToolAccessor.getSessionSpawnDepth(session), + setSessionSpawnDepth: (session, depth) => this.value.sessionServerToolAccessor.setSessionSpawnDepth(session, depth), + setSessionOrchestration: (session, orchestration) => this.value.sessionServerToolAccessor.setSessionOrchestration(session, orchestration), + }; + + readonly artifactServerToolAccessor: IArtifactServerToolAccessor = { + isEnabled: () => this.value.artifactServerToolAccessor.isEnabled(), + persist: (session, artifacts) => this.value.artifactServerToolAccessor.persist(session, artifacts), + }; + + bind(callbacks: IAgentServiceCallbacks): void { + if (this.callbacks) { + throw new Error('AgentService callbacks have already been bound'); + } + this.callbacks = callbacks; + } + + canEvictChangeset(changeset: string): boolean { + return this.callbacks?.canEvictChangeset(changeset) ?? false; + } + + get value(): IAgentServiceCallbacks { + if (!this.callbacks) { + throw new Error('AgentService callbacks have not been bound'); + } + return this.callbacks; + } +} + +export interface IAgentServiceFoundation { + readonly callbackAdapter: AgentServiceCallbackAdapter; + readonly agents: ISettableObservable; + readonly stateManager: AgentHostStateManager; + readonly configurationService: AgentConfigurationService; + readonly authenticationService: AgentHostAuthenticationService; + readonly gitHubEndpointService: AgentHostGitHubEndpointService; + readonly proxyResolver: IAgentHostProxyResolver; + readonly requestService: IRequestService; + readonly fetchFn: typeof globalThis.fetch; + readonly gitHubServiceOptions: GitHubServiceOptions; +} + +export interface ICreateAgentServiceFoundationOptions { + readonly services: ServiceCollection; + readonly owned: DisposableStore; + readonly logService: ILogService; + readonly productService: IProductService; + readonly rootConfigResource?: URI; + readonly providerConfigurations?: readonly IAgentCustomizationSettingsRegistration[]; + readonly transientProxyConfiguration: boolean; + readonly proxyResolver?: IAgentHostProxyResolver; + readonly fetchFn?: typeof globalThis.fetch; +} + +export function createAgentServiceFoundation(options: ICreateAgentServiceFoundationOptions): IAgentServiceFoundation { + const callbackAdapter = new AgentServiceCallbackAdapter(); + const agents = observableValue(callbackAdapter, []); + const stateManager = options.owned.add(new AgentHostStateManager(options.logService, { + hostBuildInfo: hostBuildInfoFromProduct(options.productService), + changesetStateRetention: { + canEvict: changeset => callbackAdapter.canEvictChangeset(changeset), + }, + })); + const configurationService = options.owned.add(new AgentConfigurationService( + stateManager, + options.logService, + options.rootConfigResource, + options.providerConfigurations ?? [], + )); + if (options.transientProxyConfiguration) { + configurationService.publishRootTransientValues(Object.fromEntries( + Object.values(AgentHostProxyConfigKey).map(key => [key, undefined]) + )); + } + const authenticationService = options.owned.add(new AgentHostAuthenticationService(options.logService)); + const gitHubEndpointService = options.owned.add(new AgentHostGitHubEndpointService(configurationService, options.logService)); + const proxyResolver = options.proxyResolver ?? options.owned.add(new AgentHostProxyResolver(configurationService, options.logService)); + const requestService = options.owned.add(new AgentHostRequestService(options.logService, proxyResolver)); + const fetchFn = options.fetchFn ?? proxyResolver.fetch.bind(proxyResolver); + + options.services.set(IAgentHostStateManager, stateManager); + options.services.set(IAgentConfigurationService, configurationService); + options.services.set(IAgentHostAuthenticationService, authenticationService); + options.services.set(IAgentHostGitHubEndpointService, gitHubEndpointService); + options.services.set(IAgentHostProxyResolver, proxyResolver); + options.services.set(IRequestService, requestService); + + return { + callbackAdapter, + agents, + stateManager, + configurationService, + authenticationService, + gitHubEndpointService, + proxyResolver, + requestService, + fetchFn, + gitHubServiceOptions: { + endpoint: gitHubEndpointService, + tokenProvider: { + getToken: () => { + const resource = gitHubEndpointService.getRepoResource(); + return authenticationService.getAuthToken({ resource: resource.resource, scopes: resource.scopes_supported }); + }, + }, + fetch: fetchFn, + }, + }; +} diff --git a/src/vs/platform/agentHost/node/serviceBootstrapping.md b/src/vs/platform/agentHost/node/serviceBootstrapping.md new file mode 100644 index 00000000000000..6769499477a568 --- /dev/null +++ b/src/vs/platform/agentHost/node/serviceBootstrapping.md @@ -0,0 +1,232 @@ +# Agent Host service construction + +> **Status: CURRENT** (2026-08-21) + +## Maintaining this document + +This is a decision guide for Agent Host service bootstrapping, not a running +implementation diary. + +- Update it when a placement rule, construction phase, ownership contract, + accepted wart, or extension checklist changes. +- Prefer rules, small representative examples, and explicit exit conditions. +- Do not append incident history, temporary symbol lists, exhaustive service + inventories, review chronology, or details already obvious from the code. +- Keep stable contracts separate from accepted debt. +- Remove obsolete guidance in the same change that makes it obsolete. +- Keep this file focused on `agentHostBootstrap.ts`, `agentHostServices.ts`, + `agentServiceFoundation.ts`, `agentServiceComposition.ts`, + `agentHostContributions.ts`, and their test graph. + +## Primary graph + +Each Agent Host process has one primary process-local `ServiceCollection` and +strict `InstantiationService`, owned by `AgentHostRuntime`. The closest VS Code +analogy is the shared process bootstrap in +`src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts`: concrete +pre-DI foundations, local `SyncDescriptor` registrations, strict DI, root +composition, then activation. + +Scoped child instantiation services or service collections are allowed when an +isolated lifetime or override scope genuinely needs them. They should inherit +from the primary graph where possible, have an explicit owner, and must not +create competing instances of primary runtime services. + +The primary Agent Host graph does not use the global `registerSingleton` +registry because tests and runtime-selected implementations need independent +process-local roots. This does not prohibit using a child graph for a scoped +subsystem. + +## Stability + +### Stable contracts + +These are the intended extension points for new work: + +- one primary runtime graph, with explicitly scoped child graphs allowed; +- foundation, core-service, host-service, composition, contribution, and + entry-activation placement categories; +- exact leading static arguments for descriptors; +- eager startup resolution of registered service IDs; +- collection sealing after bootstrap registration; +- one disposal owner per object and phase-ordered runtime teardown; +- typed test overrides that reuse the production core registration list. + +New services should follow these contracts. Do not add another exception merely +because an existing exception looks convenient. + +### Accepted debt + +The items in [Known warts](#known-warts) are not target patterns. They remain +because removing them requires a separate ownership or API refactoring. Each +wart has an explicit exit condition; update this document when one is removed. + +## Construction phases + +```ts +async function createAgentHostRuntime(options) { + const services = new AgentHostServiceCollection(); + const foundation = createAgentServiceFoundation(options, services); + const telemetry = await createAgentHostTelemetryService(foundation); + services.set(ITelemetryService, telemetry); + + const coreIds = registerAgentHostCoreServices(services, foundation); + const hostIds = registerAgentHostHostServices(services, foundation); + const instantiationService = new InstantiationService(services, true); + services.seal(); + resolveAll(instantiationService, [...coreIds, ...hostIds]); + + const composition = createAgentServiceComposition(instantiationService, foundation); + const contributions = activateAgentHostContributions(instantiationService, composition); + composition.setContributions(contributions); + wireProductionWorktree(instantiationService, composition.agentService); + + return new AgentHostRuntime(foundation, instantiationService, composition, contributions); +} +``` + +Tests use the same synchronous foundation, core registrations, and composition, +but supply telemetry and typed overrides directly, skip production host +services, and preserve the historical no-worktree path. + +## Where does a new object go? + +| If the object... | Put it in | Construction | +|---|---|---| +| must exist before DI, performs bootstrap I/O, or needs entry-point inputs | `agentHostBootstrap.ts` foundation | concrete instance registered before sealing | +| is shared by production and AgentService tests | `registerAgentHostCoreServices` | local `SyncDescriptor`; tests must supply any required host-facing dependency override | +| needs production environment, sandbox, SDK, plugin, or provider-host inputs | `registerAgentHostHostServices` | local descriptor or selected concrete null implementation | +| needs a back-reference to `AgentService` | `agentServiceComposition.ts` | explicit callback seam | +| registers providers, handlers, listeners, or other disposable behavior after construction | `agentHostContributions.ts` | create and immediately register in its returned store | +| starts transports, providers, recurring schedulers, or process listeners | entry point | activation after runtime creation | + +Place an object based on construction requirements and lifetime, not on which +existing file first needs it. + +## Descriptor rules + +- All non-service parameters that bootstrap must supply must precede the first + decorated service parameter. A trailing non-service parameter is valid only + when it has an optional/default value and the descriptor intentionally accepts + that value; DI cannot supply a trailing static argument. +- `SyncDescriptor.staticArguments.length` must equal the first service + dependency index exactly. `InstantiationService` otherwise pads or truncates + arguments after only a `console.trace`. +- Do not use `supportsDelayedInstantiation`. In Node it schedules construction + on a later macrotask, which makes startup failures and disposal timing + nondeterministic. An eager descriptor is already lazy until first resolved. +- Production eagerly resolves every returned core and host service ID before + composition. This intentionally preserves the pre-descriptor behavior, where + bootstrap constructed every service eagerly, so this migration changes + construction ownership without also introducing accidental laziness. Future + lazy construction should be a separate, measured change with targeted tests. +- Never call `createInstance()` for a class registered as a descriptor. +- Migrate a service atomically: add its descriptor and remove its old + imperative construction in the same commit. + +## Sealing + +The collection is sealed only after every concrete instance and descriptor, +including awaited telemetry, has been registered. After sealing: + +- new service IDs are rejected; +- replacements are rejected; +- descriptor-to-instance replacement by `InstantiationService` is allowed. + +Dynamic feature registration belongs in a service-owned registry or the +contribution phase, not in the service collection. + +## Ownership and disposal + +| Owner | Objects | +|---|---| +| foundation | concrete instances it constructs | +| `InstantiationService` | descriptor-created services | +| AgentService composition | callback-bound objects and `AgentService` | +| contribution store | activation objects and registration disposables | +| entry point | transports, process listeners, providers, schedulers | + +Never add a descriptor-created service to another `DisposableStore`. +`AgentHostRuntime` tears phases down explicitly: contributions, composition, +instantiation service, then foundation. Entry-point resources and logging are +disposed outside the runtime. + +## Test overrides + +`createTestAgentService` builds the shared foundation and core graph with typed +overrides; defaults never overwrite an existing override. Its returned +`AgentService` disposes the whole test graph. + +The compatibility graph intentionally defaults to: + +- no worktree isolation, preserving the historical degraded path; +- `NullAgentEditAttributionService`, avoiding background git polling in + unrelated fake-timer tests. +- the caller-supplied git service required by core git/changeset descriptors. + +Production and targeted graph tests still resolve the real implementations. + +## Known warts + +### `AgentServiceCallbackAdapter` + +**Why it exists:** callback-dependent services are constructed before +`AgentService`, while provider lookup, session restore, server-tool operations, +and changeset liveness are still owned by `AgentService`. + +**Do not extend it by default:** a new callback usually means another +responsibility should move to a narrower owning service. + +**Exit condition:** extract provider registry, session operations/restoration, +working-directory resolution, turn dispatch, and subscription liveness so +their consumers can inject those owners directly. Then delete the adapter and +its binder contract. + +### `AgentService.setWorktreeIsolation` + +**Why it exists:** worktree isolation is a production host descriptor, but +configuration, side effects, and customization enablement need its late +back-reference after composition. The compatibility test graph historically +runs without worktree isolation. + +**Do not add sibling setters:** ordinary construction-order dependencies belong +in constructor injection. + +**Exit condition:** introduce a correctly typed host-facing worktree contract +that can be injected without changing the default test graph, or relocate the +pending-worktree state so the back-reference disappears. + +### Concrete foundation services + +State manager, configuration, authentication, GitHub endpoint, proxy, and +request services are concrete foundations. + +**Why they exist:** some must precede telemetry; others have constructor shapes +that are not descriptor-safe because non-service arguments follow decorated +service arguments. + +**Exit condition:** a service may move to `agentHostServices.ts` when its +constructor has leading static arguments only and no pre-telemetry ordering +requirement. Moving one is optional cleanup, not a prerequisite for adding +unrelated services. + +## Anti-patterns + +- `services.set(...)` after the collection is sealed. +- A parallel root graph that duplicates services owned by the primary runtime. +- Public service getters on `AgentService`. +- Adding another post-construction `setX(...)` to fix ordinary ordering. +- Global `registerSingleton` for node Agent Host services. +- Process behavior in service constructors when it belongs in activation. +- A second test-only list of production service registrations. +- Descriptor registration without an exact static-argument audit. + +## Adding a service checklist + +- [ ] Classify it as foundation, core descriptor, host descriptor, composition, contribution, or entry activation. +- [ ] Keep constructor service parameters trailing and static-argument arity exact. +- [ ] Register and eagerly resolve its service ID in the appropriate graph. +- [ ] If using a child graph, document its scope, parent, and disposal owner. +- [ ] Give it exactly one disposal owner. +- [ ] Add a typed test override only when default test behavior must differ. +- [ ] Update this file if the placement rules or exceptions change. diff --git a/src/vs/platform/agentHost/test/node/agentHostBootstrap.test.ts b/src/vs/platform/agentHost/test/node/agentHostBootstrap.test.ts index aea89b8ad49262..7154812e450c9d 100644 --- a/src/vs/platform/agentHost/test/node/agentHostBootstrap.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostBootstrap.test.ts @@ -4,39 +4,27 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { mkdirSync, mkdtempSync, rmSync } from 'fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from '../../../../base/common/path.js'; import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { parseArgs, OPTIONS } from '../../../environment/node/argv.js'; import { NativeEnvironmentService } from '../../../environment/node/environmentService.js'; import { NullLogService } from '../../../log/common/log.js'; import product from '../../../product/common/product.js'; -import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; -import { IRequestService } from '../../../request/common/request.js'; -import { createAgentHostRuntime, registerAgentHostNetworkServices } from '../../node/agentHostBootstrap.js'; -import { IAgentHostProxyResolver } from '../../node/agentHostProxyResolver.js'; +import { createAgentHostRuntime } from '../../node/agentHostBootstrap.js'; import { NullByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js'; import { AgentHostLaunchKind } from '../../common/agentHostTelemetry.js'; +import { IAgentSdkDownloader } from '../../node/agentSdkDownloader.js'; +import { AgentHostServiceCollection } from '../../node/agentHostServices.js'; +import { createAgentServiceFoundation } from '../../node/agentServiceFoundation.js'; +import { AgentHostProxyConfigKey } from '../../common/agentHostSchema.js'; suite('agentHostBootstrap', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('registers network services without reading VS Code settings', () => { - const testDisposables = disposables.add(new DisposableStore()); - const services = new ServiceCollection(); - const networkServices = registerAgentHostNetworkServices(services, new NullLogService(), testDisposables); - - assert.deepStrictEqual({ - proxyResolver: services.get(IAgentHostProxyResolver) === networkServices.proxyResolver, - requestService: services.get(IRequestService) === networkServices.requestService, - }, { - proxyResolver: true, - requestService: true, - }); - }); - test('constructs the renderer BYOK runtime with strict dependency injection', async () => { const testDisposables = disposables.add(new DisposableStore()); const userDataPath = mkdtempSync(join(tmpdir(), 'agent-host-bootstrap-')); @@ -50,16 +38,63 @@ suite('agentHostBootstrap', () => { productService, logService: new NullLogService(), loggerService: undefined, - disposables: testDisposables, disableTelemetry: true, transientProxyConfiguration: true, hostLaunchKind: AgentHostLaunchKind.Unknown, providerConfigurations: [], byok: { kind: 'renderer', bridgeRegistry: new NullByokLmBridgeRegistry() }, }); - testDisposables.add(runtime.agentService); - testDisposables.add(runtime.instantiationService); + testDisposables.add(runtime); - assert.ok(runtime.agentSdkDownloader); + assert.ok(runtime.instantiationService.invokeFunction(accessor => accessor.get(IAgentSdkDownloader))); + }); + + test('loads standalone proxy configuration before resolver construction', () => { + const testDisposables = disposables.add(new DisposableStore()); + const directory = mkdtempSync(join(tmpdir(), 'agent-host-foundation-')); + testDisposables.add(toDisposable(() => rmSync(directory, { recursive: true, force: true }))); + const resource = URI.file(join(directory, 'agent-host-config.json')); + writeFileSync(resource.fsPath, JSON.stringify({ [AgentHostProxyConfigKey.Proxy]: 'http://proxy.example:8080' })); + const productService = { _serviceBrand: undefined, ...product }; + + const foundation = createAgentServiceFoundation({ + services: new AgentHostServiceCollection(), + owned: testDisposables, + logService: new NullLogService(), + productService, + rootConfigResource: resource, + transientProxyConfiguration: false, + }); + + assert.strictEqual(foundation.proxyResolver.getConfigurationValue(AgentHostProxyConfigKey.Proxy), 'http://proxy.example:8080'); + }); + + test('clears local proxy configuration before resolver construction and persistence', async () => { + const testDisposables = disposables.add(new DisposableStore()); + const directory = mkdtempSync(join(tmpdir(), 'agent-host-foundation-')); + testDisposables.add(toDisposable(() => rmSync(directory, { recursive: true, force: true }))); + const resource = URI.file(join(directory, 'agent-host-config.json')); + writeFileSync(resource.fsPath, JSON.stringify({ [AgentHostProxyConfigKey.Proxy]: 'http://stale-proxy.example:8080' })); + const productService = { _serviceBrand: undefined, ...product }; + + const foundation = createAgentServiceFoundation({ + services: new AgentHostServiceCollection(), + owned: testDisposables, + logService: new NullLogService(), + productService, + rootConfigResource: resource, + transientProxyConfiguration: true, + }); + foundation.configurationService.persistRootConfig(); + await foundation.configurationService.whenIdle(); + const persisted = JSON.parse(readFileSync(resource.fsPath, 'utf8')) as Record; + + assert.deepStrictEqual({ + resolver: foundation.proxyResolver.getConfigurationValue(AgentHostProxyConfigKey.Proxy), + persisted: persisted[AgentHostProxyConfigKey.Proxy], + }, { + resolver: undefined, + persisted: undefined, + }); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts b/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts new file mode 100644 index 00000000000000..1837dfa5563679 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../base/common/event.js'; +import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; +import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { IAgentHostChangesetOperationService, IChangesetOperationContribution } from '../../common/agentHostChangesetOperationService.js'; +import { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; +import { activateAgentHostContributions } from '../../node/agentHostContributions.js'; +import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentHostStateManager.js'; + +class FailingChangesetOperationService extends Disposable implements IAgentHostChangesetOperationService { + declare readonly _serviceBrand: undefined; + + private _registrationCount = 0; + disposedRegistrationCount = 0; + + registerContribution(contribution: IChangesetOperationContribution) { + this._registrationCount++; + if (this._registrationCount === 2) { + contribution.dispose(); + throw new Error('Contribution registration failed'); + } + return toDisposable(() => { + this.disposedRegistrationCount++; + contribution.dispose(); + }); + } + + updateOperations(): void { } + getOperations() { return []; } + async invokeChangesetOperation(): Promise { throw new Error('Not implemented'); } +} + +const nullGitStateService: IAgentHostGitStateService = { + _serviceBrand: undefined, + onDidRefreshSessionGitState: Event.None, + onDidChangeSessionGitHubState: Event.None, + async refreshSessionGitState() { }, + async resolveSessionBaseBranchName() { return undefined; }, + async setSessionGitHubState() { }, + async recordSessionMerge() { }, + async attachSessionGitHubPullRequest() { }, + async attachSessionGitHubReferences() { }, +}; + +suite('AgentHostContributions', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('disposes earlier registrations when activation fails', () => { + const changesetOperationService = disposables.add(new FailingChangesetOperationService()); + const services = new ServiceCollection( + [IAgentHostStateManager, disposables.add(new AgentHostStateManager(new NullLogService()))], + [IAgentHostChangesetOperationService, changesetOperationService], + [IAgentHostGitStateService, nullGitStateService], + ); + const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); + + assert.throws( + () => instantiationService.invokeFunction(accessor => activateAgentHostContributions(accessor, instantiationService)), + /Contribution registration failed/, + ); + assert.strictEqual(changesetOperationService.disposedRegistrationCount, 1); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostRequestService.test.ts b/src/vs/platform/agentHost/test/node/agentHostRequestService.test.ts index 044d7f22ddbb8f..9313bc99c359fa 100644 --- a/src/vs/platform/agentHost/test/node/agentHostRequestService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostRequestService.test.ts @@ -16,7 +16,7 @@ import { IProductService } from '../../../product/common/productService.js'; import { AuthInfo, IRequestService } from '../../../request/common/request.js'; import { AgentHostClientProxyChannel, createAgentHostClientProxyConnection, type IAgentHostClientProxyConnection } from '../../common/agentHostClientProxyChannel.js'; import { AgentHostProxyConfigKey } from '../../common/agentHostSchema.js'; -import { AgentConfigurationService, type IAgentConfigurationService } from '../../node/agentConfigurationService.js'; +import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentHostProxyResolver, IAgentHostProxyResolver } from '../../node/agentHostProxyResolver.js'; import { AgentHostRequestService } from '../../node/agentHostRequestService.js'; @@ -35,8 +35,6 @@ class TestProxyResolver implements IAgentHostProxyResolver { return Disposable.None; } - bindConfigurationService(_configurationService: IAgentConfigurationService, _transient: boolean): void { } - getConfigurationValue(_key: string): T | undefined { return undefined; } @@ -71,7 +69,8 @@ suite('AgentHostProxyResolver', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); test('fires when the first connection registers and after all connections reconnect', () => { - const resolver = disposables.add(new AgentHostProxyResolver(new NullLogService())); + const configurationService = createAgentConfigurationService(disposables); + const resolver = disposables.add(new AgentHostProxyResolver(configurationService, new NullLogService())); let registrations = 0; disposables.add(resolver.onDidRegisterConnection(() => registrations++)); const connection: IAgentHostClientProxyConnection = { @@ -96,11 +95,10 @@ suite('AgentHostProxyResolver', () => { }); test('reads manually configured proxy settings from Agent Host configuration', async () => { - const resolver = disposables.add(new AgentHostProxyResolver(new NullLogService())); const configurationService = createAgentConfigurationService(disposables); + const resolver = disposables.add(new AgentHostProxyResolver(configurationService, new NullLogService())); let configurationChanges = 0; disposables.add(resolver.onDidChangeConfiguration(() => configurationChanges++)); - resolver.bindConfigurationService(configurationService, false); configurationService.updateRootConfig({ [AgentHostProxyConfigKey.Proxy]: 'http://proxy.example:8080' }); assert.deepStrictEqual({ @@ -112,21 +110,25 @@ suite('AgentHostProxyResolver', () => { }); }); - test('clears persisted proxy values when binding local mirrored configuration', () => { - const resolver = disposables.add(new AgentHostProxyResolver(new NullLogService())); + test('reads local mirrored proxy values as transient before construction', () => { const configurationService = createAgentConfigurationService(disposables); configurationService.updateRootConfig({ [AgentHostProxyConfigKey.Proxy]: 'http://stale-proxy.example:8080' }); + configurationService.publishRootTransientValues({ [AgentHostProxyConfigKey.Proxy]: undefined }); + const resolver = disposables.add(new AgentHostProxyResolver(configurationService, new NullLogService())); - resolver.bindConfigurationService(configurationService, true); - - assert.strictEqual(configurationService.getRootConfigValues?.()[AgentHostProxyConfigKey.Proxy], undefined); + assert.deepStrictEqual({ + configuration: configurationService.getRootConfigValues?.()[AgentHostProxyConfigKey.Proxy], + resolver: resolver.getConfigurationValue(AgentHostProxyConfigKey.Proxy), + }, { + configuration: undefined, + resolver: undefined, + }); }); test('uses manually configured Kerberos authentication without a renderer bridge', async () => { - const resolver = disposables.add(new TestAgentHostProxyResolver(new NullLogService())); const configurationService = createAgentConfigurationService(disposables); - resolver.bindConfigurationService(configurationService, false); configurationService.updateRootConfig({ [AgentHostProxyConfigKey.ProxyKerberosServicePrincipal]: 'HTTP/proxy.example' }); + const resolver = disposables.add(new TestAgentHostProxyResolver(configurationService, new NullLogService())); const authorization = await (resolver as unknown as { _hostLookupKerberosAuthorization(url: string): Promise; diff --git a/src/vs/platform/agentHost/test/node/agentHostServices.test.ts b/src/vs/platform/agentHost/test/node/agentHostServices.test.ts new file mode 100644 index 00000000000000..889de301f047f5 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostServices.test.ts @@ -0,0 +1,206 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { URI } from '../../../../base/common/uri.js'; +import { SyncDescriptor } from '../../../instantiation/common/descriptors.js'; +import { createDecorator, IInstantiationService, _util } from '../../../instantiation/common/instantiation.js'; +import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; +import { IAgentEditAttributionService, NullAgentEditAttributionService } from '../../common/fileEditAttribution.js'; +import { NullByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js'; +import { AgentHostServiceCollection, registerAgentHostCoreServices, registerAgentHostHostServices } from '../../node/agentHostServices.js'; +import { IAgentHostWorktreeIsolation } from '../../node/shared/worktreeIsolation.js'; + +const ITestService = createDecorator('agentHostTestService'); +const IReplacementService = createDecorator('agentHostReplacementService'); + +interface ITestService { + readonly _serviceBrand: undefined; + readonly value: number; +} + +class TestService implements ITestService { + declare readonly _serviceBrand: undefined; + readonly value = 1; +} + +class ReplacementTestService implements ITestService { + declare readonly _serviceBrand: undefined; + readonly value = 2; +} + +class DisposableTestService extends Disposable implements ITestService { + declare readonly _serviceBrand: undefined; + readonly value = 1; + + constructor(private readonly onDispose: () => void) { + super(); + } + + override dispose(): void { + this.onDispose(); + super.dispose(); + } +} + +suite('AgentHostServiceCollection', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('registers the instantiation service before sealing', () => { + const services = new AgentHostServiceCollection(); + const instantiationService = disposables.add(new InstantiationService(services, true)); + + services.seal(); + + assert.strictEqual(services.get(IInstantiationService), instantiationService); + }); + + test('allows descriptor resolution after sealing', () => { + const services = new AgentHostServiceCollection(); + services.set(ITestService, new SyncDescriptor(TestService)); + const instantiationService = disposables.add(new InstantiationService(services, true)); + services.seal(); + + const resolved = instantiationService.invokeFunction(accessor => accessor.get(ITestService)); + + assert.deepStrictEqual({ + value: resolved.value, + registered: services.get(ITestService) === resolved, + }, { + value: 1, + registered: true, + }); + }); + + test('rejects registrations and replacements after sealing', () => { + const services = new AgentHostServiceCollection(); + services.set(ITestService, new TestService()); + services.set(IReplacementService, new SyncDescriptor(TestService)); + disposables.add(new InstantiationService(services, true)); + services.seal(); + + assert.throws(() => services.set(createDecorator('agentHostLateService'), new TestService()), /service collection is sealed/); + assert.throws(() => services.set(ITestService, new TestService()), /service collection is sealed/); + assert.throws(() => services.set(IReplacementService, new SyncDescriptor(TestService)), /service collection is sealed/); + assert.throws(() => services.set(IReplacementService, new ReplacementTestService()), /service collection is sealed/); + }); + + test('registers descriptors with exact leading static arguments', () => { + const services = new AgentHostServiceCollection(); + const ids = [ + ...registerAgentHostCoreServices(services, { + storageResource: URI.file('/storage.json'), + fetchFn: globalThis.fetch, + gitHubServiceOptions: { + endpoint: { + onDidChange: Event.None, + getApiBaseUri: () => 'https://api.github.com', + getGraphQlUri: () => 'https://api.github.com/graphql', + }, + tokenProvider: { getToken: () => undefined }, + fetch: globalThis.fetch, + }, + }), + ...registerAgentHostHostServices(services, { + userDataPath: URI.file('/user-data'), + fetchFn: globalThis.fetch, + byok: { kind: 'renderer', bridgeRegistry: new NullByokLmBridgeRegistry() }, + }), + ]; + + const descriptors = ids + .map(id => services.get(id)) + .filter((candidate): candidate is SyncDescriptor => candidate instanceof SyncDescriptor); + const actual = descriptors.map(descriptor => { + const dependencies = _util.getServiceDependencies(descriptor.ctor).sort((a, b) => a.index - b.index); + return { + name: descriptor.ctor.name, + staticArguments: descriptor.staticArguments.length, + firstServiceArgument: dependencies[0]?.index ?? 0, + }; + }); + + assert.ok(actual.length > 0); + assert.deepStrictEqual( + actual.filter(entry => entry.staticArguments !== entry.firstServiceArgument), + [], + ); + }); + + test('preserves typed overrides', () => { + const services = new AgentHostServiceCollection(); + const override = new NullAgentEditAttributionService(); + services.set(IAgentEditAttributionService, override); + + const ids = registerAgentHostCoreServices(services, { + storageResource: undefined, + fetchFn: globalThis.fetch, + gitHubServiceOptions: { + endpoint: { + onDidChange: Event.None, + getApiBaseUri: () => 'https://api.github.com', + getGraphQlUri: () => 'https://api.github.com/graphql', + }, + tokenProvider: { getToken: () => undefined }, + fetch: globalThis.fetch, + }, + }); + + assert.deepStrictEqual({ + preserved: services.get(IAgentEditAttributionService) === override, + returned: ids.includes(IAgentEditAttributionService), + }, { + preserved: true, + returned: false, + }); + }); + + test('keeps worktree isolation production-only', () => { + const services = new AgentHostServiceCollection(); + const coreIds = registerAgentHostCoreServices(services, { + storageResource: undefined, + fetchFn: globalThis.fetch, + gitHubServiceOptions: { + endpoint: { + onDidChange: Event.None, + getApiBaseUri: () => 'https://api.github.com', + getGraphQlUri: () => 'https://api.github.com/graphql', + }, + tokenProvider: { getToken: () => undefined }, + fetch: globalThis.fetch, + }, + }); + const hostIds = registerAgentHostHostServices(services, { + userDataPath: URI.file('/user-data'), + fetchFn: globalThis.fetch, + byok: { kind: 'renderer', bridgeRegistry: new NullByokLmBridgeRegistry() }, + }); + + assert.deepStrictEqual({ + core: coreIds.includes(IAgentHostWorktreeIsolation), + host: hostIds.includes(IAgentHostWorktreeIsolation), + }, { + core: false, + host: true, + }); + }); + + test('descriptor-created services have one disposal owner', () => { + const services = new AgentHostServiceCollection(); + let disposeCount = 0; + services.set(ITestService, new SyncDescriptor(DisposableTestService, [() => disposeCount++])); + const instantiationService = disposables.add(new InstantiationService(services, true)); + services.seal(); + instantiationService.invokeFunction(accessor => accessor.get(ITestService)); + + instantiationService.dispose(); + instantiationService.dispose(); + + assert.strictEqual(disposeCount, 1); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 5a04c0400dd51a..52522a8d731c77 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -59,13 +59,12 @@ import { buildBranchChangesetUri, buildSessionChangesetUri, buildUncommittedChan 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'; -import type { INetworkDiagnosticsService } from '../../node/networkDiagnosticsService.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { SessionServerToolName } from '../../common/serverToolNames.js'; import { buildMcpChannel } from '../../node/shared/mcpCustomizationController.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; import { readChatSurfaceMeta, withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; -import { createTestAgentService, getTestAgentServiceComposition } from './agentServiceTestUtils.js'; +import { createTestAgentService, getTestAgentServiceComposition, getTestAgentStateManager } from './agentServiceTestUtils.js'; /** * Replace individual operations on an agent's chat surface, delegating every @@ -96,6 +95,10 @@ function getCheckpointService(service: AgentService) { return getTestAgentServiceComposition(service).checkpointService; } +function getStateManager(service: AgentService) { + return getTestAgentStateManager(service); +} + /** * Provision a session directly on an agent through the exact-chat seam * an initializing {@link IAgentChats.createChat} call, mirroring what @@ -571,12 +574,6 @@ suite('AgentService (node dispatcher)', () => { disposables.add(toDisposable(() => failingProvider.dispose())); const failingProviderContract: IAgent = failingProvider; failingProviderContract.getNetworkDiagnosticsEndpoints = async () => { throw new Error('unavailable'); }; - const diagnostics: INetworkDiagnosticsService = { - _serviceBrand: undefined, - getInfo: async (endpoints, account) => ({ version: 'test', os: 'test', arch: 'test', account, proxySettings: {}, proxyEnv: {}, endpoints }), - fetch: async url => ({ url }), - }; - service.setNetworkDiagnosticsService(diagnostics); service.registerProvider(providerA); service.registerProvider(providerB); service.registerProvider(failingProvider); @@ -952,10 +949,10 @@ suite('AgentService (node dispatcher)', () => { }); assert.deepStrictEqual({ - state: localService.stateManager.getSessionState(session.toString())?._meta, + state: getStateManager(localService).getSessionState(session.toString())?._meta, persisted: await db.getMetadata(SESSION_META_MULTI_ROOT_KEY), - github: readSessionGitHubState(localService.stateManager.getSessionState(session.toString())?._meta), - overridden: readSessionMultiRootMetadata(localService.stateManager.getSessionState(overridden.toString())?._meta), + github: readSessionGitHubState(getStateManager(localService).getSessionState(session.toString())?._meta), + overridden: readSessionMultiRootMetadata(getStateManager(localService).getSessionState(overridden.toString())?._meta), }, { state: { github, multiRoot }, persisted: JSON.stringify(override), @@ -985,7 +982,7 @@ suite('AgentService (node dispatcher)', () => { }); assert.deepStrictEqual( - readSessionFolderPickerDecision(localService.stateManager.getSessionState(session.toString())?._meta), + readSessionFolderPickerDecision(getStateManager(localService).getSessionState(session.toString())?._meta), { hidden: false }, ); }); @@ -1011,7 +1008,7 @@ suite('AgentService (node dispatcher)', () => { }); assert.deepStrictEqual( - readSessionFolderPickerDecision(localService.stateManager.getSessionState(session.toString())?._meta), + readSessionFolderPickerDecision(getStateManager(localService).getSessionState(session.toString())?._meta), { hidden: true, primary: URI.file('/workspace/two').toString() }, ); }); @@ -1057,7 +1054,7 @@ suite('AgentService (node dispatcher)', () => { const restored = (await reopened.listSessions()).find(s => s.session.toString() === session.toString()); assert.deepStrictEqual({ - seeded: readSessionFolderPickerDecision(creating.stateManager.getSessionState(session.toString())?._meta), + seeded: readSessionFolderPickerDecision(getStateManager(creating).getSessionState(session.toString())?._meta), persisted: await db.getMetadata(SESSION_META_FOLDER_PICKER_KEY), restored: readSessionFolderPickerDecision(restored?._meta), }, { @@ -1115,7 +1112,7 @@ suite('AgentService (node dispatcher)', () => { const restored = (await reopened.listSessions()).find(s => s.session.toString() === session.toString()); assert.deepStrictEqual({ - seeded: readSessionFolderPickerDecision(creating.stateManager.getSessionState(session.toString())?._meta), + seeded: readSessionFolderPickerDecision(getStateManager(creating).getSessionState(session.toString())?._meta), persistedBeforeMaterialize, persistedAfterMaterialize: await db.getMetadata(SESSION_META_FOLDER_PICKER_KEY), restored: readSessionFolderPickerDecision(restored?._meta), @@ -1165,9 +1162,9 @@ suite('AgentService (node dispatcher)', () => { workingDirectories: [URI.file('/work/one'), URI.file('/work/two')], _meta: { github, multiRoot }, }); - const before = readSessionMultiRootMetadata(localService.stateManager.getSessionState(session.toString())?._meta); + const before = readSessionMultiRootMetadata(getStateManager(localService).getSessionState(session.toString())?._meta); const persistedBefore = await db.getMetadata(SESSION_META_MULTI_ROOT_KEY); - const githubBefore = readSessionGitHubState(localService.stateManager.getSessionState(session.toString())?._meta); + const githubBefore = readSessionGitHubState(getStateManager(localService).getSessionState(session.toString())?._meta); const persistedGitHubBefore = await db.getMetadata(META_GITHUB_STATE); agent.materialize(session, [URI.file('/work/materialized'), URI.file('/work/two')]); @@ -1178,9 +1175,9 @@ suite('AgentService (node dispatcher)', () => { persistedBefore, githubBefore, persistedGitHubBefore, - after: readSessionMultiRootMetadata(localService.stateManager.getSessionState(session.toString())?._meta), + after: readSessionMultiRootMetadata(getStateManager(localService).getSessionState(session.toString())?._meta), persistedAfter: await db.getMetadata(SESSION_META_MULTI_ROOT_KEY), - githubAfter: readSessionGitHubState(localService.stateManager.getSessionState(session.toString())?._meta), + githubAfter: readSessionGitHubState(getStateManager(localService).getSessionState(session.toString())?._meta), persistedGitHubAfter: await db.getMetadata(META_GITHUB_STATE), }, { before: multiRoot, @@ -1232,8 +1229,8 @@ suite('AgentService (node dispatcher)', () => { }); const creatingInitially = getConfigurationService(localService).isWorkingDirectoryPending(creatingSession.toString()); const readyInitially = getConfigurationService(localService).isWorkingDirectoryPending(readySession.toString()); - const creatingLifecycle = localService.stateManager.getSessionState(creatingSession.toString())?.lifecycle; - const readyLifecycle = localService.stateManager.getSessionState(readySession.toString())?.lifecycle; + const creatingLifecycle = getStateManager(localService).getSessionState(creatingSession.toString())?.lifecycle; + const readyLifecycle = getStateManager(localService).getSessionState(readySession.toString())?.lifecycle; localService.dispatchAction(creatingSession.toString(), { type: ActionType.SessionConfigChanged, @@ -1415,13 +1412,13 @@ suite('AgentService (node dispatcher)', () => { disposables.add(toDisposable(() => agent.dispose())); localService.registerProvider(agent); const { session } = await createAgentSession(agent); - const sessionRestoredBeforeRead = !!localService.stateManager.getSessionState(session.toString()); + const sessionRestoredBeforeRead = !!getStateManager(localService).getSessionState(session.toString()); const result = await localService.resourceRead(URI.parse(buildGitBlobUri(session.toString(), 'baseSha', 'src/app.ts', '/workspace/repoA/src/app.ts'))); assert.deepStrictEqual({ sessionRestoredBeforeRead, - sessionRestored: !!localService.stateManager.getSessionState(session.toString()), + sessionRestored: !!getStateManager(localService).getSessionState(session.toString()), showBlobCalls, data: result.data, }, { @@ -1449,7 +1446,7 @@ suite('AgentService (node dispatcher)', () => { const result = await localService.resourceRead(URI.parse(buildGitBlobUri(nestedSession.toString(), 'baseSha', 'src/app.ts', '/workspace/repoA/src/app.ts'))); localService.addSubscriber(nestedSession, 'client'); await new Promise(resolve => setTimeout(resolve, 30_000)); - const retainedForSubscriber = !!localService.stateManager.getSessionState(session.toString()); + const retainedForSubscriber = !!getStateManager(localService).getSessionState(session.toString()); localService.unsubscribe(nestedSession, 'client'); await new Promise(resolve => setTimeout(resolve, 30_000)); @@ -1457,7 +1454,7 @@ suite('AgentService (node dispatcher)', () => { showBlobCalls, data: result.data, retainedForSubscriber, - releasedAfterUnsubscribe: !localService.stateManager.getSessionState(session.toString()), + releasedAfterUnsubscribe: !getStateManager(localService).getSessionState(session.toString()), }, { showBlobCalls: [{ workingDirectory: repoA.toString(), ref: 'baseSha', repoRelativePath: 'src/app.ts' }], data: 'blob:src/app.ts', @@ -1594,14 +1591,14 @@ suite('AgentService (node dispatcher)', () => { const session = await svc.createSession({ provider: 'copilot' }); const defaultChat = buildDefaultChatUri(session.toString()); const peerChat = buildChatUri(session, 'peer-1'); - svc.stateManager.addChat(session.toString(), peerChat); - svc.stateManager.dispatchServerAction(peerChat, { + getStateManager(svc).addChat(session.toString(), peerChat); + getStateManager(svc).dispatchServerAction(peerChat, { type: ActionType.ChatTurnStarted, turnId: 'duplicate-turn', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'peer', origin: { kind: MessageKind.User } }, }); - svc.stateManager.dispatchServerAction(peerChat, { + getStateManager(svc).dispatchServerAction(peerChat, { type: ActionType.ChatTurnComplete, turnId: 'duplicate-turn', duration: 1, @@ -1615,7 +1612,7 @@ suite('AgentService (node dispatcher)', () => { message: { text: 'default', origin: { kind: MessageKind.User } }, }, 'test-client', 1); const envelope = await envelopePromise; - const defaultChatState = svc.stateManager.getChatState(defaultChat); + const defaultChatState = getStateManager(svc).getChatState(defaultChat); assert.deepStrictEqual({ rejected: envelope.rejectionReason !== undefined, @@ -1639,7 +1636,7 @@ suite('AgentService (node dispatcher)', () => { const defaultChat = buildDefaultChatUri(session.toString()); const peerChat = buildChatUri(session, 'peer-1'); let resolverCalls = 0; - svc.stateManager.registerRestoredChatSummary(session.toString(), peerChat, { + getStateManager(svc).registerRestoredChatSummary(session.toString(), peerChat, { resolver: async () => { resolverCalls++; return { @@ -1662,12 +1659,12 @@ suite('AgentService (node dispatcher)', () => { message: { text: 'default', origin: { kind: MessageKind.User } }, }, 'test-client', 1); const envelope = await envelopePromise; - const defaultChatState = svc.stateManager.getChatState(defaultChat); + const defaultChatState = getStateManager(svc).getChatState(defaultChat); assert.deepStrictEqual({ rejected: envelope.rejectionReason !== undefined, resolverCalls, - peerResolved: svc.stateManager.getChatState(peerChat) !== undefined, + peerResolved: getStateManager(svc).getChatState(peerChat) !== undefined, activeTurn: defaultChatState?.activeTurn, turns: defaultChatState?.turns, sendMessageCalls: agent.sendMessageCalls, @@ -1693,7 +1690,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ rejectionReason: envelope.rejectionReason, - confirmed: svc.stateManager.getSessionState(session.toString())?.workingDirectories, + confirmed: getStateManager(svc).getSessionState(session.toString())?.workingDirectories, }, { rejectionReason: 'Session working-directory actions require an Editor Window client.', confirmed: [primary.toString(), secondary.toString()], @@ -1721,7 +1718,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ rejectionReason: envelope.rejectionReason, - controllerState: svc.stateManager.getSessionState(session.toString())?.config?.values[SessionConfigKey.AgentMergeController], + controllerState: getStateManager(svc).getSessionState(session.toString())?.config?.values[SessionConfigKey.AgentMergeController], }, { rejectionReason: `Session config keys are host-owned and cannot be set by a client: ${SessionConfigKey.AgentMergeController}.`, controllerState: undefined, @@ -1748,7 +1745,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ rejectionReason: envelope.rejectionReason, - controllerState: svc.stateManager.getSessionState(session.toString())?.config?.values[SessionConfigKey.AgentMergeController], + controllerState: getStateManager(svc).getSessionState(session.toString())?.config?.values[SessionConfigKey.AgentMergeController], }, { rejectionReason: undefined, controllerState, @@ -1789,7 +1786,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ envelope: envelope?.action, - confirmed: svc.stateManager.getSessionState(session.toString())?.workingDirectories, + confirmed: getStateManager(svc).getSessionState(session.toString())?.workingDirectories, }, { envelope: { type: ActionType.SessionWorkingDirectorySet, directory: added.toString() }, confirmed: [primary.toString(), secondary.toString(), added.toString()], @@ -1806,7 +1803,7 @@ suite('AgentService (node dispatcher)', () => { svc.registerProvider(agent); const session = await svc.createSession({ provider: agent.id, workingDirectories: [URI.file('/workspace')] }); const changeset = buildBranchChangesetUri(session.toString()); - svc.stateManager.registerChangeset(changeset); + getStateManager(svc).registerChangeset(changeset); const rejectionPromise = Event.toPromise(Event.filter(svc.onDidAction, envelope => envelope.origin?.clientSeq === 1)); svc.dispatchAction(changeset, { @@ -1887,7 +1884,7 @@ suite('AgentService (node dispatcher)', () => { await timeout(0); assert.deepStrictEqual( - svc.stateManager.getSessionState(session.toString())?.workingDirectories, + getStateManager(svc).getSessionState(session.toString())?.workingDirectories, [primary.toString(), secondary.toString()], 'working-directory action must not overtake the pending rewrite', ); @@ -1909,7 +1906,7 @@ suite('AgentService (node dispatcher)', () => { svc.dispatchAction(session.toString(), { type: ActionType.SessionWorkingDirectoryRemoved, directory: secondary.toString() }, 'test-client', 2, AgentHostClientType.EditorWindow); assert.deepStrictEqual({ - confirmed: svc.stateManager.getSessionState(session.toString())?.workingDirectories, + confirmed: getStateManager(svc).getSessionState(session.toString())?.workingDirectories, }, { confirmed: [primary.toString(), added.toString()], }); @@ -1931,7 +1928,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ actions: envelopes.map(envelope => envelope.action), - confirmed: svc.stateManager.getSessionState(session.toString())?.workingDirectories, + confirmed: getStateManager(svc).getSessionState(session.toString())?.workingDirectories, }, { actions: [ { type: ActionType.SessionWorkingDirectorySet, directory: secondary.toString() }, @@ -1954,7 +1951,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ rejected: !!envelope.rejectionReason, - confirmed: svc.stateManager.getSessionState(session.toString())?.workingDirectories, + confirmed: getStateManager(svc).getSessionState(session.toString())?.workingDirectories, }, { rejected: true, confirmed: [primary.toString(), secondary.toString()], @@ -1973,7 +1970,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ rejected: !!envelope.rejectionReason, - confirmed: svc.stateManager.getSessionState(session.toString())?.workingDirectories, + confirmed: getStateManager(svc).getSessionState(session.toString())?.workingDirectories, }, { rejected: false, confirmed: [secondary.toString()], @@ -2047,7 +2044,7 @@ suite('AgentService (node dispatcher)', () => { 'test-client', 1, ); - await waitForCondition(() => svc.stateManager.getSessionState(session.toString())?.title === 'Fix TypeScript compile errors', 'generated title should be applied'); + await waitForCondition(() => getStateManager(svc).getSessionState(session.toString())?.title === 'Fix TypeScript compile errors', 'generated title should be applied'); await waitForCondition(async () => await db.getMetadata('customTitle') !== undefined, 'generated title should be persisted'); assert.deepStrictEqual({ @@ -2074,7 +2071,7 @@ suite('AgentService (node dispatcher)', () => { 'test-client', 1, ); - const title = svc.stateManager.getSessionState(session.toString())?.title; + const title = getStateManager(svc).getSessionState(session.toString())?.title; assert.strictEqual(title, 'Explain active agent title generation active...'); assert.strictEqual(copilotApiService.utilityCalls.length, 0); await waitForCondition(async () => await db.getMetadata(SESSION_CUSTOM_TITLE_SOURCE_KEY) === AGENT_HOST_TITLE_SOURCE_AUTO, 'active-agent fallback provenance should be persisted'); @@ -2096,7 +2093,7 @@ suite('AgentService (node dispatcher)', () => { await Promise.resolve(); assert.deepStrictEqual({ - title: svc.stateManager.getSessionState(session.toString())?.title, + title: getStateManager(svc).getSessionState(session.toString())?.title, persistedTitle: await db.getMetadata('customTitle'), }, { title: 'Explain workspace search indexing', @@ -2126,7 +2123,7 @@ suite('AgentService (node dispatcher)', () => { await waitForCondition(async () => await db.getMetadata('customTitle') === 'Manual title', 'manual title should be persisted'); assert.deepStrictEqual({ - title: svc.stateManager.getSessionState(session.toString())?.title, + title: getStateManager(svc).getSessionState(session.toString())?.title, persistedTitle: await db.getMetadata('customTitle'), }, { title: 'Manual title', @@ -2153,7 +2150,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ aborted: copilotApiService.utilityCalls[0].options?.signal?.aborted, - state: svc.stateManager.getSessionState(session.toString()), + state: getStateManager(svc).getSessionState(session.toString()), persistedTitle: await db.getMetadata('customTitle'), }, { aborted: true, @@ -2179,7 +2176,7 @@ suite('AgentService (node dispatcher)', () => { }, }); - await waitForCondition(() => svc.stateManager.getSessionState(imported.toString())?.title === 'Imported conversation title', 'imported title should be generated'); + await waitForCondition(() => getStateManager(svc).getSessionState(imported.toString())?.title === 'Imported conversation title', 'imported title should be generated'); assert.strictEqual(copilotApiService.utilityCalls.length, 1); }); @@ -2199,7 +2196,7 @@ suite('AgentService (node dispatcher)', () => { }, }); - assert.strictEqual(svc.stateManager.getSessionState(imported.toString())?.title, 'Investigate imported conversation'); + assert.strictEqual(getStateManager(svc).getSessionState(imported.toString())?.title, 'Investigate imported conversation'); assert.strictEqual(copilotApiService.utilityCalls.length, 0); await waitForCondition(async () => await db.getMetadata(SESSION_CUSTOM_TITLE_SOURCE_KEY) === AGENT_HOST_TITLE_SOURCE_AUTO, 'imported fallback provenance should be persisted'); }); @@ -2304,7 +2301,7 @@ suite('AgentService (node dispatcher)', () => { if (rewritten.type !== MessageAttachmentKind.Resource) { return; } - const stateAttachment = svc.stateManager.getSessionState(session.toString())?.activeTurn?.message.attachments?.[0]; + const stateAttachment = getStateManager(svc).getSessionState(session.toString())?.activeTurn?.message.attachments?.[0]; assert.deepStrictEqual(stateAttachment, rewritten); const resource = URI.parse(rewritten.uri); const contents = await fileService.readFile(resource); @@ -2524,7 +2521,7 @@ suite('AgentService (node dispatcher)', () => { const session = await service.createSession({ provider: agent.id }); - assert.deepStrictEqual(service.stateManager.getSessionState(session.toString())?.customizations, [customization]); + assert.deepStrictEqual(getStateManager(service).getSessionState(session.toString())?.customizations, [customization]); }); test('publishes initial customizations to a client subscribed during discovery', async () => { @@ -2571,7 +2568,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ initialSnapshotCustomizations, action: envelope.action, - currentSnapshotCustomizations: (service.stateManager.getSnapshot(session.toString())?.state as SessionState | undefined)?.customizations, + currentSnapshotCustomizations: (getStateManager(service).getSnapshot(session.toString())?.state as SessionState | undefined)?.customizations, }, { initialSnapshotCustomizations: undefined, action: { type: ActionType.SessionCustomizationsChanged, customizations: [customization] }, @@ -2662,7 +2659,7 @@ suite('AgentService (node dispatcher)', () => { // dir. assert.deepStrictEqual({ provider: AgentSession.provider(session), - meta: service.stateManager.getSessionState(session.toString())?._meta, + meta: getStateManager(service).getSessionState(session.toString())?._meta, }, { provider: 'copilot', meta: { workspaceless: true }, @@ -2806,7 +2803,7 @@ suite('AgentService (node dispatcher)', () => { deletedSessionData, providerSessionStillExists: !!(await copilotAgent.getSessionMetadata(session)), registered: (await svc.getRegisteredSessions()).map(resource => resource.toString()), - hasState: !!svc.stateManager.getSessionState(session.toString()), + hasState: !!getStateManager(svc).getSessionState(session.toString()), }, { deletedSessionData: false, providerSessionStillExists: true, @@ -2840,7 +2837,7 @@ suite('AgentService (node dispatcher)', () => { await assert.rejects(svc.disposeSession(session), /transient registry write failure/); assert.deepStrictEqual({ registeredSessions: (await svc.getRegisteredSessions()).map(resource => resource.toString()), - hasState: !!svc.stateManager.getSessionState(session.toString()), + hasState: !!getStateManager(svc).getSessionState(session.toString()), deleteSessionDataCalls, removeWorktreeCalls, }, { @@ -2854,7 +2851,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ registryWriteAttempts: db.registryWriteAttempts, registeredSessions: await svc.getRegisteredSessions(), - hasState: !!svc.stateManager.getSessionState(session.toString()), + hasState: !!getStateManager(svc).getSessionState(session.toString()), deleteSessionDataCalls, removeWorktreeCalls, }, { @@ -2955,7 +2952,7 @@ suite('AgentService (node dispatcher)', () => { ...(session._meta !== undefined ? { _meta: session._meta } : {}), }; }); - service.stateManager.prepareSessionSummariesForListing(summaries); + getStateManager(service).prepareSessionSummariesForListing(summaries); } test('listSessions aggregates sessions from all providers', async () => { @@ -3367,7 +3364,7 @@ suite('AgentService (node dispatcher)', () => { } })); - svc.stateManager.dispatchServerAction(buildDefaultChatUri(third), { + getStateManager(svc).dispatchServerAction(buildDefaultChatUri(third), { type: ActionType.ChatTurnStarted, turnId: 'turn-third', startedAt: new Date(now).toISOString(), @@ -3528,11 +3525,11 @@ suite('AgentService (node dispatcher)', () => { summary: 'Provider chat', _meta: withSessionEhcliAdoptable(undefined), }]); - for (let i = 0; i < 50 && !svc.stateManager.getSurfacedSessionSummary(session.toString()); i++) { + for (let i = 0; i < 50 && !getStateManager(svc).getSurfacedSessionSummary(session.toString()); i++) { await timeout(0); } - const surfaced = svc.stateManager.getSurfacedSessionSummary(session.toString()); + const surfaced = getStateManager(svc).getSurfacedSessionSummary(session.toString()); assert.deepStrictEqual({ resource: surfaced?.resource, title: surfaced?.title, @@ -3556,24 +3553,24 @@ suite('AgentService (node dispatcher)', () => { const session = AgentSession.uri('copilot', 'toggled-adoptable'); (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(session), session); agent.fireDiscoveredChats([{ ...discoveredChat(session, false), _meta: withSessionEhcliAdoptable(undefined) }]); - for (let i = 0; i < 50 && !svc.stateManager.getSurfacedSessionSummary(session.toString()); i++) { + for (let i = 0; i < 50 && !getStateManager(svc).getSurfacedSessionSummary(session.toString()); i++) { await timeout(0); } - const afterFirstEnable = !!svc.stateManager.getSurfacedSessionSummary(session.toString()); + const afterFirstEnable = !!getStateManager(svc).getSurfacedSessionSummary(session.toString()); getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: false }); await timeout(0); - const whileDisabled = !!svc.stateManager.getSurfacedSessionSummary(session.toString()); + const whileDisabled = !!getStateManager(svc).getSurfacedSessionSummary(session.toString()); // Discovery skips chats already in the registry, so re-enabling must restore // them from the registry rather than waiting for another discovery pass. getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); - for (let i = 0; i < 50 && !svc.stateManager.getSurfacedSessionSummary(session.toString()); i++) { + for (let i = 0; i < 50 && !getStateManager(svc).getSurfacedSessionSummary(session.toString()); i++) { await timeout(0); } assert.deepStrictEqual( - { afterFirstEnable, whileDisabled, afterReEnable: !!svc.stateManager.getSurfacedSessionSummary(session.toString()) }, + { afterFirstEnable, whileDisabled, afterReEnable: !!getStateManager(svc).getSurfacedSessionSummary(session.toString()) }, { afterFirstEnable: true, whileDisabled: false, afterReEnable: true }, ); }); @@ -3834,14 +3831,14 @@ suite('AgentService (node dispatcher)', () => { for (let i = 0; i < 50 && fail; i++) { await timeout(0); } - assert.strictEqual(svc.stateManager.getSurfacedSessionSummary(session.toString()), undefined); + assert.strictEqual(getStateManager(svc).getSurfacedSessionSummary(session.toString()), undefined); await (svc as unknown as { _announceSurfacedSession(meta: IAgentSessionMetadata, provider: string): Promise })._announceSurfacedSession({ session, startTime: Date.now(), modifiedTime: Date.now(), }, agent.id); - assert.strictEqual(svc.stateManager.getSurfacedSessionSummary(session.toString())?.resource, session.toString()); + assert.strictEqual(getStateManager(svc).getSurfacedSessionSummary(session.toString())?.resource, session.toString()); }); test('migration candidates derive provenance from the workspaceless marker', async () => { @@ -4139,7 +4136,7 @@ suite('AgentService (node dispatcher)', () => { getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); await svc.listSessions(); - const surfaced = svc.stateManager.getSurfacedSessionSummary(legacy.toString()); + const surfaced = getStateManager(svc).getSurfacedSessionSummary(legacy.toString()); assert.deepStrictEqual({ resource: surfaced?.resource, provider: surfaced?.provider, @@ -4164,7 +4161,7 @@ suite('AgentService (node dispatcher)', () => { }]); await timeout(0); - assert.strictEqual(svc.stateManager.getSurfacedSessionSummary(legacy.toString()), undefined); + assert.strictEqual(getStateManager(svc).getSurfacedSessionSummary(legacy.toString()), undefined); }); test('registry discovery retains one provider despite another provider failing', async () => { @@ -5091,7 +5088,7 @@ suite('AgentService (node dispatcher)', () => { const listed = await svc.listSessions(); assert.deepStrictEqual({ - isolation: svc.stateManager.getSessionState(session.toString())?.config?.values[SessionConfigKey.Isolation], + isolation: getStateManager(svc).getSessionState(session.toString())?.config?.values[SessionConfigKey.Isolation], project: listed[0].project && { uri: listed[0].project.uri.toString(), displayName: listed[0].project.displayName }, workingDirectory: listed[0].workingDirectories?.[0].toString(), persistedRepositoryRoot: await db.getMetadata(WORKTREE_META_REPOSITORY_ROOT), @@ -5127,7 +5124,7 @@ suite('AgentService (node dispatcher)', () => { // it in the announced-summary map that `listSessions` overlays // onto provider results. const childSessionUri = buildSubagentSessionUri(parentSession.toString(), 'tc-sub'); - service.stateManager.restoreSession( + getStateManager(service).restoreSession( { resource: childSessionUri, provider: 'subagent', @@ -5141,7 +5138,7 @@ suite('AgentService (node dispatcher)', () => { // Sanity: the subagent child session is announced. assert.ok( - service.stateManager.getOverlaySessionSummaries().some(s => s.resource === childSessionUri), + getStateManager(service).getOverlaySessionSummaries().some(s => s.resource === childSessionUri), 'subagent child session should be listed', ); @@ -5210,7 +5207,7 @@ suite('AgentService (node dispatcher)', () => { { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 }, 'test-client', 2, ); - const stateAfterTurn = service.stateManager.getSessionState(session.toString()); + const stateAfterTurn = getStateManager(service).getSessionState(session.toString()); assert.strictEqual(stateAfterTurn?.lifecycle, SessionLifecycle.Creating, 'session should still be provisional (materialize not yet fired)'); assert.strictEqual(stateAfterTurn?.activeTurn, undefined, 'completed turn should clear the active turn'); const completedListed = await service.listSessions(); @@ -5247,7 +5244,7 @@ suite('AgentService (node dispatcher)', () => { const listing = service.listSessions(); await agent.listStarted.p; const summaryNow = Date.now(); - service.stateManager.restoreSession({ + getStateManager(service).restoreSession({ resource: session.toString(), provider: 'copilot', title: 'Materialized', @@ -5394,7 +5391,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ listCatalogueEntry: sessions[0].changesets?.find(c => c.uriTemplate === changesetUri), - listSeededSnapshot: svc.stateManager.getSnapshot(changesetUri), + listSeededSnapshot: getStateManager(svc).getSnapshot(changesetUri), }, { listCatalogueEntry: { label: 'Branch Changes', @@ -5446,15 +5443,15 @@ suite('AgentService (node dispatcher)', () => { // Seed live changeset state directly: a single file with // different counts than the stale persisted blob. - const changesetUri = svc.stateManager.registerChangeset(buildSessionChangesetUri(sessionUri.toString())); - svc.stateManager.dispatchServerAction(changesetUri, { + const changesetUri = getStateManager(svc).registerChangeset(buildSessionChangesetUri(sessionUri.toString())); + getStateManager(svc).dispatchServerAction(changesetUri, { type: ActionType.ChangesetFileSet, file: { id: 'file:///wd/live.ts', edit: { after: { uri: 'file:///wd/live.ts', content: { uri: 'file:///wd/live.ts' } }, diff: { added: 1, removed: 0 } } }, }); - svc.stateManager.dispatchServerAction(changesetUri, { + getStateManager(svc).dispatchServerAction(changesetUri, { type: ActionType.ChangesetStatusChanged, status: ChangesetStatus.Ready, }); @@ -5516,8 +5513,8 @@ suite('AgentService (node dispatcher)', () => { // Seed a ready (zero-file) live changeset state โ€” this alone // must be authoritative enough to suppress the persisted-diffs // read. - const changesetUri = svc.stateManager.registerChangeset(buildSessionChangesetUri(sessionUri.toString())); - svc.stateManager.dispatchServerAction(changesetUri, { + const changesetUri = getStateManager(svc).registerChangeset(buildSessionChangesetUri(sessionUri.toString())); + getStateManager(svc).dispatchServerAction(changesetUri, { type: ActionType.ChangesetStatusChanged, status: ChangesetStatus.Ready, }); @@ -5558,7 +5555,7 @@ suite('AgentService (node dispatcher)', () => { // Register a changeset but leave it in the default // `Computing` status (no ChangesetStatusChanged dispatch). - svc.stateManager.registerChangeset(buildSessionChangesetUri(sessionUri.toString())); + getStateManager(svc).registerChangeset(buildSessionChangesetUri(sessionUri.toString())); const sessions = await svc.listSessions(); assert.deepStrictEqual(sessions[0].changesets, [ @@ -5583,7 +5580,7 @@ suite('AgentService (node dispatcher)', () => { const session = await service.createSession({ provider: 'copilot' }); // Simulate immediate title change via state manager - service.stateManager.dispatchServerAction(session.toString(), { + getStateManager(service).dispatchServerAction(session.toString(), { type: ActionType.SessionTitleChanged, title: 'User first message', }); @@ -5664,7 +5661,7 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(sessions.length, 1); assert.deepStrictEqual(calls, [workingDirectory.fsPath]); assert.deepStrictEqual( - localService.stateManager.getSessionState(session.toString())?._meta, + getStateManager(localService).getSessionState(session.toString())?._meta, { git: gitState }, ); }); @@ -5765,7 +5762,7 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(sessions.length, 1); // No input workingDirectory โ†’ inferred workspace-less (tagged), and no // git overlay because there is no working directory to probe. - assert.deepStrictEqual(localService.stateManager.getSessionState(session.toString())?._meta, { workspaceless: true }); + assert.deepStrictEqual(getStateManager(localService).getSessionState(session.toString())?._meta, { workspaceless: true }); }); test.skip('createSession strips git-only catalogue entries for non-git working directory', async () => { @@ -5786,7 +5783,7 @@ suite('AgentService (node dispatcher)', () => { await Promise.resolve(); } - const state = localService.stateManager.getSessionState(session.toString()); + const state = getStateManager(localService).getSessionState(session.toString()); assert.ok(state); assert.deepStrictEqual(state!.changesets?.length, 0); }); @@ -5817,7 +5814,7 @@ suite('AgentService (node dispatcher)', () => { await Promise.resolve(); } - const state = localService.stateManager.getSessionState(session.toString()); + const state = getStateManager(localService).getSessionState(session.toString()); assert.ok(state); assert.deepStrictEqual(state!.changesets, [ { label: 'Branch Changes', uriTemplate: `${session.toString()}/changeset/session`, description: 'main', changeKind: 'session' }, @@ -5851,7 +5848,7 @@ suite('AgentService (node dispatcher)', () => { await Promise.resolve(); } - const state = localService.stateManager.getSessionState(session.toString()); + const state = getStateManager(localService).getSessionState(session.toString()); assert.ok(state); assert.deepStrictEqual(state!.changesets, [ { label: 'Branch Changes', uriTemplate: `${session.toString()}/changeset/session`, description: 'feature/x โ†’ main', changeKind: 'session' }, @@ -5899,7 +5896,7 @@ suite('AgentService (node dispatcher)', () => { for (let i = 0; i < 5; i++) { await Promise.resolve(); } - localService.stateManager.setSessionMeta(session.toString(), undefined); + getStateManager(localService).setSessionMeta(session.toString(), undefined); calls.length = 0; // subscribe fires the git-state refresh without awaiting it, so @@ -5910,7 +5907,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual(calls, [workingDirectory.fsPath]); assert.deepStrictEqual( - localService.stateManager.getSessionState(session.toString())?._meta, + getStateManager(localService).getSessionState(session.toString())?._meta, { git: gitState }, ); }); @@ -5956,7 +5953,7 @@ suite('AgentService (node dispatcher)', () => { type: ActionType.AnnotationsSet, annotation, }, 'client-before-restart', 1); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); const restored = await localService.subscribe(URI.parse(annotationsUri), 'client-after-restart'); @@ -5982,7 +5979,7 @@ suite('AgentService (node dispatcher)', () => { entries: [{ id: 'feedback-1:0', text: 'Please revisit this.' }], }], })); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); const restored = await localService.subscribe(URI.parse(annotationsUri), 'client-after-upgrade'); @@ -6018,7 +6015,7 @@ suite('AgentService (node dispatcher)', () => { type: ActionType.AnnotationsSet, annotation, }, 'client-before-restart', 1); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); // The session restore populates session state before it restores // annotations; a subscribe racing that window must still wait. @@ -6038,7 +6035,7 @@ suite('AgentService (node dispatcher)', () => { localService.registerProvider(agent); const parent = await localService.createSession({ provider: 'copilot' }); const subagent = buildSubagentSessionUri(parent, 'tool-call'); - localService.stateManager.restoreSession({ + getStateManager(localService).restoreSession({ resource: subagent, provider: 'subagent', title: 'Subagent', @@ -6084,7 +6081,7 @@ suite('AgentService (node dispatcher)', () => { /unknown changeset resource/, ); assert.strictEqual( - service.stateManager.getSessionState(sessionUri), + getStateManager(service).getSessionState(sessionUri), undefined, 'parent session must not be materialized as a side effect of an unknown changeset subscription', ); @@ -6096,7 +6093,7 @@ suite('AgentService (node dispatcher)', () => { const config = { isolation: 'worktree', branch: 'feature/config' }; const session = await service.createSession({ provider: 'copilot', config }); - assert.deepStrictEqual(service.stateManager.getSessionState(session.toString())?.config?.values, config); + assert.deepStrictEqual(getStateManager(service).getSessionState(session.toString())?.config?.values, config); }); test('seeds activeClient into the initial session state when provided', async () => { @@ -6113,7 +6110,7 @@ suite('AgentService (node dispatcher)', () => { const session = await service.createSession({ provider: 'copilot', activeClient }); assert.deepStrictEqual({ - activeClients: service.stateManager.getSessionState(session.toString())?.activeClients, + activeClients: getStateManager(service).getSessionState(session.toString())?.activeClients, dispatchedActiveClientSet: envelopes.some(e => e.action.type === ActionType.SessionActiveClientSet), }, { activeClients: [activeClient], @@ -6126,7 +6123,7 @@ suite('AgentService (node dispatcher)', () => { const session = await service.createSession({ provider: 'copilot' }); - assert.deepStrictEqual(service.stateManager.getSessionState(session.toString())?.activeClients, []); + assert.deepStrictEqual(getStateManager(service).getSessionState(session.toString())?.activeClients, []); }); }); @@ -6568,7 +6565,7 @@ suite('AgentService (node dispatcher)', () => { hydratedBeforeMigration, metadataReadAfterMigration: agent.metadataCalls > 0, registeredSessions: (await svc.getRegisteredSessions()).map(resource => resource.toString()), - restored: !!svc.stateManager.getSessionState(session.toString()), + restored: !!getStateManager(svc).getSessionState(session.toString()), }, { hydratedBeforeMigration: false, metadataReadAfterMigration: true, @@ -6592,7 +6589,7 @@ suite('AgentService (node dispatcher)', () => { ); assert.deepStrictEqual(await svc.getRegisteredSessions(), []); - assert.strictEqual(svc.stateManager.getSessionState(session.toString()), undefined, 'a rejected restore must not have populated any state'); + assert.strictEqual(getStateManager(svc).getSessionState(session.toString()), undefined, 'a rejected restore must not have populated any state'); }); suite('initial provider migration race (#331648)', () => { @@ -6690,7 +6687,7 @@ suite('AgentService (node dispatcher)', () => { await advanceUntil(() => agent.listChatsToMigrateCalls > 0 && agent.getChatMetadataCalls > 0); const beforeGate = { metadataRead: agent.getChatMetadataCalls, - hydrated: !!svc.stateManager.getSessionState(session.toString()), + hydrated: !!getStateManager(svc).getSessionState(session.toString()), }; agent.migrationGate.complete(); @@ -6699,7 +6696,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ beforeGate, rejected, - hydratedAfter: !!svc.stateManager.getSessionState(session.toString()), + hydratedAfter: !!getStateManager(svc).getSessionState(session.toString()), }, { beforeGate: { metadataRead: 1, hydrated: false }, rejected: undefined, @@ -6727,7 +6724,7 @@ suite('AgentService (node dispatcher)', () => { isProtocolError: rejected instanceof ProtocolError, code: (rejected as ProtocolError)?.code, metadataRead: agent.getChatMetadataCalls, - hydrated: !!svc.stateManager.getSessionState(session.toString()), + hydrated: !!getStateManager(svc).getSessionState(session.toString()), }, { isProtocolError: true, code: AHP_SESSION_NOT_FOUND, @@ -6752,7 +6749,7 @@ suite('AgentService (node dispatcher)', () => { await svc.restoreSession(session); assert.deepStrictEqual( - { hydrated: !!svc.stateManager.getSessionState(session.toString()), catalogueSettled: agent.migrationGate.isSettled }, + { hydrated: !!getStateManager(svc).getSessionState(session.toString()), catalogueSettled: agent.migrationGate.isSettled }, { hydrated: true, catalogueSettled: false }, ); agent.migrationGate.complete(); @@ -6771,7 +6768,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ isProtocolError: rejected instanceof ProtocolError, code: (rejected as ProtocolError)?.code, - hydrated: !!svc.stateManager.getSessionState(session.toString()), + hydrated: !!getStateManager(svc).getSessionState(session.toString()), }, { isProtocolError: true, code: AHP_SESSION_NOT_FOUND, @@ -6794,7 +6791,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ isProtocolError: rejected instanceof ProtocolError, code: (rejected as ProtocolError)?.code, - hydrated: !!svc.stateManager.getSessionState(session.toString()), + hydrated: !!getStateManager(svc).getSessionState(session.toString()), }, { isProtocolError: true, code: JSON_RPC_INTERNAL_ERROR, @@ -6825,7 +6822,7 @@ suite('AgentService (node dispatcher)', () => { isProtocolError: rejected instanceof ProtocolError, code: (rejected as ProtocolError)?.code, migrationShortCircuited: agent.listChatsToMigrateCalls === 0, - hydrated: !!svc.stateManager.getSessionState(session.toString()), + hydrated: !!getStateManager(svc).getSessionState(session.toString()), }, { isProtocolError: true, code: JSON_RPC_INTERNAL_ERROR, @@ -6849,7 +6846,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ stalledStarted: stalled.listChatsToMigrateCalls > 0, stalledCompleted: stalled.sdkReady, - hydrated: !!svc.stateManager.getSessionState(session.toString()), + hydrated: !!getStateManager(svc).getSessionState(session.toString()), }, { stalledStarted: true, stalledCompleted: false, @@ -6875,7 +6872,7 @@ suite('AgentService (node dispatcher)', () => { await localService.restoreSession(sessionResource); - assert.deepStrictEqual(localService.stateManager.getSessionState(sessionResource.toString())?._meta, { workspaceless: true }); + assert.deepStrictEqual(getStateManager(localService).getSessionState(sessionResource.toString())?._meta, { workspaceless: true }); }); test('restores persisted multi-root metadata', async () => { @@ -6895,7 +6892,7 @@ suite('AgentService (node dispatcher)', () => { await localService.restoreSession(sessionResource); - assert.deepStrictEqual(readSessionMultiRootMetadata(localService.stateManager.getSessionState(sessionResource.toString())?._meta), multiRoot); + assert.deepStrictEqual(readSessionMultiRootMetadata(getStateManager(localService).getSessionState(sessionResource.toString())?._meta), multiRoot); }); test('restores persisted orchestration metadata', async () => { @@ -6915,7 +6912,7 @@ suite('AgentService (node dispatcher)', () => { await localService.restoreSession(sessionResource); - assert.deepStrictEqual(readSessionOrchestration(localService.stateManager.getSessionState(sessionResource.toString())?._meta), orchestration); + assert.deepStrictEqual(readSessionOrchestration(getStateManager(localService).getSessionState(sessionResource.toString())?._meta), orchestration); }); test('does not consume a child notification when its creator cannot be resolved', async () => { @@ -6940,7 +6937,7 @@ suite('AgentService (node dispatcher)', () => { await coordinator._sessionCoordination.handleStatusChange(child.toString(), SessionStatus.Idle); - assert.deepStrictEqual(readSessionOrchestration(localService.stateManager.getSessionSummary(child.toString())?._meta), orchestration); + assert.deepStrictEqual(readSessionOrchestration(getStateManager(localService).getSessionSummary(child.toString())?._meta), orchestration); }); test('restores a cold creator before delivering and consuming a child notification', async () => { @@ -6963,10 +6960,10 @@ suite('AgentService (node dispatcher)', () => { }; }; await coordinator._sessionCoordination.setOrchestration(child.toString(), orchestration); - localService.stateManager.removeSession(creator.toString()); - assert.strictEqual(localService.stateManager.getSessionState(creator.toString()), undefined); + getStateManager(localService).removeSession(creator.toString()); + assert.strictEqual(getStateManager(localService).getSessionState(creator.toString()), undefined); let notificationStarted = false; - disposables.add(localService.stateManager.onDidEmitEnvelope(envelope => { + disposables.add(getStateManager(localService).onDidEmitEnvelope(envelope => { if (envelope.channel === buildDefaultChatUri(creator) && envelope.action.type === ActionType.ChatTurnStarted && envelope.action.message.origin.kind === MessageKind.SystemNotification) { notificationStarted = true; } @@ -6974,9 +6971,9 @@ suite('AgentService (node dispatcher)', () => { await coordinator._sessionCoordination.handleStatusChange(child.toString(), SessionStatus.Idle); - assert.ok(localService.stateManager.getSessionState(creator.toString())); + assert.ok(getStateManager(localService).getSessionState(creator.toString())); assert.strictEqual(notificationStarted, true); - assert.deepStrictEqual(readSessionOrchestration(localService.stateManager.getSessionSummary(child.toString())?._meta), { + assert.deepStrictEqual(readSessionOrchestration(getStateManager(localService).getSessionSummary(child.toString())?._meta), { ...orchestration, creatorNotificationState: 'notified', }); @@ -6997,7 +6994,7 @@ suite('AgentService (node dispatcher)', () => { await localService.restoreSession(sessionResource); - assert.deepStrictEqual(readSessionSourceControlState(localService.stateManager.getSessionState(sessionResource.toString())?._meta), sourceControlState); + assert.deepStrictEqual(readSessionSourceControlState(getStateManager(localService).getSessionState(sessionResource.toString())?._meta), sourceControlState); }); test('restores a session with message history', async () => { @@ -7013,7 +7010,7 @@ suite('AgentService (node dispatcher)', () => { await service.restoreSession(sessionResource); - const state = service.stateManager.getSessionState(sessionResource.toString()); + const state = getStateManager(service).getSessionState(sessionResource.toString()); assert.ok(state, 'session should be in state manager'); assert.strictEqual(state!.lifecycle, SessionLifecycle.Ready); assert.strictEqual(state!.turns.length, 1); @@ -7032,7 +7029,7 @@ suite('AgentService (node dispatcher)', () => { await service.restoreSession(sessionResource); assert.strictEqual( - service.stateManager.getSessionState(sessionResource.toString())?.serverTools?.some(tool => tool.name === SessionServerToolName.ListSessions), + getStateManager(service).getSessionState(sessionResource.toString())?.serverTools?.some(tool => tool.name === SessionServerToolName.ListSessions), true, ); }); @@ -7056,7 +7053,7 @@ suite('AgentService (node dispatcher)', () => { await localService.restoreSession(sessionResource); assert.deepStrictEqual( - localService.stateManager.getSessionState(sessionResource.toString())?.turns.map(t => t.usage), + getStateManager(localService).getSessionState(sessionResource.toString())?.turns.map(t => t.usage), [{ inputTokens: 100, outputTokens: 20, model: 'gpt-5' }], ); }); @@ -7084,7 +7081,7 @@ suite('AgentService (node dispatcher)', () => { await localService.restoreSession(sessionResource); assert.deepStrictEqual( - localService.stateManager.getSessionState(sessionResource.toString())?.turns.map(t => t.usage), + getStateManager(localService).getSessionState(sessionResource.toString())?.turns.map(t => t.usage), [{ inputTokens: 100, outputTokens: 20, @@ -7120,7 +7117,7 @@ suite('AgentService (node dispatcher)', () => { await localService.restoreSession(sessionResource); - const state = localService.stateManager.getSessionState(sessionResource.toString()); + const state = getStateManager(localService).getSessionState(sessionResource.toString()); // head (no anchor) first, then the real turn, then its anchored local; orphan dropped. assert.deepStrictEqual(state!.turns.map(t => t.id), ['local-head', 'msg-real', 'local-after']); }); @@ -7141,7 +7138,7 @@ suite('AgentService (node dispatcher)', () => { await localService.restoreSession(sessionResource); - const state = localService.stateManager.getSessionState(sessionResource.toString()); + const state = getStateManager(localService).getSessionState(sessionResource.toString()); assert.strictEqual(state?.chats.find(c => c.resource === defaultChatUri)?.title, 'Renamed Default Chat'); }); @@ -7183,7 +7180,7 @@ suite('AgentService (node dispatcher)', () => { await localService.restoreSession(sessionResource); - assert.deepStrictEqual(localService.stateManager.getSessionState(session.toString())?.draft, draft); + assert.deepStrictEqual(getStateManager(localService).getSessionState(session.toString())?.draft, draft); }); test('restores a session with tool calls', async () => { @@ -7202,7 +7199,7 @@ suite('AgentService (node dispatcher)', () => { await service.restoreSession(sessionResource); - const state = service.stateManager.getSessionState(sessionResource.toString()); + const state = getStateManager(service).getSessionState(sessionResource.toString()); assert.ok(state); const turn = state!.turns[0]; const toolCallParts = turn.responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); @@ -7229,7 +7226,7 @@ suite('AgentService (node dispatcher)', () => { await service.restoreSession(sessionResource); - const state = service.stateManager.getSessionState(sessionResource.toString()); + const state = getStateManager(service).getSessionState(sessionResource.toString()); assert.ok(state); const turn = state!.turns[0]; const summary = turn.responseParts.map(p => { @@ -7261,7 +7258,7 @@ suite('AgentService (node dispatcher)', () => { await service.restoreSession(sessionResource); - const state = service.stateManager.getSessionState(sessionResource.toString()); + const state = getStateManager(service).getSessionState(sessionResource.toString()); assert.ok(state); assert.strictEqual(state!.turns.length, 2); assert.strictEqual(state!.turns[0].state, TurnState.Cancelled); @@ -7302,7 +7299,7 @@ suite('AgentService (node dispatcher)', () => { const session = AgentSession.uri('copilot', 'surfaced-legacy'); const sessionStr = session.toString(); - localService.stateManager.announceSurfacedSession({ + getStateManager(localService).announceSurfacedSession({ resource: sessionStr, provider: 'copilot', title: 'Legacy', @@ -7321,7 +7318,7 @@ suite('AgentService (node dispatcher)', () => { await localService.restoreSession(session); assert.deepStrictEqual( - { adoptCalls: agent.adoptCalls, restored: !!localService.stateManager.getSessionState(sessionStr) }, + { adoptCalls: agent.adoptCalls, restored: !!getStateManager(localService).getSessionState(sessionStr) }, { adoptCalls: 1, restored: true }, ); }); @@ -7401,7 +7398,7 @@ suite('AgentService (node dispatcher)', () => { const session = AgentSession.uri('copilot', 'external-chat'); await assert.rejects(() => localService.restoreSession(session), /not an adoptable legacy chat/); - assert.strictEqual(localService.stateManager.getSessionState(session.toString()), undefined); + assert.strictEqual(getStateManager(localService).getSessionState(session.toString()), undefined); }); test('a passive read/archive action does not adopt a surfaced legacy session (listing must not migrate)', async () => { @@ -7429,7 +7426,7 @@ suite('AgentService (node dispatcher)', () => { const session = AgentSession.uri('copilot', `surfaced-legacy-${action.type}`); const sessionStr = session.toString(); - localService.stateManager.announceSurfacedSession({ + getStateManager(localService).announceSurfacedSession({ resource: sessionStr, provider: 'copilot', title: 'Legacy', @@ -7444,7 +7441,7 @@ suite('AgentService (node dispatcher)', () => { await timeout(0); assert.deepStrictEqual( - { action: action.type, adoptCalls: agent.adoptCalls, restored: !!localService.stateManager.getSessionState(sessionStr) }, + { action: action.type, adoptCalls: agent.adoptCalls, restored: !!getStateManager(localService).getSessionState(sessionStr) }, { action: action.type, adoptCalls: 0, restored: false }, ); } @@ -7466,7 +7463,7 @@ suite('AgentService (node dispatcher)', () => { getConfigurationService(localService).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); const session = AgentSession.uri('copilot', 'surfaced-legacy-unsurface'); const sessionStr = session.toString(); - localService.stateManager.announceSurfacedSession({ + getStateManager(localService).announceSurfacedSession({ resource: sessionStr, provider: 'copilot', title: 'Legacy', @@ -7488,7 +7485,7 @@ suite('AgentService (node dispatcher)', () => { getConfigurationService(localService).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: false }); assert.deepStrictEqual( - { surfaced: localService.stateManager.getSurfacedSessionSummary(sessionStr), removed }, + { surfaced: getStateManager(localService).getSurfacedSessionSummary(sessionStr), removed }, { surfaced: undefined, removed: sessionStr }, ); }); @@ -7514,7 +7511,7 @@ suite('AgentService (node dispatcher)', () => { test('restores known session without listing all provider sessions', async () => { service.registerProvider(copilotAgent); const { session } = await createAgentSession(copilotAgent); - service.stateManager.deleteSession(session.toString()); + getStateManager(service).deleteSession(session.toString()); copilotAgent.sessionMessages = [ { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, @@ -7530,13 +7527,13 @@ suite('AgentService (node dispatcher)', () => { await service.restoreSession(session); assert.strictEqual(listSessionsCalled, false); - assert.ok(service.stateManager.getSessionState(session.toString())); + assert.ok(getStateManager(service).getSessionState(session.toString())); }); test('falls back to listing sessions when direct metadata restore fails', async () => { service.registerProvider(copilotAgent); const session = await service.createSession({ provider: 'copilot' }); - service.stateManager.deleteSession(session.toString()); + getStateManager(service).deleteSession(session.toString()); copilotAgent.sessionMessages = [ { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, @@ -7557,7 +7554,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ listChatsToMigrateCalled, - restored: !!service.stateManager.getSessionState(session.toString()), + restored: !!getStateManager(service).getSessionState(session.toString()), }, { listChatsToMigrateCalled: true, restored: true, @@ -7590,7 +7587,7 @@ suite('AgentService (node dispatcher)', () => { const agent = disposables.add(new BlockingRestoreAgent('copilot')); service.registerProvider(agent); const { session } = await createAgentSession(agent); - service.stateManager.deleteSession(session.toString()); + getStateManager(service).deleteSession(session.toString()); agent.sessionMessages = [ { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] }, @@ -7606,7 +7603,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ messageCalls: agent.getSessionMessagesCalls, - restored: !!service.stateManager.getSessionState(session.toString()), + restored: !!getStateManager(service).getSessionState(session.toString()), }, { messageCalls: 1, restored: true, @@ -7616,7 +7613,7 @@ suite('AgentService (node dispatcher)', () => { test('hydrates session customizations when restoring an existing session', async () => { service.registerProvider(copilotAgent); const { session } = await createAgentSession(copilotAgent); - service.stateManager.deleteSession(session.toString()); + getStateManager(service).deleteSession(session.toString()); copilotAgent.sessionMessages = [ { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, @@ -7632,7 +7629,7 @@ suite('AgentService (node dispatcher)', () => { await service.restoreSession(session); - const customizations = service.stateManager.getSessionState(session.toString())?.customizations; + const customizations = getStateManager(service).getSessionState(session.toString())?.customizations; assert.strictEqual(getSessionCustomizationsCalls, 1); assert.strictEqual(customizations?.length, 1); assert.strictEqual(customizations?.[0]?.type, CustomizationType.Plugin); @@ -7658,7 +7655,7 @@ suite('AgentService (node dispatcher)', () => { const agent = disposables.add(new FailingOnceRestoreAgent('copilot')); service.registerProvider(agent); const { session } = await createAgentSession(agent); - service.stateManager.deleteSession(session.toString()); + getStateManager(service).deleteSession(session.toString()); agent.sessionMessages = [ { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] }, @@ -7671,7 +7668,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ messageCalls: agent.getSessionMessagesCalls, - restored: !!service.stateManager.getSessionState(session.toString()), + restored: !!getStateManager(service).getSessionState(session.toString()), }, { messageCalls: 2, restored: true, @@ -7701,7 +7698,7 @@ suite('AgentService (node dispatcher)', () => { await service.restoreSession(sessionResource); - const state = service.stateManager.getSessionState(sessionResource.toString()); + const state = getStateManager(service).getSessionState(sessionResource.toString()); assert.ok(state); // Should produce exactly one turn @@ -7732,7 +7729,7 @@ suite('AgentService (node dispatcher)', () => { // Subscribing to the child session should restore it with inner tool calls const childSessionUri = buildSubagentSessionUri(sessionResource.toString(), 'tc-sub'); const snapshot = await service.subscribe(URI.parse(childSessionUri), 'client-test'); - const childState = service.stateManager.getSessionState(childSessionUri); + const childState = getStateManager(service).getSessionState(childSessionUri); assert.ok(snapshot?.state, 'Child session snapshot should exist'); assert.ok(childState, 'Child session state should exist'); assert.strictEqual(childState!.turns.length, 1, 'Child session should have 1 turn'); @@ -7757,7 +7754,7 @@ suite('AgentService (node dispatcher)', () => { await service.restoreSession(sessionResource); - const state = service.stateManager.getSessionState(sessionResource.toString()); + const state = getStateManager(service).getSessionState(sessionResource.toString()); assert.ok(state); assert.strictEqual(state!.turns.length, 1, `Expected 1 turn but got ${state!.turns.length}: ${state!.turns.map(t => `"${t.message.text.substring(0, 40)}"`).join(', ')}`); assert.strictEqual(state!.turns[0].message.text, 'Run a sync subagent to do some searches, just testing subagent rendering'); @@ -7779,7 +7776,7 @@ suite('AgentService (node dispatcher)', () => { const childSessionUri = buildSubagentSessionUri(sessionResource.toString(), parentToolCallId); const snapshot = await service.subscribe(URI.parse(childSessionUri), 'client-test'); assert.ok(snapshot?.state, 'Child session snapshot should exist'); - const childState = service.stateManager.getSessionState(childSessionUri); + const childState = getStateManager(service).getSessionState(childSessionUri); assert.ok(childState, 'Child session state should exist'); assert.strictEqual(childState!.turns.length, 1, 'Child session should have 1 turn'); const childToolParts = childState!.turns[0].responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); @@ -7824,14 +7821,14 @@ suite('AgentService (node dispatcher)', () => { await service.restoreSession(sessionResource); const childChatUri = buildSubagentChatUri(sessionResource.toString(), 'tc-sub'); - const childSummary = service.stateManager.getSessionState(sessionResource.toString())?.chats.find(chat => chat.resource === childChatUri); + const childSummary = getStateManager(service).getSessionState(sessionResource.toString())?.chats.find(chat => chat.resource === childChatUri); assert.deepStrictEqual({ childSummary: childSummary ? { title: childSummary.title, origin: childSummary.origin, interactivity: childSummary.interactivity, } : undefined, - childStateBeforeSubscribe: service.stateManager.getChatState(childChatUri), + childStateBeforeSubscribe: getStateManager(service).getChatState(childChatUri), childReadsBeforeSubscribe: agent.messageReads.filter(resource => resource === childChatUri).length, }, { childSummary: { @@ -7844,13 +7841,13 @@ suite('AgentService (node dispatcher)', () => { }); await assert.rejects(service.subscribe(URI.parse(childChatUri), 'child-reader-first'), /Subagent transcript is not available yet/); - assert.strictEqual(service.stateManager.getChatState(childChatUri), undefined); + assert.strictEqual(getStateManager(service).getChatState(childChatUri), undefined); await service.subscribe(URI.parse(childChatUri), 'child-reader-second'); - const childState = service.stateManager.getChatState(childChatUri); + const childState = getStateManager(service).getChatState(childChatUri); assert.ok(childState); assert.strictEqual(childState.turns.length, 1); assert.strictEqual(agent.messageReads.filter(resource => resource === childChatUri).length, 2); - assert.strictEqual(service.stateManager.getSessionState(buildSubagentSessionUri(sessionResource.toString(), 'tc-sub')), undefined); + assert.strictEqual(getStateManager(service).getSessionState(buildSubagentSessionUri(sessionResource.toString(), 'tc-sub')), undefined); }); test('legacy subagent reconstruction replaces only a generic restored title', async () => { @@ -7859,7 +7856,7 @@ suite('AgentService (node dispatcher)', () => { const parentChat = buildDefaultChatUri(parent); const childChat = buildSubagentChatUri(parent.toString(), 'tc-sub'); const origin = { kind: ChatOriginKind.Tool, chat: parentChat, toolCallId: 'tc-sub' } as const; - service.stateManager.registerRestoredChatSummary(parent.toString(), childChat, { + getStateManager(service).registerRestoredChatSummary(parent.toString(), childChat, { title: 'Subagent', origin, interactivity: ChatInteractivity.ReadOnly, @@ -7874,13 +7871,13 @@ suite('AgentService (node dispatcher)', () => { const turns = await copilotAgent.getSessionMessages(parent); await (service as unknown as { _registerRestoredSubagentSummaries(agent: IAgent, parentSession: URI, turns: readonly Turn[]): Promise })._registerRestoredSubagentSummaries(copilotAgent, parent, turns); - const reconstructedTitle = service.stateManager.getSessionState(parent.toString())?.chats.find(chat => chat.resource === childChat)?.title; - service.stateManager.updateChatTitle(parent.toString(), childChat, 'My Custom Worker'); + const reconstructedTitle = getStateManager(service).getSessionState(parent.toString())?.chats.find(chat => chat.resource === childChat)?.title; + getStateManager(service).updateChatTitle(parent.toString(), childChat, 'My Custom Worker'); await (service as unknown as { _registerRestoredSubagentSummaries(agent: IAgent, parentSession: URI, turns: readonly Turn[]): Promise })._registerRestoredSubagentSummaries(copilotAgent, parent, turns); assert.deepStrictEqual({ reconstructedTitle, - titleAfterCustomRename: service.stateManager.getSessionState(parent.toString())?.chats.find(chat => chat.resource === childChat)?.title, + titleAfterCustomRename: getStateManager(service).getSessionState(parent.toString())?.chats.find(chat => chat.resource === childChat)?.title, }, { reconstructedTitle: 'Summarize agent service', titleAfterCustomRename: 'My Custom Worker', @@ -7904,7 +7901,7 @@ suite('AgentService (node dispatcher)', () => { await (localService as unknown as { _registerRestoredSubagentSummaries(agent: IAgent, parentSession: URI, turns: readonly Turn[]): Promise })._registerRestoredSubagentSummaries(copilotAgent, parent, await copilotAgent.getSessionMessages(parent)); - assert.strictEqual(localService.stateManager.getSessionState(parent.toString())?.chats.find(chat => chat.resource === childChat)?.title, 'Persisted Worker'); + assert.strictEqual(getStateManager(localService).getSessionState(parent.toString())?.chats.find(chat => chat.resource === childChat)?.title, 'Persisted Worker'); }); test('subscribing to a restored canonical subagent chat reconstructs it on demand', async () => { @@ -7926,9 +7923,9 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ resource: snapshot.resource, - turnCount: service.stateManager.getChatState(chatUri)?.turns.length, - origin: service.stateManager.getChatState(chatUri)?.origin, - legacySessionExists: !!service.stateManager.getSessionState(buildSubagentSessionUri(parent, 'tc-sub')), + turnCount: getStateManager(service).getChatState(chatUri)?.turns.length, + origin: getStateManager(service).getChatState(chatUri)?.origin, + legacySessionExists: !!getStateManager(service).getSessionState(buildSubagentSessionUri(parent, 'tc-sub')), }, { resource: chatUri, turnCount: 1, @@ -7953,7 +7950,7 @@ suite('AgentService (node dispatcher)', () => { await service.restoreSession(sessionResource); const peerChat = buildChatUri(parent, 'peer-spawner'); - service.stateManager.addChat(parent, peerChat, { + getStateManager(service).addChat(parent, peerChat, { title: 'Peer', turns: [{ id: 'peer-turn', state: TurnState.Complete, usage: undefined, @@ -7973,7 +7970,7 @@ suite('AgentService (node dispatcher)', () => { const chatUri = buildSubagentChatUri(parent, 'tc-sub'); await service.subscribe(URI.parse(chatUri), 'client-peer-spawned-subagent'); - const chatState = service.stateManager.getChatState(chatUri); + const chatState = getStateManager(service).getChatState(chatUri); assert.deepStrictEqual({ origin: chatState?.origin, title: chatState?.title }, { origin: { kind: ChatOriginKind.Tool, chat: peerChat, toolCallId: 'tc-sub' }, title: 'Find related files', @@ -7996,7 +7993,7 @@ suite('AgentService (node dispatcher)', () => { await service.restoreSession(sessionResource); - const state = service.stateManager.getSessionState(sessionResource.toString()); + const state = getStateManager(service).getSessionState(sessionResource.toString()); assert.ok(state); assert.strictEqual(state!.turns.length, 1, `Expected 1 turn but got ${state!.turns.length}: ${state!.turns.map(t => `"${t.message.text.substring(0, 40)}"`).join(', ')}`); assert.strictEqual(state!.turns[0].message.text, 'Run a sync subagent to do some searches, just testing subagent rendering'); @@ -8028,7 +8025,7 @@ suite('AgentService (node dispatcher)', () => { const childSessionUri = buildSubagentSessionUri(sessionResource.toString(), parentToolCallId); const snapshot = await service.subscribe(URI.parse(childSessionUri), 'client-test'); assert.ok(snapshot?.state, 'Child session snapshot should exist'); - const childState = service.stateManager.getSessionState(childSessionUri); + const childState = getStateManager(service).getSessionState(childSessionUri); assert.ok(childState, 'Child session state should exist'); assert.strictEqual(childState!.turns.length, 1, 'Child session should have 1 turn'); const childToolParts = childState!.turns[0].responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); @@ -8080,7 +8077,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ messageCalls: agent.subagentGetSessionMessagesCalls, - childTurns: service.stateManager.getSessionState(childSessionUri.toString())?.turns.length, + childTurns: getStateManager(service).getSessionState(childSessionUri.toString())?.turns.length, }, { messageCalls: 1, childTurns: 1, @@ -8103,7 +8100,7 @@ suite('AgentService (node dispatcher)', () => { await service.restoreSession(sessionResource); const childSession = URI.parse(buildSubagentSessionUri(sessionResource.toString(), 'tc-sub')); - service.stateManager.deleteSession(childSession.toString()); + getStateManager(service).deleteSession(childSession.toString()); const childChat = buildDefaultChatUri(childSession); service.dispatchAction(childChat.toString(), { type: ActionType.ChatTurnStarted, @@ -8112,11 +8109,11 @@ suite('AgentService (node dispatcher)', () => { message: { text: 'Continue', origin: { kind: MessageKind.User } }, }, 'client-1', 1); - for (let i = 0; i < 50 && service.stateManager.getChatState(childChat.toString())?.activeTurn?.id !== 'continued-turn'; i++) { + for (let i = 0; i < 50 && getStateManager(service).getChatState(childChat.toString())?.activeTurn?.id !== 'continued-turn'; i++) { await timeout(0); } - assert.strictEqual(service.stateManager.getChatState(childChat.toString())?.activeTurn?.id, 'continued-turn'); + assert.strictEqual(getStateManager(service).getChatState(childChat.toString())?.activeTurn?.id, 'continued-turn'); }); }); @@ -8139,13 +8136,13 @@ suite('AgentService (node dispatcher)', () => { service.registerProvider(agent); const { session } = await createAgentSession(agent); // Drop any tracking so only the scheme fallback can resolve the agent. - service.stateManager.deleteSession(session.toString()); + getStateManager(service).deleteSession(session.toString()); await service.restoreSession(session); const chatUri = URI.parse(buildChatUri(session, 'peer-1')); await service.createChat(session, chatUri); - const state = service.stateManager.getSessionState(session.toString()); + const state = getStateManager(service).getSessionState(session.toString()); assert.deepStrictEqual({ created, inCatalog: !!state?.chats.some(c => c.resource.toString() === chatUri.toString()), @@ -8206,7 +8203,7 @@ suite('AgentService (node dispatcher)', () => { const chatUri = URI.parse(buildChatUri(session, 'peer-1')); await service.createChat(session, chatUri, { title: 'Peer Chat' }); - const state = service.stateManager.getSessionState(session.toString()); + const state = getStateManager(service).getSessionState(session.toString()); assert.deepStrictEqual( state?.chats.find(c => c.resource.toString() === chatUri.toString())?.title, 'Peer Chat', @@ -8217,7 +8214,7 @@ suite('AgentService (node dispatcher)', () => { let catalogHadChatDuringCreate: boolean | undefined; class MultiChatAgent extends MockAgent { override async createChat(session: URI, chat: URI): Promise { - const state = service.stateManager.getSessionState(session.toString()); + const state = getStateManager(service).getSessionState(session.toString()); catalogHadChatDuringCreate = !!state?.chats.some(c => c.resource.toString() === chat.toString()); } } @@ -8275,11 +8272,11 @@ suite('AgentService (node dispatcher)', () => { const disposing = service.disposeChat(session, chatUri); await cleanupStarted.p; - assert.strictEqual(service.stateManager.getSessionState(session.toString())?.chats.some(c => c.resource.toString() === chatUri.toString()), true); + assert.strictEqual(getStateManager(service).getSessionState(session.toString())?.chats.some(c => c.resource.toString() === chatUri.toString()), true); releaseCleanup.complete(); await disposing; - const state = service.stateManager.getSessionState(session.toString()); + const state = getStateManager(service).getSessionState(session.toString()); assert.deepStrictEqual({ disposed, inCatalog: !!state?.chats.some(c => c.resource.toString() === chatUri.toString()), @@ -8314,10 +8311,10 @@ suite('AgentService (node dispatcher)', () => { await localService.createChat(session, URI.parse(buildChatUri(session, 'peer-b'))); await localService.createChat(session, URI.parse(buildChatUri(session, 'peer-c'))); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); - const state = localService.stateManager.getSessionState(session.toString()); + const state = getStateManager(localService).getSessionState(session.toString()); const peerChatIds = (state?.chats ?? []) .map(c => parseChatUri(c.resource)?.chatId) .filter((id): id is string => !!id && id.startsWith('peer-')); @@ -8340,13 +8337,13 @@ suite('AgentService (node dispatcher)', () => { { id: 't1', state: TurnState.Complete, message: { text: 'first', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }, { id: 't2', state: TurnState.Complete, message: { text: 'second', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }, ]; - service.stateManager.seedDefaultChatTurns(session.toString(), sourceTurns); - service.stateManager.updateChatTitle(session.toString(), buildDefaultChatUri(session.toString()), 'My Session'); + getStateManager(service).seedDefaultChatTurns(session.toString(), sourceTurns); + getStateManager(service).updateChatTitle(session.toString(), buildDefaultChatUri(session.toString()), 'My Session'); const chatUri = URI.parse(buildChatUri(session, 'peer-1')); await service.createChat(session, chatUri, { fork: { source: session, turnId: 't1' } }); - const newChatState = service.stateManager.getChatState(chatUri.toString()); + const newChatState = getStateManager(service).getChatState(chatUri.toString()); const newTurnIds = newChatState?.turns.map(t => t.id) ?? []; assert.deepStrictEqual({ forkSource: receivedFork?.source.toString(), @@ -8374,14 +8371,14 @@ suite('AgentService (node dispatcher)', () => { const agent = disposables.add(new MultiChatAgent('copilot')); service.registerProvider(agent); const session = await service.createSession({ provider: 'copilot' }); - service.stateManager.seedDefaultChatTurns(session.toString(), [ + getStateManager(service).seedDefaultChatTurns(session.toString(), [ { id: 't1', state: TurnState.Complete, message: { text: 'first', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }, ]); const chatUri = URI.parse(buildChatUri(session, 'peer-fork-origin')); await service.createChat(session, chatUri, { fork: { source: session, turnId: 't1' } }); - assert.deepStrictEqual(service.stateManager.getChatState(chatUri.toString())?.origin, { + assert.deepStrictEqual(getStateManager(service).getChatState(chatUri.toString())?.origin, { kind: ChatOriginKind.Fork, chat: buildDefaultChatUri(session.toString()), turnId: 't1', @@ -8402,12 +8399,12 @@ suite('AgentService (node dispatcher)', () => { const sourceTurns: Turn[] = [ { id: 't1', state: TurnState.Complete, message: { text: 'first', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }, ]; - service.stateManager.seedDefaultChatTurns(session.toString(), sourceTurns); + getStateManager(service).seedDefaultChatTurns(session.toString(), sourceTurns); const chatUri = URI.parse(buildChatUri(session, 'peer-1')); await service.createChat(session, chatUri, { fork: { source: session, turnId: 'missing' } }); - const newChatState = service.stateManager.getChatState(chatUri.toString()); + const newChatState = getStateManager(service).getChatState(chatUri.toString()); assert.deepStrictEqual({ forkForwarded: receivedFork !== undefined, newTurnCount: newChatState?.turns.length ?? 0, @@ -8444,13 +8441,13 @@ suite('AgentService (node dispatcher)', () => { // Restore so the source chat interleaves [real-1, local-1] and the // in-memory local index knows local-1 is a local turn. await localService.restoreSession(sessionResource); - assert.deepStrictEqual(localService.stateManager.getSessionState(sessionResource.toString())?.turns.map(t => t.id), ['real-1', 'local-1']); + assert.deepStrictEqual(getStateManager(localService).getSessionState(sessionResource.toString())?.turns.map(t => t.id), ['real-1', 'local-1']); // Fork the default chat AT the local turn into a new peer chat. const peerUri = URI.parse(buildChatUri(sessionResource, 'peer-1')); await localService.createChat(sessionResource, peerUri, { fork: { source: URI.parse(defaultChatUri), turnId: 'local-1' } }); - const peerTurns = localService.stateManager.getChatState(peerUri.toString())?.turns ?? []; + const peerTurns = getStateManager(localService).getChatState(peerUri.toString())?.turns ?? []; const forkedLocals = (await db.getLocalTurns()).filter(r => r.chatUri === peerUri.toString()); assert.deepStrictEqual({ // SDK fork boundary redirected from the local turn to its concrete anchor. @@ -8547,7 +8544,7 @@ suite('AgentService (node dispatcher)', () => { }, }); - const state = svc.stateManager.getSessionState(session.toString()); + const state = getStateManager(svc).getSessionState(session.toString()); assert.deepStrictEqual({ ephemeral: readEphemeralSessionMeta(state ?? {}).isEphemeral, surface: readChatSurfaceMeta(state ?? {}), @@ -8618,7 +8615,7 @@ suite('AgentService (node dispatcher)', () => { const afterRestart = await restarted.listSessions(); assert.deepStrictEqual({ - overlayIncludesEphemeral: svc.stateManager.getOverlaySessionSummaries().some(s => s.resource === overlaySession.toString()), + overlayIncludesEphemeral: getStateManager(svc).getOverlaySessionSummaries().some(s => s.resource === overlaySession.toString()), directListIncludesEphemeral: firstList.some(s => s.session.toString() === directSession.toString()), registeredBeforeRestart: registeredBeforeRestart.map(s => s.toString()), restartedListIncludesEphemeral: afterRestart.some(s => s.session.toString() === overlaySession.toString() || s.session.toString() === directSession.toString()), @@ -8671,9 +8668,9 @@ suite('AgentService (node dispatcher)', () => { // create result from what subscribers already observed. await svc.createChat(session, chatUri); - const state = svc.stateManager.getSessionState(session.toString()); + const state = getStateManager(svc).getSessionState(session.toString()); assert.deepStrictEqual({ - chatCreated: !!svc.stateManager.getChatState(chatUri.toString()), + chatCreated: !!getStateManager(svc).getChatState(chatUri.toString()), inCatalog: !!state?.chats.some(c => c.resource.toString() === chatUri.toString()), markerPersisted: db.setMetadataCalls.some(c => c.key === 'peerChatBacking' && c.value === chatUri.toString()), }, { @@ -8716,8 +8713,8 @@ suite('AgentService (node dispatcher)', () => { await svc.createChat(session, chatUri); - const state = svc.stateManager.getSessionState(session.toString()); - assert.strictEqual(!!svc.stateManager.getChatState(chatUri.toString()), true, 'chat should still be created'); + const state = getStateManager(svc).getSessionState(session.toString()); + assert.strictEqual(!!getStateManager(svc).getChatState(chatUri.toString()), true, 'chat should still be created'); assert.strictEqual(!!state?.chats.some(c => c.resource.toString() === chatUri.toString()), true, 'chat should still be in the catalog'); assert.strictEqual(db.setMetadataCalls.some(c => c.key === 'peerChatBacking' && c.value === chatUri.toString()), false, 'the marker never persisted durably'); @@ -8786,7 +8783,7 @@ suite('AgentService (node dispatcher)', () => { const agent = disposables.add(new SideChatAgent('copilot')); service.registerProvider(agent); const session = await service.createSession({ provider: 'copilot' }); - service.stateManager.seedDefaultChatTurns(session.toString(), [completedTurn('t1')]); + getStateManager(service).seedDefaultChatTurns(session.toString(), [completedTurn('t1')]); const chatUri = URI.parse(buildChatUri(session, 'side-1')); await assert.rejects( @@ -8800,7 +8797,7 @@ suite('AgentService (node dispatcher)', () => { service.registerProvider(agent); const sessionA = await service.createSession({ provider: 'copilot' }); const sessionB = await service.createSession({ provider: 'copilot' }); - service.stateManager.seedDefaultChatTurns(sessionB.toString(), [completedTurn('t1')]); + getStateManager(service).seedDefaultChatTurns(sessionB.toString(), [completedTurn('t1')]); const chatUri = URI.parse(buildChatUri(sessionA, 'side-1')); await assert.rejects( @@ -8813,13 +8810,13 @@ suite('AgentService (node dispatcher)', () => { const agent = disposables.add(new SideChatAgent('copilot')); service.registerProvider(agent); const session = await service.createSession({ provider: 'copilot' }); - service.stateManager.seedDefaultChatTurns(session.toString(), [completedTurn('t1'), completedTurn('t2')]); + getStateManager(service).seedDefaultChatTurns(session.toString(), [completedTurn('t1'), completedTurn('t2')]); const chatUri = URI.parse(buildChatUri(session, 'side-1')); const defaultChatUri = buildDefaultChatUri(session); const selection = { text: ' selected text ', responsePartId: 'response-part-1' }; await service.createChat(session, chatUri, { sideChat: { source: session, turnId: 't1', selection } }); - const state = service.stateManager.getChatState(chatUri.toString()); + const state = getStateManager(service).getChatState(chatUri.toString()); assert.deepStrictEqual({ origin: state?.origin, @@ -8860,7 +8857,7 @@ suite('AgentService (node dispatcher)', () => { await localService.createChat(sessionResource, chatUri, { sideChat: { source: URI.parse(defaultChatUri), turnId: 'local-1' } }); assert.deepStrictEqual({ - origin: localService.stateManager.getChatState(chatUri.toString())?.origin, + origin: getStateManager(localService).getChatState(chatUri.toString())?.origin, sideChatForwarded: agent.lastCreateOptions?.sideChat && { source: agent.lastCreateOptions.sideChat.source.toString(), turnId: agent.lastCreateOptions.sideChat.turnId, @@ -8889,7 +8886,7 @@ suite('AgentService (node dispatcher)', () => { startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'still running', origin: { kind: MessageKind.User } }, }, 'test-client', 1); - service.stateManager.dispatchServerAction(sourceChat, { + getStateManager(service).dispatchServerAction(sourceChat, { type: ActionType.ChatResponsePart, turnId: 'active-turn', part: { kind: ResponsePartKind.Markdown, id: 'partial', content: 'partial answer' }, @@ -8899,8 +8896,8 @@ suite('AgentService (node dispatcher)', () => { await service.createChat(session, chatUri, { sideChat: { source: URI.parse(sourceChat), turnId: 'active-turn' } }); assert.deepStrictEqual({ - sourceActiveTurn: service.stateManager.getChatState(sourceChat)?.activeTurn?.id, - origin: service.stateManager.getChatState(chatUri.toString())?.origin, + sourceActiveTurn: getStateManager(service).getChatState(sourceChat)?.activeTurn?.id, + origin: getStateManager(service).getChatState(chatUri.toString())?.origin, sideChatForwarded: agent.lastCreateOptions?.sideChat ? { source: agent.lastCreateOptions.sideChat.source.toString(), @@ -8921,14 +8918,14 @@ suite('AgentService (node dispatcher)', () => { service.registerProvider(agent); const session = await service.createSession({ provider: 'copilot' }); const sourceChat = buildDefaultChatUri(session); - service.stateManager.seedDefaultChatTurns(session.toString(), [completedTurn('t1', 'first question', 'first answer')]); + getStateManager(service).seedDefaultChatTurns(session.toString(), [completedTurn('t1', 'first question', 'first answer')]); service.dispatchAction(sourceChat, { type: ActionType.ChatTurnStarted, turnId: 'active-turn', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'second question', origin: { kind: MessageKind.User } }, }, 'test-client', 1); - service.stateManager.dispatchServerAction(sourceChat, { + getStateManager(service).dispatchServerAction(sourceChat, { type: ActionType.ChatResponsePart, turnId: 'active-turn', part: { kind: ResponsePartKind.Markdown, id: 'partial', content: 'partial answer' }, @@ -8956,7 +8953,7 @@ suite('AgentService (node dispatcher)', () => { const agent = disposables.add(new SideChatAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); - localService.stateManager.seedDefaultChatTurns(session.toString(), [completedTurn('t1')]); + getStateManager(localService).seedDefaultChatTurns(session.toString(), [completedTurn('t1')]); const chatUri = URI.parse(buildChatUri(session, 'side-1')); const defaultChatUri = buildDefaultChatUri(session); const selection = { text: ' selected text ', responsePartId: 'response-part-1' }; @@ -8975,13 +8972,13 @@ suite('AgentService (node dispatcher)', () => { await timeout(1); } - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); assert.deepStrictEqual({ persistedOrigin, - restoredOrigin: localService.stateManager.getSessionState(session.toString())?.chats.find(chat => chat.resource === chatUri.toString())?.origin, - restoredChatState: localService.stateManager.getChatState(chatUri.toString()), + restoredOrigin: getStateManager(localService).getSessionState(session.toString())?.chats.find(chat => chat.resource === chatUri.toString())?.origin, + restoredChatState: getStateManager(localService).getChatState(chatUri.toString()), }, { persistedOrigin: { kind: ChatOriginKind.SideChat, chat: defaultChatUri, turnId: 't1', selection }, restoredOrigin: { kind: ChatOriginKind.SideChat, chat: defaultChatUri, turnId: 't1', selection }, @@ -8999,12 +8996,12 @@ suite('AgentService (node dispatcher)', () => { const target = URI.parse(buildChatUri(session, 'peer-side')); agent.chatMessages.set(source.toString(), [completedTurn('source-turn')]); await db.setMetadata('peerChats', JSON.stringify([{ uri: source.toString(), providerData: 'source-blob' }])); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); const resolvedChats: string[] = []; - const resolveChatState = localService.stateManager.resolveChatState.bind(localService.stateManager); - localService.stateManager.resolveChatState = async chat => { + const resolveChatState = getStateManager(localService).resolveChatState.bind(getStateManager(localService)); + getStateManager(localService).resolveChatState = async chat => { resolvedChats.push(chat); return resolveChatState(chat); }; @@ -9014,7 +9011,7 @@ suite('AgentService (node dispatcher)', () => { materializeCalls: agent.materializeCalls, resolvedChats, sideChatSource: agent.lastCreateOptions?.sideChat?.source.toString(), - sourceResolved: !!localService.stateManager.getChatState(source.toString()), + sourceResolved: !!getStateManager(localService).getChatState(source.toString()), }, { materializeCalls: 1, resolvedChats: [source.toString()], @@ -9035,7 +9032,7 @@ suite('AgentService (node dispatcher)', () => { await timeout(1); } agent.chatMessages.set(peerChat.toString(), [completedTurn('peer-turn', 'Remember X', 'Remembered')]); - localService.stateManager.removeChat(session.toString(), peerChat.toString()); + getStateManager(localService).removeChat(session.toString(), peerChat.toString()); const sent = Event.toPromise(agent.onDidSendMessage); localService.dispatchAction(buildDefaultChatUri(session), { @@ -9057,7 +9054,7 @@ suite('AgentService (node dispatcher)', () => { const attachment = agent.sendMessageCalls[0].attachments?.[0]; assert.deepStrictEqual({ - peerHydrated: !!localService.stateManager.getChatState(peerChat.toString()), + peerHydrated: !!getStateManager(localService).getChatState(peerChat.toString()), type: attachment?.type, hasTranscript: attachment?.type === MessageAttachmentKind.Simple && attachment.modelRepresentation?.includes('User: Remember X'), }, { @@ -9192,7 +9189,7 @@ suite('AgentService (node dispatcher)', () => { localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); assert.deepStrictEqual({ @@ -9236,7 +9233,7 @@ suite('AgentService (node dispatcher)', () => { // persisted โ€” a stand-in for a legacy session. const session = await localService.createSession({ provider: 'copilot' }); assert.strictEqual(await db.getMetadata('defaultChatProviderData'), undefined); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); assert.deepStrictEqual({ @@ -9281,7 +9278,7 @@ suite('AgentService (node dispatcher)', () => { (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(session), session); localService.registerProvider(agent); await localService.listSessions(); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); @@ -9305,14 +9302,14 @@ suite('AgentService (node dispatcher)', () => { localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); const persistedAfterFirstRestore = await db.getMetadata('defaultChatProviderData'); // Simulate another restart: the previously-recovered blob is now // canonical, so this restore must offer it (not `undefined`) and // must not persist over it again. - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); assert.deepStrictEqual({ @@ -9338,7 +9335,7 @@ suite('AgentService (node dispatcher)', () => { // Seed a canonical providerData blob directly, as if it had been // persisted by a normal (non-legacy) session creation. await db.setMetadata('defaultChatProviderData', 'canonical-backing'); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); assert.deepStrictEqual({ @@ -9367,12 +9364,12 @@ suite('AgentService (node dispatcher)', () => { agent.sessionMessages = [ { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, ]; - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); assert.deepStrictEqual({ persisted: await db.getMetadata('defaultChatProviderData'), - turns: localService.stateManager.getSessionState(session.toString())?.turns.map(turn => turn.id), + turns: getStateManager(localService).getSessionState(session.toString())?.turns.map(turn => turn.id), }, { persisted: undefined, turns: ['msg-1'], @@ -9459,7 +9456,7 @@ suite('AgentService (node dispatcher)', () => { const sourceTurns: Turn[] = [ { id: 't1', state: TurnState.Complete, message: { text: 'first', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }, ]; - service.stateManager.seedDefaultChatTurns(session.toString(), sourceTurns); + getStateManager(service).seedDefaultChatTurns(session.toString(), sourceTurns); const chatUri = URI.parse(buildChatUri(session, 'peer-1')); await service.createChat(session, chatUri, { fork: { source: session, turnId: 't1' } }); @@ -9473,7 +9470,7 @@ suite('AgentService (node dispatcher)', () => { service.registerProvider(agent); const session = await service.createSession({ provider: 'copilot' }); const source = buildSubagentChatUri(session.toString(), 'tool-1'); - service.stateManager.addChat(session.toString(), source, { + getStateManager(service).addChat(session.toString(), source, { origin: { kind: ChatOriginKind.Tool, chat: buildDefaultChatUri(session), toolCallId: 'tool-1' }, turns: [{ id: 't1', state: TurnState.Complete, message: { text: 'work', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }], }); @@ -9490,7 +9487,7 @@ suite('AgentService (node dispatcher)', () => { const agent = disposables.add(new ChatSurfaceAgent('copilot')); service.registerProvider(agent); const { session } = await createAgentSession(agent); - service.stateManager.deleteSession(session.toString()); + getStateManager(service).deleteSession(session.toString()); await service.restoreSession(session); @@ -9535,8 +9532,8 @@ suite('AgentService (node dispatcher)', () => { title: 'Explore', }); - const chatState = service.stateManager.getChatState(spawned.toString()); - const sessionChats = (service.stateManager.getSessionState(session.toString())?.chats ?? []).map(c => c.resource); + const chatState = getStateManager(service).getChatState(spawned.toString()); + const sessionChats = (getStateManager(service).getSessionState(session.toString())?.chats ?? []).map(c => c.resource); assert.deepStrictEqual({ title: chatState?.title, origin: chatState?.origin, @@ -9556,7 +9553,7 @@ suite('AgentService (node dispatcher)', () => { const spawned = URI.parse(buildChatUri(session, 'spawned-2')); agent.fireSpawn({ session, chat: spawned }); - const chatState = service.stateManager.getChatState(spawned.toString()); + const chatState = getStateManager(service).getChatState(spawned.toString()); assert.deepStrictEqual({ // No spawn edge to record, but the catalog is exhaustive: every // chat carries an origin, so it falls back to the plain @@ -9596,14 +9593,14 @@ suite('AgentService (node dispatcher)', () => { }); const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub'); - const chatState = service.stateManager.getChatState(subagentUri); - const matching = (service.stateManager.getSessionState(session.toString())?.chats ?? []).filter(c => c.resource === subagentUri); + const chatState = getStateManager(service).getChatState(subagentUri); + const matching = (getStateManager(service).getSessionState(session.toString())?.chats ?? []).filter(c => c.resource === subagentUri); assert.deepStrictEqual({ catalogEntries: matching.length, title: chatState?.title, origin: chatState?.origin, interactivity: chatState?.interactivity, - hasStartedTurn: service.stateManager.getActiveTurnId(subagentUri) !== undefined, + hasStartedTurn: getStateManager(service).getActiveTurnId(subagentUri) !== undefined, }, { catalogEntries: 1, // The concise per-task description names the tab (distinct even for @@ -9637,7 +9634,7 @@ suite('AgentService (node dispatcher)', () => { // The resource the inline pill carries for this subagent. const pillResource = buildSubagentChatUri(session.toString(), 'tc-sub'); const pillChatId = parseChatUri(pillResource)?.chatId; - const catalog = service.stateManager.getSessionState(session.toString())?.chats ?? []; + const catalog = getStateManager(service).getSessionState(session.toString())?.chats ?? []; const resolvedByPill = catalog.filter(c => parseChatUri(c.resource)?.chatId === pillChatId); assert.deepStrictEqual({ pillChatId, @@ -9660,7 +9657,7 @@ suite('AgentService (node dispatcher)', () => { }); const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub'); - assert.strictEqual(service.stateManager.getChatState(subagentUri)?.title, 'Explore'); + assert.strictEqual(getStateManager(service).getChatState(subagentUri)?.title, 'Explore'); }); test('membership stays a single entry when the agent also mirrors the subagent onto onDidSpawnChat, regardless of order', async () => { @@ -9699,11 +9696,11 @@ suite('AgentService (node dispatcher)', () => { }); const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub'); - const matching = (service.stateManager.getSessionState(session.toString())?.chats ?? []).filter(c => c.resource === subagentUri); + const matching = (getStateManager(service).getSessionState(session.toString())?.chats ?? []).filter(c => c.resource === subagentUri); assert.deepStrictEqual({ catalogEntries: matching.length, - origin: service.stateManager.getChatState(subagentUri)?.origin, - hasStartedTurn: service.stateManager.getActiveTurnId(subagentUri) !== undefined, + origin: getStateManager(service).getChatState(subagentUri)?.origin, + hasStartedTurn: getStateManager(service).getActiveTurnId(subagentUri) !== undefined, }, { catalogEntries: 1, origin: { kind: ChatOriginKind.Tool, chat: parentChat, toolCallId: 'tc-sub' }, @@ -9729,9 +9726,9 @@ suite('AgentService (node dispatcher)', () => { copilotAgent.fireProgress({ kind: 'subagent_started', chat: URI.parse(parentChat), toolCallId: 'tc-sub', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' }); const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub'); - const subState = service.stateManager.getSessionState(subagentUri); + const subState = getStateManager(service).getSessionState(subagentUri); const innerOnSubagent = subState?.activeTurn?.responseParts.some(rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-1'); - const innerOnParent = service.stateManager.getSessionState(session.toString())?.activeTurn?.responseParts.some(rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-1'); + const innerOnParent = getStateManager(service).getSessionState(session.toString())?.activeTurn?.responseParts.some(rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-1'); assert.deepStrictEqual({ innerOnSubagent, innerOnParent }, { innerOnSubagent: true, innerOnParent: false }); }); @@ -9743,15 +9740,15 @@ suite('AgentService (node dispatcher)', () => { copilotAgent.fireProgress({ kind: 'subagent_started', chat: URI.parse(parentChat), toolCallId: 'tc-sub', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' }); const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub'); - assert.ok(service.stateManager.getChatState(subagentUri), 'precondition: subagent chat present after start'); + assert.ok(getStateManager(service).getChatState(subagentUri), 'precondition: subagent chat present after start'); copilotAgent.fireProgress({ kind: 'subagent_completed', chat: URI.parse(parentChat), toolCallId: 'tc-sub' }); - const stillInCatalog = (service.stateManager.getSessionState(session.toString())?.chats ?? []).some(c => c.resource === subagentUri); + const stillInCatalog = (getStateManager(service).getSessionState(session.toString())?.chats ?? []).some(c => c.resource === subagentUri); assert.deepStrictEqual({ - hasChatState: service.stateManager.getChatState(subagentUri) !== undefined, + hasChatState: getStateManager(service).getChatState(subagentUri) !== undefined, stillInCatalog, - hasActiveTurn: service.stateManager.getActiveTurnId(subagentUri) !== undefined, + hasActiveTurn: getStateManager(service).getActiveTurnId(subagentUri) !== undefined, }, { hasChatState: true, stillInCatalog: true, @@ -9833,7 +9830,7 @@ suite('AgentService (node dispatcher)', () => { }); const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub'); - assert.strictEqual(service.stateManager.getSnapshot(subagentUri), undefined, 'precondition: resource not registered yet'); + assert.strictEqual(getStateManager(service).getSnapshot(subagentUri), undefined, 'precondition: resource not registered yet'); // Subscribe before the resource exists โ€” this must not reject. const subscribePromise = service.subscribe(URI.parse(subagentUri), 'client-race'); @@ -9932,7 +9929,7 @@ suite('AgentService (node dispatcher)', () => { await assert.rejects(() => localService.createChat(session, peer), /peer catalog write failed/); assert.deepStrictEqual({ - chats: localService.stateManager.getSessionState(session.toString())?.chats.map(chat => chat.resource.toString()), + chats: getStateManager(localService).getSessionState(session.toString())?.chats.map(chat => chat.resource.toString()), disposed: agent.disposedPeers.map(call => call.toString()), }, { chats: [buildDefaultChatUri(session)], @@ -9963,7 +9960,7 @@ suite('AgentService (node dispatcher)', () => { const peerUri = URI.parse(buildChatUri(session, 'peer-1')); await db.setMetadata('peerChats', JSON.stringify([{ uri: peerUri.toString(), providerData: 'blob-1' }])); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); // First access triggers `_materializeRestoredPeerChat`. @@ -10002,10 +9999,10 @@ suite('AgentService (node dispatcher)', () => { localService.dispatchAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'Session B' }, 'test-client', 2); await waitForMetadata(db, 'customTitle', 'Session B'); - localService.stateManager.deleteSession(sessionUri); + getStateManager(localService).deleteSession(sessionUri); await localService.restoreSession(session); - const restored = localService.stateManager.getSessionState(sessionUri); + const restored = getStateManager(localService).getSessionState(sessionUri); assert.deepStrictEqual({ sessionTitle: restored?.title, defaultChatTitle: restored?.chats.find(chat => chat.resource === defaultChat)?.title, @@ -10052,10 +10049,10 @@ suite('AgentService (node dispatcher)', () => { await db.setMetadata(`customChatTitle:${peerUri.toString()}`, 'Persisted Peer Title'); await db.setChatDraft(peerUri, { text: 'Persisted draft', origin: { kind: MessageKind.User } }); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); - const state = localService.stateManager.getSessionState(session.toString()); + const state = getStateManager(localService).getSessionState(session.toString()); const restored = { calls: [...calls], chatIds: (state?.chats ?? []).map(chat => parseChatUri(chat.resource)?.chatId), @@ -10063,10 +10060,10 @@ suite('AgentService (node dispatcher)', () => { const summary = state?.chats.find(chat => chat.resource.toString() === peerUri.toString()); return summary && { title: summary.title, origin: summary.origin }; })(), - chatState: localService.stateManager.getChatState(peerUri.toString()), + chatState: getStateManager(localService).getChatState(peerUri.toString()), }; await localService.subscribe(peerUri, 'first-peer-reader'); - const hydrated = localService.stateManager.getChatState(peerUri.toString()); + const hydrated = getStateManager(localService).getChatState(peerUri.toString()); assert.deepStrictEqual({ restored, @@ -10128,14 +10125,14 @@ suite('AgentService (node dispatcher)', () => { const session = await localService.createSession({ provider: 'copilot' }); const peerUri = URI.parse(buildChatUri(session, 'peer-1')); await db.setMetadata('peerChats', JSON.stringify([{ uri: peerUri.toString(), providerData: 'blob-1' }])); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); const first = localService.subscribe(peerUri, 'first-reader'); const second = localService.subscribe(peerUri, 'second-reader'); await timeout(0); - const stateWhileBlocked = localService.stateManager.getChatState(peerUri.toString()); - const snapshotWhileBlocked = localService.stateManager.getSnapshot(peerUri.toString()); + const stateWhileBlocked = getStateManager(localService).getChatState(peerUri.toString()); + const snapshotWhileBlocked = getStateManager(localService).getSnapshot(peerUri.toString()); materialization.complete(); await Promise.all([first, second]); @@ -10144,7 +10141,7 @@ suite('AgentService (node dispatcher)', () => { historyCalls, stateWhileBlocked, snapshotWhileBlocked, - stateAfterResolve: !!localService.stateManager.getChatState(peerUri.toString()), + stateAfterResolve: !!getStateManager(localService).getChatState(peerUri.toString()), }, { materializeCalls: 1, historyCalls: 1, @@ -10187,10 +10184,10 @@ suite('AgentService (node dispatcher)', () => { const session = await localService.createSession({ provider: 'copilot' }); const peerUri = URI.parse(buildChatUri(session, 'peer-1')); await db.setMetadata('peerChats', JSON.stringify([{ uri: peerUri.toString(), providerData: 'blob-1' }])); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); - const beforeContext = localService.stateManager.getChatState(peerUri.toString()); + const beforeContext = getStateManager(localService).getChatState(peerUri.toString()); const result = await agent.serverToolHost!.executeTool( buildDefaultChatUri(session), SessionServerToolName.GetSessionContext, @@ -10259,7 +10256,7 @@ suite('AgentService (node dispatcher)', () => { const sourceSession = await localService.createSession({ provider: 'copilot' }); const sourceChat = URI.parse(buildChatUri(sourceSession, 'source-chat')); await localService.createChat(sourceSession, sourceChat); - localService.stateManager.setSessionConfig(sourceSession.toString(), { + getStateManager(localService).setSessionConfig(sourceSession.toString(), { schema: { type: 'object', properties: {} }, values: { [SessionConfigKey.AutoApprove]: 'autoApprove', @@ -10275,7 +10272,7 @@ suite('AgentService (node dispatcher)', () => { startedAt: new Date().toISOString(), message: { text: 'Create more work', origin: { kind: MessageKind.User }, model: { id: 'source-model' } }, }, 'test-client', 1); - const sourceModelBeforeCreation = localService.stateManager.getSessionState(sourceChat.toString())?.activeTurn?.message.model; + const sourceModelBeforeCreation = getStateManager(localService).getSessionState(sourceChat.toString())?.activeTurn?.message.model; await agent.serverToolHost!.executeTool(sourceChat.toString(), SessionServerToolName.CreateSession, { workspace: URI.file('/workspace').toString(), @@ -10335,13 +10332,13 @@ suite('AgentService (node dispatcher)', () => { localService.registerProvider(agent); const sourceSession = await localService.createSession({ provider: 'copilot' }); const sourceChat = buildDefaultChatUri(sourceSession); - localService.stateManager.dispatchServerAction(sourceChat, { + getStateManager(localService).dispatchServerAction(sourceChat, { type: ActionType.ChatTurnStarted, turnId: 'previous-turn', startedAt: new Date().toISOString(), message: { text: 'previous', origin: { kind: MessageKind.User }, model: { id: 'previous-model' } }, }); - localService.stateManager.dispatchServerAction(sourceChat, { + getStateManager(localService).dispatchServerAction(sourceChat, { type: ActionType.ChatTurnComplete, turnId: 'previous-turn', duration: 1, @@ -10396,12 +10393,12 @@ suite('AgentService (node dispatcher)', () => { const source = URI.parse(buildChatUri(session, 'peer-source')); const target = URI.parse(buildChatUri(session, 'peer-fork')); await db.setMetadata('peerChats', JSON.stringify([{ uri: source.toString(), providerData: 'source-blob' }])); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); const resolvedChats: string[] = []; - const resolveChatState = localService.stateManager.resolveChatState.bind(localService.stateManager); - localService.stateManager.resolveChatState = async chat => { + const resolveChatState = getStateManager(localService).resolveChatState.bind(getStateManager(localService)); + getStateManager(localService).resolveChatState = async chat => { resolvedChats.push(chat); return resolveChatState(chat); }; @@ -10412,8 +10409,8 @@ suite('AgentService (node dispatcher)', () => { resolvedChats, providerForkTurnId, providerForkSource, - sourceResolved: !!localService.stateManager.getChatState(source.toString()), - forkedTurnCount: localService.stateManager.getChatState(target.toString())?.turns.length, + sourceResolved: !!getStateManager(localService).getChatState(source.toString()), + forkedTurnCount: getStateManager(localService).getChatState(target.toString())?.turns.length, }, { materializeCalls: 1, resolvedChats: [source.toString()], @@ -10450,7 +10447,7 @@ suite('AgentService (node dispatcher)', () => { { uri: firstPeer.toString(), providerData: 'blob-1' }, { uri: secondPeer.toString(), providerData: 'blob-2' }, ])); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); const first = localService.subscribe(firstPeer, 'first-reader'); @@ -10487,19 +10484,19 @@ suite('AgentService (node dispatcher)', () => { const session = await localService.createSession({ provider: 'copilot' }); const peerUri = URI.parse(buildChatUri(session, 'peer-1')); await db.setMetadata('peerChats', JSON.stringify([{ uri: peerUri.toString(), providerData: 'blob-1' }])); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); await assert.rejects(() => localService.subscribe(peerUri, 'first-reader'), /first materialization failed/); - const visibleAfterFailure = !!localService.stateManager.getSessionState(session.toString())?.chats.some(chat => chat.resource.toString() === peerUri.toString()); - const stateAfterFailure = localService.stateManager.getChatState(peerUri.toString()); + const visibleAfterFailure = !!getStateManager(localService).getSessionState(session.toString())?.chats.some(chat => chat.resource.toString() === peerUri.toString()); + const stateAfterFailure = getStateManager(localService).getChatState(peerUri.toString()); await localService.subscribe(peerUri, 'second-reader'); assert.deepStrictEqual({ materializeCalls, visibleAfterFailure, stateAfterFailure, - stateAfterRetry: !!localService.stateManager.getChatState(peerUri.toString()), + stateAfterRetry: !!getStateManager(localService).getChatState(peerUri.toString()), }, { materializeCalls: 2, visibleAfterFailure: true, @@ -10528,7 +10525,7 @@ suite('AgentService (node dispatcher)', () => { const session = await localService.createSession({ provider: 'copilot' }); const peerUri = URI.parse(buildChatUri(session, 'peer-1')); await db.setMetadata('peerChats', JSON.stringify([{ uri: peerUri.toString(), providerData: 'blob-1' }])); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); localService.dispatchAction(peerUri.toString(), { @@ -10540,12 +10537,12 @@ suite('AgentService (node dispatcher)', () => { for (let i = 0; i < 50 && materializeCalls === 0; i++) { await timeout(0); } - const stateWhileBlocked = localService.stateManager.getChatState(peerUri.toString()); + const stateWhileBlocked = getStateManager(localService).getChatState(peerUri.toString()); materialization.complete(); - for (let i = 0; i < 50 && localService.stateManager.getChatState(peerUri.toString())?.activeTurn?.id !== 'turn-1'; i++) { + for (let i = 0; i < 50 && getStateManager(localService).getChatState(peerUri.toString())?.activeTurn?.id !== 'turn-1'; i++) { await timeout(0); } - const stateAfterResolution = localService.stateManager.getChatState(peerUri.toString()); + const stateAfterResolution = getStateManager(localService).getChatState(peerUri.toString()); assert.deepStrictEqual({ materializeCalls, @@ -10573,7 +10570,7 @@ suite('AgentService (node dispatcher)', () => { localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); const chat = buildDefaultChatUri(session); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); localService.dispatchAction(chat.toString(), { type: ActionType.ChatTurnStarted, @@ -10584,12 +10581,12 @@ suite('AgentService (node dispatcher)', () => { for (let i = 0; i < 50 && restoreCalls === 0; i++) { await timeout(0); } - const stateWhileBlocked = localService.stateManager.getChatState(chat.toString()); + const stateWhileBlocked = getStateManager(localService).getChatState(chat.toString()); restoration.complete(); - for (let i = 0; i < 50 && localService.stateManager.getChatState(chat.toString())?.activeTurn?.id !== 'turn-1'; i++) { + for (let i = 0; i < 50 && getStateManager(localService).getChatState(chat.toString())?.activeTurn?.id !== 'turn-1'; i++) { await timeout(0); } - const stateAfterRestoration = localService.stateManager.getChatState(chat.toString()); + const stateAfterRestoration = getStateManager(localService).getChatState(chat.toString()); assert.deepStrictEqual({ restoreCalls, @@ -10619,7 +10616,7 @@ suite('AgentService (node dispatcher)', () => { provider: 'copilot', config: { [SessionConfigKey.AutoApprove]: 'autoApprove' }, }); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); localService.dispatchAction(session.toString(), { type: ActionType.SessionConfigChanged, @@ -10628,12 +10625,12 @@ suite('AgentService (node dispatcher)', () => { for (let i = 0; i < 50 && restoreCalls === 0; i++) { await timeout(0); } - const stateWhileBlocked = localService.stateManager.getSessionState(session.toString()); + const stateWhileBlocked = getStateManager(localService).getSessionState(session.toString()); restoration.complete(); - for (let i = 0; i < 50 && localService.stateManager.getSessionState(session.toString())?.config?.values[SessionConfigKey.AutoApprove] !== 'default'; i++) { + for (let i = 0; i < 50 && getStateManager(localService).getSessionState(session.toString())?.config?.values[SessionConfigKey.AutoApprove] !== 'default'; i++) { await timeout(0); } - const stateAfterRestoration = localService.stateManager.getSessionState(session.toString()); + const stateAfterRestoration = getStateManager(localService).getSessionState(session.toString()); assert.deepStrictEqual({ restoreCalls, @@ -10669,7 +10666,7 @@ suite('AgentService (node dispatcher)', () => { await localService.createSession({ provider: 'copilot', session }); const peerUri = URI.parse(buildChatUri(session, 'peer-1')); await db.setMetadata('peerChats', JSON.stringify([{ uri: peerUri.toString(), providerData: 'blob-1' }])); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); const firstSubscribe = localService.subscribe(peerUri, 'first-reader'); @@ -10680,17 +10677,17 @@ suite('AgentService (node dispatcher)', () => { const dispose = localService.disposeSession(session); firstMaterialization.complete(); await Promise.all([dispose, firstSubscribeRejected]); - const stateAfterStaleResolution = localService.stateManager.getChatState(peerUri.toString()); + const stateAfterStaleResolution = getStateManager(localService).getChatState(peerUri.toString()); await localService.createSession({ provider: 'copilot', session }); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); await localService.subscribe(peerUri, 'second-reader'); assert.deepStrictEqual({ materializeCalls, stateAfterStaleResolution, - recreatedPeerState: !!localService.stateManager.getChatState(peerUri.toString()), + recreatedPeerState: !!getStateManager(localService).getChatState(peerUri.toString()), }, { materializeCalls: 2, stateAfterStaleResolution: undefined, @@ -10722,7 +10719,7 @@ suite('AgentService (node dispatcher)', () => { const peerUri = URI.parse(buildChatUri(session, 'peer-1')); await localService.createChat(session, peerUri); const afterCreate = await readCatalog(db); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); await localService.subscribe(peerUri, 'peer-reader'); @@ -10736,14 +10733,14 @@ suite('AgentService (node dispatcher)', () => { } await timeout(0); } - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); await localService.subscribe(peerUri, 'restored-peer-reader'); assert.deepStrictEqual({ afterCreate: afterCreate.find(e => e.uri === peerUri.toString())?.providerData, afterChange: updated.find(e => e.uri === peerUri.toString())?.providerData, - hydrated: !!localService.stateManager.getChatState(peerUri.toString()), + hydrated: !!getStateManager(localService).getChatState(peerUri.toString()), materializedProviderData, }, { afterCreate: 'v1', @@ -10812,7 +10809,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ catalog: await readCatalog(db), - inMemory: localService.stateManager.getSessionState(session.toString())?.chats.some(chat => chat.resource.toString() === peer.toString()), + inMemory: getStateManager(localService).getSessionState(session.toString())?.chats.some(chat => chat.resource.toString() === peer.toString()), }, { catalog: [], inMemory: false, @@ -10845,14 +10842,14 @@ suite('AgentService (node dispatcher)', () => { db.failRemoval = true; await assert.rejects(() => localService.disposeChat(session, peer), /catalog removal failed/); - const retainedAfterFailure = localService.stateManager.getSessionState(session.toString())?.chats.some(chat => chat.resource.toString() === peer.toString()); + const retainedAfterFailure = getStateManager(localService).getSessionState(session.toString())?.chats.some(chat => chat.resource.toString() === peer.toString()); db.failRemoval = false; await localService.disposeChat(session, peer); assert.deepStrictEqual({ retainedAfterFailure, catalog: await readCatalog(db), - inMemoryAfterRetry: localService.stateManager.getSessionState(session.toString())?.chats.some(chat => chat.resource.toString() === peer.toString()), + inMemoryAfterRetry: getStateManager(localService).getSessionState(session.toString())?.chats.some(chat => chat.resource.toString() === peer.toString()), }, { retainedAfterFailure: true, catalog: [], @@ -10903,21 +10900,21 @@ suite('AgentService (node dispatcher)', () => { await db.setMetadata(`customChatTitle:${legacyAUri.toString()}`, 'Legacy A Title'); // No peerChats key exists (undefined catalog) -> migration runs. - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); const catalogAfterFirst = await readCatalog(db); // Second restore: catalog now present -> legacy read not consulted again. - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); - const restoredState = localService.stateManager.getSessionState(session.toString()); + const restoredState = getStateManager(localService).getSessionState(session.toString()); assert.deepStrictEqual({ legacyCalls: agent.listLegacyCallCount, catalog: catalogAfterFirst.map(e => ({ uri: e.uri, providerData: e.providerData })), aTitle: restoredState?.chats.find(chat => chat.resource === legacyAUri.toString())?.title, - aState: localService.stateManager.getChatState(legacyAUri.toString()), - bState: localService.stateManager.getChatState(legacyBUri.toString()), + aState: getStateManager(localService).getChatState(legacyAUri.toString()), + bState: getStateManager(localService).getChatState(legacyBUri.toString()), }, { legacyCalls: 1, catalog: [ @@ -10946,10 +10943,10 @@ suite('AgentService (node dispatcher)', () => { // Known-empty catalog must be treated as "no peer chats", never migrated. await db.setMetadata('peerChats', '[]'); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); - const state = localService.stateManager.getSessionState(session.toString()); + const state = getStateManager(localService).getSessionState(session.toString()); assert.deepStrictEqual({ legacyCalls: agent.listLegacyCallCount, peerChats: (state?.chats ?? []).map(c => parseChatUri(c.resource)?.chatId).filter(id => id !== 'default'), @@ -10981,10 +10978,10 @@ suite('AgentService (node dispatcher)', () => { await localService.createChat(session, peerUri); await readCatalog(db); - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); - const state = localService.stateManager.getSessionState(session.toString()); + const state = getStateManager(localService).getSessionState(session.toString()); assert.deepStrictEqual({ legacyCalls: agent.listLegacyCallCount, peerInCatalog: !!state?.chats.some(c => c.resource.toString() === peerUri.toString()), @@ -11016,11 +11013,11 @@ suite('AgentService (node dispatcher)', () => { const session = await localService.createSession({ provider: 'copilot' }); // Absent peerChats key => migration runs and must write the full set once. - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); const catalog = await readCatalog(db); - const restoredIds = (localService.stateManager.getSessionState(session.toString())?.chats ?? []) + const restoredIds = (getStateManager(localService).getSessionState(session.toString())?.chats ?? []) .map(c => parseChatUri(c.resource)?.chatId) .filter(id => id !== 'default'); assert.deepStrictEqual({ @@ -11061,13 +11058,13 @@ suite('AgentService (node dispatcher)', () => { // First restore: the single catalog write is rejected. Because the write // is all-or-nothing, the key must stay absent (never a proper subset). - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await assert.rejects(() => localService.restoreSession(session), /simulated catalog write failure/); const catalogAfterFailedWrite = await db.getMetadata('peerChats'); // Second restore: catalog still absent => migration re-runs and now // persists the complete set. - localService.stateManager.deleteSession(session.toString()); + getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); const catalog = await readCatalog(db); @@ -11112,7 +11109,7 @@ suite('AgentService (node dispatcher)', () => { const defaultChat = buildDefaultChatUri(session); const peerChat = buildChatUri(sessionUri, 'peer-rename'); db.finalRenameKey = `customChatTitle:${peerChat}`; - localService.stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'Previous user title' }); + getStateManager(localService).dispatchServerAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'Previous user title' }); await db.setMetadata('customTitle', 'Previous user title'); await db.setMetadata('customTitleSource', 'user'); @@ -11120,14 +11117,14 @@ suite('AgentService (node dispatcher)', () => { title: 'Single-chat title', }); - localService.stateManager.addChat(sessionUri, peerChat, { title: 'Previous peer title' }); - localService.stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'Multi-chat session title' }); + getStateManager(localService).addChat(sessionUri, peerChat, { title: 'Previous peer title' }); + getStateManager(localService).dispatchServerAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'Multi-chat session title' }); await db.setMetadata('customTitle', 'Multi-chat session title'); await db.setMetadata('customTitleSource', 'user'); await db.setMetadata(`customChatTitle:${peerChat}`, 'Previous peer title'); await db.setMetadata(`customChatTitleSource:${peerChat}`, 'user'); await timeout(0); - localService.stateManager.prepareSessionSummariesForListing([localService.stateManager.getSessionSummary(sessionUri)!]); + getStateManager(localService).prepareSessionSummariesForListing([getStateManager(localService).getSessionSummary(sessionUri)!]); const summaryTitleChanged = new DeferredPromise(); disposables.add(localService.onDidNotification(notification => { if (notification.type === NotificationType.SessionSummaryChanged && notification.changes.title) { @@ -11149,9 +11146,9 @@ suite('AgentService (node dispatcher)', () => { singleChatResult, multiChatDefaultResult, chatResult, - liveSessionTitle: localService.stateManager.getSessionState(sessionUri)?.title, - liveDefaultChatTitle: localService.stateManager.getChatState(defaultChat)?.title, - liveChatTitle: localService.stateManager.getChatState(peerChat)?.title, + liveSessionTitle: getStateManager(localService).getSessionState(sessionUri)?.title, + liveDefaultChatTitle: getStateManager(localService).getChatState(defaultChat)?.title, + liveChatTitle: getStateManager(localService).getChatState(peerChat)?.title, persistedSessionTitle: await db.getMetadata('customTitle'), persistedSessionSource: await db.getMetadata('customTitleSource'), persistedDefaultChatTitle: await db.getMetadata(`customChatTitle:${defaultChat}`), @@ -11208,7 +11205,7 @@ suite('AgentService (node dispatcher)', () => { const sessionUri = session.toString(); const defaultChat = buildDefaultChatUri(session); const peerChat = buildChatUri(sessionUri, 'peer-failure'); - localService.stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'Original session' }); + getStateManager(localService).dispatchServerAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'Original session' }); await db.setMetadata('customTitle', 'Original session'); await db.setMetadata('customTitleSource', 'user'); @@ -11217,7 +11214,7 @@ suite('AgentService (node dispatcher)', () => { /title persistence failed/ ); - localService.stateManager.addChat(sessionUri, peerChat, { title: 'Original chat' }); + getStateManager(localService).addChat(sessionUri, peerChat, { title: 'Original chat' }); await db.setMetadata(`customChatTitle:${defaultChat}`, 'Original session'); await db.setMetadata(`customChatTitleSource:${defaultChat}`, 'user'); await db.setMetadata(`customChatTitle:${peerChat}`, 'Original chat'); @@ -11236,13 +11233,13 @@ suite('AgentService (node dispatcher)', () => { ); await db.allFailuresObserved.p; assert.deepStrictEqual({ - liveSession: localService.stateManager.getSessionState(sessionUri)?.title, + liveSession: getStateManager(localService).getSessionState(sessionUri)?.title, sessionTitle: await db.getMetadata('customTitle'), sessionSource: await db.getMetadata('customTitleSource'), - liveDefaultChat: localService.stateManager.getChatState(defaultChat)?.title, + liveDefaultChat: getStateManager(localService).getChatState(defaultChat)?.title, defaultChatTitle: await db.getMetadata(`customChatTitle:${defaultChat}`), defaultChatSource: await db.getMetadata(`customChatTitleSource:${defaultChat}`), - liveChat: localService.stateManager.getChatState(peerChat)?.title, + liveChat: getStateManager(localService).getChatState(peerChat)?.title, chatTitle: await db.getMetadata(`customChatTitle:${peerChat}`), chatSource: await db.getMetadata(`customChatTitleSource:${peerChat}`), }, { @@ -11320,7 +11317,7 @@ suite('AgentService (node dispatcher)', () => { // Empty sessions are routed to the GC pipeline rather than the // eviction pipeline, so their state stays observable in the // grace window for a re-subscribe to find. - assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'empty created session must remain observable for the GC grace window'); + assert.ok(getStateManager(service).getSessionState(sessionResource.toString()), 'empty created session must remain observable for the GC grace window'); }); test('a session with an active turn is NOT evicted when its last subscriber drops', async () => { @@ -11339,7 +11336,7 @@ suite('AgentService (node dispatcher)', () => { service.unsubscribe(sessionResource, 'client-1'); - assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'active-turn session must not be evicted'); + assert.ok(getStateManager(service).getSessionState(sessionResource.toString()), 'active-turn session must not be evicted'); }); test('a session with an active peer chat is NOT evicted when its last subscriber drops', () => { @@ -11347,7 +11344,7 @@ suite('AgentService (node dispatcher)', () => { service.registerProvider(copilotAgent); const sessionResource = await service.createSession({ provider: 'copilot' }); const peerChat = URI.parse(buildChatUri(sessionResource, 'peer-1')); - service.stateManager.addChat(sessionResource.toString(), peerChat.toString(), {}); + getStateManager(service).addChat(sessionResource.toString(), peerChat.toString(), {}); service.addSubscriber(sessionResource, 'client-1'); service.dispatchAction( peerChat.toString(), @@ -11359,8 +11356,8 @@ suite('AgentService (node dispatcher)', () => { await new Promise(resolve => setTimeout(resolve, 30_000)); assert.deepStrictEqual({ - hasActiveTurn: service.stateManager.hasActiveTurn(sessionResource.toString()), - hasCachedState: service.stateManager.getSessionState(sessionResource.toString()) !== undefined, + hasActiveTurn: getStateManager(service).hasActiveTurn(sessionResource.toString()), + hasCachedState: getStateManager(service).getSessionState(sessionResource.toString()) !== undefined, releaseCalls: copilotAgent.releaseSessionCalls.length, }, { hasActiveTurn: true, @@ -11387,9 +11384,9 @@ suite('AgentService (node dispatcher)', () => { const sessionResource = await localService.createSession({ provider: 'copilot' }); const defaultChat = buildDefaultChatUri(sessionResource); const peerChat = URI.parse(buildChatUri(sessionResource, 'peer-1')); - localService.stateManager.dispatchServerAction(defaultChat, { type: ActionType.ChatTurnStarted, turnId: 'initial-turn', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'initial', origin: { kind: MessageKind.User } } }); - localService.stateManager.dispatchServerAction(defaultChat, { type: ActionType.ChatTurnComplete, turnId: 'initial-turn', duration: 1000 }); - localService.stateManager.addChat(sessionResource.toString(), peerChat.toString(), {}); + getStateManager(localService).dispatchServerAction(defaultChat, { type: ActionType.ChatTurnStarted, turnId: 'initial-turn', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'initial', origin: { kind: MessageKind.User } } }); + getStateManager(localService).dispatchServerAction(defaultChat, { type: ActionType.ChatTurnComplete, turnId: 'initial-turn', duration: 1000 }); + getStateManager(localService).addChat(sessionResource.toString(), peerChat.toString(), {}); localService.addSubscriber(sessionResource, 'client-1'); localService.unsubscribe(sessionResource, 'client-1'); @@ -11409,7 +11406,7 @@ suite('AgentService (node dispatcher)', () => { ); await new Promise(resolve => setTimeout(resolve, 30_000)); - assert.strictEqual(localService.stateManager.getSessionState(sessionResource.toString()), undefined); + assert.strictEqual(getStateManager(localService).getSessionState(sessionResource.toString()), undefined); }); }); @@ -11429,7 +11426,7 @@ suite('AgentService (node dispatcher)', () => { await new Promise(resolve => setTimeout(resolve, 30_000)); assert.deepStrictEqual({ releaseAttempts: agent.releaseAttempts, - hasCachedState: service.stateManager.getSessionState(session.toString()) !== undefined, + hasCachedState: getStateManager(service).getSessionState(session.toString()) !== undefined, }, { releaseAttempts: 1, hasCachedState: true, @@ -11438,7 +11435,7 @@ suite('AgentService (node dispatcher)', () => { await new Promise(resolve => setTimeout(resolve, 30_000)); assert.deepStrictEqual({ releaseAttempts: agent.releaseAttempts, - hasCachedState: service.stateManager.getSessionState(session.toString()) !== undefined, + hasCachedState: getStateManager(service).getSessionState(session.toString()) !== undefined, }, { releaseAttempts: 2, hasCachedState: false, @@ -11474,7 +11471,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ releaseAttempts: agent.releaseAttempts, - hasCachedState: service.stateManager.getSessionState(session.toString()) !== undefined, + hasCachedState: getStateManager(service).getSessionState(session.toString()) !== undefined, }, { releaseAttempts: 2, hasCachedState: false, @@ -11527,10 +11524,10 @@ suite('AgentService (node dispatcher)', () => { service.unsubscribe(sessionResource, 'client-1'); // Release is deferred behind the grace window โ€” still cached until it elapses. - assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'session stays cached during the release grace'); + assert.ok(getStateManager(service).getSessionState(sessionResource.toString()), 'session stays cached during the release grace'); await new Promise(resolve => setTimeout(resolve, 30_000)); - assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'restored idle session should be evicted after the grace'); + assert.strictEqual(getStateManager(service).getSessionState(sessionResource.toString()), undefined, 'restored idle session should be evicted after the grace'); assert.deepStrictEqual( copilotAgent.releaseSessionCalls.map(u => u.toString()), [sessionResource.toString()], @@ -11555,7 +11552,7 @@ suite('AgentService (node dispatcher)', () => { ]; await service.restoreSession(session); const chat = URI.parse(buildChatUri(session, 'peer-1')); - service.stateManager.addChat(session.toString(), chat.toString(), {}); + getStateManager(service).addChat(session.toString(), chat.toString(), {}); service.addSubscriber(session, 'client-1'); service.unsubscribe(session, 'client-1'); @@ -11595,7 +11592,7 @@ suite('AgentService (node dispatcher)', () => { service.addSubscriber(sessionResource, 'client-2'); await new Promise(resolve => setTimeout(resolve, 30_000)); - assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'session must stay cached when re-subscribed within the grace'); + assert.ok(getStateManager(service).getSessionState(sessionResource.toString()), 'session must stay cached when re-subscribed within the grace'); assert.strictEqual(copilotAgent.releaseSessionCalls.length, 0, 'chat release must not fire when the grace was cancelled'); }); }); @@ -11613,16 +11610,16 @@ suite('AgentService (node dispatcher)', () => { ]; await service.restoreSession(sessionResource); service.addSubscriber(sessionResource, 'client-1'); - const before = service.stateManager.getSessionState(sessionResource.toString()); + const before = getStateManager(service).getSessionState(sessionResource.toString()); assert.ok(before, 'session state present before eviction'); service.unsubscribe(sessionResource, 'client-1'); await new Promise(resolve => setTimeout(resolve, 30_000)); - assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'session evicted after last subscriber drops'); + assert.strictEqual(getStateManager(service).getSessionState(sessionResource.toString()), undefined, 'session evicted after last subscriber drops'); // Re-subscribe rehydrates from the preserved durable data. await service.subscribe(sessionResource, 'client-2'); - const after = service.stateManager.getSessionState(sessionResource.toString()); + const after = getStateManager(service).getSessionState(sessionResource.toString()); assert.ok(after, 'session restored on re-subscribe'); // Response-part ids are freshly generated on each reconstruction, so // normalize them out before comparing the durable turn content. @@ -11662,7 +11659,7 @@ suite('AgentService (node dispatcher)', () => { await subscription; assert.deepStrictEqual({ events: agent.events, - hasCachedState: service.stateManager.getSessionState(sessionResource.toString()) !== undefined, + hasCachedState: getStateManager(service).getSessionState(sessionResource.toString()) !== undefined, }, { events: ['release:start', 'release:end', 'metadata'], hasCachedState: true, @@ -11691,7 +11688,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ events: agent.events, - hasCachedState: service.stateManager.getSessionState(session.toString()) !== undefined, + hasCachedState: getStateManager(service).getSessionState(session.toString()) !== undefined, }, { events: ['canRelease:start', 'canRelease:end'], hasCachedState: true, @@ -11716,11 +11713,11 @@ suite('AgentService (node dispatcher)', () => { service.unsubscribe(sessionResource, 'client-1'); await new Promise(resolve => setTimeout(resolve, 30_000)); - assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'still subscribed by client-2'); + assert.ok(getStateManager(service).getSessionState(sessionResource.toString()), 'still subscribed by client-2'); service.unsubscribe(sessionResource, 'client-2'); await new Promise(resolve => setTimeout(resolve, 30_000)); - assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'evicted after last subscriber drops'); + assert.strictEqual(getStateManager(service).getSessionState(sessionResource.toString()), undefined, 'evicted after last subscriber drops'); }); }); @@ -11750,14 +11747,14 @@ suite('AgentService (node dispatcher)', () => { // Parent drops โ€” child still subscribed, parent must not be evicted service.unsubscribe(sessionResource, 'client-parent'); await new Promise(resolve => setTimeout(resolve, 30_000)); - assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'parent must stay while child is subscribed'); - assert.ok(service.stateManager.getSessionState(childUri.toString()), 'child still present'); + assert.ok(getStateManager(service).getSessionState(sessionResource.toString()), 'parent must stay while child is subscribed'); + assert.ok(getStateManager(service).getSessionState(childUri.toString()), 'child still present'); // Child drops โ€” parent and child can now be evicted. service.unsubscribe(childUri, 'client-child'); await new Promise(resolve => setTimeout(resolve, 30_000)); - assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'parent evicted after subagent drops'); - assert.strictEqual(service.stateManager.getSessionState(childUri.toString()), undefined, 'child also evicted with parent'); + assert.strictEqual(getStateManager(service).getSessionState(sessionResource.toString()), undefined, 'parent evicted after subagent drops'); + assert.strictEqual(getStateManager(service).getSessionState(childUri.toString()), undefined, 'child also evicted with parent'); }); }); @@ -11786,8 +11783,8 @@ suite('AgentService (node dispatcher)', () => { service.addSubscriber(nestedChildUri, 'client-nested-child'); service.unsubscribe(sessionResource, 'client-parent'); - assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'ancestor parent must stay while nested child is subscribed'); - assert.ok(service.stateManager.getSessionState(childUri.toString()), 'intermediate child still present'); + assert.ok(getStateManager(service).getSessionState(sessionResource.toString()), 'ancestor parent must stay while nested child is subscribed'); + assert.ok(getStateManager(service).getSessionState(childUri.toString()), 'intermediate child still present'); }); test('depth-2 subagent unsubscribe evicts the root session state', () => { @@ -11813,7 +11810,7 @@ suite('AgentService (node dispatcher)', () => { service.unsubscribe(nestedUri, 'client-nested'); await new Promise(resolve => setTimeout(resolve, 30_000)); - assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'root state must be evicted when no subscribers remain'); + assert.strictEqual(getStateManager(service).getSessionState(sessionResource.toString()), undefined, 'root state must be evicted when no subscribers remain'); }); }); }); @@ -12104,10 +12101,10 @@ suite('AgentService (node dispatcher)', () => { service.unsubscribe(sessionResource, 'client-1'); await new Promise(resolve => setTimeout(resolve, 5_000)); - service.stateManager.deleteSession(sessionResource.toString()); + getStateManager(service).deleteSession(sessionResource.toString()); copilotAgent.sessionMessages = []; await service.restoreSession(sessionResource); - assert.strictEqual(service.stateManager.isUnusedDraft(sessionResource.toString()), false, 'precondition: session is now durable state'); + assert.strictEqual(getStateManager(service).isUnusedDraft(sessionResource.toString()), false, 'precondition: session is now durable state'); await new Promise(resolve => setTimeout(resolve, 30_000)); @@ -12129,7 +12126,7 @@ suite('AgentService (node dispatcher)', () => { // Truncate every turn away, then drop the last subscriber. service.dispatchAction(chatUri, { type: ActionType.ChatTruncated }, 'client-1', 3); - assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString())?.turns.length, 0, 'precondition: session now looks empty'); + assert.strictEqual(getStateManager(service).getSessionState(sessionResource.toString())?.turns.length, 0, 'precondition: session now looks empty'); service.unsubscribe(sessionResource, 'client-1'); await new Promise(resolve => setTimeout(resolve, 30_000)); @@ -12206,7 +12203,7 @@ suite('AgentService (node dispatcher)', () => { await localService.restoreSession(session); - const values = localService.stateManager.getSessionState(session.toString())?.config?.values; + const values = getStateManager(localService).getSessionState(session.toString())?.config?.values; assert.deepStrictEqual({ isolation: values?.[SessionConfigKey.Isolation], autoApprove: values?.autoApprove, @@ -12238,7 +12235,7 @@ suite('AgentService (node dispatcher)', () => { await localService.restoreSession(session); - assert.deepStrictEqual(localService.stateManager.getDefaultChatState(session.toString())?.draft, { + assert.deepStrictEqual(getStateManager(localService).getDefaultChatState(session.toString())?.draft, { text: 'unsent text', origin: { kind: MessageKind.User }, model, @@ -12268,7 +12265,7 @@ suite('AgentService (node dispatcher)', () => { await localService.restoreSession(sessionResource); - const state = localService.stateManager.getSessionState(sessionResource.toString()); + const state = getStateManager(localService).getSessionState(sessionResource.toString()); assert.ok(state); // MockAgent.resolveSessionConfig echoes params.config back as values, so the // persisted values are forwarded through and end up on state.config.values. @@ -12302,7 +12299,7 @@ suite('AgentService (node dispatcher)', () => { await localService.restoreSession(sessionResource); - const state = localService.stateManager.getSessionState(sessionResource.toString()); + const state = getStateManager(localService).getSessionState(sessionResource.toString()); assert.ok(state); // The session has no working directory, so `_attachGitState` // treats it as transient and does NOT strip the two git-only @@ -12322,7 +12319,7 @@ suite('AgentService (node dispatcher)', () => { }, ]); - const changesetSnapshot = localService.stateManager.getSnapshot(`${sessionResource.toString()}/changeset/session`); + const changesetSnapshot = getStateManager(localService).getSnapshot(`${sessionResource.toString()}/changeset/session`); assert.ok(changesetSnapshot); const changesetState = changesetSnapshot.state as { status: string; files: Array<{ id: string }> }; assert.strictEqual(changesetState.status, 'ready'); @@ -12350,7 +12347,7 @@ suite('AgentService (node dispatcher)', () => { await localService.restoreSession(sessionResource); - const state = localService.stateManager.getSessionState(sessionResource.toString()); + const state = getStateManager(localService).getSessionState(sessionResource.toString()); assert.ok(state); // Catalogue is seeded by `_buildInitialSummary` / `restoreSession`. // The session has no working directory, so `_attachGitState` does @@ -12370,7 +12367,7 @@ suite('AgentService (node dispatcher)', () => { }, ]); - const changesetSnapshot = localService.stateManager.getSnapshot(`${sessionResource.toString()}/changeset/session`); + const changesetSnapshot = getStateManager(localService).getSnapshot(`${sessionResource.toString()}/changeset/session`); assert.ok(changesetSnapshot); const changesetState = changesetSnapshot.state as { status: string; files: Array<{ id: string }> }; assert.strictEqual(changesetState.status, 'computing'); @@ -12394,7 +12391,7 @@ suite('AgentService (node dispatcher)', () => { await new Promise(r => setTimeout(r, 50)); // Simulate a server restart: drop the in-memory state - localService.stateManager.removeSession(session.toString()); + getStateManager(localService).removeSession(session.toString()); localAgent.sessionMessages = [ { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, @@ -12402,7 +12399,7 @@ suite('AgentService (node dispatcher)', () => { ]; await localService.restoreSession(session); - const state = localService.stateManager.getSessionState(session.toString()); + const state = getStateManager(localService).getSessionState(session.toString()); assert.ok(state); assert.deepStrictEqual(state!.config?.values, { autoApprove: 'autoApprove' }); }); @@ -12429,7 +12426,7 @@ suite('AgentService (node dispatcher)', () => { // Should not throw despite the malformed JSON await localService.restoreSession(sessionResource); - const state = localService.stateManager.getSessionState(sessionResource.toString()); + const state = getStateManager(localService).getSessionState(sessionResource.toString()); assert.ok(state); // MockAgent has a workingDirectory? No โ€” but the metadata supplies it as undefined. // _resolveCreatedSessionConfig bails when both .config and .workingDirectory are @@ -12471,7 +12468,7 @@ suite('AgentService (node dispatcher)', () => { const session = await service.createSession({ provider: 'copilot', workingDirectories: [sourceDir] }); // The state manager should have the worktree path, not the source path - const state = service.stateManager.getSessionState(session.toString()); + const state = getStateManager(service).getSessionState(session.toString()); assert.strictEqual(state?.workingDirectories?.[0], worktreeDir.toString()); }); @@ -12483,7 +12480,7 @@ suite('AgentService (node dispatcher)', () => { const sourceDir = URI.file('/source/repo'); const session = await service.createSession({ provider: 'copilot', workingDirectories: [sourceDir] }); - const state = service.stateManager.getSessionState(session.toString()); + const state = getStateManager(service).getSessionState(session.toString()); assert.strictEqual(state?.workingDirectories?.[0], sourceDir.toString()); }); @@ -12496,13 +12493,13 @@ suite('AgentService (node dispatcher)', () => { const session = await service.createSession({ provider: 'copilot' }); // Delete from state to simulate a server restart - service.stateManager.deleteSession(session.toString()); - assert.strictEqual(service.stateManager.getSessionState(session.toString()), undefined); + getStateManager(service).deleteSession(session.toString()); + assert.strictEqual(getStateManager(service).getSessionState(session.toString()), undefined); // Restore the session (simulates a client subscribing after restart) await service.restoreSession(session); - const state = service.stateManager.getSessionState(session.toString()); + const state = getStateManager(service).getSessionState(session.toString()); assert.strictEqual(state?.workingDirectories?.[0], worktreeDir.toString()); }); @@ -12574,7 +12571,7 @@ suite('AgentService (node dispatcher)', () => { await timeout(0); const beforeMaterialization = { - workingDirectory: localService.stateManager.getSessionState(session.toString())?.workingDirectories?.[0], + workingDirectory: getStateManager(localService).getSessionState(session.toString())?.workingDirectories?.[0], gitStateCalls: [...gitStateCalls], diffCalls: [...diffCalls], }; @@ -12588,7 +12585,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ beforeMaterialization, afterMaterialization: { - workingDirectory: localService.stateManager.getSessionState(session.toString())?.workingDirectories?.[0], + workingDirectory: getStateManager(localService).getSessionState(session.toString())?.workingDirectories?.[0], gitStateCalls, diffCalls: [...new Set(diffCalls)], }, @@ -12612,7 +12609,7 @@ suite('AgentService (node dispatcher)', () => { _resolveWorkingDirectoryBeforeSend: (p: { session: string; chat: string; turnId: string; prompt: string }) => Promise; }; const resolve = (resource: string) => resolver._resolveWorkingDirectoryBeforeSend({ session: resource, chat: `${resource}/chat`, turnId: 't', prompt: 'hi' }); - const inject = (resource: string, dirs?: readonly URI[]) => service.stateManager.restoreSession({ + const inject = (resource: string, dirs?: readonly URI[]) => getStateManager(service).restoreSession({ resource, provider: 'copilot', title: 't', @@ -12670,7 +12667,7 @@ suite('AgentService (node dispatcher)', () => { const session = AgentSession.uri('copilot', 'worktree-failure'); const sessionResource = session.toString(); const chat = buildDefaultChatUri(sessionResource); - localService.stateManager.restoreSession({ + getStateManager(localService).restoreSession({ resource: sessionResource, provider: 'copilot', title: 'Worktree failure', @@ -12680,11 +12677,11 @@ suite('AgentService (node dispatcher)', () => { project: undefined, workingDirectories: [sourceDir.toString()], }, []); - localService.stateManager.setSessionConfig(sessionResource, { + getStateManager(localService).setSessionConfig(sessionResource, { schema: { type: 'object', properties: {} }, values: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main' }, }); - localService.stateManager.dispatchServerAction(chat, { + getStateManager(localService).dispatchServerAction(chat, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', @@ -12696,7 +12693,7 @@ suite('AgentService (node dispatcher)', () => { _resolveWorkingDirectoryBeforeSend: (params: { session: string; chat: string; turnId: string; prompt: string }) => Promise; }; const resolved = await resolver._resolveWorkingDirectoryBeforeSend({ session: sessionResource, chat, turnId: 'turn-1', prompt: 'test' }); - const chatState = localService.stateManager.getChatState(chat); + const chatState = getStateManager(localService).getChatState(chat); assert.deepStrictEqual({ resolved: resolved?.map(uri => uri.toString()), @@ -12737,7 +12734,7 @@ suite('AgentService (node dispatcher)', () => { const session = AgentSession.uri('copilot', 'worktree-fallback'); const sessionResource = session.toString(); const chat = buildDefaultChatUri(sessionResource); - localService.stateManager.restoreSession({ + getStateManager(localService).restoreSession({ resource: sessionResource, provider: 'copilot', title: 'Worktree fallback', @@ -12747,11 +12744,11 @@ suite('AgentService (node dispatcher)', () => { project: undefined, workingDirectories: [sourceDir.toString()], }, []); - localService.stateManager.setSessionConfig(sessionResource, { + getStateManager(localService).setSessionConfig(sessionResource, { schema: { type: 'object', properties: {} }, values: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main' }, }); - localService.stateManager.dispatchServerAction(chat, { + getStateManager(localService).dispatchServerAction(chat, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', @@ -12766,7 +12763,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ resolved: resolved?.map(uri => uri.toString()), - responseParts: localService.stateManager.getChatState(chat)?.activeTurn?.responseParts, + responseParts: getStateManager(localService).getChatState(chat)?.activeTurn?.responseParts, persistedFailure: JSON.parse((await database.getMetadata('copilot.worktree.creationFailure'))!), }, { resolved: [sourceDir.toString()], @@ -12815,18 +12812,18 @@ suite('AgentService (node dispatcher)', () => { const uncommittedUri = buildUncommittedChangesetUri(workspaceSession.toString()); localService.addSubscriber(URI.parse(uncommittedUri), 'client-1'); for (let i = 0; i < 100; i++) { - if (localService.stateManager.getChangesetState(uncommittedUri)?.operations?.some(operation => operation.id === 'commit')) { + if (getStateManager(localService).getChangesetState(uncommittedUri)?.operations?.some(operation => operation.id === 'commit')) { break; } await timeout(2); } - const workspaceState = localService.stateManager.getSessionState(workspaceSession.toString()); + const workspaceState = getStateManager(localService).getSessionState(workspaceSession.toString()); assert.deepStrictEqual({ lifecycle: workspaceState?.lifecycle, changesets: workspaceState?.changesets?.map(changeset => changeset.changeKind), gitCalls, - hasCommit: localService.stateManager.getChangesetState(uncommittedUri)?.operations?.some(operation => operation.id === 'commit'), + hasCommit: getStateManager(localService).getChangesetState(uncommittedUri)?.operations?.some(operation => operation.id === 'commit'), }, { lifecycle: SessionLifecycle.Creating, changesets: ['uncommitted'], @@ -12837,7 +12834,7 @@ suite('AgentService (node dispatcher)', () => { const workspaceLessSession = await localService.createSession({ provider: provisionalAgent.id }); assert.deepStrictEqual( - localService.stateManager.getSessionState(workspaceLessSession.toString())?.changesets ?? [], + getStateManager(localService).getSessionState(workspaceLessSession.toString())?.changesets ?? [], [], ); }); @@ -12856,7 +12853,7 @@ suite('AgentService (node dispatcher)', () => { suite.skip('item-2: initial changeset seeding at create time', () => { /** Returns `true` when both static changeset URIs exist with `status: 'computing'`. */ - function assertBackingChangesetsComputing(stateManager: AgentService['stateManager'], sessionStr: string): void { + function assertBackingChangesetsComputing(stateManager: ReturnType, sessionStr: string): void { const uncommitted = stateManager.getSnapshot(buildUncommittedChangesetUri(sessionStr)); const sessionWide = stateManager.getSnapshot(buildSessionChangesetUri(sessionStr)); assert.ok(uncommitted, `expected ${sessionStr}/changeset/uncommitted to be subscribable`); @@ -12892,10 +12889,10 @@ suite('AgentService (node dispatcher)', () => { const session = await service.createSession({ provider: 'copilot' }); const sessionStr = session.toString(); - const state = service.stateManager.getSessionState(sessionStr); + const state = getStateManager(service).getSessionState(sessionStr); assert.ok(state); assert.deepStrictEqual(state!.changesets, defaultCatalogue(sessionStr)); - assertBackingChangesetsComputing(service.stateManager, sessionStr); + assertBackingChangesetsComputing(getStateManager(service), sessionStr); }); test('provisional session materialization preserves both halves', async () => { @@ -12922,10 +12919,10 @@ suite('AgentService (node dispatcher)', () => { const sessionStr = session.toString(); // Snapshot the create-time state BEFORE materialization. - const stateBefore = service.stateManager.getSessionState(sessionStr); + const stateBefore = getStateManager(service).getSessionState(sessionStr); assert.ok(stateBefore, 'provisional session should already have state'); assert.deepStrictEqual(stateBefore!.changesets, defaultCatalogue(sessionStr)); - assertBackingChangesetsComputing(service.stateManager, sessionStr); + assertBackingChangesetsComputing(getStateManager(service), sessionStr); // `markSessionPersisted` (called from `_onDidMaterializeChat`) // re-spreads flattened session metadata. A future change to that spread @@ -12933,10 +12930,10 @@ suite('AgentService (node dispatcher)', () => { // the post-materialization re-assertion is what catches it. provisionalAgent.materialize(session, URI.file('/wd')); - const stateAfter = service.stateManager.getSessionState(sessionStr); + const stateAfter = getStateManager(service).getSessionState(sessionStr); assert.ok(stateAfter, 'materialized session should still have state'); assert.deepStrictEqual(stateAfter!.changesets, defaultCatalogue(sessionStr)); - assertBackingChangesetsComputing(service.stateManager, sessionStr); + assertBackingChangesetsComputing(getStateManager(service), sessionStr); }); test('restoreSession with no persisted diffs seeds both halves in computing state', async () => { @@ -12959,10 +12956,10 @@ suite('AgentService (node dispatcher)', () => { await localService.restoreSession(sessionResource); - const state = localService.stateManager.getSessionState(sessionStr); + const state = getStateManager(localService).getSessionState(sessionStr); assert.ok(state); assert.deepStrictEqual(state!.changesets, defaultCatalogue(sessionStr)); - assertBackingChangesetsComputing(localService.stateManager, sessionStr); + assertBackingChangesetsComputing(getStateManager(localService), sessionStr); }); }); @@ -13007,14 +13004,14 @@ suite('AgentService (node dispatcher)', () => { localService.unsubscribe(sessionResource, 'client-1'); await new Promise(resolve => setTimeout(resolve, 60_000)); - const residentWhileEnabled = localService.stateManager.getSessionState(sessionStr) !== undefined; + const residentWhileEnabled = getStateManager(localService).getSessionState(sessionStr) !== undefined; getConfigurationService(localService).updateSessionConfig(sessionStr, { [SessionConfigKey.AgentMerge]: { enabled: false } }); await new Promise(resolve => setTimeout(resolve, 60_000)); assert.deepStrictEqual({ residentWhileEnabled, - residentAfterDisable: localService.stateManager.getSessionState(sessionStr) !== undefined, + residentAfterDisable: getStateManager(localService).getSessionState(sessionStr) !== undefined, indexedAfterDisable: await orchestratorDb.listAgentMergeEnabledSessions(), }, { residentWhileEnabled: true, @@ -13044,10 +13041,10 @@ suite('AgentService (node dispatcher)', () => { restarted.registerProvider(localAgent); await restarted.whenAgentMergeSessionsRestored(); const resumed = { - materialized: restarted.stateManager.getSessionState(sessionStr) !== undefined, + materialized: getStateManager(restarted).getSessionState(sessionStr) !== undefined, // Distinguishes a genuine resume from a session that was // materialized and immediately disabled. - enabled: readAgentMergeSessionState(restarted.stateManager.getSessionState(sessionStr)?.config?.values)?.enabled, + enabled: readAgentMergeSessionState(getStateManager(restarted).getSessionState(sessionStr)?.config?.values)?.enabled, indexed: await orchestratorDb.listAgentMergeEnabledSessions(), }; @@ -13058,7 +13055,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ resumed, - residentAfterDisable: restarted.stateManager.getSessionState(sessionStr) !== undefined, + residentAfterDisable: getStateManager(restarted).getSessionState(sessionStr) !== undefined, }, { resumed: { materialized: true, enabled: true, indexed: [sessionStr] }, residentAfterDisable: false, @@ -13077,7 +13074,7 @@ suite('AgentService (node dispatcher)', () => { await restarted.whenAgentMergeSessionsRestored(); assert.deepStrictEqual({ - materialized: restarted.stateManager.getSessionState(sessionResource.toString()) !== undefined, + materialized: getStateManager(restarted).getSessionState(sessionResource.toString()) !== undefined, indexed: await orchestratorDb.listAgentMergeEnabledSessions(), }, { materialized: false, @@ -13089,7 +13086,7 @@ suite('AgentService (node dispatcher)', () => { const orchestratorDb = new TestAgentHostOrchestratorDatabase(); const { localService, sessionResource } = await createEnabledSession(new TestSessionDatabase(), orchestratorDb); - localService.stateManager.dispatchServerAction(sessionResource.toString(), { type: ActionType.SessionIsArchivedChanged, isArchived: true }); + getStateManager(localService).dispatchServerAction(sessionResource.toString(), { type: ActionType.SessionIsArchivedChanged, isArchived: true }); await localService.whenAgentMergeSessionsRestored(); assert.deepStrictEqual(await orchestratorDb.listAgentMergeEnabledSessions(), []); @@ -13110,12 +13107,12 @@ suite('AgentService (node dispatcher)', () => { const sessionStr = sessionResource.toString(); localService.addSubscriber(sessionResource, 'client-1'); // Archiving is the terminal state that must not keep the session pinned. - localService.stateManager.dispatchServerAction(sessionStr, { type: ActionType.SessionIsArchivedChanged, isArchived: true }); + getStateManager(localService).dispatchServerAction(sessionStr, { type: ActionType.SessionIsArchivedChanged, isArchived: true }); localService.unsubscribe(sessionResource, 'client-1'); await new Promise(resolve => setTimeout(resolve, 60_000)); - assert.strictEqual(localService.stateManager.getSessionState(sessionStr), undefined, 'an archived session must not stay pinned'); + assert.strictEqual(getStateManager(localService).getSessionState(sessionStr), undefined, 'an archived session must not stay pinned'); }); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts b/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts index 637cc092c87a76..6259c31565e7fb 100644 --- a/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts +++ b/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts @@ -4,17 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../../base/common/event.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { IFileService } from '../../../files/common/files.js'; import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; -import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; import { ILogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; import { type IAgentCustomizationSettingsRegistration } from '../../common/agentCustomizationSettings.js'; import { IAgentHostGitService } from '../../common/agentHostGitService.js'; +import { IAgentEditAttributionService, NullAgentEditAttributionService } from '../../common/fileEditAttribution.js'; import { AgentHostLaunchKind } from '../../common/agentHostTelemetry.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { IAgentHostDatabase } from '../../node/agentHostDatabase.js'; @@ -22,8 +22,12 @@ import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from '../.. import { IAgentHostProxyResolver } from '../../node/agentHostProxyResolver.js'; import { AgentService } from '../../node/agentService.js'; import { createAgentServiceComposition, type IAgentServiceComposition } from '../../node/agentServiceComposition.js'; +import { activateAgentHostContributions } from '../../node/agentHostContributions.js'; +import { createAgentServiceFoundation } from '../../node/agentServiceFoundation.js'; +import { AgentHostServiceCollection, instantiateAgentHostServices, registerAgentHostCoreServices } from '../../node/agentHostServices.js'; import { ICopilotApiService } from '../../node/shared/copilotApiService.js'; import { AgentHostClientConnectionService, IAgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; const compositions = new WeakMap(); @@ -35,6 +39,10 @@ export function getTestAgentServiceComposition(agentService: AgentService): IAge return composition; } +export function getTestAgentStateManager(agentService: AgentService): AgentHostStateManager { + return getTestAgentServiceComposition(agentService).stateManager; +} + export function createTestAgentService( logService: ILogService, fileService: IFileService, @@ -58,12 +66,11 @@ export function createTestAgentService( onDidRegisterConnection: Event.None, onDidChangeConfiguration: Event.None, register: () => Disposable.None, - bindConfigurationService: () => { }, getConfigurationValue: () => undefined, resolveProxy: async () => undefined, fetch: fetchFn, }; - const services = new ServiceCollection( + const services = new AgentHostServiceCollection( [ILogService, logService], [IFileService, fileService], [ISessionDataService, sessionDataService], @@ -71,10 +78,9 @@ export function createTestAgentService( [IAgentHostGitService, gitService], [ITelemetryService, telemetryService], [IAgentHostFileMonitorService, effectiveFileMonitorService], - [IAgentHostProxyResolver, proxyResolver], + [IAgentEditAttributionService, new NullAgentEditAttributionService()], [IAgentHostClientConnectionService, clientConnectionService], ); - const instantiationService = new InstantiationService(services, /*strict*/ true); const options = { rootConfigResource, copilotApiService, @@ -83,16 +89,39 @@ export function createTestAgentService( storageResource, orchestratorDatabase, }; - const composition = createAgentServiceComposition( - options, + const foundationDisposables = new DisposableStore(); + const foundation = createAgentServiceFoundation({ services, - instantiationService, - fetchFn, + owned: foundationDisposables, logService, productService, + rootConfigResource, + providerConfigurations, + transientProxyConfiguration: false, + proxyResolver, + fetchFn, + }); + const coreServiceIds = registerAgentHostCoreServices(services, { + storageResource, + fetchFn, + gitHubServiceOptions: foundation.gitHubServiceOptions, + copilotApiService, + }); + const instantiationService = new InstantiationService(services, /*strict*/ true); + services.seal(); + instantiateAgentHostServices(instantiationService, coreServiceIds); + const composition = instantiationService.invokeFunction(accessor => createAgentServiceComposition( + options, + accessor, + instantiationService, + logService, sessionDataService, - fileMonitorService ? [clientConnectionService, instantiationService] : [effectiveFileMonitorService, clientConnectionService, instantiationService], - ); + foundation, + fileMonitorService + ? [clientConnectionService, instantiationService, foundationDisposables] + : [effectiveFileMonitorService, clientConnectionService, instantiationService, foundationDisposables], + )); + composition.setContributions(instantiationService.invokeFunction(accessor => activateAgentHostContributions(accessor, instantiationService))); compositions.set(composition.agentService, composition); return composition.agentService; } diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 827505b09eec04..f2334171939cbd 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -56,7 +56,7 @@ import { customChatTitleMetadataKey, customChatTitleSourceMetadataKey, SESSION_C import { createNoopGitService, createNullSessionDataService, createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; import { MockAgent } from './mockAgent.js'; import { TestAgentHostTerminalManager } from './testAgentHostTerminalManager.js'; -import { createTestAgentService } from './agentServiceTestUtils.js'; +import { createTestAgentService, getTestAgentStateManager } from './agentServiceTestUtils.js'; // ---- Tests ------------------------------------------------------------------ @@ -5207,7 +5207,7 @@ suite('AgentSideEffects', () => { await localService.restoreSession(sessionResource); - const state = localService.stateManager.getSessionState(sessionResource.toString()); + const state = getTestAgentStateManager(localService).getSessionState(sessionResource.toString()); assert.ok(state); assert.strictEqual(state!.title, 'Restored Title'); }); @@ -5242,7 +5242,7 @@ suite('AgentSideEffects', () => { await localService.restoreSession(sessionResource); - const state = localService.stateManager.getSessionState(sessionResource.toString()); + const state = getTestAgentStateManager(localService).getSessionState(sessionResource.toString()); assert.deepStrictEqual(state?.turns.map(t => t.id), ['real-1', 'local-1']); }); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 87d4340d0603cb..b229e27cae19b5 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -68,7 +68,7 @@ import { AgentHostSessionTitleSignal, IAgentHostSessionTitleSignal } from '../.. import { IAgentHostGitHubEndpointService } from '../../node/agentHostGitHubEndpointService.js'; import { IAgentHostAuthenticationService, type IAgentHostAuthTokenChangeEvent } from '../../node/agentHostAuthenticationService.js'; import { createTestGitHubEndpointService } from './testGitHubEndpointService.js'; -import { createTestAgentService } from './agentServiceTestUtils.js'; +import { createTestAgentService, getTestAgentStateManager } from './agentServiceTestUtils.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { makeMcpServerCustomization } from '../../../agentPlugins/common/pluginParsers.js'; import { ClaudeAgent, fromSdkModelInfo } from '../../node/claude/claudeAgent.js'; @@ -2162,7 +2162,7 @@ suite('ClaudeAgent', () => { // AgentSideEffects publishes registered providers into root state // on the next autorun tick. The state manager exposes the root // state via a public accessor. - const rootAgents = service.stateManager.rootState.agents; + const rootAgents = getTestAgentStateManager(service).rootState.agents; assert.deepStrictEqual( rootAgents.map(a => ({ provider: a.provider, displayName: a.displayName })), [{ provider: 'claude', displayName: 'Claude' }], diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index aa3a2df8e10375..0dd243c69015b1 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -728,8 +728,6 @@ class TestProxyResolver implements IAgentHostProxyResolver { }); } - bindConfigurationService(_configurationService: IAgentConfigurationService, _transient: boolean): void { } - getConfigurationValue(_key: string): T | undefined { return undefined; }