diff --git a/.agents/skills/launch/SKILL.md b/.agents/skills/launch/SKILL.md index 72606d1aab6f5..93fb1f58f784d 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 cc3105ec1bea5..ab34e92c633bb 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:' diff --git a/.github/instructions/agentHostTesting.instructions.md b/.github/instructions/agentHostTesting.instructions.md index 7f916384e189b..d068e1a1516b6 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/.github/workflows/pr-darwin-test.yml b/.github/workflows/pr-darwin-test.yml index c8d2ff95d9b36..0a6f018cf7b53 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 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 0602c8ce23bf6..ff91152c72fe4 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 8b037c1cbcc44..52d7a75c20bc2 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 5052fe2c8fe7a..75105da06d61e 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 }; diff --git a/src/vs/base/node/zip.ts b/src/vs/base/node/zip.ts index 09d86cd2fb78c..c94ecdfc99816 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 63671eff1b52a..5f0cfa61dccda 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/AGENTS.md b/src/vs/platform/agentHost/AGENTS.md index 8546d1fe00f70..74d1c44cfef6f 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/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index 8c9652ab8a179..5a2da82606256 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/agent.ts b/src/vs/platform/agentHost/common/agent.ts index 91e548bed76f4..f6d4cc6afc3d5 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/common/agentMerge.ts b/src/vs/platform/agentHost/common/agentMerge.ts index 171eb71d0b6ce..6470396fbe21e 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/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 68b3618ba3988..8e6f4d8ba7c13 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/agentHostBootstrap.ts b/src/vs/platform/agentHost/node/agentHostBootstrap.ts index 5a25a0938989d..66d8e40c62c51 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,62 +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'; - -export interface IAgentHostNetworkServices { - readonly proxyResolver: IAgentHostProxyResolver; - readonly requestService: IRequestService; -} +import { AgentHostClientConnectionService, IAgentHostClientConnectionService } from './agentHostClientConnectionService.js'; 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; @@ -82,83 +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], - ); - 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, @@ -169,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/agentHostClientConnectionService.ts b/src/vs/platform/agentHost/node/agentHostClientConnectionService.ts new file mode 100644 index 0000000000000..973cfed7b3990 --- /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 be272c4aef37e..0000000000000 --- 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/agentHostContributions.ts b/src/vs/platform/agentHost/node/agentHostContributions.ts new file mode 100644 index 0000000000000..4cdbcc3600717 --- /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 f4daa0e1726b6..ea9095c9d9b67 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/agentHostDebugLogs.ts b/src/vs/platform/agentHost/node/agentHostDebugLogs.ts index c65140993fc95..7cceb2c63800d 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/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 4cc4df6171b12..f27bde7a150b3 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,9 +29,8 @@ 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 { 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'; @@ -55,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. @@ -106,13 +108,14 @@ 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; 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({ @@ -120,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: @@ -171,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; } @@ -222,9 +236,8 @@ 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, + completionTriggerCharacters, terminalCommandPrefix: BANG_COMMAND_PREFIX, otlpLogEmitter, allowExtensionMethods: false, @@ -234,7 +247,7 @@ async function startAgentHost(): Promise { const messagePortProtocolHandler = localDataPlaneDisposables.add(instantiationService.createInstance( ProtocolServerHandler, agentService, - runtime.stateManager, + stateManager, messagePortProtocolServer, localProtocolHandlerConfig, clientFileSystemProvider, @@ -307,7 +320,7 @@ async function startAgentHost(): Promise { const localEndpointProtocolHandler = localDataPlaneDisposables.add(instantiationService.createInstance( ProtocolServerHandler, agentService, - runtime.stateManager, + stateManager, localEndpoint.server, localProtocolHandlerConfig, clientFileSystemProvider, @@ -364,13 +377,12 @@ async function startAgentHost(): Promise { const protocolHandler = protocolIngressDisposables.add(instantiationService.createInstance( ProtocolServerHandler, agentService, - runtime.stateManager, + stateManager, wsServer, { hostLaunchKind, - connectionTelemetryTracker, defaultDirectory: URI.file(os.homedir()).toString(), - completionTriggerCharacters: runtime.completions.triggerCharacters, + completionTriggerCharacters, terminalCommandPrefix: BANG_COMMAND_PREFIX, otlpLogEmitter, }, @@ -447,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, @@ -456,7 +468,6 @@ async function startAgentHost(): Promise { otlpLogEmitter, protocolIngressDisposables, hostLaunchKind, - connectionTelemetryTracker, count => connectionCountEmitter.fire(count), handler => protocolHandlers.push(handler), ); @@ -466,10 +477,8 @@ async function startAgentHost(): Promise { }); process.once('exit', () => { - agentService.dispose(); logService.dispose(); disposables.dispose(); - instantiationService.dispose(); }); } @@ -551,7 +560,6 @@ async function startWebSocketServer( otlpLogEmitter: OtlpLogEmitter, disposables: DisposableStore, hostLaunchKind: AgentHostLaunchKind, - connectionTelemetryTracker: AgentHostClientConnectionTelemetryTracker, onConnectionCountChanged: (count: number) => void, onProtocolHandlerCreated: (handler: ProtocolServerHandler) => void, ): Promise { @@ -596,7 +604,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/agentHostProxyResolver.ts b/src/vs/platform/agentHost/node/agentHostProxyResolver.ts index c0607fec515b0..2e835e48fb65e 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 bd1cfa766acfb..8e35d5add58a2 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -23,31 +23,37 @@ 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'; 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'; 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 { @@ -189,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); @@ -294,19 +317,16 @@ 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, agentService, - runtime.stateManager, + stateManager, wsServer, { hostLaunchKind: AgentHostLaunchKind.VSCodeCLI, - connectionTelemetryTracker, defaultDirectory: URI.file(os.homedir()).toString(), - completionTriggerCharacters: runtime.completions.triggerCharacters, + completionTriggerCharacters: completions.triggerCharacters, terminalCommandPrefix: BANG_COMMAND_PREFIX, otlpLogEmitter, }, @@ -368,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 0000000000000..22f35bd174396 --- /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/agentHostTelemetryReporter.ts b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts index bb42ed8fe11db..0106d75c10968 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 1c66fc71c6f98..66febde0d5fbf 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/agentMergeController.ts b/src/vs/platform/agentHost/node/agentMergeController.ts index 3aa6995c59474..e6779a093d73a 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/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 9336c79e51c54..c996057d7b05a 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); @@ -4660,8 +4657,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 +4684,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 @@ -5773,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 { @@ -6205,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 { @@ -6270,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 756ed692021f8..82336855bb057 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 0000000000000..07489e41626ce --- /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/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index ca2f65b70fde5..0c27cf1849342 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 23b0748d65d98..9ef193a9022c3 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 d217da3733530..3e82d8a575dff 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 777fcb0edeef2..03abbed91a2e9 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 fa2c0501d4ad0..cbe77ad7a22b7 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/node/serviceBootstrapping.md b/src/vs/platform/agentHost/node/serviceBootstrapping.md new file mode 100644 index 0000000000000..6769499477a56 --- /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/common/agentMerge.test.ts b/src/vs/platform/agentHost/test/common/agentMerge.test.ts index bda060b221b31..3fbeec053b2ab 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/electron-browser/agentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index 626226f39519f..231557b224432 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/agentHostBootstrap.test.ts b/src/vs/platform/agentHost/test/node/agentHostBootstrap.test.ts index aea89b8ad4926..7154812e450c9 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 0000000000000..1837dfa556367 --- /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/agentHostDebugLogs.test.ts b/src/vs/platform/agentHost/test/node/agentHostDebugLogs.test.ts index ae5b866956d2e..9a8339c918217 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/agentHost/test/node/agentHostRequestService.test.ts b/src/vs/platform/agentHost/test/node/agentHostRequestService.test.ts index 044d7f22ddbb8..9313bc99c359f 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 0000000000000..889de301f047f --- /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/agentHostTelemetryReporter.test.ts b/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts index 5308b4a56606e..f97d69c6bc13d 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 348db62d58efc..47be4c046c737 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 9ad80f1698c7e..7bf4524efb08d 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 332de78f134e0..019c4818599d4 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/agentMergeController.test.ts b/src/vs/platform/agentHost/test/node/agentMergeController.test.ts index d49b0e0c00d9c..acb92d3d0e879 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/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index b3234280e9272..52522a8d731c7 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, []); }); }); @@ -6559,17 +6556,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()), + restored: !!getStateManager(svc).getSessionState(session.toString()), }, { - metadataCallsBeforeMigration: 0, + hydratedBeforeMigration: false, metadataReadAfterMigration: true, registeredSessions: [session.toString()], restored: true, @@ -6591,10 +6589,50 @@ 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)', () => { + /** 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; @@ -6649,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(); @@ -6658,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, @@ -6686,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, @@ -6711,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(); @@ -6730,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, @@ -6753,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, @@ -6784,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, @@ -6808,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, @@ -6834,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 () => { @@ -6854,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 () => { @@ -6874,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 () => { @@ -6899,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 () => { @@ -6922,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; } @@ -6933,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', }); @@ -6956,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 () => { @@ -6972,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); @@ -6991,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, ); }); @@ -7015,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' }], ); }); @@ -7043,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, @@ -7079,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']); }); @@ -7100,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'); }); @@ -7142,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 () => { @@ -7161,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); @@ -7188,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 => { @@ -7220,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); @@ -7261,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', @@ -7280,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 }, ); }); @@ -7360,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 () => { @@ -7388,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', @@ -7403,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 }, ); } @@ -7425,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', @@ -7447,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 }, ); }); @@ -7473,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: [] }, @@ -7489,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: [] }, @@ -7516,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, @@ -7549,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: [] }, @@ -7565,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, @@ -7575,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: [] }, @@ -7591,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); @@ -7617,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: [] }, @@ -7630,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, @@ -7660,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 @@ -7691,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'); @@ -7716,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'); @@ -7738,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); @@ -7783,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: { @@ -7803,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 () => { @@ -7818,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, @@ -7833,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', @@ -7863,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 () => { @@ -7885,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, @@ -7912,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, @@ -7932,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', @@ -7955,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'); @@ -7987,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); @@ -8039,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, @@ -8062,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, @@ -8071,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'); }); }); @@ -8098,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()), @@ -8165,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', @@ -8176,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()); } } @@ -8234,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()), @@ -8273,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-')); @@ -8299,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(), @@ -8333,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', @@ -8361,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, @@ -8403,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. @@ -8506,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 ?? {}), @@ -8577,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()), @@ -8630,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()), }, { @@ -8675,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'); @@ -8745,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( @@ -8759,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( @@ -8772,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, @@ -8819,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, @@ -8848,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' }, @@ -8858,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(), @@ -8880,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' }, @@ -8915,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' }; @@ -8934,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 }, @@ -8958,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); }; @@ -8973,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()], @@ -8994,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), { @@ -9016,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'), }, { @@ -9151,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({ @@ -9195,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({ @@ -9240,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); @@ -9264,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({ @@ -9297,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({ @@ -9326,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'], @@ -9418,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' } }); @@ -9432,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 }], }); @@ -9449,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); @@ -9494,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, @@ -9515,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 @@ -9555,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 @@ -9596,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, @@ -9619,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 () => { @@ -9658,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' }, @@ -9688,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 }); }); @@ -9702,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, @@ -9792,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'); @@ -9891,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)], @@ -9922,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`. @@ -9961,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, @@ -10011,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), @@ -10022,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, @@ -10087,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]); @@ -10103,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, @@ -10146,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, @@ -10218,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', @@ -10234,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(), @@ -10294,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, @@ -10355,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); }; @@ -10371,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()], @@ -10409,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'); @@ -10446,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, @@ -10487,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(), { @@ -10499,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, @@ -10532,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, @@ -10543,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, @@ -10578,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, @@ -10587,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, @@ -10628,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'); @@ -10639,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, @@ -10681,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'); @@ -10695,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', @@ -10771,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, @@ -10804,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: [], @@ -10862,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: [ @@ -10905,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'), @@ -10940,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()), @@ -10975,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({ @@ -11020,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); @@ -11071,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'); @@ -11079,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) { @@ -11108,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}`), @@ -11167,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'); @@ -11176,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'); @@ -11195,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}`), }, { @@ -11279,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 () => { @@ -11298,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', () => { @@ -11306,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(), @@ -11318,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, @@ -11346,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'); @@ -11368,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); }); }); @@ -11388,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, @@ -11397,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, @@ -11433,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, @@ -11486,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()], @@ -11514,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'); @@ -11554,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'); }); }); @@ -11572,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. @@ -11621,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, @@ -11650,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, @@ -11675,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'); }); }); @@ -11709,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'); }); }); @@ -11745,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', () => { @@ -11772,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'); }); }); }); @@ -12063,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)); @@ -12088,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)); @@ -12165,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, @@ -12197,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, @@ -12227,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. @@ -12261,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 @@ -12281,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'); @@ -12309,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 @@ -12329,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'); @@ -12353,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: [] }, @@ -12361,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' }); }); @@ -12388,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 @@ -12430,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()); }); @@ -12442,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()); }); @@ -12455,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()); }); @@ -12533,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], }; @@ -12547,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)], }, @@ -12571,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', @@ -12629,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', @@ -12639,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', @@ -12655,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()), @@ -12696,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', @@ -12706,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', @@ -12725,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()], @@ -12774,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'], @@ -12796,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 ?? [], [], ); }); @@ -12815,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`); @@ -12851,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 () => { @@ -12881,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 @@ -12892,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 () => { @@ -12918,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); }); }); @@ -12966,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, @@ -13003,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(), }; @@ -13017,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, @@ -13036,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, @@ -13048,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(), []); @@ -13069,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 c54ddb226f769..6259c31565e7f 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,7 +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(); @@ -34,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, @@ -51,17 +60,17 @@ 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, 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], @@ -69,9 +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, @@ -80,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 ? [instantiationService] : [effectiveFileMonitorService, 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 663af6eb74a75..f2334171939cb 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'; @@ -55,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 ------------------------------------------------------------------ @@ -144,6 +145,7 @@ function createTestSideEffects( [ITelemetryService, telemetryService], [IAgentHostTerminalManager, terminalManager], [ISessionDataService, options.sessionDataService], + [IAgentHostClientConnectionService, disposables.add(new AgentHostClientConnectionService())], ), /*strict*/ true)); const resolvedOptions: IAgentSideEffectsOptions = { ...options, @@ -5205,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'); }); @@ -5240,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 87d4340d0603c..b229e27cae19b 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 aa3a2df8e1037..0dd243c69015b 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; } diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 772723af116d6..3ed044a5a6c39 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 00835e39c023f..0fefd0ab8326d 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 de853adb602d2..d468255901946 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, )); }); diff --git a/src/vs/platform/github/common/githubService.ts b/src/vs/platform/github/common/githubService.ts index 7714e0d1b9bb5..ec49f4907b4ca 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 e52da91cb37dc..36fde39a6aa9d 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 85929799f9e37..ba62656683551 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 3e95deb4d9aad..b080dc4bfac60 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( diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index fd5c10d5fd755..f148a678200c5 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 cb912063fe68c..db3a6aa999434 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/browser/parts/statusbar/media/statusbarpart.css b/src/vs/workbench/browser/parts/statusbar/media/statusbarpart.css index 1f3b102ebb13e..6fc44ed872a26 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 5c827aa15d11e..bbee47f5a3aca 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'); } } diff --git a/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts b/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts index 19856c6983a93..e4ba744cfb207 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 19a1915e59980..a24254da9ea5e 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 = ''; 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 bf32959b2313c..d1bb5528c624e 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 {