From f120d4411eabe9850a0fd8d693a53a5a4f825681 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:42:15 +0000 Subject: [PATCH] fix(patches): rebase for VS Code 1.145.0 Upstream 1.145.0 (a717861) broke three patches: - 00-brand-remove-branding: upstream deleted the "New Workspace" setup tool and the topLevelNewWorkspaceChat start entry. Drop the hunk that rebranded the tool and keep only the topLevelOpenTunnel removal. - 21-policy-use-custom-lib: @vscode/policy-watcher bumped 1.6.0 -> 1.7.0; re-target the package.json/package-lock.json removals. - 51-ext-copilot-remove-it: @github/copilot-sdk bumped to a new build; 33 deleted agent-host files drifted (re-captured as deletions); four edited files rebased. Also: * delete three new upstream SDK-dependent files (copilotSdkCustomizationDiscovery/copilotSdkNativeAgents integration tests and copilotSessionLauncherTestHelpers); * extend the local CopilotClient alias in sessionCustomizationDiscovery with the hooks/mcp discover endpoints upstream now calls; * model ICopilotMcpCommandServer locally in copilotCustomizationCommandDisplay (upstream now derives it from CopilotSession/CopilotClient SDK types). Bump upstream/stable.json to 1.145.0 (git tag commit a717861). Refs #123 --- patches/00-brand-remove-branding.patch | 58 +- patches/21-policy-use-custom-lib.patch | 23 +- patches/51-ext-copilot-remove-it.patch | 32238 +++++++++++++---------- upstream/stable.json | 4 +- 4 files changed, 19003 insertions(+), 13320 deletions(-) diff --git a/patches/00-brand-remove-branding.patch b/patches/00-brand-remove-branding.patch index dd051b7..77c2506 100644 --- a/patches/00-brand-remove-branding.patch +++ b/patches/00-brand-remove-branding.patch @@ -774,7 +774,7 @@ index 4c350900..f2d603af 100644 + key: 'chat.mcp.access.registry', value: nls.localize('chat.mcp.access.registry', "Allows access to MCP servers listed in the registry that BradfordCode is connected to."), }, diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts -index 899c849d..ea190570 100644 +index e71460a8c88..eae6fed74b6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts @@ -120,3 +120,3 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { @@ -782,13 +782,6 @@ index 899c849d..ea190570 100644 - const { disposable: vscodeDisposable } = SetupAgent.doRegisterAgent(instantiationService, chatAgentService, 'setup.vscode', 'vscode', false, localize2('vscodeAgentDescription', "Ask questions about VS Code").value, ChatAgentLocation.Chat, ChatModeKind.Agent, context, controller); + const { disposable: vscodeDisposable } = SetupAgent.doRegisterAgent(instantiationService, chatAgentService, 'setup.vscode', 'vscode', false, localize2('vscodeAgentDescription', "Ask questions about BradfordCode").value, ChatAgentLocation.Chat, ChatModeKind.Agent, context, controller); disposables.add(vscodeDisposable); -@@ -137,4 +137,4 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { - displayName: localize('setupToolDisplayName', "New Workspace"), -- modelDescription: 'Scaffold a new workspace in VS Code', -- userDescription: localize('setupToolsDescription', "Scaffold a new workspace in VS Code"), -+ modelDescription: 'Scaffold a new workspace in BradfordCode', -+ userDescription: localize('setupToolsDescription', "Scaffold a new workspace in BradfordCode"), - canBeReferencedInPrompt: true, diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts index a80feca3..0c8e13bb 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -1190,10 +1183,10 @@ index 297598ef..45282b79 100644 + description: localize('walkthroughs.steps.completionEvents.onCommand', 'Check off step when a given command is executed anywhere in BradfordCode.'), body: 'onCommand:${1:commandId}' diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts b/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts -index 168a3d01..fd76dda2 100644 +index 7a870e4010d..fd76dda25f2 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts -@@ -201,24 +201,2 @@ export const startEntries: GettingStartedStartEntryContent = [ +@@ -201,13 +201,2 @@ export const startEntries: GettingStartedStartEntryContent = [ }, - { - id: 'topLevelOpenTunnel', @@ -1205,20 +1198,9 @@ index 168a3d01..fd76dda2 100644 - type: 'startEntry', - command: 'command:workbench.action.remote.showWebStartEntryActions', - } -- }, -- { -- id: 'topLevelNewWorkspaceChat', -- title: localize('gettingStarted.newWorkspaceChat.title', "Generate New Workspace..."), -- description: localize('gettingStarted.newWorkspaceChat.description', "Chat to create a new workspace"), -- icon: Codicon.chatSparkle, -- when: '!isWeb && !chatSetupHidden && !chatSetupDisabledInWorkspace', -- content: { -- type: 'startEntry', -- command: 'command:welcome.newWorkspaceChat', -- } - }, ]; -@@ -227,26 +205,2 @@ const Button = (title: string, href: string) => `[${title}](${href})`; +@@ -216,26 +205,2 @@ const Button = (title: string, href: string) => `[${title}](${href})`; -const CopilotStepTitle = localize('gettingStarted.copilotSetup.title', "Use AI features with Copilot for free"); -const CopilotDescription = localize({ key: 'gettingStarted.copilotSetup.description', comment: ['{Locked="["}', '{Locked="]({0})"}'] }, "You can use [Copilot]({0}) to generate code across multiple files, fix errors, ask questions about your code, and much more using natural language.", defaultChat.documentationUrl ?? ''); @@ -1245,41 +1227,41 @@ index 168a3d01..fd76dda2 100644 -} - export const walkthroughs: GettingStartedWalkthroughContent = [ -@@ -254,3 +208,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ +@@ -243,3 +208,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ id: 'Setup', - title: localize('gettingStarted.setup.title', "Get started with VS Code"), + title: localize('gettingStarted.setup.title', "Get started with BradfordCode"), description: localize('gettingStarted.setup.description', "Customize your editor, learn the basics, and start coding"), -@@ -259,3 +213,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ +@@ -248,3 +213,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ when: '!isWeb', - walkthroughPageTitle: localize('gettingStarted.setup.walkthroughPageTitle', 'Setup VS Code'), + walkthroughPageTitle: localize('gettingStarted.setup.walkthroughPageTitle', 'Setup BradfordCode'), next: 'Beginner', -@@ -264,6 +218,2 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ +@@ -253,6 +218,2 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ steps: [ - createCopilotSetupStep('CopilotSetupAnonymous', CopilotAnonymousButton, 'chatAnonymous && !chatSetupCompleted', true), - createCopilotSetupStep('CopilotSetupSignedOut', CopilotSignedOutButton, 'chatEntitlementSignedOut && !chatAnonymous && !github.copilot.hasByokModels', false), - createCopilotSetupStep('CopilotSetupComplete', CopilotCompleteButton, 'chatSetupCompleted && !chatSetupDisabled && (chatAnonymous || chatPlanPro || chatPlanProPlus || chatPlanMax || chatPlanBusiness || chatPlanEnterprise || chatPlanFree)', false), - createCopilotSetupStep('CopilotSetupSignedIn', CopilotSignedInButton, '!chatEntitlementSignedOut && (!chatSetupCompleted || chatSetupDisabled || chatPlanCanSignUp)', false), { -@@ -281,4 +231,4 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ +@@ -270,4 +231,4 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ title: localize('gettingStarted.videoTutorial.title', "Watch video tutorials"), - description: localize('gettingStarted.videoTutorial.description.interpolated', "Watch the first in a series of short & practical video tutorials for VS Code's key features.\n{0}", Button(localize('watch', "Watch Tutorial"), 'https://aka.ms/vscode-getting-started-video')), - media: { type: 'svg', altText: 'VS Code Settings', path: 'learn.svg' }, + description: localize('gettingStarted.videoTutorial.description.interpolated', "Watch the first in a series of short & practical video tutorials for BradfordCode's key features.\n{0}", Button(localize('watch', "Watch Tutorial"), 'https://aka.ms/vscode-getting-started-video')), + media: { type: 'svg', altText: 'BradfordCode Settings', path: 'learn.svg' }, } -@@ -290,3 +240,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ +@@ -279,3 +240,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ id: 'SetupWeb', - title: localize('gettingStarted.setupWeb.title', "Get Started with VS Code for the Web"), + title: localize('gettingStarted.setupWeb.title', "Get Started with BradfordCode for the Web"), description: localize('gettingStarted.setupWeb.description', "Customize your editor, learn the basics, and start coding"), -@@ -296,3 +246,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ +@@ -285,3 +246,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ next: 'Beginner', - walkthroughPageTitle: localize('gettingStarted.setupWeb.walkthroughPageTitle', 'Setup VS Code Web'), + walkthroughPageTitle: localize('gettingStarted.setupWeb.walkthroughPageTitle', 'Setup BradfordCode Web'), content: { -@@ -322,6 +272,6 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ +@@ -311,6 +272,6 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ title: localize('gettingStarted.extensions.title', "Code with extensions"), - description: localize('gettingStarted.extensionsWeb.description.interpolated', "Extensions are VS Code's power-ups. A growing number are becoming available in the web.\n{0}", Button(localize('browsePopularWeb', "Browse Popular Web Extensions"), 'command:workbench.extensions.action.showPopularExtensions')), + description: localize('gettingStarted.extensionsWeb.description.interpolated', "Extensions are BradfordCode's power-ups. A growing number are becoming available in the web.\n{0}", Button(localize('browsePopularWeb', "Browse Popular Web Extensions"), 'command:workbench.extensions.action.showPopularExtensions')), @@ -1288,7 +1270,7 @@ index 168a3d01..fd76dda2 100644 - type: 'svg', altText: 'VS Code extension marketplace with featured language extensions', path: 'extensions-web.svg' + type: 'svg', altText: 'BradfordCode extension marketplace with featured language extensions', path: 'extensions-web.svg' }, -@@ -337,12 +287,2 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ +@@ -326,12 +287,2 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ }, - { - id: 'settingsSyncWeb', @@ -1301,42 +1283,42 @@ index 168a3d01..fd76dda2 100644 - }, - }, { -@@ -350,3 +290,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ +@@ -339,3 +290,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ title: localize('gettingStarted.commandPalette.title', "Unlock productivity with the Command Palette "), - description: localize('gettingStarted.commandPalette.description.interpolated', "Run commands without reaching for your mouse to accomplish any task in VS Code.\n{0}", Button(localize('commandPalette', "Open Command Palette"), 'command:workbench.action.showCommands')), + description: localize('gettingStarted.commandPalette.description.interpolated', "Run commands without reaching for your mouse to accomplish any task in BradfordCode.\n{0}", Button(localize('commandPalette', "Open Command Palette"), 'command:workbench.action.showCommands')), media: { type: 'svg', altText: 'Command Palette overlay for searching and executing commands.', path: 'commandPalette.svg' }, -@@ -356,3 +296,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ +@@ -345,3 +296,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ title: localize('gettingStarted.setup.OpenFolder.title', "Open up your code"), - description: localize('gettingStarted.setup.OpenFolderWeb.description.interpolated', "You're all set to start coding. You can open a local project or a remote repository to get your files into VS Code.\n{0}\n{1}", Button(localize('openFolder', "Open Folder"), 'command:workbench.action.addRootFolder'), Button(localize('openRepository', "Open Repository"), 'command:remoteHub.openRepository')), + description: localize('gettingStarted.setup.OpenFolderWeb.description.interpolated', "You're all set to start coding. You can open a local project or a remote repository to get your files into BradfordCode.\n{0}\n{1}", Button(localize('openFolder', "Open Folder"), 'command:workbench.action.addRootFolder'), Button(localize('openRepository', "Open Repository"), 'command:remoteHub.openRepository')), when: 'workspaceFolderCount == 0', -@@ -377,3 +317,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ +@@ -366,3 +317,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ title: localize('gettingStarted.setupAccessibility.title', "Get Started with Accessibility Features"), - description: localize('gettingStarted.setupAccessibility.description', "Learn the tools and shortcuts that make VS Code accessible. Note that some actions are not actionable from within the context of the walkthrough."), + description: localize('gettingStarted.setupAccessibility.description', "Learn the tools and shortcuts that make BradfordCode accessible. Note that some actions are not actionable from within the context of the walkthrough."), isFeatured: true, -@@ -382,3 +322,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ +@@ -371,3 +322,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ next: 'Setup', - walkthroughPageTitle: localize('gettingStarted.setupAccessibility.walkthroughPageTitle', 'Setup VS Code Accessibility'), + walkthroughPageTitle: localize('gettingStarted.setupAccessibility.walkthroughPageTitle', 'Setup BradfordCode Accessibility'), content: { -@@ -413,3 +353,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ +@@ -402,3 +353,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ title: localize('gettingStarted.commandPaletteAccessibility.title', "Unlock productivity with the Command Palette "), - description: localize('gettingStarted.commandPaletteAccessibility.description.interpolated', "Run commands without reaching for your mouse to accomplish any task in VS Code.\n{0}", Button(localize('commandPalette', "Open Command Palette"), 'command:workbench.action.showCommands')), + description: localize('gettingStarted.commandPaletteAccessibility.description.interpolated', "Run commands without reaching for your mouse to accomplish any task in BradfordCode.\n{0}", Button(localize('commandPalette', "Open Command Palette"), 'command:workbench.action.showCommands')), media: { type: 'markdown', path: 'empty' }, -@@ -493,3 +433,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ +@@ -482,3 +433,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ title: localize('gettingStarted.settings.title', "Tune your settings"), - description: localize('gettingStarted.settingsAndSync.description.interpolated', "Customize every aspect of VS Code and [sync](command:workbench.userDataSync.actions.turnOn) customizations across devices.\n{0}", Button(localize('tweakSettings', "Open Settings"), 'command:toSide:workbench.action.openSettings')), + description: localize('gettingStarted.settingsAndSync.description.interpolated', "Customize every aspect of BradfordCode and [sync](command:workbench.userDataSync.actions.turnOn) customizations across devices.\n{0}", Button(localize('tweakSettings', "Open Settings"), 'command:toSide:workbench.action.openSettings')), when: 'workspacePlatform != \'webworker\' && syncStatus != uninitialized', -@@ -497,3 +437,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ +@@ -486,3 +437,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ media: { - type: 'svg', altText: 'VS Code Settings', path: 'settings.svg' + type: 'svg', altText: 'BradfordCode Settings', path: 'settings.svg' }, -@@ -503,6 +443,6 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ +@@ -492,6 +443,6 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ title: localize('gettingStarted.extensions.title', "Code with extensions"), - description: localize('gettingStarted.extensions.description.interpolated', "Extensions are VS Code's power-ups. They range from handy productivity hacks, expanding out-of-the-box features, to adding completely new capabilities.\n{0}", Button(localize('browsePopular', "Browse Popular Extensions"), 'command:workbench.extensions.action.showPopularExtensions')), + description: localize('gettingStarted.extensions.description.interpolated', "Extensions are BradfordCode's power-ups. They range from handy productivity hacks, expanding out-of-the-box features, to adding completely new capabilities.\n{0}", Button(localize('browsePopular', "Browse Popular Extensions"), 'command:workbench.extensions.action.showPopularExtensions')), diff --git a/patches/21-policy-use-custom-lib.patch b/patches/21-policy-use-custom-lib.patch index 4dd4b2a..34697d6 100644 --- a/patches/21-policy-use-custom-lib.patch +++ b/patches/21-policy-use-custom-lib.patch @@ -74,12 +74,12 @@ index 10564f5ccf8..68cb8138e53 100644 + '@vscodium/policy-watcher', '@vscode/proxy-agent', diff --git a/package-lock.json b/package-lock.json -index ed3f93d4881..2d62e123462 100644 +index 82afa03af8f..ec34cac31e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,3 +33,2 @@ "@vscode/os-proxy-resolver": "^0.4.0", -- "@vscode/policy-watcher": "^1.6.0", +- "@vscode/policy-watcher": "^1.7.0", "@vscode/proxy-agent": "^0.45.0", @@ -81,3 +80,4 @@ "yazl": "^2.4.3", @@ -87,17 +87,20 @@ index ed3f93d4881..2d62e123462 100644 + "zod": "^4.4.3", + "@vscodium/policy-watcher": "^1.3.2-252465" }, -@@ -4969,22 +4969,2 @@ +@@ -4969,25 +4969,2 @@ }, - "node_modules/@vscode/policy-watcher": { -- "version": "1.6.0", -- "resolved": "https://registry.npmjs.org/@vscode/policy-watcher/-/policy-watcher-1.6.0.tgz", -- "integrity": "sha512-v7ucf7atz36xRpo4EvqgIZrf0uXKYkmpLQzmT+dzS5M+quMYW8d5TKBKetUGvxOEhKWuwHeR7plAzI4I8K5vyQ==", +- "version": "1.7.0", +- "resolved": "https://registry.npmjs.org/@vscode/policy-watcher/-/policy-watcher-1.7.0.tgz", +- "integrity": "sha512-l6N5iJnPoAQpRVm1TPP7fOHO9TrT2W/cbn/SwFT0yfXwoE8veq9waFwblxcmfqJCzUNSEYouMWhuMhnc67kkPA==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "node-addon-api": "^8.2.0" +- }, +- "engines": { +- "node": ">=22" - } - }, - "node_modules/@vscode/policy-watcher/node_modules/node-addon-api": { @@ -110,7 +113,7 @@ index ed3f93d4881..2d62e123462 100644 - } - }, "node_modules/@vscode/proxy-agent": { -@@ -5401,2 +5381,22 @@ +@@ -5404,2 +5381,22 @@ }, + "node_modules/@vscodium/policy-watcher": { + "version": "1.3.2-255408", @@ -134,12 +137,12 @@ index ed3f93d4881..2d62e123462 100644 + }, "node_modules/@webgpu/types": { diff --git a/package.json b/package.json -index 8ccf58f6d97..f7ecf52c396 100644 +index 1a44519d020..399d863d9b7 100644 --- a/package.json +++ b/package.json -@@ -123,3 +123,3 @@ +@@ -124,3 +124,3 @@ "@vscode/os-proxy-resolver": "^0.4.0", -- "@vscode/policy-watcher": "^1.6.0", +- "@vscode/policy-watcher": "^1.7.0", + "@vscodium/policy-watcher": "^1.3.2-252465", "@vscode/proxy-agent": "^0.45.0", diff --git a/src/vs/base/test/node/uri.perf.data.txt b/src/vs/base/test/node/uri.perf.data.txt diff --git a/patches/51-ext-copilot-remove-it.patch b/patches/51-ext-copilot-remove-it.patch index 67550e8..5e64309 100644 --- a/patches/51-ext-copilot-remove-it.patch +++ b/patches/51-ext-copilot-remove-it.patch @@ -142,19 +142,19 @@ index 17badba1cc8..f22545b955a 100644 - 'extensions/copilot', 'extensions/css-language-features', diff --git a/package-lock.json b/package-lock.json -index da390232e46..fd1d5da2d23 100644 +index ec34cac31e4..0d5bf1c754d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,3 +14,2 @@ "@devcontainers/cli": "0.88.0", -- "@github/copilot-sdk": "1.0.27-unstable.40031404408.gba6c003", +- "@github/copilot-sdk": "1.0.27-unstable.41379698026.g3219c66", "@hpke/core": "^1.9.0", @@ -1172,151 +1171,2 @@ }, - "node_modules/@github/copilot-sdk": { -- "version": "1.0.27-unstable.40031404408.gba6c003", -- "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.27-unstable.40031404408.gba6c003.tgz", -- "integrity": "sha512-WPuh3q1gV7h02W2riOyx4AD0dqGY8QKExd2Fwv3aiHevpSPTn0mHAuIPCSOvNoy14LxBJ3ViBOORHEAucClX2A==", +- "version": "1.0.27-unstable.41379698026.g3219c66", +- "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.27-unstable.41379698026.g3219c66.tgz", +- "integrity": "sha512-vxgi3xkRrx2v6nbHwuBUImfRaxz+c7jQe8SxP+jcXYe9LsQGinuscDkZGTG7Rvtp8vFfm4WuGrvlhk7lWAjKuA==", - "license": "MIT", - "dependencies": { - "koffi": "3.2.1", @@ -165,20 +165,20 @@ index da390232e46..fd1d5da2d23 100644 - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { -- "@github/copilot-sdk-darwin-arm64": "1.0.27-unstable.40031404408.gba6c003", -- "@github/copilot-sdk-darwin-x64": "1.0.27-unstable.40031404408.gba6c003", -- "@github/copilot-sdk-linux-arm64": "1.0.27-unstable.40031404408.gba6c003", -- "@github/copilot-sdk-linux-x64": "1.0.27-unstable.40031404408.gba6c003", -- "@github/copilot-sdk-linuxmusl-arm64": "1.0.27-unstable.40031404408.gba6c003", -- "@github/copilot-sdk-linuxmusl-x64": "1.0.27-unstable.40031404408.gba6c003", -- "@github/copilot-sdk-win32-arm64": "1.0.27-unstable.40031404408.gba6c003", -- "@github/copilot-sdk-win32-x64": "1.0.27-unstable.40031404408.gba6c003" +- "@github/copilot-sdk-darwin-arm64": "1.0.27-unstable.41379698026.g3219c66", +- "@github/copilot-sdk-darwin-x64": "1.0.27-unstable.41379698026.g3219c66", +- "@github/copilot-sdk-linux-arm64": "1.0.27-unstable.41379698026.g3219c66", +- "@github/copilot-sdk-linux-x64": "1.0.27-unstable.41379698026.g3219c66", +- "@github/copilot-sdk-linuxmusl-arm64": "1.0.27-unstable.41379698026.g3219c66", +- "@github/copilot-sdk-linuxmusl-x64": "1.0.27-unstable.41379698026.g3219c66", +- "@github/copilot-sdk-win32-arm64": "1.0.27-unstable.41379698026.g3219c66", +- "@github/copilot-sdk-win32-x64": "1.0.27-unstable.41379698026.g3219c66" - } - }, - "node_modules/@github/copilot-sdk-darwin-arm64": { -- "version": "1.0.27-unstable.40031404408.gba6c003", -- "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-arm64/-/copilot-sdk-darwin-arm64-1.0.27-unstable.40031404408.gba6c003.tgz", -- "integrity": "sha512-QTre6C8lPpx1AZ1ZVc+XUUKGO9yoK259/rFwAsq7dNzjTUlMDX0zlgu1jx5cwEX7IeM37LDAVVnMYytvrA+W7Q==", +- "version": "1.0.27-unstable.41379698026.g3219c66", +- "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-arm64/-/copilot-sdk-darwin-arm64-1.0.27-unstable.41379698026.g3219c66.tgz", +- "integrity": "sha512-zzG1dCIVZ6JPVzrVuEx00yid8XH+IhdzTuCaiCyGYehlzTkH21QxaCpNnv7NoMt59GOyQ9Ii+LkrXg1vA9cyJw==", - "cpu": [ - "arm64" - ], @@ -189,9 +189,9 @@ index da390232e46..fd1d5da2d23 100644 - ] - }, - "node_modules/@github/copilot-sdk-darwin-x64": { -- "version": "1.0.27-unstable.40031404408.gba6c003", -- "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-x64/-/copilot-sdk-darwin-x64-1.0.27-unstable.40031404408.gba6c003.tgz", -- "integrity": "sha512-N8Ts8b5QkVADH9gBbpYzukBikly3Q5TLGx73tGNv3WBzpsCRZsp4nudS/F/9aC6EbKpXUlIm6RYJPHWCcYe88g==", +- "version": "1.0.27-unstable.41379698026.g3219c66", +- "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-x64/-/copilot-sdk-darwin-x64-1.0.27-unstable.41379698026.g3219c66.tgz", +- "integrity": "sha512-otXdAim2ViCvJ5MU31OIs3EJLvsE+uQCgYJcFkr7LljZsZ4IO5nRKMhQJaPMaOmR6M3r4jxeAlAjBfIW470vmg==", - "cpu": [ - "x64" - ], @@ -202,9 +202,9 @@ index da390232e46..fd1d5da2d23 100644 - ] - }, - "node_modules/@github/copilot-sdk-linux-arm64": { -- "version": "1.0.27-unstable.40031404408.gba6c003", -- "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-arm64/-/copilot-sdk-linux-arm64-1.0.27-unstable.40031404408.gba6c003.tgz", -- "integrity": "sha512-0SaRSB7zpPPmVUqtjcia3GoX3uBIKgwYZcBgVSEam2z8yUpUWcf6FNsav5XkB8VDJe7W0GVZh0UzvTIVGPqK4g==", +- "version": "1.0.27-unstable.41379698026.g3219c66", +- "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-arm64/-/copilot-sdk-linux-arm64-1.0.27-unstable.41379698026.g3219c66.tgz", +- "integrity": "sha512-QBFLm2gZBHSQ8mXyW5Op0B7FQP2poseR6uI4XFESJFpj6T2n1iXGa6CZ6pGcWruX7TX5JaHGJ5Qj7oPOwRWhxA==", - "cpu": [ - "arm64" - ], @@ -218,9 +218,9 @@ index da390232e46..fd1d5da2d23 100644 - ] - }, - "node_modules/@github/copilot-sdk-linux-x64": { -- "version": "1.0.27-unstable.40031404408.gba6c003", -- "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-x64/-/copilot-sdk-linux-x64-1.0.27-unstable.40031404408.gba6c003.tgz", -- "integrity": "sha512-6Rk0wqvhN+TE/1TgrBQogNy7OnHiMJnbhV8lzuHbQ/O4gRst74W3dMwFuIBukK9Z1CgaSURFd8zoJWBelLe1iw==", +- "version": "1.0.27-unstable.41379698026.g3219c66", +- "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-x64/-/copilot-sdk-linux-x64-1.0.27-unstable.41379698026.g3219c66.tgz", +- "integrity": "sha512-tln/9o+ydaVYzmHKZMebkZsQVjzYinqBdnVJUD2F4Ydc5Qc0Dy10FIupI10KrG7T1aBeRJPOH8WpmON4JF+Tcw==", - "cpu": [ - "x64" - ], @@ -234,9 +234,9 @@ index da390232e46..fd1d5da2d23 100644 - ] - }, - "node_modules/@github/copilot-sdk-linuxmusl-arm64": { -- "version": "1.0.27-unstable.40031404408.gba6c003", -- "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-arm64/-/copilot-sdk-linuxmusl-arm64-1.0.27-unstable.40031404408.gba6c003.tgz", -- "integrity": "sha512-ZmpHEbYCY8QufCBI6NEl04G1jEYR15pIAGzKd9PhuWJr6cIDhhzOB4970l9Ct3p26TtmfqFjSKyZfrZQW6YV1w==", +- "version": "1.0.27-unstable.41379698026.g3219c66", +- "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-arm64/-/copilot-sdk-linuxmusl-arm64-1.0.27-unstable.41379698026.g3219c66.tgz", +- "integrity": "sha512-Fq3cJkLyn3TpLDTE79T8AJlN5JA8BqBxaeUIjbwPC43igvpByqlwdxamZ05SdVjns/Eh2tz5cUzOOl/34h+bGA==", - "cpu": [ - "arm64" - ], @@ -250,9 +250,9 @@ index da390232e46..fd1d5da2d23 100644 - ] - }, - "node_modules/@github/copilot-sdk-linuxmusl-x64": { -- "version": "1.0.27-unstable.40031404408.gba6c003", -- "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-x64/-/copilot-sdk-linuxmusl-x64-1.0.27-unstable.40031404408.gba6c003.tgz", -- "integrity": "sha512-APvrYK5sFpozDezVw5sd/rec1P/oRQ01HWH/i1H9jptQ40A2vKiqDh5524+cWMh1wjnEMpm/YeMC2xycsT0WUA==", +- "version": "1.0.27-unstable.41379698026.g3219c66", +- "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-x64/-/copilot-sdk-linuxmusl-x64-1.0.27-unstable.41379698026.g3219c66.tgz", +- "integrity": "sha512-kAQWlXzByt9hVQG357rgh3JG02zhFSOFEDLnV3kRFWczqxJcZz02sZvG9/ZMSe9KQHAUm9zjWFKLTa4TlloksA==", - "cpu": [ - "x64" - ], @@ -266,9 +266,9 @@ index da390232e46..fd1d5da2d23 100644 - ] - }, - "node_modules/@github/copilot-sdk-win32-arm64": { -- "version": "1.0.27-unstable.40031404408.gba6c003", -- "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-arm64/-/copilot-sdk-win32-arm64-1.0.27-unstable.40031404408.gba6c003.tgz", -- "integrity": "sha512-nhuYKYmZEzJCwyBlREPmAc93K/YhX0y6xpTEn6wmToxGUVZ1oMml+7hcedkdc0xiPExoocMTtEc/dqVBcF07Tw==", +- "version": "1.0.27-unstable.41379698026.g3219c66", +- "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-arm64/-/copilot-sdk-win32-arm64-1.0.27-unstable.41379698026.g3219c66.tgz", +- "integrity": "sha512-NbGAPN6UKgD66X97uGnV9ovOTUZhes96wP68POlEK2ulI0IbiZNWZgTu0ogwIQ2lJi2A6siOifv+e2VvdZXEdg==", - "cpu": [ - "arm64" - ], @@ -279,9 +279,9 @@ index da390232e46..fd1d5da2d23 100644 - ] - }, - "node_modules/@github/copilot-sdk-win32-x64": { -- "version": "1.0.27-unstable.40031404408.gba6c003", -- "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-x64/-/copilot-sdk-win32-x64-1.0.27-unstable.40031404408.gba6c003.tgz", -- "integrity": "sha512-eZfFpxxqk5qMcSkRdmeYKfLx8l+CbXE8Xbsl7C6zkaODaVKIopmVntHCPjKdd+luP5Q0VL2Zc2Ss/EzdlLsONg==", +- "version": "1.0.27-unstable.41379698026.g3219c66", +- "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-x64/-/copilot-sdk-win32-x64-1.0.27-unstable.41379698026.g3219c66.tgz", +- "integrity": "sha512-KEjG64gWypaEQYNO732T6QkINOr2sOpmWtirtuiq1j/PuO7CfRM97sDcL8Ra0yTz/4EJmRkNBbKW/8sCKIzQiQ==", - "cpu": [ - "x64" - ], @@ -302,7 +302,7 @@ index da390232e46..fd1d5da2d23 100644 - }, "node_modules/@gulp-sourcemaps/identity-map": { diff --git a/package.json b/package.json -index 97f71ea521f..1e9c52ea52a 100644 +index 399d863d9b7..366b96607fd 100644 --- a/package.json +++ b/package.json @@ -25,5 +25,4 @@ @@ -332,7 +332,7 @@ index 97f71ea521f..1e9c52ea52a 100644 "update-build-ts-version": "npm install -D typescript@npm:@typescript/typescript6@^6.0.2 && npm install -D @typescript/native@npm:typescript@next && (cd build && npm run typecheck)", @@ -105,3 +99,2 @@ "@devcontainers/cli": "0.88.0", -- "@github/copilot-sdk": "1.0.27-unstable.40031404408.gba6c003", +- "@github/copilot-sdk": "1.0.27-unstable.41379698026.g3219c66", "@hpke/core": "^1.9.0", diff --git a/src/vs/platform/agentHost/common/otel/agentHostOTelService.ts b/src/vs/platform/agentHost/common/otel/agentHostOTelService.ts index a6f8c8f7ab7..5e62385d292 100644 @@ -375,14 +375,14 @@ index ceb02d2f663..b6992721a61 100644 +type GitHubTelemetryNotification = any; import { multiplexProperties, type IAgentHostRestrictedTelemetry, type IAgentHostRestrictedTelemetryContext, type TelemetryProps } from './agentHostRestrictedTelemetry.js'; diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts -index 44f7ac0e5b4..e9d4957eedb 100644 +index d7c3453ae73..01e8586ad73 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts -@@ -25,3 +25,2 @@ import { IAgentConfigurationService } from './agentConfigurationService.js'; +@@ -25,3 +25,2 @@ import { IAgentConfigurationService, seedMultipleChatProvidersFromEnvironment } import { IAgentHostCompletions } from './agentHostCompletions.js'; -import { CopilotAgent } from './copilot/copilotAgent.js'; import { ClaudeAgent } from './claude/claudeAgent.js'; -@@ -189,3 +188,2 @@ async function startAgentHost(): Promise { +@@ -193,3 +192,2 @@ async function startAgentHost(): Promise { sdkDownloadProgress = runtime.sdkDownloadProgress; - providerService.registerProvider(instantiationService.createInstance(CopilotAgent)); // Claude and Codex providers are gated on two things: @@ -597,10 +597,10 @@ index 63a5c801f10..9c1374b791a 100644 import { isObject } from '../../../../base/common/types.js'; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts deleted file mode 100644 -index d7239a4129e..00000000000 +index 66d4563d1a4..00000000000 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ /dev/null -@@ -1,11234 +0,0 @@ +@@ -1,11769 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -636,6 +636,7 @@ index d7239a4129e..00000000000 -import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; -import { ILogService, LogLevel } from '../../../log/common/log.js'; -import type { IMcpServerConfiguration } from '../../../mcp/common/mcpPlatformTypes.js'; +-import { IRequestService } from '../../../request/common/request.js'; -import { IProductService } from '../../../product/common/productService.js'; -import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; -import { logSettingExperimentTrigger } from '../../../telemetry/common/experimentTrigger.js'; @@ -659,8 +660,9 @@ index d7239a4129e..00000000000 -import { hasCopilotManagedAutoPreference, omitCopilotManagedModelDefaults, projectCopilotManagedModelConfiguration } from './copilotManagedModelConfiguration.js'; -import { buildCopilotBuiltinAgentsContainer, COPILOT_BUILTIN_AGENT_NAMES_BY_URI, CopilotBuiltinAgentsStore } from './copilotBuiltinAgents.js'; -import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; +-import { toAgentCustomizationMeta } from '../../common/meta/agentCustomizationMeta.js'; -import { decodeProviderData, encodeProviderData, type IPersistedChat } from '../agentChatBackings.js'; --import { type AgentCanvasDefinitionSnapshot, AgentChatOperationContext, AgentSession, fixedChatOwnerRef, type IAgentChatOwnerRef, resolveSessionBackedStorage, AgentSignal, AuthenticateParams, COPILOT_CLI_AGENT_PROVIDER_ID, IActiveClient, IAgent, IAgentChatAdoptionResult, type IAgentAdoptedWorktree, type IAgentCanvasCloseRequest, type IAgentCanvasOpenRequest, IAgentChatConfigCompletionsParams, IAgentChatContext, IAgentChatSessionSettings, IAgentChatDataChange, IAgentChatMetadata, IAgentChats, IAgentLegacyChat, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCustomizationInstallation, IAgentCustomizationInstallationApplyResult, IAgentCustomizationInstallationRequest, IAgentCustomizationInstallationReview, IAgentCustomizationMarketplaceSearchRequest, IAgentCustomizationMarketplaceSearchResult, IAgentDescriptor, IAgentDiscoveredChat, type IAgentExtensionInfo, type IAgentExtensionInventory, IAgentListedChat, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentKnownSessionsFilter, type IAgentManagedPluginRetryEntry, IAgentMaterializeChatEvent, IAgentModelInfo, type IAgentPendingMessageSender, type IAgentPermissionResponseContext, type IAgentPluginInstallRequest, type IAgentPluginMarketplace, type IAgentPluginMarketplaceItem, type IAgentPluginMarketplaceRefreshResult, type IAgentPluginMarketplaceRemoveResult, type IAgentPluginUninstallRequest, IAgentResolveChatConfigParams, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, SubagentChatSignal, resolveAgentChatContext, resolveAgentHostCustomizations, resolveAgentHostInstructions, resolveSubagentChatParent, type IAgentTurnDiagnosticSnapshot, type IAgentTurnTokenUsage, type IAgentChatSessionEvent } from '../../common/agent.js'; +-import { type AgentCanvasDefinitionSnapshot, AgentChatOperationContext, AgentSession, fixedChatOwnerRef, getChatSurfaceOptions, type IAgentChatOwnerRef, resolveSessionBackedStorage, AgentSignal, AuthenticateParams, COPILOT_CLI_AGENT_PROVIDER_ID, IActiveClient, IAgent, IAgentChatAdoptionResult, type IAgentAdoptedWorktree, type IAgentCanvasCloseRequest, type IAgentCanvasOpenRequest, IAgentChatConfigCompletionsParams, IAgentChatContext, type IAgentChatDiscoveryOptions, IAgentChatSessionSettings, IAgentChatDataChange, IAgentChatMetadata, IAgentChats, IAgentLegacyChat, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCustomizationInstallation, IAgentCustomizationInstallationApplyResult, IAgentCustomizationInstallationRequest, IAgentCustomizationInstallationReview, IAgentCustomizationMarketplaceSearchRequest, IAgentCustomizationMarketplaceSearchResult, IAgentDescriptor, IAgentDiscoveredChat, type IAgentExtensionInfo, type IAgentExtensionInventory, IAgentListedChat, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentKnownSessionsFilter, type IAgentManagedPluginRetryEntry, IAgentMaterializeChatEvent, IAgentModelInfo, type IAgentPendingMessageSender, type IAgentPermissionResponseContext, type IAgentPluginInstallRequest, type IAgentPluginMarketplace, type IAgentPluginMarketplaceItem, type IAgentPluginMarketplaceRefreshResult, type IAgentPluginMarketplaceRemoveResult, type IAgentPluginUninstallRequest, IAgentResolveChatConfigParams, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, SubagentChatSignal, resolveAgentChatContext, resolveAgentHostCustomizations, resolveAgentHostInstructions, resolveSubagentChatParent, type IAgentTurnDiagnosticSnapshot, type IAgentTurnTokenUsage, type IAgentChatSessionEvent } from '../../common/agent.js'; -import { getReasoningEffortDescription, getReasoningEffortLabel, resolveDefaultReasoningEffort } from '../../common/reasoningEffort.js'; -import { autoModeTiers, defaultAutoModeTier, DynamicAutoTiersFeatureFlag, getAutoModeTierDescription, getAutoModeTierLabel, getFusionAutoModeTierLabel, setAdvertisedAutoModeTiers } from '../../common/autoModeTiers.js'; -import { AUTO_MODEL_ID, isAutoModel } from './modelIdentifiers.js'; @@ -671,17 +673,19 @@ index d7239a4129e..00000000000 -import { ICopilotConfigSlashCommandState } from '../../common/copilotConfigSlashCommands.js'; -import { ICopilotHomeService } from '../../../environment/common/copilotHome.js'; -import { CopilotCustomizationInstallations } from './customizations/copilotCustomizationInstallations.js'; --import { ISessionDataService, SESSION_DB_FILENAME, type ISessionDatabase } from '../../common/sessionDataService.js'; +-import { ISessionDataService, SESSION_DB_FILENAME, type ISessionDatabase, type StoredAssistantResponsePart } from '../../common/sessionDataService.js'; -import { IAgentHostProxyResolver } from '../agentHostProxyResolver.js'; +-import { writeUserDataCertificatesFile } from '../../../request/node/certificates.js'; -import { MODEL_REFRESH_BASE_DELAY_MS, MODEL_REFRESH_MAX_ATTEMPTS, MODEL_REFRESH_MAX_DELAY_MS, modelRefreshBackoff } from '../shared/modelRefreshRetry.js'; -import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; -import type { ErrorInfo } from '../../common/state/protocol/common/state.js'; --import { ProtectedResourceMetadata, type AgentSelection, type BackgroundWork, type ConfigPropertySchema, type ConfigSchema, type CustomizationEnablement, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; +-import { ProtectedResourceMetadata, type AgentCustomization, type AgentSelection, type BackgroundWork, type ConfigPropertySchema, type ConfigSchema, type CustomizationEnablement, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; -import { ActionType, AuthRequiredReason, type AuthRequiredParams, type SessionAction } from '../../common/state/sessionActions.js'; -import { areAdditionalWorkingDirectoriesEqual } from '../../common/state/sessionWorkingDirectories.js'; -import { getRepositoryRootFromWorktree } from '../../common/worktreePaths.js'; --import { CustomizationLoadStatus, CustomizationType, ChatInputResponseKind, customizationId, buildChatUri, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_EHCLI_LAST_TURN_DB_KEY, AH_META_IS_READ_DB_KEY, SessionStatus, withSessionEhcliAdoptable, withSessionStatusFlag, withSessionWorkspaceless, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type ISessionFolderPickerDecision, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ChatInputAnswer, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; +-import { CustomizationLoadStatus, CustomizationType, ChatInputResponseKind, customizationId, buildChatUri, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_EHCLI_LAST_TURN_DB_KEY, AH_META_IS_READ_DB_KEY, ResponsePartKind, SessionStatus, withSessionEhcliAdoptable, withSessionStatusFlag, withSessionWorkspaceless, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type ISessionFolderPickerDecision, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ChatInputAnswer, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; -import { CopilotChatDiscovery, ICopilotChatDiscoveryScan } from './copilotChatDiscovery.js'; +-import { EXTENSION_HOST_CLI_PER_INSTALL_BULK_FILE, EXTENSION_HOST_CLI_SHARED_BULK_FILE, restoreMissingLegacyMarkers } from './copilotLegacyMarkerFallback.js'; -import { getByokLmAgentModelId, resolveByokLmEnablement } from '../../common/agentHostByokLm.js'; -import { isCustomizationEnabled } from '../../common/customizationEnablement.js'; -import { ActiveClientToolSet } from '../activeClientState.js'; @@ -692,9 +696,11 @@ index d7239a4129e..00000000000 -import { AGENT_HOST_TITLE_SOURCE_AUTO, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY, SESSION_WORKING_DIRECTORIES_KEY } from '../shared/persistSessionMetadata.js'; -import { IAgentHostCompletions } from '../agentHostCompletions.js'; -import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js'; --import { applyMcpServerEnablement, applyMcpServerRuntimeStates, buildMcpTopLevelCustomizationId, mergeMcpServerCustomizations, type IMcpServerRuntimeState } from '../shared/mcpCustomizationController.js'; +-import { applyMcpServerEnablement, applyMcpServerRuntimeStates, buildMcpTopLevelCustomizationId, findDiscoveredPluginMcpServer, getMcpServerCustomizations, mergeMcpServerCustomizations, type IMcpServerRuntimeState } from '../shared/mcpCustomizationController.js'; +-import { readMcpServerSource, readMcpServerSourcePlugin } from '../../common/meta/mcpCustomizationMeta.js'; +-import { readCustomizationDiscoveryMeta } from '../../common/meta/customizationDiscoveryMeta.js'; -import { IAgentHostCustomizationEnablementService } from '../agentHostCustomizationEnablementService.js'; --import { getSdkMcpServerEnablement, isCustomizationSdkEligible, resolveCustomizationEnablement } from '../shared/customizationEnablementGate.js'; +-import { getSdkMcpServerEnablement, isCustomizationSdkEligible, resolveCustomizationEnablement, targetForMcpServer } from '../shared/customizationEnablementGate.js'; -import { McpServerStatus, type McpServerCustomization } from '../../common/state/protocol/channels-session/state.js'; -import { IAgentHostSessionTitleSignal } from '../agentHostSessionTitleSignal.js'; -import { IByokLmBridgeRegistry } from '../byokLmBridgeRegistry.js'; @@ -705,7 +711,7 @@ index d7239a4129e..00000000000 -import { CopilotSandboxCertificate } from './copilotSandboxCertificate.js'; -import { readCopilotSessionMetadataEvents, toCopilotSessionMetadataEvent } from './copilotSessionMetadataEvents.js'; -import { deferCopilotSdkExecution, getDeferredCopilotSdkExecution } from './copilotSessionExecutionMarker.js'; --import { readCopilotSessionEvents, restoreStoredShellOutputsForChat } from './copilotSessionHistory.js'; +-import { readCopilotSessionEvents, restoreStoredShellOutputs } from './copilotSessionHistory.js'; -import { mapSessionEvents } from './mapSessionEvents.js'; -import { subagentChatUri } from '../../common/agentHostChatMinting.js'; -import { addCopilotSandboxPath, createCopilotCliEnvironment, setLocalModelRequestHandlerEnvironment } from './copilotCliEnvironment.js'; @@ -717,9 +723,10 @@ index d7239a4129e..00000000000 -import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; -import { findRelocatedAgentName, parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js'; -import { CopilotGitHubTelemetryForwarder, isHydraFusionTelemetryKind, type ICopilotModelCallCorrelationTelemetry } from './copilotGitHubTelemetryForwarder.js'; +-import { COMPACTION_REQUEST_KIND, isCompactionTelemetryKind, type IForwardedCompactionCorrelation } from './compactionTurnCorrelation.js'; -import { CopilotGitHubCredentials, CopilotGitHubSessionCredentials } from './copilotGitHubCredentials.js'; -import { CopilotSecondaryAssignmentContext } from './copilotSecondaryAssignmentContext.js'; --import { CopilotSessionLauncher, getCopilotHydraFusionConfig, AutoTierConfigKey, ContextSizeConfigKey, CopilotAutoTierConfigKey, CopilotReasoningEffortConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getCopilotCustomAgents, getLocalModelSelectionId, isCopilotReasoningEffort, isCopilotSessionTelemetryEnabled, resolveCopilotAutoTier, resolveCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotAppliedCustomizations } from './copilotSessionLauncher.js'; +-import { CopilotSessionLauncher, type CopilotChatSurface, getCopilotHydraFusionConfig, getInlineChatFusionAutoTier, AutoTierConfigKey, ContextSizeConfigKey, CopilotAutoTierConfigKey, CopilotReasoningEffortConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getCopilotCustomAgents, getCopilotAgentSelection, getCopilotAgentSelectionName, getLocalModelSelectionId, isCopilotReasoningEffort, isCopilotSessionTelemetryEnabled, resolveCopilotAutoTier, resolveCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotAppliedCustomizations } from './copilotSessionLauncher.js'; -import { createLocalModelRowMeta } from '../../common/meta/localModelStatus.js'; -import { errorCodeForLog } from '../../common/logRedaction.js'; -import { withSessionInitiator } from '../../common/meta/agentSessionInitiatorMeta.js'; @@ -764,16 +771,6 @@ index d7239a4129e..00000000000 - } -} - --/** Keeps tgrep disabled in control, or permits the SDK's size-gated feature flag in treatment. */ --function setCopilotTgrepEnvironment(env: Record, enabled: boolean): void { -- deleteEnvironmentVariable(env, 'USE_TGREP'); -- if (enabled) { -- deleteEnvironmentVariable(env, 'USE_BUILTIN_RIPGREP'); -- } else { -- env['USE_TGREP'] = 'false'; -- } --} -- -function addCopilotFeatureFlag(env: Record, flag: string): void { - const enabledFlags = env['COPILOT_CLI_ENABLED_FEATURE_FLAGS']; - const flags = new Set((enabledFlags ?? '').split(',').map(f => f.trim()).filter(Boolean)); @@ -799,7 +796,7 @@ index d7239a4129e..00000000000 - * Fusion is only enabled for some accounts; the others get the Fusion tiers back as unavailable. - */ -export function offersFusionAutoTier(metadata: CopilotAutoTierMetadata, hydraFusionV2: boolean): boolean { -- return metadata.tiers.some(tier => tier.type === 'fusion' && isRunnableAutoTier(tier, hydraFusionV2) && isInAutoTierPicker(tier)); +- return metadata.tiers.some(tier => tier.type === 'fusion' && isPickerAutoTierKind(tier, hydraFusionV2) && isInAutoTierPicker(tier)); -} - -/** @@ -863,6 +860,15 @@ index d7239a4129e..00000000000 - return noProxy ?? (proxy ? 'localhost,127.0.0.1,::1,::ffff:127.0.0.1' : undefined); -} - +-/** +- * The proxy a Kerberos principal from the Copilot runtime names. The runtime asks for its proxy's principal, `HTTP/host` +- * or a configured override, optionally with `@REALM`; clients derive the principal for a proxy themselves. +- */ +-function getKerberosPrincipalProxyUrl(spn: string): string | undefined { +- const host = /^HTTP\/(?[^/@\s]+)(?:@\S+)?$/i.exec(spn)?.groups?.host; +- return host ? `http://${host}` : undefined; +-} +- -async function validateCopilotRuntimePath(runtimePath: string): Promise { - if (!isAbsolute(runtimePath)) { - throw new Error(`Invalid chat.agentHost.copilot.runtimePath: expected an absolute path, got '${runtimePath}'`); @@ -938,6 +944,8 @@ index d7239a4129e..00000000000 - readonly isEphemeral: boolean; - /** Whether the owning chat surface is scoped to editing a single file. */ - readonly hasScopedEditSurface: boolean; +- /** Whether the owning chat surface is an inline chat, editor or terminal. */ +- readonly isInlineChatSurface: boolean; - /** - * Folder the user picked at create time. Used as both the - * pre-worktree working directory and the customization directory @@ -1034,6 +1042,8 @@ index d7239a4129e..00000000000 - readonly launchWorkingDirectories: readonly URI[] | undefined; - /** The chat's own workspace-less decision as the host recorded it; `false` when the host supplied none. */ - readonly workspaceless: boolean; +- /** How the chat's surface scopes its runtime, from the host's surface metadata, else the live runtime's. */ +- readonly surface?: CopilotChatSurface; -} - -class ScopedCopilotClient extends Disposable { @@ -1079,6 +1089,7 @@ index d7239a4129e..00000000000 - readonly proxy: string | undefined; - readonly noProxy: string | undefined; - readonly kerberosSpn: string | undefined; +- readonly certificates: readonly string[]; -} - -/** What a configuration check runs for, and how it may wait. */ @@ -1154,9 +1165,28 @@ index d7239a4129e..00000000000 -/** Stable empty host-customization snapshot used before the host publishes one. */ -const NO_HOST_CUSTOMIZATIONS: readonly Customization[] = Object.freeze([]); -const CHAT_QUEUE_STALL_WARNING_MS = 60_000; +-const EXTENSION_RECONCILIATION_LIFECYCLE_FAILED = 'extension_reconciliation_lifecycle_failed'; - -class IncompleteCopilotSessionMarkerError extends Error { } - +-function isExtensionLifecycleReconciliationError(error: unknown): boolean { +- if (!error || typeof error !== 'object') { +- return false; +- } +- const data = (error as { readonly data?: unknown }).data; +- return !!data +- && typeof data === 'object' +- && (data as { readonly code?: unknown }).code === EXTENSION_RECONCILIATION_LIFECYCLE_FAILED; +-} +- +-class CopilotExtensionReconciliationError extends Error { +- constructor(message: string, readonly failedExtensions: readonly IAgentExtensionInfo[]) { +- super(message); +- } +-} +- +-class CopilotExtensionReconcileTargetUnavailableError extends Error { } +- -class CopilotSessionConfigurationBusyError extends Error { - constructor(readonly session: CopilotAgentSession) { - super('Cannot refresh Copilot session configuration while the session has an active turn.'); @@ -1457,6 +1487,8 @@ index d7239a4129e..00000000000 - */ -const MAX_STARTUP_CONFIG_RETRIES = 1; -const MAX_SESSION_RESTORE_ATTEMPTS = 5; +-/** How many SDK clients chat config resolution tries when the client is replaced while it resolves managed settings. */ +-const MAX_CHAT_CONFIG_CLIENT_ATTEMPTS = 3; -/** Bounds the background Computer Use plugin download, including a stalled response body. */ -const COMPUTER_USE_DOWNLOAD_TIMEOUT_MS = 2 * 60 * 1000; -/** Clients show the helper app's icon at up to 64 pixels, so 128 stays sharp on Retina displays. */ @@ -1468,6 +1500,32 @@ index d7239a4129e..00000000000 -/** File name of the marker written beside a Copilot CLI session's SDK event log. */ -const EXTENSION_HOST_CLI_MARKER_FILE = 'vscode.metadata.json'; - +-type LegacyMarkerFallbackEvent = { +- sharedBulkFile: string; +- perInstallBulkFile: string; +- candidateCount: number; +- notVSCodeCount: number; +- existingFileCount: number; +- missingSessionDirectoryCount: number; +- writtenCount: number; +- failedCount: number; +- durationMs: number; +-}; +- +-type LegacyMarkerFallbackClassification = { +- sharedBulkFile: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the shared bulk metadata file in the Copilot home was present, missing or unreadable.' }; +- perInstallBulkFile: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the older per-install globalStorage bulk metadata file was present, missing or unreadable.' }; +- candidateCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of distinct sessions across both bulk files.' }; +- notVSCodeCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Entries skipped because they are not VS Code-created sessions or have an invalid session id.' }; +- existingFileCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'VS Code sessions that already had a per-session marker.' }; +- missingSessionDirectoryCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'VS Code sessions whose session-state directory no longer exists.' }; +- writtenCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'VS Code sessions whose missing per-session marker the agent host restored, making them adoptable.' }; +- failedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Per-session markers that could not be written.' }; +- durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time taken in milliseconds.' }; +- owner: 'vijayupadya'; +- comment: 'Agent host fallback for legacy extension-host Copilot CLI sessions recorded only in the bulk metadata files, for machines where the extension backfill never ran (including remote hosts). Counts only; sent once per host process when migration is on and a bulk file exists.'; +-}; +- -/** - * Shape of the `vscode.metadata.json` marker written next to a Copilot CLI - * session's SDK event log. Other Copilot CLI hosts (e.g. the GitHub Copilot @@ -1731,9 +1789,7 @@ index d7239a4129e..00000000000 - private _resolvedProxy: string | undefined; - private _proxyRefresh: Promise | undefined; - private _proxyResolutionGeneration = 0; -- private _appliedProxy: string | undefined; -- private _appliedNoProxy: string | undefined; -- private _appliedProxyKerberosSpn: string | undefined; +- private _appliedProxyState: ICopilotClientProxyState = { proxy: undefined, noProxy: undefined, kerberosSpn: undefined, certificates: [] }; - /** - * Reasons for a client restart that is parked until every chat is idle. See - * {@link _requestClientRestart}; drained by {@link _applyPendingClientRestart}. @@ -1921,6 +1977,7 @@ index d7239a4129e..00000000000 - @ILocalModelSetupService private readonly _localModelSetupService: ILocalModelSetupService, - @IAgentHostSessionStateStore private readonly _sessionStateStore: IAgentHostSessionStateStore, - @ICopilotHomeService private readonly _copilotHomeService: ICopilotHomeService, +- @IRequestService requestService: IRequestService, - ) { - super(); - this._managedSettingsResolver = this._register(new CopilotManagedSettingsResolver(this._logService)); @@ -1983,6 +2040,7 @@ index d7239a4129e..00000000000 - }, - }; - }, +- requestService, - )); - const discoveryRoot = resourceJoinPath(this._copilotHomeService.home, 'session-state'); - this._copilotChatDiscovery = this._register(this._instantiationService.createInstance(CopilotChatDiscovery, @@ -2126,10 +2184,6 @@ index d7239a4129e..00000000000 - return this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.ClaudeAdvisor) === true; - } - -- private _isTgrepEnabled(): boolean { -- return this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.Tgrep) === true; -- } -- - private _isExecutionSubagentEnabled(): boolean { - return this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.ExecutionSubagent) === true; - } @@ -2424,8 +2478,8 @@ index d7239a4129e..00000000000 - } - - private _isMigrateLegacyCopilotCliEnabled(): boolean { -- // Frozen at startup: changing the setting requires a window reload, so a -- // single discovery pass sees one stable gate value for the process lifetime. +- // Set from the host's startup-frozen gate when discovery starts (see `startChatDiscovery`), +- // so discovery and the host's listing agree. The setting is only read when no host passed it. - return this._migrateLegacyEnabledSnapshot ??= this._configurationService.getRootValue(platformRootSchema, AgentHostMigrateLegacyCopilotCliEnabledConfigKey) === true; - } - @@ -2433,7 +2487,6 @@ index d7239a4129e..00000000000 - return new CopilotAgentStartupConfig( - this._isRubberDuckEnabled(), - this._isClaudeAdvisorEnabled(), -- this._isTgrepEnabled(), - this._isHydraFusionEnabled(), - this._isHydraFusionV2Enabled(), - this._isHydraFusionMultiRootEnabled(), @@ -2981,10 +3034,9 @@ index d7239a4129e..00000000000 - } - - getProtectedResources(): ProtectedResourceMetadata[] { -- const allowSignedOutWhenUsable = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.AllowSignedOutWhenUsable) === true; - const copilotResource = this._gitHubEndpointService.getCopilotResource(); - return [ -- allowSignedOutWhenUsable && this._byokModels.length > 0 ? { ...copilotResource, required: false } : copilotResource, +- this._byokModels.length > 0 ? { ...copilotResource, required: false } : copilotResource, - this._gitHubEndpointService.getRepoResource(), - ]; - } @@ -3492,8 +3544,11 @@ index d7239a4129e..00000000000 - directSourceId: request.directSourceId, - })); - } -- await Promises.settled(this._allLiveSessions().map(candidate => -- this._queueChat(candidate.sessionId, candidate.sessionId, 'reloadPluginUninstall', () => candidate.reloadPlugins('uninstall')))); +- await Promises.settled([ +- ...this._allLiveSessions().map(candidate => +- this._queueChat(candidate.sessionId, candidate.sessionId, 'reloadPluginUninstall', () => candidate.reloadPlugins('uninstall'))), +- this._refreshDiscoveredCustomizations(), +- ]); - } - - async listPluginInstallations() { @@ -3543,7 +3598,7 @@ index d7239a4129e..00000000000 - mode: inventory.mode === 'disabled' || inventory.mode === 'load_only' || inventory.mode === 'load_and_augment' - ? inventory.mode - : 'unknown', -- extensions, +- extensions: this._mergeExtensionInventoryEntries(extensions), - }; - if (!session) { - return persisted; @@ -3557,7 +3612,7 @@ index d7239a4129e..00000000000 - const liveExtensionIds = new Set(liveExtensions.map(extension => extension.id)); - return { - mode: persisted.mode, -- extensions: [ +- extensions: this._mergeExtensionInventoryEntries([ - ...liveExtensions.map(extension => { - const persistedExtension = persistedById.get(extension.id); - return { @@ -3568,10 +3623,33 @@ index d7239a4129e..00000000000 - }), - ...persisted.extensions - .filter(extension => !liveExtensionIds.has(extension.id)), -- ], +- ]), - }; - } - +- private _mergeExtensionInventoryEntries(extensions: readonly IAgentExtensionInfo[]): IAgentExtensionInfo[] { +- const result = new Map(); +- for (const extension of extensions) { +- const current = result.get(extension.id); +- if (!current) { +- result.set(extension.id, extension); +- continue; +- } +- const statuses = new Set([current.status, extension.status]); +- const status = statuses.size === 1 ? current.status +- : statuses.has('starting') ? 'starting' +- : statuses.has('failed') ? 'failed' +- : 'unknown'; +- result.set(extension.id, { +- ...current, +- status, +- resource: current.resource ?? extension.resource, +- pluginName: current.pluginName ?? extension.pluginName, +- }); +- } +- return [...result.values()]; +- } +- - async setAgentExtensionEnabled(extensionId: string, enabled: boolean, session?: URI, chat?: URI): Promise { - if (!extensionId) { - throw new Error('Extension id is required.'); @@ -3584,9 +3662,15 @@ index d7239a4129e..00000000000 - if (extension.source !== 'user' && extension.source !== 'plugin') { - throw new Error(`Extension '${extensionId}' does not have a manageable source.`); - } -- await this._retryAfterClosedConnection('setExtensionEnabled', client => enabled -- ? client.rpc.extensions.enable({ ids: [extensionId] }) -- : client.rpc.extensions.disable({ ids: [extensionId] })); +- try { +- await this._retryAfterClosedConnection('setExtensionEnabled', client => enabled +- ? client.rpc.extensions.enable({ ids: [extensionId] }) +- : client.rpc.extensions.disable({ ids: [extensionId] })); +- } catch (error) { +- throw new Error(enabled +- ? localize('copilot.extensionEnableFailed', "Couldn't enable canvas extension '{0}'. {1}", extension.name, getErrorMessage(error)) +- : localize('copilot.extensionDisableFailed', "Couldn't disable canvas extension '{0}'. {1}", extension.name, getErrorMessage(error))); +- } - const targetSession = session ? this._findPluginOperationSession(session, chat) : undefined; - if (!targetSession) { - return; @@ -3596,12 +3680,91 @@ index d7239a4129e..00000000000 - await this._queueChat(targetSessionId, targetSessionId, 'reconcileExtensions', () => { - const currentSession = this._findPluginOperationSession(session, chat); - if (!currentSession || currentSession.sessionId !== targetSessionId) { -- throw new Error(localize('copilot.extensionReconcileTargetUnavailable', "The selected chat stopped or was replaced before its extension state could be updated.")); +- throw new CopilotExtensionReconcileTargetUnavailableError(); - } -- return currentSession.reconcileExtensions(); +- return this._reconcileExtensionEnablement(currentSession, extensionId, enabled); - }); - } catch (error) { -- throw new Error(localize('copilot.extensionReconcileFailed', "The extension preference was saved, but the selected chat could not be updated: {0}", getErrorMessage(error))); +- if (error instanceof CopilotExtensionReconcileTargetUnavailableError) { +- throw new Error(enabled +- ? localize('copilot.extensionEnableTargetUnavailable', "After enabling '{0}', the canvas extension change couldn't be confirmed because the selected chat stopped or was replaced. '{0}' is enabled in settings.", extension.name) +- : localize('copilot.extensionDisableTargetUnavailable', "After disabling '{0}', the canvas extension change couldn't be confirmed because the selected chat stopped or was replaced. '{0}' is disabled in settings.", extension.name)); +- } +- if (error instanceof CopilotExtensionReconciliationError && error.failedExtensions.length > 0) { +- if (enabled) { +- throw new Error(error.failedExtensions.length === 1 +- ? localize( +- 'copilot.extensionEnableRefreshFailed', +- "After enabling '{0}', this chat couldn't finish updating canvas extensions because '{1}' failed to start. This chat may not match your canvas extension settings.", +- extension.name, +- error.failedExtensions[0].name, +- ) +- : localize( +- 'copilot.extensionEnableRefreshMultipleFailed', +- "After enabling '{0}', this chat couldn't finish updating canvas extensions because other canvas extensions failed to start. This chat may not match your canvas extension settings.", +- extension.name, +- )); +- } +- throw new Error(error.failedExtensions.length === 1 +- ? localize( +- 'copilot.extensionDisableRefreshFailed', +- "After disabling '{0}', this chat couldn't finish updating canvas extensions because '{1}' failed to start. This chat may not match your canvas extension settings.", +- extension.name, +- error.failedExtensions[0].name, +- ) +- : localize( +- 'copilot.extensionDisableRefreshMultipleFailed', +- "After disabling '{0}', this chat couldn't finish updating canvas extensions because other canvas extensions failed to start. This chat may not match your canvas extension settings.", +- extension.name, +- )); +- } +- if (error instanceof CopilotExtensionReconciliationError) { +- throw new Error(enabled +- ? localize('copilot.extensionEnableTargetFailed', "After enabling '{0}', this chat couldn't finish updating canvas extensions because '{0}' failed to start. This chat may not match your canvas extension settings.", extension.name) +- : localize('copilot.extensionDisableTargetFailed', "After disabling '{0}', this chat couldn't finish updating canvas extensions because '{0}' couldn't be stopped. This chat may not match your canvas extension settings.", extension.name)); +- } +- throw new Error(enabled +- ? localize('copilot.extensionEnableReconcileFailed', "Canvas extension '{0}' was enabled in settings, but couldn't be started in this chat. {1}", extension.name, getErrorMessage(error)) +- : localize('copilot.extensionDisableReconcileFailed', "Canvas extension '{0}' was disabled in settings, but couldn't be stopped in this chat. {1}", extension.name, getErrorMessage(error))); +- } +- } +- +- private async _reconcileExtensionEnablement(session: CopilotAgentSession, extensionId: string, enabled: boolean): Promise { +- try { +- await session.reconcileExtensions(); +- } catch (error) { +- if (session.isDisposed || !isExtensionLifecycleReconciliationError(error)) { +- throw error; +- } +- let extensions: readonly IAgentExtensionInfo[]; +- try { +- extensions = await session.listExtensions(); +- } catch (verificationError) { +- throw new AggregateError([error, verificationError], getErrorMessage(error)); +- } +- if (session.isDisposed) { +- throw error; +- } +- const targets = extensions.filter(extension => extension.id === extensionId); +- const targetSettled = targets.length > 0 && targets.every(extension => extension.status === (enabled ? 'running' : 'disabled')); +- if (enabled && targetSettled) { +- this._logService.warn(`[Copilot:${session.sessionId}] Extension '${extensionId}' enablement was applied, but extension reconciliation reported a lifecycle failure`, error); +- return; +- } +- const targetFailed = targets.some(extension => extension.status === 'failed'); +- const targetStatuses = new Set(targets.map(extension => extension.status)); +- const targetConsistentlyStarting = enabled && targetStatuses.size === 1 && targetStatuses.has('starting'); +- const canAttributeOtherFailures = !targetFailed && (targetSettled || targetConsistentlyStarting); +- const failedExtensions = canAttributeOtherFailures +- ? [...new Map(extensions +- .filter(extension => extension.id !== extensionId && extension.status === 'failed') +- .map(extension => [extension.id, extension])).values()] +- : []; +- if ((!enabled && targetSettled || enabled && !targetFailed) && failedExtensions.length === 0) { +- throw error; +- } +- this._logService.warn(`[Copilot:${session.sessionId}] Extension '${extensionId}' reconciliation reported a lifecycle failure`, error); +- throw new CopilotExtensionReconciliationError(getErrorMessage(error), failedExtensions); - } - } - @@ -3674,15 +3837,17 @@ index d7239a4129e..00000000000 - }); - await Promise.all(this._allLiveSessions().map(session => - this._queueChat(session.sessionId, session.sessionId, 'reloadPluginEnablement', () => session.reloadPlugins('enablement')))); +- await this._refreshDiscoveredCustomizations(); - } - - async installPlugin(request: IAgentPluginInstallRequest, session?: URI, chat?: URI): Promise { - const liveSession = this._findPluginOperationSession(session, chat); - if (liveSession) { - await liveSession.installPlugin(request.source); -- return; +- } else { +- await this._retryAfterClosedConnection('installPlugin', client => client.rpc.plugins.install({ source: request.source })); - } -- await this._retryAfterClosedConnection('installPlugin', client => client.rpc.plugins.install({ source: request.source })); +- await this._refreshDiscoveredCustomizations(); - } - - async retryManagedPlugins(session: URI, chat?: URI): Promise { @@ -3738,6 +3903,9 @@ index d7239a4129e..00000000000 - const result = liveSession - ? await liveSession.removePluginMarketplace(name, force) - : await this._retryAfterClosedConnection('removePluginMarketplace', client => client.rpc.plugins.marketplaces.remove({ name, force })); +- if (result.removed) { +- await this._refreshDiscoveredCustomizations(); +- } - return { - removed: result.removed, - dependentPlugins: result.dependentPlugins, @@ -3770,8 +3938,14 @@ index d7239a4129e..00000000000 - return this._customizationInstallations.prepare(request); - } - -- applyCustomizationInstallation(operationId: string): Promise { -- return this._customizationInstallations.apply(operationId); +- async applyCustomizationInstallation(operationId: string): Promise { +- const result = await this._customizationInstallations.apply(operationId); +- await this._refreshDiscoveredCustomizations(); +- return result; +- } +- +- private async _refreshDiscoveredCustomizations(): Promise { +- await Promise.all([...this._activeClients.values()].map(client => client.pluginController.refreshDiscovery())); - } - - recoverCustomizationInstallations(_session: URI): Promise { @@ -3902,8 +4076,7 @@ index d7239a4129e..00000000000 - return true; - } - const models = this._models.read(reader); -- const allowSignedOut = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.AllowSignedOutWhenUsable) === true; -- if (!allowSignedOut || model === undefined) { +- if (model === undefined) { - return false; - } - const selectedModel = models.find(candidate => candidate.id === model.id); @@ -4158,6 +4331,8 @@ index d7239a4129e..00000000000 - private async _forwardGitHubTelemetry(notification: GitHubTelemetryNotification): Promise { - if (notification.event.kind === 'response.success' || notification.event.kind === 'response.error') { - await this._forwardResponseTelemetry(notification); +- } else if (isCompactionTelemetryKind(notification.event.kind)) { +- await this._forwardCompactionTelemetry(notification); - } else { - const modelCallId = this._modelCallIdForTelemetry(notification); - const session = notification.sessionId ? this._findSessionBySdkId(notification.sessionId) : undefined; @@ -4168,6 +4343,19 @@ index d7239a4129e..00000000000 - } - } - +- private async _forwardCompactionTelemetry(notification: GitHubTelemetryNotification): Promise { +- const session = notification.sessionId ? this._findSessionBySdkId(notification.sessionId) : undefined; +- const attemptId = notification.event.properties.compaction_attempt_id; +- const modelCallId = this._modelCallIdForTelemetry(notification); +- const telemetryContext = modelCallId ? session?.modelCallTurnCorrelation.getTelemetryContext(modelCallId) : undefined; +- const correlation: IForwardedCompactionCorrelation = !session +- ? { turnId: undefined, outcome: 'sessionNotFound' } +- : typeof attemptId !== 'string' || !attemptId +- ? { turnId: undefined, outcome: 'missingAttemptId' } +- : await session.compactionTurnCorrelation.resolve(attemptId); +- this._gitHubTelemetryForwarder.forwardCompaction(notification, correlation, telemetryContext); +- } +- - private _modelCallIdForTelemetry(notification: GitHubTelemetryNotification): string | undefined { - const modelCallId = notification.event.properties.modelCallId ?? notification.event.model_call_id; - return typeof modelCallId === 'string' && modelCallId.length > 0 ? modelCallId : undefined; @@ -4190,6 +4378,17 @@ index d7239a4129e..00000000000 - forward(undefined, 'sessionNotFound'); - return; - } +- // A summarization call never completes a turn's model call; it belongs to the turn that started its compaction attempt. +- if (event.properties.requestKind === COMPACTION_REQUEST_KIND) { +- const attemptId = event.properties.compaction_attempt_id; +- if (typeof attemptId !== 'string' || !attemptId) { +- forward(undefined, 'missingAttemptId'); +- return; +- } +- const result = await session.compactionTurnCorrelation.resolve(attemptId); +- forward(result.turnId, result.outcome, result.waitMs); +- return; +- } - if (modelCallId !== undefined) { - const correlatedTurnId = session.modelCallTurnCorrelation.take(modelCallId); - if (correlatedTurnId) { @@ -4526,7 +4725,8 @@ index d7239a4129e..00000000000 - name: localize('copilotAgent.fusionModelName', "Copilot Fusion"), - configSchema, - // Fusion's tiers come from CAPI, which offers HydraFusion as a tier of its own when it supports it. -- _meta: withAgentModelFusionMeta(model._meta), +- // Inline chat runs Fusion at a fixed tier when the provider accepts it, so clients hide its effort level there. +- _meta: withAgentModelFusionMeta(model._meta, getInlineChatFusionAutoTier()), - }; - } - @@ -4739,11 +4939,22 @@ index d7239a4129e..00000000000 - const startupStopWatch = StopWatch.create(); - const startClient = async (computerUseRestarted = false): Promise => { - this._logService.info('[Copilot] Starting CopilotClient...'); +- await this._proxyResolver.loadClientCertificates(); - - // Build a clean env for the CLI subprocess, stripping Electron/VS Code vars - // that can interfere with the Node.js process the SDK spawns. - const appliedProxy = this._readCopilotProxyState(); - const env = this._createCopilotCliEnvironment(startupConfig.skillCharBudget, scoped !== undefined, appliedProxy, startupConfig.executionSubagent, startupConfig.executionSubagentModel); +- if (appliedProxy.certificates.length) { +- try { +- const path = await writeUserDataCertificatesFile(await this._proxyResolver.loadCertificates(), this._environmentService.userDataPath, this._logService); +- if (path) { +- env['NODE_EXTRA_CA_CERTS'] = path; +- } +- } catch { +- this._logService.warn('[Copilot] Failed to configure client CA certificates; starting without them.'); +- } +- } - env['COPILOT_HOME'] = this._copilotHomeService.home.fsPath; - // Family aliases are host-side (prompt and tool-profile routing) and - // deliberately never reach the runtime; an ambient value here would @@ -4793,10 +5004,10 @@ index d7239a4129e..00000000000 - delete env['RUBBER_DUCK_AGENT']; - } - -- setCopilotTgrepEnvironment(env, startupConfig.tgrep); -- if (startupConfig.tgrep) { -- this._logService.info('[Copilot] Enabled repository-size-gated tgrep indexed search'); -- } +- // Let the SDK's TGREP feature flag select indexed search for eligible repositories. +- deleteEnvironmentVariable(env, 'USE_TGREP'); +- deleteEnvironmentVariable(env, 'USE_BUILTIN_RIPGREP'); +- this._logService.info('[Copilot] Enabled repository-size-gated tgrep indexed search'); - - // Keep the SDK wrapper and native module paired within the bundled platform - // package. Only the runtime executable can be explicitly overridden. @@ -4859,6 +5070,14 @@ index d7239a4129e..00000000000 - } - - const clientOptions: CopilotClientOptions = { +- // The runtime tries its own tickets first. A client sharing this agent host's network, such as the Windows +- // client of a WSL agent host, can then supply its own, as for a remote extension host using the local proxy +- // configuration. The runtime process belongs to this agent host, so its shared background traffic may use them too. +- kerberosAuth: { +- mode: 'fallback', +- scope: 'runtime', +- callback: (request: { readonly spn: string }) => this._lookupClientKerberosToken(request.spn), +- }, - useLoggedInUser: false, - gitHubToken, - ...(scoped ? { @@ -4915,9 +5134,7 @@ index d7239a4129e..00000000000 - this._scopedClientStartup.set(client, { config: startupConfig, proxy: appliedProxy }); - return client; - } -- this._appliedProxy = appliedProxy.proxy; -- this._appliedNoProxy = appliedProxy.noProxy; -- this._appliedProxyKerberosSpn = appliedProxy.kerberosSpn; +- this._appliedProxyState = appliedProxy; - this._client = client; - this._clientGeneration++; - this._managedSettingsResolver.invalidate(); @@ -5069,9 +5286,9 @@ index d7239a4129e..00000000000 - const metadata = this._hasProviderAutoTiers() ? this._autoTierMetadata : undefined; - const fusionTiers = metadata?.tiers.filter(tier => tier.type === 'fusion').map(tier => `${tier.id}=${tier.status.enabled ? 'enabled' : `disabled${tier.status.message ? ` (${tier.status.message})` : ''}`}`).join(', ') || 'none'; - const presentation = fusion -- ? `Fusion: CAPI offers a Fusion tier this account can run [${fusionTiers}]` +- ? `Fusion: CAPI offers a Fusion tier [${fusionTiers}]` - : metadata -- ? `Auto with CAPI's tiers, no Fusion: CAPI offers no Fusion tier this account can run, so they are shown as unavailable [${fusionTiers}]` +- ? `Auto with CAPI's tiers, no Fusion: CAPI offers no Fusion tier [${fusionTiers}]` - : `Auto beside HydraFusion (fallback): ${this._lastStartupConfig.dynamicAutoTiers ? 'CAPI provided no Auto tiers' : 'dynamic Auto tiers are off'}; HydraFusion setting adds [${unlistedHydraFusionModels.map(model => model.id).join(', ') || 'none'}]`; - if (presentation !== this._loggedAutoPresentation) { - this._loggedAutoPresentation = presentation; @@ -5184,11 +5401,28 @@ index d7239a4129e..00000000000 - - /** Resolves a selection's SDK-facing name from the built-in registry or applied customizations. */ - private _resolveAgentName(snapshot: IActiveClientSnapshot, agent: AgentSelection): string | undefined { +- return this._resolveAgentSelection(snapshot, agent)?.name; +- } +- +- private _resolveAgentSelection(snapshot: IActiveClientSnapshot, agent: AgentSelection, restoreLegacyFlatSelection = false): { agent: AgentSelection; name: string } | undefined { - const builtinName = COPILOT_BUILTIN_AGENT_NAMES_BY_URI.get(getComparisonKey(URI.parse(agent.uri))); - if (builtinName) { -- return builtinName; +- return { agent, name: builtinName }; +- } +- const resolved = getCopilotAgentSelection(snapshot, agent.uri, restoreLegacyFlatSelection); +- return resolved ? { agent: { uri: resolved.uri }, name: resolved.name } : undefined; +- } +- +- private _updateRestoredAgentBacking(chat: URI, sdkSessionId: string, previous: AgentSelection, agent: AgentSelection): void { +- const current = this._chatBackings.get(chat.toString()); +- if (!current?.agent || current.sdkSessionId !== sdkSessionId +- || !isEqual(URI.parse(current.agent.uri), URI.parse(previous.uri)) +- || isEqual(URI.parse(current.agent.uri), URI.parse(agent.uri))) { +- return; - } -- return getCopilotCustomAgents(snapshot).find(candidate => candidate.uri.toString() === agent.uri)?.name; +- const updated: IPersistedChat = { ...current, agent }; +- this._chatBackings.set(chat.toString(), updated); +- this._onDidChangeChatData.fire({ chat, providerData: encodeProviderData(updated) }); - } - - async listChatsToMigrate(): Promise { @@ -5337,11 +5571,19 @@ index d7239a4129e..00000000000 - this._knownSessionsFilter = filter; - } - -- startChatDiscovery(): Promise { +- startChatDiscovery(options?: IAgentChatDiscoveryOptions): Promise { - if (this._shutdownPromise || this._store.isDisposed) { - return Promise.resolve(); - } -- return this._copilotChatDiscovery.start(); +- if (options) { +- // The host's frozen gate is authoritative: reading the setting here could see a client's value mirrored after host startup. +- this._migrateLegacyEnabledSnapshot = options.migrateLegacyChats; +- } +- if (!this._isMigrateLegacyCopilotCliEnabled()) { +- return this._copilotChatDiscovery.start(); +- } +- // Restored markers must exist before the first scan classifies the chats they describe. +- return this._restoreLegacyMarkersOnce().then(() => this._shutdownPromise || this._store.isDisposed ? undefined : this._copilotChatDiscovery.start()); - } - - /** Publishes eligible chats and returns the candidates that no longer need readiness watching. */ @@ -5768,10 +6010,14 @@ index d7239a4129e..00000000000 - - async getChatMetadata(chat: URI, context: URI | IAgentChatContext, providerData?: string): Promise { - const resolvedContext = resolveAgentChatContext(context, chat); +- const ownerSession = resolvedContext.owner.get(); +- if (!AgentSession.isCanonical(ownerSession)) { +- return undefined; +- } - // A chat stored in a session's database takes that session's SDK id, deferred-execution marker and stored - // metadata, whichever session owns it now; without its backing, only such a chat names a conversation. - const sessionStorage = resolveSessionBackedStorage(resolvedContext, chat); -- const session = sessionStorage ?? resolvedContext.owner.get(); +- const session = sessionStorage ?? ownerSession; - const sessionId = providerData ? decodeProviderData(providerData)?.sdkSessionId : sessionStorage ? AgentSession.id(sessionStorage) : undefined; - if (!sessionId) { - return undefined; @@ -6092,6 +6338,7 @@ index d7239a4129e..00000000000 - sessionSettings: undefined, - launchWorkingDirectories: undefined, - workspaceless: target.workspaceless, +- surface: target.surface, - }; - } - @@ -6123,6 +6370,7 @@ index d7239a4129e..00000000000 - sessionSettings: undefined, - launchWorkingDirectories: undefined, - workspaceless: target.workspaceless, +- surface: target.surface, - }; - } - @@ -6151,6 +6399,7 @@ index d7239a4129e..00000000000 - sessionSettings: context.sessionSettings, - launchWorkingDirectories: context.launchWorkingDirectories, - workspaceless: context.workspaceless, +- surface: getChatSurfaceOptions(context.chatSurface), - }; - } - @@ -6448,6 +6697,7 @@ index d7239a4129e..00000000000 - chat, - isEphemeral: options.isEphemeral === true, - hasScopedEditSurface: options.hasScopedEditSurface === true, +- isInlineChatSurface: options.isInlineChatSurface === true, - workingDirectory, - workingDirectories: options.workingDirectories, - model: options.model, @@ -6543,13 +6793,29 @@ index d7239a4129e..00000000000 - // so the git probe and file I/O overlap on the session-creation path. - const projectPromise = projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService); - const eventsPath = resourceJoinPath(this._copilotHomeService.home, 'session-state', sessionId, 'events.jsonl').fsPath; +- const responses: { turnId: string; responseId: string; parts: readonly StoredAssistantResponsePart[] }[] = []; - const jsonl = buildSessionEventLogFromTurns(importConfig.turns, { - sessionId, - workingDirectory: workingDirectory.fsPath, - model: model?.id, +- onAssistantMessage: (turnId, responseId, parts) => { +- if (parts.some(part => part.kind === ResponsePartKind.Reasoning)) { +- responses.push({ turnId, responseId, parts }); +- } +- }, - }); - await fs.mkdir(dirname(eventsPath), { recursive: true }); - await fs.writeFile(eventsPath, jsonl, 'utf8'); +- if (responses.length) { +- const reference = this._sessionDataService.openDatabase(this._resolveChatStorageScope(chat)); +- try { +- for (const response of responses) { +- await reference.object.setAssistantResponseParts(response.turnId, response.responseId, response.parts); +- } +- } finally { +- reference.dispose(); +- } +- } - - // Persist metadata before resume so `_resumeSession` can resolve the - // working directory and model. @@ -6734,6 +7000,50 @@ index d7239a4129e..00000000000 - } - } - +- private _legacyMarkerFallback: Promise | undefined; +- +- /** +- * Restores, once per host process, the per-session markers of VS Code +- * sessions the extension host recorded only in its bulk metadata files: the +- * shared one in the Copilot home and the older per-install one in this +- * install's `globalStorage` (on a remote, the server's). This covers machines +- * where the extension's own backfill never ran. Never fails: an error leaves +- * those sessions un-adoptable, as before. +- */ +- private _restoreLegacyMarkersOnce(): Promise { +- // Memoized for the process, so it must never reject: that would stop discovery and every later adoption. +- return this._legacyMarkerFallback ??= this._restoreLegacyMarkers().catch(error => { +- this._logService.warn('[Copilot] Failed to restore extension-host markers from bulk metadata', error); +- }); +- } +- +- private async _restoreLegacyMarkers(): Promise { +- const startTime = Date.now(); +- const settingsHome: URI | undefined = this._environmentService.appSettingsHome; +- const result = await restoreMissingLegacyMarkers( +- resourceJoinPath(this._copilotHomeService.home, 'session-state').fsPath, +- resourceJoinPath(this._copilotHomeService.home, EXTENSION_HOST_CLI_SHARED_BULK_FILE).fsPath, +- settingsHome ? resourceJoinPath(settingsHome, ...EXTENSION_HOST_CLI_PER_INSTALL_BULK_FILE).fsPath : undefined, +- EXTENSION_HOST_CLI_MARKER_FILE, +- this._logService, +- ); +- if (result.sharedBulkFile === 'missing' && result.perInstallBulkFile === 'missing') { +- return; +- } +- this._logService.info(`[Copilot] Restored extension-host markers from bulk metadata: shared=${result.sharedBulkFile}, perInstall=${result.perInstallBulkFile}, candidates=${result.candidates}, notVSCode=${result.notVSCode}, exists=${result.exists}, missingSessionDirectory=${result.missingSessionDirectory}, written=${result.written}, failed=${result.failed}`); +- this._telemetryService.publicLog2('agentHost.legacyCopilotCliMarkerFallback', { +- sharedBulkFile: result.sharedBulkFile, +- perInstallBulkFile: result.perInstallBulkFile, +- candidateCount: result.candidates, +- notVSCodeCount: result.notVSCode, +- existingFileCount: result.exists, +- missingSessionDirectoryCount: result.missingSessionDirectory, +- writtenCount: result.written, +- failedCount: result.failed, +- durationMs: Date.now() - startTime, +- }); +- } +- - private async _readExtensionHostCliMarker(sessionId: string): Promise { - return (await this._readExtensionHostCliMarkerWithDiagnostics(sessionId)).marker; - } @@ -6853,6 +7163,65 @@ index d7239a4129e..00000000000 - }; - } - +- /** The worktree the extension host recorded for a session the Agent Host has not adopted. */ +- async readLegacyChatWorktree(session: URI): Promise { +- if (!AgentSession.isCanonical(session)) { +- return undefined; +- } +- // Adoption records the worktree in the host's own metadata, which then owns its removal. +- if ((await this._readStoredSessionMetadata(sessionMetadataScope(session)))?.workingDirectory) { +- return undefined; +- } +- const sessionId = AgentSession.id(session); +- const marker = await this._readExtensionHostCliMarker(sessionId); +- const worktree = isExtensionHostCliMarker(marker) ? marker?.worktreeProperties : undefined; +- if (!worktree?.worktreePath || !worktree.repositoryPath || !worktree.branchName) { +- return undefined; +- } +- if (await this._isLegacyWorktreeUsedByAnotherSession(sessionId, URI.file(worktree.worktreePath))) { +- this._logService.info(`[Copilot] Keeping the worktree of deleted legacy session ${sessionId}: another session recorded by the extension host still uses it`); +- return undefined; +- } +- return { +- branchName: worktree.branchName, +- baseBranch: worktree.baseBranchName, +- worktreePath: URI.file(worktree.worktreePath), +- repositoryRoot: URI.file(worktree.repositoryPath), +- }; +- } +- +- /** +- * Whether another session the extension host recorded works in `worktree` +- * and is not archived, as the extension host checked before deleting a +- * worktree. Read from the markers on disk rather than the host's registry, +- * which discovery may not have filled yet. `true` when that cannot be +- * established, which keeps the folder. +- */ +- private async _isLegacyWorktreeUsedByAnotherSession(sessionId: string, worktree: URI): Promise { +- const root = resourceJoinPath(this._copilotHomeService.home, 'session-state').fsPath; +- let sessionIds: string[]; +- try { +- sessionIds = (await fs.readdir(root, { withFileTypes: true })).filter(entry => entry.isDirectory() && entry.name !== sessionId).map(entry => entry.name); +- } catch (error) { +- this._logService.warn(`[Copilot] Failed to list legacy sessions sharing the worktree of ${sessionId}; keeping it`, error); +- return true; +- } +- const limiter = new Limiter(16); +- const usages = await Promise.all(sessionIds.map(id => limiter.queue(async () => { +- let raw: string; +- try { +- raw = await fs.readFile(join(root, id, EXTENSION_HOST_CLI_MARKER_FILE), 'utf8'); +- } catch (error) { +- const code = getErrorCode(error); +- return code !== 'ENOENT' && code !== 'ENOTDIR'; +- } +- const marker = parseExtensionHostCliMarker(raw); +- return !!marker && marker.archived !== true +- && extensionHostCliWorkingDirectoryPaths(marker).some(path => extUriBiasedIgnorePathCase.isEqualOrParent(URI.file(path), worktree)); +- }))); +- return usages.some(used => used); +- } +- - /** - * Repairs durable metadata on a legacy Copilot CLI session adopted by a build - * that predates it. Keyed off the extension-host marker, so it never claims a @@ -6913,6 +7282,9 @@ index d7239a4129e..00000000000 - /** Adopts a legacy extension-host Copilot CLI session in place when it is eligible on disk. */ - async ensureChatAdopted(chat: URI, context: URI | IAgentChatContext): Promise { - const session = resolveAgentChatContext(context, chat).owner.get(); +- if (!AgentSession.isCanonical(session)) { +- return { adopted: false, eligible: false, reason: 'notLegacyChat' }; +- } - const sessionId = AgentSession.id(session); - return this._queueSession(sessionId, async (): Promise => { - // A genuine native / already-adopted session always has a persisted @@ -6929,6 +7301,8 @@ index d7239a4129e..00000000000 - } - // Only migrate legacy EH Copilot CLI sessions — never other Copilot SDK - // sessions (standalone CLI, Local agent, …) that share `~/.copilot`. +- // A session the extension recorded only in its bulk metadata gets its marker first. +- await this._restoreLegacyMarkersOnce(); - const markerRead = await this._readExtensionHostCliMarkerWithDiagnostics(sessionId); - if (!isExtensionHostCliMarker(markerRead.marker)) { - this._logService.info(`[Copilot] Adoption declined for ${sessionId}: marker=${markerRead.diagnostics.markerStatus}, provenance=${markerRead.diagnostics.provenance}, cached=${markerRead.diagnostics.markerFromCache}`); @@ -7221,6 +7595,7 @@ index d7239a4129e..00000000000 - sessionId: sdkSessionId, - isEphemeral: provisional.isEphemeral, - hasScopedEditSurface: provisional.hasScopedEditSurface, +- isInlineChatSurface: provisional.isInlineChatSurface, - workingDirectory, - additionalDirectories: this._additionalCustomizationDirectories(resolvedWorkingDirectories), - resolvedAgentName: resolvedAgent?.name, @@ -7281,6 +7656,12 @@ index d7239a4129e..00000000000 - if (agent !== undefined) { - await this._storeSessionAgentMetadata(storage, agent); - } +- if (provisional.agent) { +- const restored = getCopilotAgentSelection(agentSession.appliedSnapshot, provisional.agent.uri, true); +- if (restored) { +- this._updateRestoredAgentBacking(chat, sdkSessionId, provisional.agent, { uri: restored.uri }); +- } +- } - - // Capture the per-session baseline (turn/0) git checkpoint so - // per-turn diffs computed on `ChatTurnComplete` can reflect the @@ -7530,13 +7911,14 @@ index d7239a4129e..00000000000 - const alternativeAgentName = alternativeAgent ? this._resolveAgentName(snapshot, alternativeAgent) : undefined; - - if (originalAgentName) { -- return { agent: agent, name: originalAgentName }; +- return { agent, name: originalAgentName }; - } - if (alternativeAgentName && alternativeAgent) { - this._logService.info(`[Copilot] Agent file ${agent.uri} is in the original repo; using worktree agent ${alternativeAgent?.uri}`); - return { agent: alternativeAgent, name: alternativeAgentName }; - } -- return undefined; +- return this._resolveAgentSelection(snapshot, agent, true) +- ?? (alternativeAgent ? this._resolveAgentSelection(snapshot, alternativeAgent, true) : undefined); - } - private _getAlternativeAgentForWorktree(provisional: IProvisionalSession, workingDirectory: URI | undefined): AgentSelection | undefined { - const agent = provisional.agent; @@ -7596,8 +7978,7 @@ index d7239a4129e..00000000000 - if (params.config?.[SessionConfigKey.AutoApprove] === undefined) { - delete values[SessionConfigKey.AutoApprove]; - } -- const client = await this._ensureClient(); -- const resolved = await this._managedSettingsResolver.resolve(client.rpc.managedSettings, this._githubCredentials.token, params.workingDirectory?.fsPath); +- const resolved = await this._resolveChatConfigManagedSettings(params.workingDirectory?.fsPath); - const policy = getCopilotApprovalPolicy(resolved.resolved, - this._configurationService.getRootValue(platformRootSchema, AgentHostAutoApprovePolicyRestrictedConfigKey) === true, - this._managedSettingsService.permissions); @@ -7609,6 +7990,27 @@ index d7239a4129e..00000000000 - return result; - } - +- /** +- * Resolves managed settings for {@link resolveChatConfig}, retrying with the current SDK client when the one it +- * acquired is stopped or replaced before the query settles. That restart disposes the query's connection and can +- * land right after the first start, for example when a CAPI proxy resolved through a client sharing this agent +- * host's network arrives while the client is starting. Unlike chat turns, config resolution does not defer it. +- */ +- private async _resolveChatConfigManagedSettings(workingDirectory: string | undefined): ReturnType { +- for (let attempt = 1; ; attempt++) { +- const client = await this._ensureClient(); +- const generation = this._clientGeneration; +- try { +- return await this._managedSettingsResolver.resolve(client.rpc.managedSettings, this._githubCredentials.token, workingDirectory); +- } catch (error) { +- if (attempt >= MAX_CHAT_CONFIG_CLIENT_ATTEMPTS || this._shutdownPromise || this._client === client && this._clientGeneration === generation) { +- throw error; +- } +- this._logService.info('[Copilot] SDK client replaced while resolving chat config; retrying with the current client', error); +- } +- } +- } +- - getInheritedChatConfig(config: Readonly>): Record | undefined { - const inherited: Record = {}; - for (const key of [SessionConfigKey.AutoApprove, SessionConfigKey.Permissions]) { @@ -7865,7 +8267,7 @@ index d7239a4129e..00000000000 - await this._destroyLiveSession(entry, true); - const token = operation === 'startMcpServer' ? CancellationToken.None : options.token; - if (entry.sessionId === context.sessionBackedId) { -- replacement = await this._resumeSession(entry.sessionId, context.chat, context.workspaceless, workingDirectories, undefined, token); +- replacement = await this._resumeSession(entry.sessionId, context.chat, context.workspaceless, workingDirectories, undefined, token, context.surface); - } else { - replacement = await this._ensureResolvedChatSession(context, workingDirectories, undefined, token); - if (!replacement) { @@ -8155,6 +8557,7 @@ index d7239a4129e..00000000000 - try { - const backing = this._chatBackings.get(context.chatKey); - const result = await mapSessionEvents(context.resource, reference.object, events, context.chat, context.ownerSession, { +- logService: this._logService, - workingDirectory: metadata.workingDirectory, - clientToolNames: new Set(this._activeClients.get(context.ownerSession)?.toolSet.merged().map(tool => tool.name) ?? []), - model: backing?.model ?? metadata.model, @@ -8164,11 +8567,13 @@ index d7239a4129e..00000000000 - message: localize('copilotAgent.interruptedTurn', "The agent was interrupted before this request finished."), - }, - }); -- await restoreStoredShellOutputsForChat(result.turns, context.resource, context.ownerSession, context.chat, reference.object); -- for (const [toolCallId, turns] of result.subagentTurnsByToolCallId) { -- const child = URI.parse(subagentChatUri(context.ownerRef.getSubagentChatUriFormat(), context.ownerSession.toString(), context.chatKey, toolCallId)); -- await restoreStoredShellOutputsForChat(turns, context.resource, context.ownerSession, child, reference.object); -- } +- await restoreStoredShellOutputs([ +- { turns: result.turns, chat: context.chat }, +- ...Array.from(result.subagentTurnsByToolCallId, ([toolCallId, turns]) => ({ +- turns, +- chat: URI.parse(subagentChatUri(context.ownerRef.getSubagentChatUriFormat(), context.ownerSession.toString(), context.chatKey, toolCallId)), +- })), +- ], context.resource, context.ownerSession, reference.object); - return result; - } finally { - reference.dispose(); @@ -8489,7 +8894,7 @@ index d7239a4129e..00000000000 - return this._materializeProvisional(context, workingDirectories, stageRecorder, { token }); - } - if (context.sdkSessionId !== undefined && context.sdkSessionId === context.sessionBackedId) { -- return context.target ? this._withRecordedScratchFolder(context.target, context.ownerSessionId) : this._resumeSession(context.sdkSessionId, context.chat, context.workspaceless, workingDirectories, stageRecorder, token); +- return context.target ? this._withRecordedScratchFolder(context.target, context.ownerSessionId) : this._resumeSession(context.sdkSessionId, context.chat, context.workspaceless, workingDirectories, stageRecorder, token, context.surface); - } - if (context.sdkSessionId) { - const lifetime = this._getOrCreateSessionLifetime(context.sdkSessionId); @@ -8772,6 +9177,7 @@ index d7239a4129e..00000000000 - chat, - isEphemeral: false, - hasScopedEditSurface: false, +- isInlineChatSurface: false, - workingDirectory: metadata.workingDirectory, - workingDirectories: metadata.workingDirectories, - model: backing.model ?? metadata.model, @@ -9017,6 +9423,10 @@ index d7239a4129e..00000000000 - // The chat's own anchor set, held until its entry holds it so the set is discovered only once. - anchor = await this._acquireChatCustomizationAnchor(activeClient, workingDirectory, additionalDirectories, context.workspaceless); - const snapshot = await activeClient.snapshot(chatKey, anchor?.object, info.agent?.uri); +- const resolvedAgent = info.agent ? this._resolveAgentSelection(snapshot, info.agent, true) : undefined; +- if (info.agent && !resolvedAgent) { +- this._logService.info(`[Copilot] Stored custom agent ${info.agent.uri} is unavailable or ambiguous in the current snapshot; resuming without a custom agent`); +- } - const disabledRootMcpServers = await this._disabledRootMcpServers(ownerSession, snapshot); - const shellManager = this._instantiationService.createInstance(ShellManager, ownerSession, chat, context.resource, workingDirectory); - const launchPlan: CopilotSessionLaunchPlan = { @@ -9026,7 +9436,7 @@ index d7239a4129e..00000000000 - sessionId: info.sdkSessionId, - workingDirectory, - additionalDirectories, -- resolvedAgentName: info.agent ? this._resolveAgentName(snapshot, info.agent) : undefined, +- resolvedAgentName: resolvedAgent?.name, - snapshot, - disabledRootMcpServers, - activeClientToolSet: activeClient.toolSet, @@ -9034,6 +9444,7 @@ index d7239a4129e..00000000000 - githubCredentials: this._getGitHubSessionCredentials(chat), - workspaceless: storedMetadata.workspaceless, - stageRecorder, +- ...context.surface, - fallback: { model: info.model, longContextWindow: this._longContextWindowFor(info.model?.id), freeLongContext: this._isFreeLongContext(info.model?.id) }, - }; - agentSession = this._createAgentSession(launchPlan, workingDirectory, activeClient, { owner: context.ownerRef, chatChannelUri: chat, resource: context.resource }); @@ -9043,6 +9454,9 @@ index d7239a4129e..00000000000 - this._throwIfWorkingDirectoryMutationBlocksChat(ownerSession, chat); - this._registerLiveChat(chat, initializingSession, activeClient); - }, undefined, token); +- if (resolvedAgent && info.agent) { +- this._updateRestoredAgentBacking(chat, info.sdkSessionId, info.agent, resolvedAgent.agent); +- } - stageRecorder?.mark('persist'); - // An archived worktree's repository-root fallback is for history, not a workspace change. - if (launchWorkingDirectories && isEqual(workingDirectory, persistedWorkingDirectory)) { @@ -9235,7 +9649,7 @@ index d7239a4129e..00000000000 - const omittedContextDefault = model.config?.[ContextSizeConfigKey] !== undefined && requestedModel.config?.[ContextSizeConfigKey] === undefined; - // Clear stale SDK preferences when a selection or an override is removed. - const autoTier = isAutoModel(model.id) -- ? resolveCopilotAutoTier(requestedModel, this._configurationService, this._logService, context.ownerSessionId) ?? (hasCopilotManagedAutoPreference(policy) ? undefined : null) +- ? resolveCopilotAutoTier(requestedModel, this._configurationService, this._logService, context.ownerSessionId, context.surface?.isInlineChatSurface === true) ?? (hasCopilotManagedAutoPreference(policy) ? undefined : null) - : undefined; - const contextTier = omittedContextDefault ? undefined : getCopilotContextTier(requestedModel, longContextWindow, freeLongContext); - await entry.setModel(model.id, resolveCopilotReasoningEffort(requestedModel, this._configurationService, this._logService, context.ownerSessionId), contextTier ?? (isAutoModel(model.id) ? 'default' : undefined), autoTier, model); @@ -9378,12 +9792,20 @@ index d7239a4129e..00000000000 - return this._configurationService.getRootValue(agentHostProxyConfigSchema, AgentHostProxyConfigKey.Proxy)?.trim() || undefined; - } - +- /** A base64 initial SPNEGO token for the runtime's proxy principal from a client sharing this agent host's network. */ +- private async _lookupClientKerberosToken(spn: string): Promise { +- const proxyUrl = getKerberosPrincipalProxyUrl(spn); +- const authorization = proxyUrl ? await this._proxyResolver.lookupClientKerberosAuthorization(proxyUrl) : undefined; +- return authorization ? /^Negotiate (?\S+)$/i.exec(authorization)?.groups?.token : undefined; +- } +- - private _readCopilotProxyState(): ICopilotClientProxyState { - const proxy = this._readConfiguredProxy() ?? (this._isSystemProxyEnabled() ? this._resolvedProxy : undefined); - return { - proxy, - noProxy: getCopilotNoProxy(proxy, this._readNoProxy(process.env)), - kerberosSpn: this._readKerberosSpn(process.env), +- certificates: this._proxyResolver.clientCertificates, - }; - } - @@ -9453,14 +9875,15 @@ index d7239a4129e..00000000000 - private _refreshProxy(): void { - const generation = ++this._proxyResolutionGeneration; - const refresh = this._resolveProxyForSdk().then(async proxy => { +- if (this._client || this._clientStarting || this._scopedClients.size > 0) { +- await this._proxyResolver.loadClientCertificates(); +- } - if (generation !== this._proxyResolutionGeneration) { - return; - } - this._resolvedProxy = proxy; -- const effectiveProxy = this._readConfiguredProxy() ?? (this._isSystemProxyEnabled() ? proxy : undefined); -- const effectiveNoProxy = getCopilotNoProxy(effectiveProxy, this._readNoProxy(process.env)); -- const effectiveKerberosSpn = this._readKerberosSpn(process.env); -- if (effectiveProxy === this._appliedProxy && effectiveNoProxy === this._appliedNoProxy && effectiveKerberosSpn === this._appliedProxyKerberosSpn) { +- const effectiveProxy = this._readCopilotProxyState(); +- if (equals(effectiveProxy, this._appliedProxyState)) { - return; - } - if (this._clientStarting) { @@ -9472,20 +9895,23 @@ index d7239a4129e..00000000000 - // A newer proxy resolution (or the client start we just awaited) - // may have already superseded this one; re-check both so we don't - // restart based on a stale comparison. -- if (generation !== this._proxyResolutionGeneration || (effectiveProxy === this._appliedProxy && effectiveNoProxy === this._appliedNoProxy && effectiveKerberosSpn === this._appliedProxyKerberosSpn)) { +- if (generation !== this._proxyResolutionGeneration || equals(effectiveProxy, this._appliedProxyState)) { - return; - } - } - const changes: string[] = []; -- if (effectiveProxy !== this._appliedProxy) { -- changes.push(`proxy ${this._appliedProxy ?? '(none)'} -> ${effectiveProxy ?? '(none)'}`); +- if (effectiveProxy.proxy !== this._appliedProxyState.proxy) { +- changes.push(`proxy ${this._appliedProxyState.proxy ?? '(none)'} -> ${effectiveProxy.proxy ?? '(none)'}`); - } -- if (effectiveNoProxy !== this._appliedNoProxy) { +- if (effectiveProxy.noProxy !== this._appliedProxyState.noProxy) { - changes.push('NO_PROXY changed'); - } -- if (effectiveKerberosSpn !== this._appliedProxyKerberosSpn) { +- if (effectiveProxy.kerberosSpn !== this._appliedProxyState.kerberosSpn) { - changes.push('Kerberos SPN changed'); - } +- if (!equals(effectiveProxy.certificates, this._appliedProxyState.certificates)) { +- changes.push('client certificates changed'); +- } - await this._requestClientRestart(`CAPI proxy configuration changed (${changes.join(', ')})`); - }).catch(error => this._logService.error('[Copilot] Failed to refresh CAPI proxy', error)); - this._proxyRefresh = refresh; @@ -9618,7 +10044,10 @@ index d7239a4129e..00000000000 - session, - rootServers, - )); -- return rootServers.filter(server => enablement.get(server.id) !== true).map(server => server.name); +- return [...new Set([ +- ...rootServers.filter(server => enablement.get(server.id) !== true).map(server => server.name), +- ...(snapshot.disabledNativeMcpServers ?? []), +- ])]; - } - - /** @@ -9659,7 +10088,7 @@ index d7239a4129e..00000000000 - } - }, () => { - this._slashCommandProvider.clearCache(); -- activeClient.pluginController.refreshDiscovery(); +- void activeClient.pluginController.refreshDiscovery(); - }); - } - @@ -9795,7 +10224,7 @@ index d7239a4129e..00000000000 - } - } - -- protected _resumeSession(sessionId: string, chatChannelUri: URI, workspaceless: boolean | undefined, workingDirectories?: readonly URI[], stageRecorder?: IAgentProviderSendStageRecorder, token = CancellationToken.None): Promise { +- protected _resumeSession(sessionId: string, chatChannelUri: URI, workspaceless: boolean | undefined, workingDirectories?: readonly URI[], stageRecorder?: IAgentProviderSendStageRecorder, token = CancellationToken.None, surface?: CopilotChatSurface): Promise { - const current = this._chatBackings.get(chatChannelUri.toString()); - this._chatBackings.set(chatChannelUri.toString(), { ...current, sdkSessionId: sessionId }); - const lifetime = this._getOrCreateSessionLifetime(sessionId); @@ -9808,17 +10237,17 @@ index d7239a4129e..00000000000 - throw new CancellationError(); - } - try { -- return await this._doResumeSession(sessionId, chatChannelUri, workspaceless, workingDirectories, stageRecorder, token); +- return await this._doResumeSession(sessionId, chatChannelUri, workspaceless, workingDirectories, stageRecorder, token, surface); - } finally { - lease.dispose(); - } - }); - } - -- private _doResumeSession(sessionId: string, chatChannelUri: URI, workspaceless: boolean | undefined, workingDirectories?: readonly URI[], stageRecorder?: IAgentProviderSendStageRecorder, token = CancellationToken.None): Promise { +- private _doResumeSession(sessionId: string, chatChannelUri: URI, workspaceless: boolean | undefined, workingDirectories?: readonly URI[], stageRecorder?: IAgentProviderSendStageRecorder, token = CancellationToken.None, surface?: CopilotChatSurface): Promise { - const canvasHandoff = this._canvasHandoffs.get(chatChannelUri); - let replacement: CopilotAgentSession | undefined; -- return this._retryRestoreAfterClientReplacement(acquireClient => this._resumeSessionOnce(sessionId, chatChannelUri, workspaceless, acquireClient, workingDirectories, stageRecorder, token), chatChannelUri).then(session => { +- return this._retryRestoreAfterClientReplacement(acquireClient => this._resumeSessionOnce(sessionId, chatChannelUri, workspaceless, acquireClient, workingDirectories, stageRecorder, token, surface), chatChannelUri).then(session => { - replacement = session; - return session; - }).finally(() => { @@ -9828,7 +10257,7 @@ index d7239a4129e..00000000000 - }); - } - -- private async _resumeSessionOnce(sessionId: string, chatChannelUri: URI, workspaceless: boolean | undefined, acquireClient: () => Promise, workingDirectories?: readonly URI[], stageRecorder?: IAgentProviderSendStageRecorder, token = CancellationToken.None): Promise { +- private async _resumeSessionOnce(sessionId: string, chatChannelUri: URI, workspaceless: boolean | undefined, acquireClient: () => Promise, workingDirectories?: readonly URI[], stageRecorder?: IAgentProviderSendStageRecorder, token = CancellationToken.None, surface?: CopilotChatSurface): Promise { - this._logService.info(`[Copilot:${sessionId}] _resumeSession called — session not in memory, resuming...`); - // The chat stores in this session's database; its owner is the one the host recorded (a legacy resume no host context recorded is owned by that session). - const sessionUri = AgentSession.uri(this.id, sessionId); @@ -9884,7 +10313,8 @@ index d7239a4129e..00000000000 - - const disabledRootMcpServers = await this._disabledRootMcpServers(owner.get(), snapshot); - const shellManager = this._instantiationService.createInstance(ShellManager, owner.get(), this._findBoundSessionChatUri(sessionId) ?? sessionUri, sessionUri, resolvedWorkingDirectory); -- const resolvedAgentName = storedMetadata.agent ? this._resolveAgentName(snapshot, storedMetadata.agent) : undefined; +- const resolvedAgent = storedMetadata.agent ? this._resolveAgentSelection(snapshot, storedMetadata.agent, true) : undefined; +- const resolvedAgentName = resolvedAgent?.name; - if (storedMetadata.agent && !resolvedAgentName) { - this._logService.info(`[Copilot:${sessionId}] Stored custom agent is not available in the current plugin snapshot; resuming without a custom agent`); - } @@ -9903,6 +10333,7 @@ index d7239a4129e..00000000000 - githubCredentials: this._getGitHubSessionCredentials(chatChannelUri), - workspaceless: storedMetadata.workspaceless, - stageRecorder, +- ...surface, - fallback: { - model: storedMetadata.model, - longContextWindow: this._longContextWindowFor(storedMetadata.model?.id), @@ -9917,6 +10348,9 @@ index d7239a4129e..00000000000 - }, async () => { - stageRecorder?.mark('persist'); - await this._storeSessionMetadata({ storage: sessionUri, owner: owner.get() }, undefined, undefined, launchWorkingDirectories, undefined, undefined); +- if (resolvedAgent && storedMetadata.agent && !isEqual(URI.parse(resolvedAgent.agent.uri), URI.parse(storedMetadata.agent.uri))) { +- await this._storeSessionAgentMetadata(sessionUri, resolvedAgent.agent); +- } - }, token); - } catch (err) { - agentSession.dispose(); @@ -10404,13 +10838,19 @@ index d7239a4129e..00000000000 - super.dispose(); - } - -- whenSettled(): Promise { -- return this._settled; +- /** Waits for discovery to finish, including scans that supersede the current one. */ +- async whenSettled(): Promise { +- let settled: Promise; +- do { +- settled = this._settled; +- await settled; +- } while (settled !== this._settled); - } - - /** Re-runs discovery for changes no watched file reports, such as plugins the runtime installed. */ -- refresh(): void { -- this._settled = this._queueRefresh(true); +- refresh(): Promise { +- this._settled = this._queueRefresh(true, 0); +- return this.whenSettled(); - } - - /** Whether no discovery refresh is queued or running. */ @@ -10671,11 +11111,12 @@ index d7239a4129e..00000000000 - - public async resolveSyncedCustomization(item: ISyncedCustomization, clientId: string, input: ClientPluginCustomization | undefined): Promise { - const baseCustomization: PluginCustomization = { ...item.customization, clientId }; -- if (!item.pluginDir) { +- const pluginDir = item.pluginDir; +- if (!pluginDir) { - return { customization: baseCustomization, input }; - } - -- const parsed = await this.tryParsePlugin(item.pluginDir); +- const parsed = await this.tryParsePlugin(pluginDir, false); - if (!parsed) { - return { - customization: { @@ -10686,20 +11127,35 @@ index d7239a4129e..00000000000 - }; - } - +- const materializedRoot = URI.parse(pluginDir.toString()); +- const sourceRoot = URI.parse(baseCustomization.uri); - return { - customization: { - ...baseCustomization, -- children: toChildCustomizations([parsed]), +- children: toChildCustomizations([parsed]).map(child => { +- if (child.type !== CustomizationType.Agent) { +- return child; +- } +- const resource = URI.parse(child.uri); +- const path = isEqualOrParent(resource, materializedRoot) ? relativePath(materializedRoot, resource) : undefined; +- return path === undefined ? child : { +- ...child, +- _meta: { +- ...child._meta, +- ...toAgentCustomizationMeta({ 'vscode.sourceUri': resourceJoinPath(sourceRoot, path).toString() }), +- }, +- }; +- }), - }, -- pluginDir: item.pluginDir, +- pluginDir, - plugin: parsed, - input, - }; - } - -- public async tryParsePlugin(pluginDir: URI): Promise { +- public async tryParsePlugin(pluginDir: URI, deduplicateAgentsByName = true): Promise { - try { -- return await parsePlugin(pluginDir, this._fileService, undefined, this.getUserHome(), pluginDir); +- return await parsePlugin(pluginDir, this._fileService, undefined, this.getUserHome(), pluginDir, { deduplicateAgentsByName }); - } catch (error) { - this._logService.warn(`[Copilot:PluginController] Error parsing plugin '${pluginDir.toString()}': ${error instanceof Error ? error.message : String(error)}`); - return undefined; @@ -10788,10 +11244,11 @@ index d7239a4129e..00000000000 - } - - /** Re-runs on-disk discovery if it has started; an anchor that has not discovered yet will see current state when it does. */ -- refreshDiscovery(): void { +- refreshDiscovery(): Promise | undefined { - if (!this._store.isDisposed) { -- this._discovered.value?.refresh(); +- return this._discovered.value?.refresh(); - } +- return undefined; - } -} - @@ -10951,10 +11408,8 @@ index d7239a4129e..00000000000 - } - - /** Re-runs discovery for every live anchor set; a changed published set republishes the session's customizations. */ -- public refreshDiscovery(): void { -- for (const { anchor } of this._anchors.values()) { -- anchor.refreshDiscovery(); -- } +- public async refreshDiscovery(): Promise { +- await Promise.all([...this._anchors.values()].map(({ anchor }) => anchor.refreshDiscovery())); - } - - private _onDidChangeAnchor(anchor: CustomizationAnchor): void { @@ -11073,17 +11528,67 @@ index d7239a4129e..00000000000 - ...clientCustomizations.map(item => this._projectForPublish(item.customization)), - ]; - const discovered = anchor?.discoveredEntry()?.currentCustomizations() ?? []; +- const workspaceDefinitions = anchor?.mcpDiscoveryEntry()?.definitions ?? []; +- const declaredMcpNames = new Set(getMcpServerCustomizations([ +- ...result, +- ...workspaceDefinitions.map(definition => definition.customization), +- ]).map(server => server.name)); - for (const customization of discovered) { - if (customization.type === CustomizationType.Plugin && explicitPluginIds.has(customization.id)) { - continue; - } +- if (customization.type === CustomizationType.Plugin) { +- const children = customization.children?.filter(child => child.type !== CustomizationType.McpServer || !declaredMcpNames.has(child.name)); +- if (customization.children?.length && children?.length === 0) { +- continue; +- } +- result.push(this._projectForPublish({ ...customization, children })); +- continue; +- } +- if (customization.type === CustomizationType.McpServer) { +- if (!declaredMcpNames.has(customization.name)) { +- result.push(this._projectForPublish({ +- ...customization, +- id: buildMcpTopLevelCustomizationId(COPILOT_CLI_AGENT_PROVIDER_ID, AgentSession.id(this._session), customization.name), +- })); +- } +- continue; +- } - result.push(this._projectForPublish(customization)); - } -- for (const definition of anchor?.mcpDiscoveryEntry()?.definitions ?? []) { +- for (const definition of workspaceDefinitions) { - result.push(this._projectForPublish(definition.customization)); - } - result.push(buildCopilotBuiltinAgentsContainer()); -- return resolveCustomizationEnablement(this._customizationEnablementService, this._session, result, this._clientChildEnablement(), this._clientPlugins()); +- const hasNativePluginMcp = result.some(customization => customization.type === CustomizationType.Plugin +- && customization.children?.some(child => child.type === CustomizationType.McpServer && readCustomizationDiscoveryMeta(child)?.source === 'plugin')); +- const live = hasNativePluginMcp ? this._topLevelMcpServers() : undefined; +- const owners = new Map(live?.owners); +- const nativeLiveServers: McpServerCustomization[] = []; +- for (const server of live?.customizations ?? []) { +- if (server.type !== CustomizationType.McpServer) { +- continue; +- } +- const native = findDiscoveredPluginMcpServer(result, server.name, readMcpServerSource(server), readMcpServerSourcePlugin(server)); +- if (!native) { +- continue; +- } +- if (!readCustomizationDiscoveryMeta(server) && !owners.has(server.name)) { +- this._customizationEnablementService.adoptMcpServerOwnership(this._session.toString(), targetForMcpServer(server, undefined, false), URI.parse(native.plugin.uri)); +- } +- // A confirmed runtime default belongs to both identities of the same SDK declaration. +- const projected = { ...this._projectForPublish({ ...native.server, id: server.id }), id: native.server.id }; +- result[result.indexOf(native.plugin)] = { ...native.plugin, children: native.plugin.children?.map(child => child === native.server ? projected : child) }; +- nativeLiveServers.push(server); +- if (!owners.has(server.name)) { +- owners.set(server.name, native.plugin.uri); +- } +- } +- const merged = mergeMcpServerCustomizations(result, nativeLiveServers); +- return { +- ...resolveCustomizationEnablement(this._customizationEnablementService, this._session, merged, this._clientChildEnablement(), this._clientPlugins(), owners), +- explicitPluginIds, +- }; - } - - /** @@ -11178,7 +11683,7 @@ index d7239a4129e..00000000000 - } - - private _selectedAgentFallbackAnchor(customizations: ICopilotAppliedCustomizations, anchor: CustomizationAnchor | undefined, selectedAgentUri: string | undefined): CustomizationAnchor | undefined { -- if (!selectedAgentUri || !anchor || getCopilotCustomAgents(customizations).some(agent => agent.uri.toString() === selectedAgentUri)) { +- if (!selectedAgentUri || !anchor || getCopilotAgentSelectionName(customizations, selectedAgentUri, true) !== undefined) { - return undefined; - } - const published = this._publishedAnchor(); @@ -11186,6 +11691,10 @@ index d7239a4129e..00000000000 - } - - private _withAgentFrom(customizations: ICopilotAppliedCustomizations, source: ICopilotAppliedCustomizations, selectedAgentUri: string): ICopilotAppliedCustomizations { +- const nativeAgent = source.nativeAgents?.find(agent => isEqual(URI.parse(agent.uri), URI.parse(selectedAgentUri))); +- if (nativeAgent) { +- return { ...customizations, nativeAgents: [...(customizations.nativeAgents ?? []), nativeAgent] }; +- } - const agent = getCopilotCustomAgents(source).find(candidate => candidate.uri.toString() === selectedAgentUri); - return agent ? { - ...customizations, @@ -11213,6 +11722,21 @@ index d7239a4129e..00000000000 - const desiredByUri = new Map(resolved.customizations.map(customization => [customization.uri, customization])); - const desiredById = new Map(resolved.customizations.map(customization => [customization.id, customization])); - const mcpEnablement = getSdkMcpServerEnablement(resolved); +- const disabledNativeMcpServers: string[] = []; +- for (const customization of resolved.customizations) { +- for (const server of getMcpServerCustomizations([customization])) { +- if (readCustomizationDiscoveryMeta(server)?.defaultEnabled === undefined || mcpEnablement.get(server.id) === true) { +- continue; +- } +- const hostDecision = this._customizationEnablementService.resolve(this._session.toString(), { +- ...targetForMcpServer(server, customization.type === CustomizationType.Plugin ? customization.uri : undefined, false), +- defaultEnabled: undefined, +- }); +- if (hostDecision.kind === 'pending' || hostDecision.enablement.length > 0 || (customization.type === CustomizationType.Plugin && (customization.enablement?.length ?? 0) > 0)) { +- disabledNativeMcpServers.push(server.name); +- } +- } +- } - const isEnabledForSdk = (customization: Customization) => { - const desired = desiredById.get(customization.id) ?? desiredByUri.get(customization.uri) ?? customization; - return isCustomizationSdkEligible(resolved, desired) && (desired.type === CustomizationType.Directory ? desired.enabled : isCustomizationEnabled(desired)); @@ -11227,6 +11751,9 @@ index d7239a4129e..00000000000 - const discovered = anchor?.discoveredEntry()?.currentCustomizations() ?? []; - const discoveredDirectories = discovered.filter((customization): customization is DirectoryCustomization => customization.type === CustomizationType.Directory && isEnabledForSdk(customization)); - const discoveredCustomizations = mapDiscoveredCustomizations(discoveredDirectories, anchor?.directory, anchor?.additionalDirectories ?? []); +- const nativeAgents = discovered.flatMap(customization => customization.type === CustomizationType.Plugin && !resolved.explicitPluginIds.has(customization.id) && isEnabledForSdk(customization) +- ? customization.children?.filter((child): child is AgentCustomization => child.type === CustomizationType.Agent) ?? [] +- : []); - const withSdkRegistration = (plugin: IParsedPlugin, pluginDir: URI | undefined): ICopilotPluginInfo => ({ - ...plugin, - pluginDir, @@ -11261,6 +11788,7 @@ index d7239a4129e..00000000000 - instructions: [], - } satisfies ICopilotPluginInfo] : []; - return { +- ...(disabledNativeMcpServers.length > 0 ? { disabledNativeMcpServers } : {}), - plugins: [ - ...workspaceMcp, - ...host.filter(item => !!item.plugin && isEnabledForSdk(item.customization)) @@ -11269,6 +11797,7 @@ index d7239a4129e..00000000000 - .map(item => ({ ...withClientDefaults(item), sourceUri: URI.parse(item.customization.uri), ...(disabledChildren(item.customization) ? { disabledMcpServers: disabledChildren(item.customization) } : {}) })), - ], - ...discoveredCustomizations, +- ...(nativeAgents.length > 0 ? { nativeAgents } : {}), - }; - } - @@ -11350,7 +11879,8 @@ index d7239a4129e..00000000000 - return; - } - publishUpdate({ -- customization: { ...status, clientId }, +- // Transfer completion precedes parsing; only the resolved result is ready for selection. +- customization: { ...status, clientId, load: status.load?.kind === CustomizationLoadStatus.Loaded ? { kind: CustomizationLoadStatus.Loading } : status.load }, - input: inputByUri.get(status.uri), - }); - }); @@ -11517,6 +12047,9 @@ index d7239a4129e..00000000000 - } - - private _desiredEnabled(customization: Customization | ChildCustomization): boolean | undefined { +- if (customization.type === CustomizationType.Hook && readCustomizationDiscoveryMeta(customization)?.hookType) { +- return customization.enabled; +- } - const exact = this._getDesiredCustomization(customization.id); - if (exact) { - return exact.type === CustomizationType.Plugin || exact.type === CustomizationType.McpServer @@ -11582,7 +12115,7 @@ index d7239a4129e..00000000000 - * stable. - */ - private _projectForPublish(customization: T): T { -- return applyMcpServerRuntimeStates(this._applyEnablement(customization), this.mcpServerStates.get()); +- return this._applyEnablement(applyMcpServerRuntimeStates(customization, this.mcpServerStates.get())); - } -} - @@ -11811,14 +12344,16 @@ index d7239a4129e..00000000000 - async getRestartReason(snap: IActiveClientSnapshot, chatKey?: string, current?: IActiveClientSnapshot, anchor?: CustomizationAnchor): Promise { - current ??= await this.snapshot(chatKey, anchor); - const component = !parsedPluginsEqual(snap.plugins, current.plugins) ? 'plugins' -- : !arrayEquals(snap.discoveredAgents, current.discoveredAgents, discoveredResourceEquals) ? 'discoveredAgents' -- : !arrayEquals(snap.additionalRootInstructions, current.additionalRootInstructions, discoveredResourceEquals) ? 'additionalRootInstructions' -- : await this._hasEvictedRelocatedRoot(snap.plugins, current.plugins) ? 'evictedRelocatedRoot' -- : undefined; +- : !equals(snap.nativeAgents ?? [], current.nativeAgents ?? []) ? 'nativeAgents' +- : !arrayEquals(snap.discoveredAgents, current.discoveredAgents, discoveredResourceEquals) ? 'discoveredAgents' +- : !arrayEquals(snap.additionalRootInstructions, current.additionalRootInstructions, discoveredResourceEquals) ? 'additionalRootInstructions' +- : await this._hasEvictedRelocatedRoot(snap.plugins, current.plugins) ? 'evictedRelocatedRoot' +- : undefined; - if (component) { - const [appliedCount, currentCount] = component === 'discoveredAgents' ? [snap.discoveredAgents.length, current.discoveredAgents.length] -- : component === 'additionalRootInstructions' ? [snap.additionalRootInstructions.length, current.additionalRootInstructions.length] -- : [snap.plugins.length, current.plugins.length]; +- : component === 'nativeAgents' ? [snap.nativeAgents?.length ?? 0, current.nativeAgents?.length ?? 0] +- : component === 'additionalRootInstructions' ? [snap.additionalRootInstructions.length, current.additionalRootInstructions.length] +- : [snap.plugins.length, current.plugins.length]; - this._logService.info(`[Copilot:${AgentSession.id(this._sessionUri)}] Session customization changed: component=${component}, appliedCount=${appliedCount}, currentCount=${currentCount}`); - return 'clientPluginsChanged'; - } @@ -11837,10 +12372,10 @@ index d7239a4129e..00000000000 -} diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts deleted file mode 100644 -index 1a70b000302..00000000000 +index 282b8ac10ae..00000000000 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ /dev/null -@@ -1,12318 +0,0 @@ +@@ -1,12804 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -11894,12 +12429,13 @@ index 1a70b000302..00000000000 -import { SAVE_SANDBOX_PATH, type ISandboxPathGrant } from '../../common/sandboxPathGrant.js'; -import { getCopilotBrowserSandboxNetworkRestrictions } from './copilotSandboxPolicy.js'; -import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyAnswer, AgentHostAutoReplyEnabledConfigKey, AgentHostComputerUseEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostMcpConnectorsEnabledConfigKey, AgentHostMcpSamplingAllowedServersConfigKey, AgentHostMcpToolRoutingEnabledConfigKey, platformRootSchema, platformSessionSchema, type IAgentHostWorkspaceTrust } from '../../common/agentHostSchema.js'; --import { reconcileCopilotConnectors, type ICopilotConnectorServer, type ICopilotConnectorServers } from './copilotConnectors.js'; +-import { copilotConnectorServers, reconcileCopilotConnectors, type ICopilotConnectorServer, type ICopilotConnectorServers } from './copilotConnectors.js'; -import { copilotConnectorsScope } from '../../../github/common/connectors/connectors.js'; -import { createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext, type IAgentProviderSendStageRecorder } from '../../common/agentHostTelemetry.js'; -import { measureAgentProviderOperation } from '../../common/agentHostProviderTiming.js'; -import { type AgentCanvasDefinitionSnapshot, AgentSession, type IAgentChatOwnerRef, type IAgentExtensionInfo, type IAgentManagedPluginRetryEntry, COPILOT_CLI_AGENT_PROVIDER_ID, AgentSignal, AgentWorkingDirectoryChangedError, AuthenticateParams, IMcpNotification, subagentChatTitle, type AgentCanvasExtensionSource, type AgentExtensionStatus, type AgentSubagentTaskModelSource, type AgentTurnProviderCallState, type IAgentCanvasCloseRequest, type IAgentCanvasOpenRequest, type IAgentPendingMessageSender, type IAgentPermissionResponseContext, type IAgentTelemetryContext, type IAgentToolPendingConfirmationSignal, type IAgentTurnDiagnosticSnapshot, type IAgentTurnTokenUsage } from '../../common/agent.js'; -import { isReasoningEffortLevel } from '../../common/reasoningEffort.js'; +-import { CopilotReasoningTiming } from './copilotReasoningTiming.js'; -import { agentModelConfigurationMetaKey, IAgentRuntimeModelConfiguration, readAgentRuntimeModelConfiguration } from '../../common/meta/agentModelConfigurationMeta.js'; -import { agentModelConfigurationReportKey, type IAgentModelConfigurationReport } from '../../common/agentModelConfiguration.js'; -import { createCopilotModelConfigurationReport } from './copilotManagedModelConfiguration.js'; @@ -11907,9 +12443,10 @@ index 1a70b000302..00000000000 -import { META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js'; -import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; -import { getToolCallDurationMs, toToolCallMeta, type IToolCallMeta, type IToolCallUiMeta, type IToolSearchCandidate } from '../../common/meta/agentToolCallMeta.js'; +-import { readAgentToolOrigin } from '../../common/meta/agentToolDefinitionMeta.js'; -import { OtelData, type OtelAttributeValue } from '../../common/otlp/otlpLogEmitter.js'; -import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; --import { CopilotSessionApprovalPolicy, fromCopilotPermissionMode } from './copilotApprovalPolicy.js'; +-import { CopilotSessionApprovalPolicy, fromCopilotPermissionMode, isWithheldClientTool } from './copilotApprovalPolicy.js'; -import { isShellInitScriptList, type IShellInitScript } from '../../common/shellInitScript.js'; -import { SEMANTIC_SEARCH_TOOL_NAME } from '../../common/semanticSearchConstants.js'; -import { resolveCopilotConfigSlashCommandOnSend } from '../../common/copilotConfigSlashCommands.js'; @@ -11928,7 +12465,7 @@ index 1a70b000302..00000000000 -import { allowCopilotSdkExecution, restoreDeferredCopilotSdkExecution } from './copilotSessionExecutionMarker.js'; -import { getCopilotSdkToolResourceUri } from './copilotSdkMeta.js'; -import { isAutoModel } from './modelIdentifiers.js'; --import { clientToolNamesFromSnapshot, composeByokSessionConfig, findLocalModelDirectFamily, getCopilotWorkingDirectoryTrust, isMcpServerExplicitlyProjected, resolveCopilotSessionManagedSettings, toSessionConfigMcpServers, type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotClientToolConfiguration, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from './copilotSessionLauncher.js'; +-import { clientToolNamesFromSnapshot, composeByokSessionConfig, findLocalModelDirectFamily, getCopilotWorkingDirectoryTrust, isMcpServerExplicitlyProjected, resolveCopilotSessionManagedSettings, toSessionConfigMcpServers, type CopilotChatSurface, type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotClientToolConfiguration, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from './copilotSessionLauncher.js'; -import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, NON_DEFERRED_CLIENT_TOOL_NAMES, RUNTIME_TOOL_SEARCH_TOOL_NAME, searchToolsWithoutClient } from './toolSearchDeferral.js'; -import { ActiveClientToolSet, structuralToolsEqual } from '../activeClientState.js'; -import { AgentHostTelemetryReporter, toInitiatorTelemetry, type IAgentHostEventClassification, type IAgentHostEventTelemetry, type ICanvasExtensionsReadyEvent } from '../agentHostTelemetryReporter.js'; @@ -11938,7 +12475,7 @@ index 1a70b000302..00000000000 -import { parseLeadingSlashCommand } from '../../common/agentHostSlashCommand.js'; -import type { IUnsandboxedCommandConfirmationRequest, ShellManager } from './copilotShellTools.js'; -import { NonPtyShellTerminalStreams, type INonPtyShellToolCompletion } from './copilotNonPtyShellTerminals.js'; --import { readCopilotSessionEvents, restoreStoredShellOutputsForChat } from './copilotSessionHistory.js'; +-import { readCopilotSessionEvents, restoreStoredShellOutputs } from './copilotSessionHistory.js'; -import { CopilotSessionSandbox } from './copilotSessionSandbox.js'; -import { reportCopilotRuntimeRestart, type CopilotRuntimeMutation, type CopilotRuntimeRestartEvent } from './copilotRuntimeRestartTelemetry.js'; -import type { CopilotSandboxCertificate } from './copilotSandboxCertificate.js'; @@ -11955,9 +12492,10 @@ index 1a70b000302..00000000000 -import { ICopilotApiService, type IRestrictedTelemetryContext } from '../shared/copilotApiService.js'; -import type { IAgentHostRestrictedTelemetryContext } from '../agentHostRestrictedTelemetry.js'; -import { buildChatErrorInfoFromCopilotSdkFields } from './copilotSdkChatError.js'; --import { buildMcpChannel, buildMcpTopLevelCustomizationId, getMcpServerCustomizations, McpCustomizationController, type IMcpServerProvenance, type ISdkMcpServer } from '../shared/mcpCustomizationController.js'; +-import { applyMcpServerRuntimeStates, buildMcpChannel, buildMcpTopLevelCustomizationId, getMcpServerCustomizations, McpCustomizationController, type IMcpServerProvenance, type ISdkMcpServer } from '../shared/mcpCustomizationController.js'; -import { getSdkMcpServerEnablement, resolveCustomizationEnablement, targetForMcpServer } from '../shared/customizationEnablementGate.js'; --import { appendSdkToolResultContent, getSdkToolResultContent, getSdkToolResultText, mapSessionEvents, persistSdkToolResultResources } from './mapSessionEvents.js'; +-import { readCustomizationDiscoveryMeta } from '../../common/meta/customizationDiscoveryMeta.js'; +-import { appendSdkToolResultContent, getSdkMcpResultMeta, getSdkToolResultContent, getSdkToolResultText, mapSessionEvents, persistSdkToolResultResources } from './mapSessionEvents.js'; -import { withPermissionDiff } from '../../common/meta/agentPermissionRequestMeta.js'; -import { COPILOT_FUSION_PHASE_AGENT_NAME, CopilotFusionProgress, formatFusionReviewContent, getFusionPhaseToolCallId, isProvisionalFusionConversationEvent, type CopilotFusionEvent, type FusionPhasePresentation, type ICopilotFusionProgressUpdate } from './copilotFusionProgress.js'; -import { CopilotFusionRequestCorrelation, getFusionEventKey, getFusionEventSdkTurnId, isSyntheticUserMessage } from './copilotFusionEventIdentity.js'; @@ -11976,9 +12514,11 @@ index 1a70b000302..00000000000 -import type { ErrorInfo, ProtectedResourceMetadata } from '../../common/state/protocol/common/state.js'; -import { CopilotSlashCommandProvider } from './copilotSlashCommandProvider.js'; -import { CopilotMcpToolRoutingCache, getMcpRoutingCacheKey, getMcpRoutingProxyName, type ICopilotMcpRoutingServer, type ICopilotMcpRoutingTool } from './copilotMcpToolRoutingCache.js'; --import { readMcpRoutingServers, withMcpRoutingServers } from '../../common/meta/mcpCustomizationMeta.js'; +-import { readMcpRoutingServers, readMcpServerSource, withMcpRoutingServers } from '../../common/meta/mcpCustomizationMeta.js'; -import { AgentHostStateManager, IAgentHostStateManager } from '../agentHostStateManager.js'; --import { type CopilotPluginReloadReason, getCopilotCustomizationCommandHandler, installCopilotPlugin, type ICopilotPluginCommandApi, removeCopilotPluginMarketplace } from './copilotCustomizationCommandDisplay.js'; +-import { type CopilotPluginReloadReason, getCopilotCustomizationCommandHandler, installCopilotPlugin, type ICopilotMcpCommandServer, type ICopilotPluginCommandApi, removeCopilotPluginMarketplace } from './copilotCustomizationCommandDisplay.js'; +-import { toCopilotMcpServerConfiguration } from '../../../mcp/common/mcpCopilotConfiguration.js'; +-import { resolveMcpServerWorkingDirectory } from '../shared/mcpServerWorkingDirectory.js'; -import { renderCopilotSlashCommandOutput, type RuntimeSlashCommandInfo } from './copilotSlashCommand.js'; -import { CopilotSandboxPolicyDisplay } from './copilotSandboxPolicyDisplay.js'; -import { toSlashCommandResourceMeta } from '../../common/meta/agentSlashCommandOutputMeta.js'; @@ -11986,6 +12526,7 @@ index 1a70b000302..00000000000 -import { reportCopilotFusionPhase, type CopilotFusionPhaseEvent } from './copilotFusionPhaseTelemetry.js'; -import { reportCopilotTodoStoreOperation } from './copilotTodoStoreTelemetry.js'; -import { ModelCallTurnCorrelation } from './modelCallTurnCorrelation.js'; +-import { CompactionTurnCorrelation } from './compactionTurnCorrelation.js'; -import { CopilotSubagentTracking } from './copilotSubagentTracking.js'; -import { agentModelCallMetaKey, readAgentModelCallDiagnostics } from '../../common/meta/agentModelCallMeta.js'; - @@ -12049,8 +12590,10 @@ index 1a70b000302..00000000000 - return isCopilotHydraFusionModelId(syntheticModel) ? syntheticModel : undefined; -} - --function getClientToolSdkPolicy(toolName: string): IClientToolSdkPolicy { -- return CLIENT_TOOL_SDK_POLICIES.get(toolName) ?? DEFAULT_CLIENT_TOOL_SDK_POLICY; +-function getClientToolSdkPolicy(tool: ToolDefinition | undefined): IClientToolSdkPolicy { +- return tool && readAgentToolOrigin(tool) !== 'mcpApp' +- ? CLIENT_TOOL_SDK_POLICIES.get(tool.name) ?? DEFAULT_CLIENT_TOOL_SDK_POLICY +- : DEFAULT_CLIENT_TOOL_SDK_POLICY; -} - -interface CopilotExitPlanModeResponse extends ExitPlanModeResult { @@ -12073,9 +12616,12 @@ index 1a70b000302..00000000000 - /** - * The server is owned by a Copilot Connector. The runtime authenticates it only with the session's GitHub - * credential, so a forwarded MCP token never satisfies it; the request is cancelled after Connector -- * authorization rebinds that credential and the Connector is reconciled. +- * authorization rebinds that credential and the Connector is reconciled. Set later when the runtime reports +- * ownership that published Connector state did not know when the request arrived. - */ -- readonly copilotConnector: boolean; +- copilotConnector: boolean; +- /** Settles once the runtime has reported whether a server unknown to published Connector state is Connector-owned. */ +- ownershipResolved?: Promise; - completion?: Promise; -} - @@ -12130,6 +12676,22 @@ index 1a70b000302..00000000000 - displayedMessage: string | undefined; -} - +-interface ICopilotHeldClientToolCall { +- readonly ready: DeferredPromise; +- readonly abortToken: CancellationToken; +- readonly turn: CopilotTurn | undefined; +- agentId?: string; +- start?: (failure?: string) => void; +- progress?: () => void; +- completed?: boolean; +-} +- +-interface ICopilotClientToolReplacement { +- readonly toolNames: Set; +- readonly calls: Map; +- accepted?: boolean; +-} +- -interface IMcpRoutingProxy { - readonly server: ICopilotMcpRoutingServer; -} @@ -12207,7 +12769,6 @@ index 1a70b000302..00000000000 - -interface ICopilotResponsePartState { - readonly markdownPartIds: Map; -- readonly reasoningPartIds: Map; -} - -function getEmptyToolResultText(binaryResults: readonly { readonly type: 'image' | 'resource' }[] | undefined): string { @@ -12431,6 +12992,8 @@ index 1a70b000302..00000000000 -// A non-settling control RPC must not permanently block the per-chat sequencer. -const CONTROL_PLANE_RPC_TIMEOUT_MS = 30_000; -const CONNECTOR_REFRESH_TIMEOUT_MS = 60_000; +-/** Bounds the Connector status read that classifies a server published Connector state does not know, so a token never waits on it for long. */ +-const CONNECTOR_OWNERSHIP_LOOKUP_TIMEOUT_MS = 10_000; -const SUBAGENT_TASK_COMPLETION_DELAY_MS = 250; -/** The longest a `tasks.readOutput` call may wait for a detached shell to write more output. */ -const DETACHED_SHELL_OUTPUT_WAIT_MS = 30_000; @@ -12546,6 +13109,8 @@ index 1a70b000302..00000000000 - readonly controlPlaneRpcTimeoutMs?: number; - /** Bounds one Connector reconciliation attempt; a timed-out request remains pending. */ - readonly connectorRefreshTimeoutMs?: number; +- /** Bounds the Connector status read that classifies a server published Connector state does not know. */ +- readonly connectorOwnershipLookupTimeoutMs?: number; - /** Requests a later attempt after an abandoned Connector RPC settles. */ - readonly onConnectorRefreshRetryable?: (session: CopilotAgentSession) => void; - /** Live canvases handed over by the runtime this session replaces during a configuration refresh. */ @@ -12760,8 +13325,6 @@ index 1a70b000302..00000000000 - /** Current markdown response part IDs for this root turn and its Fusion phase scopes. */ - readonly markdownPartIds = new Map(); - -- /** Current reasoning response part IDs for this root turn and its Fusion phase scopes. */ -- readonly reasoningPartIds = new Map(); - - readonly toolTitles = new Map(); - readonly toolIntentions = new Map(); @@ -12924,6 +13487,17 @@ index 1a70b000302..00000000000 - private readonly _chatChannelUri: URI; - /** Fixed persistence scope for this chat; never re-derived from the mutable routing channel. Config reads/writes go through {@link _configTarget} instead — peer chats share that scope but have distinct storage. */ - private readonly _storageUri: URI; +- private readonly _reasoningTiming = this._register(new CopilotReasoningTiming( +- (action, parentToolCallId) => this._emitProgress({ kind: 'action', resource: this._chatChannelUri, action, parentToolCallId }), +- (turnId, responseId, parts) => { +- if (!turnId) { +- this._logService.trace(`[Copilot:${this.sessionId}] Cannot persist reasoning timing without an owning turn`); +- return; +- } +- void this._withDatabase(db => db.setAssistantResponseParts(turnId, responseId, parts)).catch(error => +- this._logService.error(`[Copilot:${this.sessionId}] Failed to persist response parts`, error)); +- }, +- )); - - get chatChannelUri(): URI { - return this._chatChannelUri; @@ -13051,10 +13625,10 @@ index 1a70b000302..00000000000 - private readonly _pendingSubagentToolCompletions = new Map>(); - private readonly _subagentResponsePartState: ICopilotResponsePartState = { - markdownPartIds: new Map(), -- reasoningPartIds: new Map(), - }; - private readonly _resolveAgentName = (agentId: string) => this._subagents.getDisplayName(agentId); - readonly modelCallTurnCorrelation = new ModelCallTurnCorrelation(); +- readonly compactionTurnCorrelation = new CompactionTurnCorrelation(); - private readonly _subagentDirectUsageByToolCallId = new Map(); - private readonly _lastSubagentUsageByToolCallId = new Map(); - /** @@ -13391,7 +13965,7 @@ index 1a70b000302..00000000000 - return; - } - if (result.connectorServers) { -- this._setCopilotConnectorServers(result.connectorServers); +- await this._setCopilotConnectorServers(result.connectorServers); - } - await raceCancellationError(this._refreshMcpServersFromRpc(), token); - if (token.isCancellationRequested || this.isDisposed) { @@ -13686,12 +14260,13 @@ index 1a70b000302..00000000000 - /** Contributions successfully applied to the SDK session. */ - private _appliedSnapshot: IActiveClientSnapshot; - private _clientToolConfiguration: ICopilotClientToolConfiguration | undefined; +- private _pendingClientToolReplacement: ICopilotClientToolReplacement | undefined; - private readonly _appliedPluginSources: ReadonlySet; - private readonly _appliedPluginDirectories: readonly URI[]; - private readonly _projectedMcpServerLaunchEnablement: ReadonlyMap; - private readonly _declaredMcpServerNames: ReadonlySet; -- /** Servers this host disabled at runtime, as opposed to servers the SDK configuration disables. */ -- private readonly _hostDisabledMcpServerNames = new Set(); +- /** Last host-applied values, retained until SDK-native servers return to their inherited defaults. */ +- private readonly _hostMcpServerEnablement = new Map(); - private _mcpLaunchConfigurationDirty = false; - private readonly _observedMcpEnablementOverrides = new Map(); - private readonly _mcpConfiguredEnablement = new Map(); @@ -13727,9 +14302,12 @@ index 1a70b000302..00000000000 - private _outstandingConnectorReconcile: Promise | undefined; - private _retryAfterConnectorReconcile = false; - private readonly _connectorRefreshTimeoutMs: number; +- private readonly _connectorOwnershipLookupTimeoutMs: number; - private readonly _onConnectorRefreshRetryable: (session: CopilotAgentSession) => void; - private _mcpServerControlOperations = 0; - private readonly _copilotConnectorServers = new Map(); +- /** Incremented whenever reconciliation replaces {@link _copilotConnectorServers}. */ +- private _copilotConnectorServersGeneration = 0; - private _copilotConnectorAuthorizationRequired = false; - /** - * Retains challenge metadata and its latest callback id so token delivery can report Starting. @@ -13949,6 +14527,7 @@ index 1a70b000302..00000000000 - this.launchOwner = options.owner.get(); - this._controlPlaneRpcTimeoutMs = options.controlPlaneRpcTimeoutMs ?? CONTROL_PLANE_RPC_TIMEOUT_MS; - this._connectorRefreshTimeoutMs = options.connectorRefreshTimeoutMs ?? CONNECTOR_REFRESH_TIMEOUT_MS; +- this._connectorOwnershipLookupTimeoutMs = options.connectorOwnershipLookupTimeoutMs ?? CONNECTOR_OWNERSHIP_LOOKUP_TIMEOUT_MS; - this._onConnectorRefreshRetryable = options.onConnectorRefreshRetryable ?? (() => { }); - this.resourceUri = options.resource ?? this.launchOwner; - this._chatChannelUri = options.chatChannelUri; @@ -13956,7 +14535,7 @@ index 1a70b000302..00000000000 - const sandboxPolicyDisplay = this._instantiationService.createInstance(CopilotSandboxPolicyDisplay, this.sessionId, this._storageUri); - this._slashCommandProvider = new CopilotSlashCommandProvider( - () => this._wrapper.session.rpc.commands.list({ includeBuiltins: true, includeSkills: true, includeClientCommands: true }).then(c => c.commands), -- { getCommandHandler: command => sandboxPolicyDisplay.getHandler(command) ?? getCopilotCustomizationCommandHandler(command, { api: this._wrapper.session.rpc.plugins, reload: reason => this.reloadPlugins(reason) }, options.getInstalledPlugins) }, +- { getCommandHandler: command => sandboxPolicyDisplay.getHandler(command) ?? getCopilotCustomizationCommandHandler(command, { api: this._wrapper.session.rpc.plugins, reload: reason => this.reloadPlugins(reason) }, options.getInstalledPlugins, name => this._getMcpCommandServer(name)) }, - this._logService, - ); - this._onDidSessionProgress = options.onDidSessionProgress; @@ -13983,13 +14562,18 @@ index 1a70b000302..00000000000 - storage: this._storageUri, - getConfigWriteTarget: () => this._configWriteTarget(), - platform: options.platform ?? process.platform, -- getSession: () => this._wrapper?.session, +- getSession: () => !this._store.isDisposed && this._wrapper?.lifecycleState === 'active' ? this._wrapper.session : undefined, +- // Only the session of a closing wrapper counts as closed, so a replacement SDK session from a relaunch is still configured. +- isSessionClosed: session => this._store.isDisposed || (session !== undefined && this._wrapper?.session === session && this._wrapper.lifecycleState !== 'active'), - getHostSupport: () => this._launchPlan.client.rpc.sandbox.getHostSupport(), - getCertificateStatus: config => options.sandboxCertificate.getStatus(this._launchPlan.client.rpc.sandbox.proxyCa, config), - installCertificate: (getConfig, scopeId) => options.sandboxCertificate.install(this._launchPlan.client.rpc.sandbox.proxyCa, getConfig, scopeId), - rotateCertificate: (getConfig, scopeId) => options.sandboxCertificate.rotate(this._launchPlan.client.rpc.sandbox.proxyCa, getConfig, scopeId), - removeCertificate: async (scopeId, validate) => { await options.sandboxCertificate.remove(this._launchPlan.client.rpc.sandbox.proxyCa, scopeId, validate); }, - getShellInitDirectory: () => this._shellInitScriptMaterialized ? this._shellInitScriptDirectory() : undefined, +- getAppliedPluginDirectories: () => this._appliedPluginDirectories, +- getWorkingDirectory: () => this._workingDirectory, +- getAdditionalWorkingDirectories: () => this._appliedAdditionalDirectories, - shellManager: options.shellManager, - onDidChangeRuntimeConfig: () => this._recordRuntimeMutation('sandboxUpdate'), - })); @@ -14040,6 +14624,9 @@ index 1a70b000302..00000000000 - ...this._appliedSnapshot.plugins.flatMap(plugin => plugin.disabledMcpServers ?? []), - ...(this._launchPlan.disabledRootMcpServers ?? []), - ]); +- for (const name of disabledMcpServers) { +- this._hostMcpServerEnablement.set(name, false); +- } - this._projectedMcpServerLaunchEnablement = new Map(this._appliedSnapshot.plugins.flatMap(plugin => - plugin.mcpServers - .filter(isMcpServerExplicitlyProjected) @@ -14086,6 +14673,12 @@ index 1a70b000302..00000000000 - emit: action => this._emitLaunchAction(action), - pluginMcpServerSources: () => pluginMcpServerSources, - resolveProvenance: serverName => this._resolveMcpServerProvenance(serverName, pluginMcpServerSources), +- adoptPluginOwnership: (server, owningPluginUri) => { +- const source = readMcpServerSource(server); +- if (!readCustomizationDiscoveryMeta(server) && (source === undefined || source === 'plugin')) { +- this._customizationEnablementService.adoptMcpServerOwnership(this.launchOwner.toString(), targetForMcpServer(server, undefined, false), URI.parse(owningPluginUri)); +- } +- }, - resolveEnablement: (server, owningPluginUri) => { - const resolution = this._customizationEnablementService.resolve(this.launchOwner.toString(), targetForMcpServer(server, owningPluginUri, false)); - return resolution.kind === 'resolved' ? resolution.enablement : undefined; @@ -14176,6 +14769,19 @@ index 1a70b000302..00000000000 - if (action.type === ActionType.ChatUsage && !parentToolCallId && this._launchPlan.localModelProvider) { - this._lastRootUsage = { turnId: action.turnId, usage: action.usage }; - } +- if (action.type === ActionType.ChatDelta) { +- const timingTurnId = parentToolCallId ? this._subagents.getRootTurnId(parentToolCallId) ?? action.turnId : action.turnId; +- this._reasoningTiming.markdown(timingTurnId, action.partId, action.content, parentToolCallId); +- } else if (action.type === ActionType.ChatResponsePart && action.part.kind === ResponsePartKind.Markdown) { +- const timingTurnId = parentToolCallId ? this._subagents.getRootTurnId(parentToolCallId) ?? action.turnId : action.turnId; +- this._reasoningTiming.markdown(timingTurnId, action.part.id, action.part.content, parentToolCallId); +- } else if (action.type === ActionType.ChatToolCallStart) { +- const timingTurnId = parentToolCallId ? this._subagents.getRootTurnId(parentToolCallId) ?? action.turnId : action.turnId; +- this._reasoningTiming.toolCall(timingTurnId, action.toolCallId, parentToolCallId); +- } else if (action.type === ActionType.ChatTurnComplete || action.type === ActionType.ChatTurnCancelled || action.type === ActionType.ChatError) { +- this._reasoningTiming.finish(parentToolCallId); +- this._reasoningTiming.clear(parentToolCallId); +- } - this._emitProgress({ - kind: 'action', - resource: isChatAction(action) ? this._chatChannelUri : this._ownerSessionUri, @@ -15037,6 +15643,16 @@ index 1a70b000302..00000000000 - return turn; - } - +- private _recordCompactionTurn(e: { readonly agentId?: string; readonly data: { readonly compactionAttemptId?: string } }): void { +- const attemptId = e.data.compactionAttemptId; +- if (!attemptId) { +- return; +- } +- const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); +- const turn = e.agentId && !parentToolCallId ? undefined : this._owningRootTurn(parentToolCallId); +- this.compactionTurnCorrelation.record(attemptId, turn?.id); +- } +- - private _shouldDropUnmappedSubagentEvent(e: { readonly agentId?: string }, eventName: string): boolean { - const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); - if (!parentToolCallId && e.agentId) { @@ -15046,11 +15662,15 @@ index 1a70b000302..00000000000 - return false; - } - +- private _getClientToolDefinition(toolName: string): ToolDefinition | undefined { +- return this._offeredClientTools?.find(tool => tool.name === toolName) +- ?? this._appliedSnapshot.tools.find(tool => tool.name === toolName); +- } +- - /** Resolves the owning client for a chat-scoped tool call, honoring host-published chat membership. */ - private _resolveClientToolOwner(toolName: string): string | undefined { - const chat = this._chatChannelUri; -- const applied = this._offeredClientTools?.find(tool => tool.name === toolName) -- ?? this._appliedSnapshot.tools.find(tool => tool.name === toolName); +- const applied = this._getClientToolDefinition(toolName); - const provides = (clientId: string) => !!applied && this._activeClientToolSet.get(clientId) - .some(tool => tool.name === toolName && structuralToolsEqual([applied], [tool])); - const preferred = this._currentTurn.value?.senderClientId; @@ -15156,15 +15776,15 @@ index 1a70b000302..00000000000 - } - - private _clearSubagentResponseParts(parentToolCallId: string): void { +- this._reasoningTiming.finish(parentToolCallId); +- this._reasoningTiming.clear(parentToolCallId); - this._subagentResponsePartState.markdownPartIds.delete(parentToolCallId); -- this._subagentResponsePartState.reasoningPartIds.delete(parentToolCallId); - } - - private _beginToolCallRound(parentToolCallId: string | undefined): void { - const scope = parentToolCallId ?? ''; - const state = this._getResponsePartState(parentToolCallId); - state?.markdownPartIds.delete(scope); -- state?.reasoningPartIds.delete(scope); - } - - /** @@ -15173,6 +15793,7 @@ index 1a70b000302..00000000000 - * response part. The turn becomes `running` on the first SDK event. - */ - resetTurnState(turnId: string, senderClientId?: string, clientType = AgentHostClientType.Unknown, clientContext = createUnknownAgentHostClientTelemetryContext(clientType), providerInitiated = false): void { +- this._cancelHeldClientToolCalls(false); - this._clearPendingFusionEvents(); - this._fusionRequestCorrelation.clear(); - this._fusionTurnCancelled = false; @@ -15187,6 +15808,7 @@ index 1a70b000302..00000000000 - } - - private _startTurn(turnId: string, senderClientId: string | undefined, clientContext = createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown), providerInitiated = false): void { +- this._reasoningTiming.clear(undefined); - this._clearActivity(); - this._setManagedPluginActivity(undefined); - this._detectInterruptedTurnOnRestore = false; @@ -15409,6 +16031,7 @@ index 1a70b000302..00000000000 - */ - private _clearActiveTurn(preservesFusionState = false): void { - const turn = this._currentTurn.value; +- this._cancelHeldClientToolCalls(false); - if (!preservesFusionState) { - this._clearPendingFusionEvents(); - this._hasFusionRootTurnBoundary = false; @@ -15617,23 +16240,30 @@ index 1a70b000302..00000000000 - if (this._shouldDropLateRootTurnEvent('assistant.reasoning_delta')) { - return; - } +- if (!content) { +- return; +- } - const state = this._getResponsePartState(parentToolCallId); - if (!state) { - this._logService.error(`[Copilot:${this.sessionId}] Reasoning delta emitted with no active turn; dropping`); - return; - } - const reasoningScope = parentToolCallId ?? ''; -- let partId = state.reasoningPartIds.get(reasoningScope); +- const timingTurnId = parentToolCallId ? this._subagents.getRootTurnId(parentToolCallId) ?? this._turnId : this._turnId; +- let partId = this._reasoningTiming.activePartId(timingTurnId, parentToolCallId); - if (!partId) { - partId = generateUuid(); -- state.reasoningPartIds.set(reasoningScope, partId); +- state.markdownPartIds.delete(reasoningScope); +- const startedAt = this._reasoningTiming.start(timingTurnId, partId, parentToolCallId); +- this._reasoningTiming.append(content, parentToolCallId); - this._emitAction({ - type: ActionType.ChatResponsePart, - turnId: this._turnId, -- part: { kind: ResponsePartKind.Reasoning, id: partId, content }, +- part: { kind: ResponsePartKind.Reasoning, id: partId, content, startedAt }, - }, parentToolCallId); - return; - } +- this._reasoningTiming.append(content, parentToolCallId); - this._emitAction({ - type: ActionType.ChatReasoning, - turnId: this._turnId, @@ -15658,10 +16288,17 @@ index 1a70b000302..00000000000 - - /** Replaces client tools at the caller's queued configuration boundary. */ - async updateClientTools(tools: readonly ToolDefinition[], token: CancellationToken = CancellationToken.None): Promise { +- if (this._launchConnectorReconcile) { +- await raceCancellationError(this._launchConnectorReconcile, token); +- } - const appliedByName = new Map(this._appliedSnapshot.tools.map(tool => [tool.name, tool])); - if (tools.length === appliedByName.size && tools.every(tool => equals(tool, appliedByName.get(tool.name)))) { - return; - } +- await this._replaceClientTools(tools, token); +- } +- +- private async _replaceClientTools(tools: readonly ToolDefinition[], token: CancellationToken, onlyIfRoutingChanged = false): Promise { - if (token.isCancellationRequested) { - throw new CancellationError(); - } @@ -15669,31 +16306,117 @@ index 1a70b000302..00000000000 - if (!configuration) { - throw new Error('Client tool configuration is unavailable'); - } -- const replacement = this._buildClientSdkTools(tools, configuration.toolSearchActive, configuration.hostToolNames); +- const pending: ICopilotClientToolReplacement = { toolNames: new Set(), calls: new Map() }; +- const replacement = this._buildClientSdkTools(tools, configuration.toolSearchActive, configuration.hostToolNames, configuration.withheldToolNames, pending); +- if (onlyIfRoutingChanged && equals([...replacement.mcpRoutingProxies], [...this._mcpRoutingProxies])) { +- return; +- } +- const appliedByName = new Map(this._appliedSnapshot.tools.map(tool => [tool.name, tool])); +- for (const tool of tools) { +- const applied = appliedByName.get(tool.name); +- if (replacement.clientToolNames.has(tool.name) && (!applied || !structuralToolsEqual([tool], [applied]))) { +- pending.toolNames.add(tool.name); +- } +- } - const resolved = configuration.resolve(tools, replacement.tools); - if (!equals(configuration.sessionConfig, resolved.sessionConfig)) { - throw new Error('Client tools require a session configuration refresh'); - } -- // The runtime can start offered tools before `replaceTools` resolves, so route -- // both the applied and offered tools until the replacement settles. +- // Stage offered routing for Connector proxies; changed client calls wait for SDK acceptance. - const appliedClientToolNames = this._clientToolNames; +- const appliedRoutingProxies = new Map(this._mcpRoutingProxies); - this._clientToolNames = new Set([...appliedClientToolNames, ...replacement.clientToolNames]); +- for (const [name, proxy] of replacement.mcpRoutingProxies) { +- this._mcpRoutingProxies.set(name, proxy); +- } - this._offeredClientTools = tools; +- this._pendingClientToolReplacement = pending; - try { - await configuration.replaceTools(this._wrapper.session, resolved.tools); +- this._appliedSnapshot = { ...this._appliedSnapshot, tools }; +- this._applyClientSdkTools(replacement); +- pending.accepted = true; - } catch (error) { - this._clientToolNames = appliedClientToolNames; +- this._mcpRoutingProxies.clear(); +- for (const [name, proxy] of appliedRoutingProxies) { +- this._mcpRoutingProxies.set(name, proxy); +- } - throw error; - } finally { - this._offeredClientTools = undefined; +- pending.accepted ??= false; +- this._pendingClientToolReplacement = undefined; +- // Publish starts with accepted routing before handlers and permission callbacks resume. +- for (const [toolCallId, call] of pending.calls) { +- const current = this._isHeldClientToolCallCurrent(call); +- if (current && !call.completed) { +- call.start?.(pending.accepted ? undefined : localize('copilot.clientToolReplacementFailed', "Client tool configuration replacement failed.")); +- if (pending.accepted) { +- call.progress?.(); +- } +- } else { +- this._streamingToolDisplaySchedulers.deleteAndDispose(toolCallId); +- this._streamingToolCalls.delete(toolCallId); +- } +- call.ready.complete(pending.accepted && current && !call.completed); +- } +- pending.calls.clear(); - } -- this._appliedSnapshot = { ...this._appliedSnapshot, tools }; -- this._applyClientSdkTools(replacement); - if (token.isCancellationRequested) { - throw new CancellationError(); - } - } - +- private _holdClientToolCall(toolName: string, toolCallId: string, agentId?: string, replacement = this._pendingClientToolReplacement): ICopilotHeldClientToolCall | undefined { +- if (!replacement || replacement.accepted !== undefined || !replacement.toolNames.has(this._clientToolName(toolName)) || this._activeToolCalls.has(toolCallId)) { +- return undefined; +- } +- let call = replacement.calls.get(toolCallId); +- if (!call) { +- call = { ready: new DeferredPromise(), abortToken: this._abortToken, turn: this._currentTurn.value }; +- replacement.calls.set(toolCallId, call); +- } +- call.agentId ??= agentId; +- return call; +- } +- +- private _waitForClientToolReplacement(toolName: string, toolCallId: string, replacement = this._pendingClientToolReplacement): Promise | undefined { +- if (replacement?.accepted !== false) { +- replacement = this._pendingClientToolReplacement ?? replacement; +- } +- if (!replacement || !replacement.toolNames.has(this._clientToolName(toolName)) || this._activeToolCalls.has(toolCallId)) { +- return undefined; +- } +- if (replacement.accepted !== undefined) { +- return replacement.accepted ? undefined : Promise.resolve(false); +- } +- const call = this._holdClientToolCall(toolName, toolCallId, undefined, replacement)!; +- return this._waitForHeldClientToolCall(call); +- } +- +- private async _waitForHeldClientToolCall(call: ICopilotHeldClientToolCall): Promise { +- const accepted = await raceCancellation(call.ready.p, call.abortToken, false); +- return accepted && this._isHeldClientToolCallCurrent(call); +- } +- +- private _isHeldClientToolCallCurrent(call: ICopilotHeldClientToolCall): boolean { +- return !this._store.isDisposed && !call.abortToken.isCancellationRequested +- && (call.turn === this._currentTurn.value || call.agentId !== undefined); +- } +- +- private _cancelHeldClientToolCalls(includeSubagents = true): void { +- for (const call of this._pendingClientToolReplacement?.calls.values() ?? []) { +- if (!includeSubagents && call.agentId !== undefined) { +- continue; +- } +- call.completed = true; +- call.start = undefined; +- call.progress = undefined; +- call.ready.complete(false); +- } +- } +- - get requiresRestartAfterWorkingDirectoryChange(): boolean { - return this._requiresRestartAfterWorkingDirectoryChange; - } @@ -15747,6 +16470,14 @@ index 1a70b000302..00000000000 - return this._launchPlan.workspaceless === true; - } - +- /** How this chat's surface scopes its runtime, so a runtime that replaces it keeps the same scope. */ +- get surface(): CopilotChatSurface { +- return { +- ...(this._launchPlan.hasScopedEditSurface ? { hasScopedEditSurface: true } : {}), +- ...(this._launchPlan.isInlineChatSurface ? { isInlineChatSurface: true } : {}), +- }; +- } +- - get customizationDirectory(): URI | undefined { - return this._customizationDirectory; - } @@ -15758,26 +16489,26 @@ index 1a70b000302..00000000000 - * `session/toolCallComplete`. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- private _createClientSdkTools(toolSearchActive: boolean, hostToolNames?: ReadonlySet): Tool[] { -- const result = this._buildClientSdkTools(this._appliedSnapshot.tools, toolSearchActive, hostToolNames); +- private _createClientSdkTools(toolSearchActive: boolean, hostToolNames?: ReadonlySet, withheldToolNames?: ReadonlySet): Tool[] { +- const result = this._buildClientSdkTools(this._appliedSnapshot.tools, toolSearchActive, hostToolNames, withheldToolNames); - this._toolSearchActive = toolSearchActive; - this._applyClientSdkTools(result); - return result.tools; - } - -- private _buildClientSdkTools(definitions: readonly ToolDefinition[], toolSearchActive: boolean, hostToolNames?: ReadonlySet) { +- private _buildClientSdkTools(definitions: readonly ToolDefinition[], toolSearchActive: boolean, hostToolNames?: ReadonlySet, withheldToolNames?: ReadonlySet, replacement?: ICopilotClientToolReplacement) { - const reservedHostToolNames = hostToolNames ?? new Set(this._getServerToolDefinitions().map(tool => tool.name)); - const mcpRoutingProxies = new Map(); - const proxyTools = toolSearchActive ? this._createMcpRoutingProxyTools(mcpRoutingProxies) : []; - const tools = definitions.filter(tool => -- !reservedHostToolNames.has(tool.name) && !mcpRoutingProxies.has(tool.name) && !(toolSearchActive && tool.name === RUNTIME_TOOL_SEARCH_TOOL_NAME)); +- !reservedHostToolNames.has(tool.name) && !isWithheldClientTool(tool, withheldToolNames) && !mcpRoutingProxies.has(tool.name) && !(toolSearchActive && tool.name === RUNTIME_TOOL_SEARCH_TOOL_NAME)); - const sessionTools = toolSearchActive - ? tools -- : tools.filter(def => def.name !== CLIENT_TOOL_SEARCH_REFERENCE_NAME); +- : tools.filter(def => def.name !== CLIENT_TOOL_SEARCH_REFERENCE_NAME || readAgentToolOrigin(def) === 'mcpApp'); - const clientToolNames = new Set(sessionTools.map(tool => tool.name)); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sdkTools = sessionTools.map((def): Tool => { -- if (toolSearchActive && def.name === CLIENT_TOOL_SEARCH_REFERENCE_NAME) { +- if (toolSearchActive && def.name === CLIENT_TOOL_SEARCH_REFERENCE_NAME && readAgentToolOrigin(def) !== 'mcpApp') { - return { - name: RUNTIME_TOOL_SEARCH_TOOL_NAME, - description: def.description ?? '', @@ -15787,6 +16518,12 @@ index 1a70b000302..00000000000 - skipPermission: true, - handler: this._guarded(async (args: Record, invocation) => { - try { +- const pending = this._waitForClientToolReplacement(invocation.toolName, invocation.toolCallId, replacement ?? this._pendingClientToolReplacement); +- if (pending && !await pending) { +- return this._pendingClientToolCalls.hasBufferedResult(invocation.toolCallId) +- ? this._pendingClientToolCalls.register(invocation.toolCallId) +- : this._toolSearchFailure('Tool call cancelled: client tool replacement was not applied'); +- } - this._surfaceUnobservedClientToolStart(invocation); - const candidates = this._toToolSearchCandidates(invocation.availableTools); - const tracked = this._activeToolCalls.get(invocation.toolCallId); @@ -15806,9 +16543,9 @@ index 1a70b000302..00000000000 - }; - } - const defer: 'auto' | 'never' | undefined = toolSearchActive -- ? (NON_DEFERRED_CLIENT_TOOL_NAMES.has(def.name) ? 'never' : 'auto') +- ? (readAgentToolOrigin(def) !== 'mcpApp' && NON_DEFERRED_CLIENT_TOOL_NAMES.has(def.name) ? 'never' : 'auto') - : undefined; -- const sdkPolicy = getClientToolSdkPolicy(def.name); +- const sdkPolicy = getClientToolSdkPolicy(def); - return { - name: def.name, - description: def.description ?? '', @@ -15818,6 +16555,12 @@ index 1a70b000302..00000000000 - handler: this._guarded(async (_args: Record, invocation) => { - const { toolCallId } = invocation; - try { +- const pending = this._waitForClientToolReplacement(invocation.toolName, toolCallId, replacement ?? this._pendingClientToolReplacement); +- if (pending && !await pending) { +- return this._pendingClientToolCalls.hasBufferedResult(toolCallId) +- ? this._pendingClientToolCalls.register(toolCallId) +- : this._toolSearchFailure('Tool call cancelled: client tool replacement was not applied'); +- } - this._surfaceUnobservedClientToolStart(invocation); - return await this._pendingClientToolCalls.register(toolCallId); - } catch (error) { @@ -15854,13 +16597,15 @@ index 1a70b000302..00000000000 - return []; - } - const tools: Tool>[] = []; -- for (const server of this._configuredMcpRoutingServers.values()) { -- const cached = this._mcpToolRoutingCache.get(server); +- for (const serverName of new Set([...this._configuredMcpRoutingServers.keys(), ...this._copilotConnectorServers.keys()])) { +- const server = this._mcpRoutingServer(serverName)!; +- const cached = this._copilotConnectorServers.has(serverName) ? undefined : this._mcpToolRoutingCache.get(server); - const name = getMcpRoutingProxyName(server); +- const displayName = server.displayName ?? server.serverName; - proxies.set(name, { server }); - tools.push({ - name, -- description: getMcpRoutingProxyDescription(server.serverName, cached?.tools ?? []).slice(0, MCP_ROUTING_PROXY_DESCRIPTION_MAX_LENGTH), +- description: getMcpRoutingProxyDescription(displayName, cached?.tools ?? []).slice(0, MCP_ROUTING_PROXY_DESCRIPTION_MAX_LENGTH), - parameters: { type: 'object', properties: {}, additionalProperties: false }, - defer: 'auto', - handler: this._guarded(async (_args, invocation) => { @@ -15870,7 +16615,7 @@ index 1a70b000302..00000000000 - const liveTools = await this._refreshMcpToolRoutingCache(server.serverName); - return { - resultType: 'success', -- textResultForLlm: `The ${server.serverName} MCP server is connected. Search tools again to use one of its current tools: ${JSON.stringify(liveTools.map(tool => tool.name))}`, +- textResultForLlm: `The ${displayName} MCP server is connected. Search tools again to use one of its current tools: ${JSON.stringify(liveTools.map(tool => tool.name))}`, - }; - } catch (error) { - const message = getErrorMessage(error); @@ -15902,24 +16647,44 @@ index 1a70b000302..00000000000 - - private async _doActivateMcpRoutingServer(serverName: string, token: CancellationToken): Promise { - await raceCancellationError(this._refreshMcpServersFromRpc(), token); -- const authentication = this._adoptPendingMcpAuthentication(serverName); +- if (!this._mcpRoutingServer(serverName) || this._getDesiredMcpServerEnablementByName([serverName]).get(serverName) === false) { +- throw new Error(`MCP server '${serverName}' is no longer available for routing.`); +- } - const state = this._mcpCustomizations.stateForServer(serverName); +- const authentication = this._adoptPendingMcpAuthentication(serverName); - if (authentication) { - await raceCancellationError(authentication, token); -- } else if (state?.kind !== McpServerStatus.Starting && state?.kind !== McpServerStatus.Ready) { +- } else if (state?.kind !== McpServerStatus.Starting && state?.kind !== McpServerStatus.Ready +- && !(state?.kind === McpServerStatus.AuthRequired && this._copilotConnectorServers.has(serverName))) { - await this._startMcpServer(serverName, token); - } - await raceCancellationError(this._refreshMcpServersFromRpc(), token); -- await waitForState( -- this._mcpCustomizations.runtimeStates.map(() => this._mcpCustomizations.stateForServer(serverName)), -- state => state?.kind === McpServerStatus.Ready, -- state => state?.kind === McpServerStatus.Error -- ? new Error(state.error.message) -- : !state || state.kind === McpServerStatus.Stopped -- ? new Error(`MCP server '${serverName}' stopped before its tools became available.`) -- : undefined, +- const runtimeState = this._mcpCustomizations.runtimeStates.map(() => this._mcpCustomizations.stateForServer(serverName)); +- const activationError = (state: McpServerState | undefined) => state?.kind === McpServerStatus.Error +- ? new Error(state.error.message) +- : !state || state.kind === McpServerStatus.Stopped +- ? new Error(`MCP server '${serverName}' stopped before its tools became available.`) +- : undefined; +- const settledState = await waitForState( +- runtimeState, +- state => state?.kind === McpServerStatus.Ready || (state?.kind === McpServerStatus.AuthRequired && this._copilotConnectorServers.has(serverName)), +- activationError, - token, - ); +- if (settledState?.kind !== McpServerStatus.AuthRequired) { +- return; +- } +- // Connector status can report a missing session-account scope without an SDK auth callback. +- // Handle it once; credential rebind cancels this activation and reconciliation runs after the turn. +- await raceCancellationError(this._adoptPendingMcpAuthentication(serverName) ?? this._handleMcpAuthRequest({ +- requestId: generateUuid(), +- serverName, +- serverUrl: settledState.resource.resource, +- reason: 'upscope', +- wwwAuthenticateParams: { scope: settledState.requiredScopes?.join(' ') ?? copilotConnectorsScope }, +- }).then(() => undefined), token); +- await raceCancellationError(this._refreshMcpServersFromRpc(), token); +- await waitForState(runtimeState, state => state?.kind === McpServerStatus.Ready, activationError, token); - } - - private _isToolSearchActive(): boolean { @@ -16081,16 +16846,28 @@ index 1a70b000302..00000000000 - if (!this._mcpToolRoutingEnabled) { - return []; - } -- const server = this._configuredMcpRoutingServers.get(serverName); +- const server = this._mcpRoutingServer(serverName); - if (!server) { - throw new Error(`MCP server '${serverName}' is not configured for this session.`); - } - const result = await this._wrapper.session.rpc.mcp.listTools({ serverName }); - const tools = result.tools.map(tool => ({ name: tool.name, ...(tool.description ? { description: tool.description } : {}) })); -- this._mcpToolRoutingCache.store(server, tools); +- // Connector tools belong to the session account, not the shared configured-server cache. +- if (this._configuredMcpRoutingServers.get(serverName) === server && !this._copilotConnectorServers.has(serverName)) { +- this._mcpToolRoutingCache.store(server, tools); +- } - return tools; - } - +- private _mcpRoutingServer(serverName: string): ICopilotMcpRoutingServer | undefined { +- const connector = this._copilotConnectorServers.get(serverName); +- return connector ? { +- serverName, +- displayName: localize('copilot.mcpRouting.connector', "{0} (Connector)", connector.displayName), +- configuration: { connectorName: connector.connectorName }, +- } : this._configuredMcpRoutingServers.get(serverName); +- } +- - private _refreshReadyMcpToolRoutingCaches(): void { - for (const { serverName } of this._mcpCustomizations.readyChannels()) { - void this._refreshMcpToolRoutingCache(serverName).catch(error => { @@ -16907,8 +17684,8 @@ index 1a70b000302..00000000000 - chatUri: this._chatChannelUri, - launchOwner: this.launchOwner, - reportManagedSettingsActivity: activity => this._setManagedSettingsActivity(activity), -- getLaunchSandboxConfig: () => this._sandbox.getLaunchConfig(), -- applyLaunchSandbox: session => this._sandbox.applyLaunchConfig(session), +- getLaunchSandboxConfig: isResume => this._sandbox.getLaunchConfig(isResume), +- applyLaunchSandbox: (session, isResume) => this._sandbox.applyLaunchConfig(session, isResume), - handlePermissionRequest: this._guarded((request, invocation) => { - const recorder = this._currentTurn.value?.timingRecorder; - recorder?.markMilestone?.('permissionRequest'); @@ -16940,7 +17717,7 @@ index 1a70b000302..00000000000 - recorder?.markMilestone?.('unsandboxedConfirmation'); - return measureAgentProviderOperation(recorder, 'unsandboxedConfirmation', () => this._requestUnsandboxedCommandConfirmation(request)); - }, false, 'unsandboxed-command-confirmation'), -- createClientSdkTools: (toolSearchActive, hostToolNames) => this._createClientSdkTools(toolSearchActive, hostToolNames), +- createClientSdkTools: (toolSearchActive, hostToolNames, withheldToolNames) => this._createClientSdkTools(toolSearchActive, hostToolNames, withheldToolNames), - configureClientTools: configuration => { this._clientToolConfiguration = configuration; }, - createServerSdkTools: toolSearchActive => this._createServerSdkTools(toolSearchActive), - createComputerUseActivationTools: () => this._computerUseActivation.createTools(), @@ -16977,7 +17754,7 @@ index 1a70b000302..00000000000 - return; - } - if (connectorServers) { -- this._setCopilotConnectorServers(connectorServers); +- await this._setCopilotConnectorServers(connectorServers); - } - await this._refreshMcpServersFromRpc(); - if (this.isDisposed || this._wrapper !== wrapper) { @@ -16989,7 +17766,8 @@ index 1a70b000302..00000000000 - } - } - -- private _setCopilotConnectorServers(connectorServers: ICopilotConnectorServers): void { +- private async _setCopilotConnectorServers(connectorServers: ICopilotConnectorServers): Promise { +- this._copilotConnectorServersGeneration++; - const previous = [...this._copilotConnectorServers.keys()]; - this._copilotConnectorServers.clear(); - for (const [serverName, server] of connectorServers.servers) { @@ -17003,14 +17781,61 @@ index 1a70b000302..00000000000 - this._mcpCustomizations.applyOne({ name: serverName, displayName: this._copilotConnectorServers.get(serverName)?.displayName, state }); - } - } +- if (this._mcpToolRoutingEnabled && this._clientToolConfiguration?.toolSearchActive) { +- await this._replaceClientTools(this._appliedSnapshot.tools, this._lifetimeCts.token, true); +- } +- } +- +- /** Whether Connector state published to this session reports the server as owned by a Copilot Connector. */ +- private _isCopilotConnectorServer(serverName: string): boolean { +- return this._copilotConnectorServers.has(serverName); - } - - /** -- * Whether a server is owned by a Copilot Connector, either as reported by Connector reconciliation or, when -- * that is unavailable, because its challenge asks for the Connector authorization scope. +- * Classifies a pending request from a server that published Connector state does not know, by asking the runtime. +- * The runtime lists a Connector's server before it can ask for authentication, while published state can lag +- * behind it (for example, while launch-time reconciliation is still settling). Ownership is never inferred from a +- * challenge: any server given a Connector-authorized credential echoes the Connector scope when it asks to step +- * that credential up, as the built-in GitHub MCP server does. - */ -- private _isCopilotConnectorServer(serverName: string, requiredScopes: readonly string[] = []): boolean { -- return this._copilotConnectorServers.has(serverName) || requiredScopes.includes(copilotConnectorsScope); +- private async _classifyPendingMcpAuthRequest(requestId: string, pending: IPendingMcpAuthRequest): Promise { +- if (!await this._readRuntimeCopilotConnectorOwnership(pending.serverName) || this._pendingMcpAuthRequests.getMetadata(requestId) !== pending) { +- return; +- } +- pending.copilotConnector = true; +- this._disableMcpTokenCompletion(pending.serverName, requestId); +- const state = this._mcpCustomizations.stateForServer(pending.serverName); +- if (state) { +- this._mcpCustomizations.applyOne({ name: pending.serverName, displayName: this._copilotConnectorServers.get(pending.serverName)?.displayName, state }); +- } +- this._logService.info(`[Copilot:${this.sessionId}] The runtime reports MCP server '${pending.serverName}' as owned by Copilot Connector '${this._copilotConnectorServers.get(pending.serverName)?.connectorName}'; it requires Copilot Connector authorization of the session account`); +- } +- +- /** Whether the runtime's Connector state lists the server, recording it in published state when that has not described it since. */ +- private async _readRuntimeCopilotConnectorOwnership(serverName: string): Promise { +- const wrapper = this._wrapper; +- const publishedGeneration = this._copilotConnectorServersGeneration; +- let connectorServers: ICopilotConnectorServers; +- try { +- const status = await raceTimeout(wrapper.session.rpc.connectors.getStatus(), this._connectorOwnershipLookupTimeoutMs); +- if (!status) { +- this._logService.warn(`[Copilot:${this.sessionId}] Timed out reading Connector state for MCP server '${serverName}'; treating it as not Connector-owned`); +- return false; +- } +- connectorServers = copilotConnectorServers(status); +- } catch (error) { +- this._logService.warn(`[Copilot:${this.sessionId}] Unable to read Connector state for MCP server '${serverName}'; treating it as not Connector-owned: ${getErrorMessage(error)}`); +- return false; +- } +- const server = connectorServers.servers.get(serverName); +- if (!server || this.isDisposed || this._wrapper !== wrapper) { +- return false; +- } +- // Published state is replaced as a whole by reconciliation; only fill in a server that state has not described since. +- if (this._copilotConnectorServersGeneration === publishedGeneration && !this._copilotConnectorServers.has(serverName)) { +- this._copilotConnectorServers.set(serverName, server); +- } +- return this._copilotConnectorServers.has(serverName); - } - - /** @@ -17036,6 +17861,14 @@ index 1a70b000302..00000000000 - continue; - } - } +- if (pending.ownershipResolved) { +- // Stop waiting as soon as the request itself settles (Stop, cancellation, dispose). +- const settled = pending.completion?.then(() => undefined, () => undefined); +- await (settled ? Promise.race([pending.ownershipResolved, settled]) : pending.ownershipResolved); +- if (this._pendingMcpAuthRequests.getMetadata(requestId) !== pending) { +- continue; +- } +- } - if (pending.copilotConnector || this._isCopilotConnectorServer(pending.serverName)) { - this._logService.info(`[Copilot:${this.sessionId}] Ignoring an MCP token for Connector-owned server '${pending.serverName}'; it requires Copilot Connector authorization of the session account`); - continue; @@ -17064,8 +17897,13 @@ index 1a70b000302..00000000000 - } - - private _adoptPendingMcpAuthentication(serverName: string): Promise | undefined { +- const pendingRequests = this._attachActiveMcpToolCallsToPendingAuthentication(serverName); +- return pendingRequests.length ? Promise.all(pendingRequests.map(pending => pending.completion)).then(() => undefined) : undefined; +- } +- +- private _attachActiveMcpToolCallsToPendingAuthentication(serverName: string): IPendingMcpAuthRequest[] { - const toolCalls = this._activeMcpToolCalls(serverName); -- const completions: Promise[] = []; +- const pendingRequests: IPendingMcpAuthRequest[] = []; - for (const [requestId, pending] of this._pendingMcpAuthRequests.entries()) { - if (pending.serverName !== serverName || !pending.completion) { - continue; @@ -17085,9 +17923,9 @@ index 1a70b000302..00000000000 - }, toolCall.parentToolCallId); - } - } -- completions.push(pending.completion); +- pendingRequests.push(pending); - } -- return completions.length ? Promise.all(completions).then(() => undefined) : undefined; +- return pendingRequests; - } - - private async _handleMcpAuthRequest(request: McpAuthRequest): Promise { @@ -17123,7 +17961,7 @@ index 1a70b000302..00000000000 - this._mcpLifecycleVersion++; - this._mcpAwaitingOAuthReconnect.delete(request.serverName); - const toolCalls = this._activeMcpToolCalls(request.serverName); -- const copilotConnector = this._isCopilotConnectorServer(request.serverName, requiredScopes); +- const copilotConnector = this._isCopilotConnectorServer(request.serverName); - const pending: IPendingMcpAuthRequest = { - serverName: request.serverName, - resource, @@ -17133,6 +17971,10 @@ index 1a70b000302..00000000000 - }; - const result = this._pendingMcpAuthRequests.register(request.requestId, pending); - pending.completion = result; +- if (!copilotConnector && this._configurationService.getRootValue(platformRootSchema, AgentHostMcpConnectorsEnabledConfigKey) === true) { +- // Without Copilot Connectors no server is Connector-owned, so only ask the runtime when they are enabled. +- pending.ownershipResolved = this._classifyPendingMcpAuthRequest(request.requestId, pending); +- } - this._lastMcpAuthRequirements.set(request.serverName, { - requestId: request.requestId, - auth, @@ -17446,7 +18288,9 @@ index 1a70b000302..00000000000 - } - const turn = this._currentTurn.value; - this._hostInstructions = hostInstructions; -- this._pendingSnapshotReminder = this._snapshotReadonlyReminder(attachments); +- // Attaching a file or folder lets this chat's sandbox read it; `_prepareSdkTurn` applies the grant before the turn runs. +- this._sandbox.addAttachments(attachments); +- this._pendingSnapshotReminder = this._attachmentReminder(attachments); - if (this._tryStartDevelopmentRecoverableError(prompt)) { - return; - } @@ -17510,6 +18354,40 @@ index 1a70b000302..00000000000 - * on steering) rather than the attachment, because the runtime drops a file - * attachment's `displayName` for text snapshots. - */ +- private _attachmentReminder(attachments: readonly MessageAttachment[] | undefined): string | undefined { +- const parts = [this._snapshotReadonlyReminder(attachments), this._sandboxBlockedAttachmentReminder(attachments)].filter(isDefined); +- return parts.length ? parts.join('\n\n') : undefined; +- } +- +- /** +- * Names attachments the effective sandbox policy keeps unreadable (a denied path, or +- * outside a managed read-only list), so the model tells the user the policy blocks +- * them instead of asking the user to change a policy they may not control. +- */ +- private _sandboxBlockedAttachmentReminder(attachments: readonly MessageAttachment[] | undefined): string | undefined { +- const { blocked, containsWritable } = this._sandbox.getUnreadableAttachments(attachments); +- if (!blocked.length && !containsWritable.length) { +- return undefined; +- } +- this._logService.info(`[Copilot:${this.sessionId}] Sandbox keeps ${blocked.length} attachment(s) unreadable by policy and ${containsWritable.length} folder(s) containing a writable folder ungranted`); +- const parts: string[] = []; +- if (blocked.length) { +- parts.push('The sandbox policy for this session does not allow reading the following attached paths, ' +- + 'because they are denied or outside the read-only paths set by the sandbox policy:\n' +- + blocked.map(path => `- ${path}`).join('\n') +- + '\nIf you need their contents, tell the user that the sandbox policy blocks access to them and ask them to paste the relevant content instead. ' +- + 'Do not ask the user to change the sandbox policy, which may be managed by their organization.'); +- } +- if (containsWritable.length) { +- parts.push('The following attached folders contain the session\'s working directory or another writable folder, ' +- + 'so the sandbox does not make them readable as a whole, which could make that folder read-only:\n' +- + containsWritable.map(path => `- ${path}`).join('\n') +- + '\nFiles inside the writable folders stay readable. If you need other files in these folders, ask the user to attach those files or subfolders directly. ' +- + 'Do not ask the user to change the sandbox policy.'); +- } +- return parts.join('\n\n'); +- } +- - private _snapshotReadonlyReminder(attachments: readonly MessageAttachment[] | undefined): string | undefined { - if (!attachments?.length) { - return undefined; @@ -17832,15 +18710,21 @@ index 1a70b000302..00000000000 - if (previousEnabled === undefined || previousEnabled === enabled) { - return; - } -- const desired = this._getDesiredMcpServerEnablementByName().get(serverName); -- if (desired === undefined || desired === enabled) { -- return; -- } - const server = this._mcpCustomizations.customizationForServer(serverName); - if (!server) { - this._logService.warn(`[Copilot:${this.sessionId}] Cannot record observed enablement for unknown MCP server ${serverName}`); - return; - } +- if (readCustomizationDiscoveryMeta(server)?.defaultEnabled !== undefined) { +- // A confirmed SDK configuration change updates inheritance, not host-owned intent. +- this._hostMcpServerEnablement.delete(serverName); +- this._mcpCustomizations.applyOne({ name: serverName, state: this._mcpCustomizations.stateForServer(serverName) ?? server.state, enabled, defaultEnabled: enabled }); +- return; +- } +- const desired = this._getDesiredMcpServerEnablementByName().get(serverName); +- if (desired === undefined || desired === enabled) { +- return; +- } - this._observedMcpEnablementOverrides.set(serverName, enabled); - this._emitAction({ - type: ActionType.SessionCustomizationToggled, @@ -18265,12 +19149,17 @@ index 1a70b000302..00000000000 - this._logService.info(`[Copilot:${this.sessionId}] Sending steering message: "${steeringMessage.message.text.substring(0, 100)}"`); - try { - await this._reconcileMcpServerEnablement(); +- if (this._sandbox.addAttachments(steeringMessage.message.attachments)) { +- // Steering joins the running turn, so its attachments must be granted before it is sent, even when an +- // earlier message recorded the same path and its update is still in flight. An unchanged config isn't re-sent. +- await this._sandbox.applyConfig(false); +- } - const sdkAttachments = await this._toSdkAttachments(steeringMessage.message.attachments); - // Steering is injected into the active turn and never fires the SDK's `user-prompt-submitted` - // hook, so the read-only snapshot signal can't ride `additionalContext` here. Fold it into the - // prompt as a `` block instead: the runtime forwards it to the model, and the host's - // `stripPromptScaffolding` removes it from the displayed message (#331154). -- const snapshotReminder = this._snapshotReadonlyReminder(steeringMessage.message.attachments); +- const snapshotReminder = this._attachmentReminder(steeringMessage.message.attachments); - const steeringPrompt = snapshotReminder - ? `${steeringMessage.message.text}\n\n\n${snapshotReminder}\n` - : steeringMessage.message.text; @@ -18393,6 +19282,7 @@ index 1a70b000302..00000000000 - this._logService.trace(`[Copilot:${this.sessionId}] Read ${events.length} persisted event(s); reconstructing turns`); - return this._withDatabase(async db => { - const result = await mapSessionEvents(this._storageUri, db, events, this._chatChannelUri, this._ownerSessionUri, { +- logService: this._logService, - workingDirectory: this._workingDirectory, - clientToolNames: this._clientToolNames, - model: this._launchPlan.kind === 'create' @@ -18413,10 +19303,11 @@ index 1a70b000302..00000000000 - } - - private async _restoreStoredShellOutputs(mapped: IMappedSessionEvents, db: ISessionDatabase): Promise { -- await restoreStoredShellOutputsForChat(mapped.turns, this._storageUri, this._ownerSessionUri, this._chatChannelUri, db); -- for (const [parentToolCallId, turns] of mapped.subagentTurnsByToolCallId) { -- await restoreStoredShellOutputsForChat(turns, this._storageUri, this._ownerSessionUri, URI.parse(this._subagentChatUri(parentToolCallId)), db); -- } +- // The parent chat and every subagent chat share one batched lookup. +- await restoreStoredShellOutputs([ +- { turns: mapped.turns, chat: this._chatChannelUri }, +- ...Array.from(mapped.subagentTurnsByToolCallId, ([parentToolCallId, turns]) => ({ turns, chat: URI.parse(this._subagentChatUri(parentToolCallId)) })), +- ], this._storageUri, this._ownerSessionUri, db); - } - - private _seedSubagentDisplayNames(events: readonly SessionEvent[]): void { @@ -18831,7 +19722,7 @@ index 1a70b000302..00000000000 - } - - private _getDesiredMcpServerEnablementByName(configuredServerNames: readonly string[] = [], includeInheritedEnablement = true): ReadonlyMap { -- const customizations = [...this._hostCustomizations()]; +- const customizations = this._hostCustomizations().map(customization => applyMcpServerRuntimeStates(customization, this._mcpCustomizations.runtimeStates.get())); - const retainedNames = new Set(getMcpServerCustomizations(customizations).map(server => server.name)); - for (const server of this._mcpCustomizations.topLevelCustomizations()) { - if (!retainedNames.has(server.name)) { @@ -18865,16 +19756,27 @@ index 1a70b000302..00000000000 - const owningPluginSource = this._mcpCustomizations.pluginMcpServerSources?.get(server.name); - const source = customization.type === CustomizationType.Plugin ? customization.uri : owningPluginSource; - const applied = source === undefined || this._appliedPluginSources.has(URI.parse(source).toString()); +- const defaultEnabled = readCustomizationDiscoveryMeta(server)?.defaultEnabled; +- const sdkDiscovered = defaultEnabled !== undefined; +- // Observing SDK enablement is not a host request to change the runtime. +- const hostDecision = sdkDiscovered ? this._customizationEnablementService.resolve(this.launchOwner.toString(), { +- ...targetForMcpServer(server, source, false), defaultEnabled: undefined, +- }) : undefined; +- const hasDecision = hostDecision?.kind === 'resolved' ? hostDecision.enablement.length > 0 : (server.enablement?.length ?? 0) > 0; +- const hostApplied = this._hostMcpServerEnablement.get(server.name); +- const needsRestore = sdkDiscovered ? hostApplied !== undefined && hostApplied !== defaultEnabled : hostApplied === false; +- const explicit = this._declaredMcpServerNames.has(server.name) || this._projectedMcpServerLaunchEnablement.has(server.name) +- || needsRestore || hasDecision +- || (customization.type === CustomizationType.Plugin && (customization.enablement?.length ?? 0) > 0); +- if (sdkDiscovered && !explicit && hostDecision?.kind === 'resolved') { +- continue; +- } - let namedCandidates = candidates.get(server.name); - if (!namedCandidates) { - namedCandidates = []; - candidates.set(server.name, namedCandidates); - } -- // A server this host disabled is restored when its decision returns to the inherited default. -- const explicit = this._declaredMcpServerNames.has(server.name) || this._projectedMcpServerLaunchEnablement.has(server.name) -- || this._hostDisabledMcpServerNames.has(server.name) -- || (server.enablement?.length ?? 0) > 0 -- || (customization.type === CustomizationType.Plugin && (customization.enablement?.length ?? 0) > 0); +- // Restore both host-enabled and host-disabled servers before relinquishing control. - namedCandidates.push({ server, applied, explicit }); - } - } @@ -18891,7 +19793,7 @@ index 1a70b000302..00000000000 - } - } - for (const name of this._launchPlan.disabledRootMcpServers ?? []) { -- if (!result.has(name)) { +- if (!result.has(name) && !retainedNames.has(name)) { - result.set(name, false); - } - } @@ -18958,7 +19860,7 @@ index 1a70b000302..00000000000 - try { - this._recordRuntimeMutation('mcpEnable'); - await this._wrapper.session.rpc.mcp.enable({ serverName }); -- this._hostDisabledMcpServerNames.delete(serverName); +- this._hostMcpServerEnablement.set(serverName, true); - } catch (error) { - if (this._expectedMcpEnablementChanges.get(serverName) === true) { - this._expectedMcpEnablementChanges.delete(serverName); @@ -18975,7 +19877,7 @@ index 1a70b000302..00000000000 - try { - this._recordRuntimeMutation('mcpDisable'); - await this._wrapper.session.rpc.mcp.disable({ serverName }); -- this._hostDisabledMcpServerNames.add(serverName); +- this._hostMcpServerEnablement.set(serverName, false); - } catch (error) { - if (this._expectedMcpEnablementChanges.get(serverName) === false) { - this._expectedMcpEnablementChanges.delete(serverName); @@ -19261,6 +20163,12 @@ index 1a70b000302..00000000000 - this._logService.warn(`[Copilot:${this.sessionId}] Permission request without toolCallId, auto-denying: kind=${request.kind}`); - return attributePermissionResult({ kind: 'reject' }, 'host_policy'); - } +- if (request.kind === 'custom-tool') { +- const pending = this._waitForClientToolReplacement(request.toolName, toolCallId); +- if (pending && !await pending) { +- return attributePermissionResult({ kind: 'reject' }, 'host_policy'); +- } +- } - if (this._unroutableSubagentToolCallIds.delete(toolCallId)) { - this._logService.error(`[Copilot:${this.sessionId}] Rejecting permission request for unroutable subagent tool call: toolCallId=${toolCallId}, kind=${request.kind}`); - return attributePermissionResult({ kind: 'reject' }, 'unattended_fallback'); @@ -19472,7 +20380,7 @@ index 1a70b000302..00000000000 - const permissionDisplay = getPermissionDisplay(request, this._workingDirectory, isNewFile, this._appliedAdditionalDirectories, trackedToolCall?.parameters); - // A server tool's confirmation as its group builds it with the host's state (e.g. linking only known sessions). - const serverConfirmation = serverToolName && request.kind === 'custom-tool' ? serverToolHost?.getConfirmationDisplay(serverToolName, request.args) : undefined; -- const { permissionKind, permissionPath, shellCommand } = permissionDisplay; +- const { permissionKind, permissionPath, permissionUrl, shellCommand } = permissionDisplay; - const confirmationTitle = serverConfirmation?.confirmationTitle ?? permissionDisplay.confirmationTitle; - const invocationMessage = serverConfirmation?.confirmationMessage ?? permissionDisplay.invocationMessage; - const toolInput = serverConfirmation?.hideConfirmationInput ? undefined : permissionDisplay.toolInput; @@ -19525,6 +20433,7 @@ index 1a70b000302..00000000000 - }, - permissionKind, - permissionPath, +- permissionUrl, - managedApprovalRequired, - requestSandboxBypass, - requestSandboxPermissive, @@ -19678,14 +20587,21 @@ index 1a70b000302..00000000000 - })); - } - +- private _isInactiveForBackgroundConfigSync(): boolean { +- return this._store.isDisposed || (this._wrapper !== undefined && this._wrapper.lifecycleState !== 'active'); +- } +- - private async _syncImageGenerationAfterConfigChange(): Promise { -- if (!this._wrapper) { +- if (!this._wrapper || this._isInactiveForBackgroundConfigSync()) { - // Wrapper initialization applies the latest preference before the session becomes usable. - return; - } - try { - await this._syncImageGeneration(); - } catch (error) { +- if (this._isInactiveForBackgroundConfigSync()) { +- return; +- } - this._logService.error(error, `[Copilot:${this.sessionId}] Failed to apply image generation setting`); - const turn = this._currentTurn.value ?? this._resumingTurnAwaitingProviderStart; - if (turn) { @@ -19713,6 +20629,9 @@ index 1a70b000302..00000000000 - - private _syncImageGeneration(): Promise { - return this._imageGenerationSequencer.queue(async () => { +- if (this._isInactiveForBackgroundConfigSync()) { +- return; +- } - const enabled = this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.ImageGeneration) === true; - const watermark = this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.ImageGenerationWatermark) === true; - if (enabled === this._appliedImageGenerationEnabled && watermark === this._appliedImageGenerationWatermark) { @@ -19729,7 +20648,7 @@ index 1a70b000302..00000000000 - } - - private _syncShellInitScript(recorder?: IAgentProviderSendStageRecorder): Promise { -- if (this._shellInitScriptDisposing) { +- if (this._shellInitScriptDisposing || this._isInactiveForBackgroundConfigSync()) { - return Promise.resolve(); - } - return measureAgentProviderOperation(recorder, 'shell', () => this._applyEffectiveShellInitScripts(recorder), this._shellInitScriptSequencer); @@ -19741,6 +20660,9 @@ index 1a70b000302..00000000000 - } - - private async _syncPermissionModeAfterConfigChange(event?: IAgentChatConfigurationChangeEvent): Promise { +- if (this._isInactiveForBackgroundConfigSync()) { +- return; +- } - const sandboxChange = this._sandbox.prepareConfigChange(event); - try { - if (this.hasActiveTurn && (!sandboxChange.sessionCommand || (event && Object.hasOwn(event.config, SessionConfigKey.AutoApprove)))) { @@ -19748,6 +20670,9 @@ index 1a70b000302..00000000000 - } - await sandboxChange.apply(); - } catch (error) { +- if (this._isInactiveForBackgroundConfigSync()) { +- return; +- } - this._logService.error(error, `[Copilot:${this.sessionId}] Failed to apply permission config change${this.hasActiveTurn ? '; aborting active turn' : ''}`); - sandboxChange.reject(error); - if (!this.hasActiveTurn) { @@ -19763,6 +20688,9 @@ index 1a70b000302..00000000000 - - syncPermissionMode(source: 'config-change' | 'turn-start', recorder?: IAgentProviderSendStageRecorder): Promise { - return measureAgentProviderOperation(recorder, 'permission', async () => { +- if (source === 'config-change' && this._isInactiveForBackgroundConfigSync()) { +- return; +- } - const { mode, configuredLevel } = this._approvalPolicy.resolveSelection(this._getApprovalInputs()); - this._logService.info(`[Copilot:${this.sessionId}] Syncing permission mode: source=${source}, agentMode=${this._getConfiguredAgentMode()}, configuredLevel=${configuredLevel}, sdkMode=${mode}, previousSdkMode=${this._lastAppliedPermissionMode ?? 'unknown'}, globalAutoApprove=${this._configurationService.getRootValue(platformRootSchema, AgentHostGlobalAutoApproveEnabledConfigKey) === true}`); - const experimentalModeEnabled = mode === 'assisted' || this._isHydraFusionEnabled(); @@ -19858,7 +20786,7 @@ index 1a70b000302..00000000000 - * Best-effort: failures are logged and retried on the next turn. - */ - private async _applyEffectiveShellInitScripts(recorder?: IAgentProviderSendStageRecorder): Promise { -- if (this._shellInitScriptDisposing) { +- if (this._shellInitScriptDisposing || this._isInactiveForBackgroundConfigSync()) { - return; - } - try { @@ -19884,6 +20812,9 @@ index 1a70b000302..00000000000 - // The file and its sandbox grant stay until dispose, so a command - // that already captured the path can still source it. - if (this._registeredShellInitScriptPath) { +- if (this._shellInitScriptDisposing || this._isInactiveForBackgroundConfigSync()) { +- return; +- } - const result = await measureAgentProviderOperation(recorder, 'shellRpc', () => this._wrapper.session.rpc.options.update({ shell: { initScripts: [] } })); - if (!result.success) { - throw new Error('Copilot SDK rejected shell init script update'); @@ -19896,7 +20827,7 @@ index 1a70b000302..00000000000 - - this._shellInitScriptMaterialized = true; - const ref = await measureAgentProviderOperation(recorder, 'shellFile', () => this._materializeShellInitScript(scripts[0])); -- if (!ref) { +- if (!ref || this._shellInitScriptDisposing || this._isInactiveForBackgroundConfigSync()) { - // Leave the cache unchanged so the next turn retries. - return; - } @@ -19905,6 +20836,9 @@ index 1a70b000302..00000000000 - if (ref.path !== this._registeredShellInitScriptPath) { - // The SDK requires init scripts to be readable when registered. - await this._sandbox.applyConfig(true, undefined, recorder); +- if (this._shellInitScriptDisposing || this._isInactiveForBackgroundConfigSync()) { +- return; +- } - const result = await measureAgentProviderOperation(recorder, 'shellRpc', () => this._wrapper.session.rpc.options.update({ shell: { initScripts: [ref] } })); - if (!result.success) { - throw new Error('Copilot SDK rejected shell init script update'); @@ -19914,6 +20848,9 @@ index 1a70b000302..00000000000 - this._lastAppliedShellInitScripts = serialized; - this._logService.trace(`[Copilot:${this.sessionId}] Applied shell init script`); - } catch (err) { +- if (this._shellInitScriptDisposing || this._isInactiveForBackgroundConfigSync()) { +- return; +- } - this._logService.warn(`[Copilot:${this.sessionId}] Failed to update shell init scripts`, err); - } - } @@ -20473,7 +21410,7 @@ index 1a70b000302..00000000000 - if (isEditTool(input.toolName, getToolCommand(input))) { - const filePaths = this._getEditFilePaths(input.toolArgs); - const mode = this._getConfiguredAgentMode(); -- await Promise.all(filePaths.map(p => this._editTracker.trackEditStart(p, mode))); +- await Promise.all(filePaths.map(p => this._editTracker.trackEditStart(p, mode, input.toolArgs))); - } - } catch (error) { - this._logService.error(error, `[Copilot:${this.sessionId}] Failed in onPreToolUse: tool=${input.toolName}`); @@ -20485,7 +21422,7 @@ index 1a70b000302..00000000000 - try { - if (isEditTool(input.toolName, getToolCommand(input))) { - const filePaths = this._getEditFilePaths(input.toolArgs); -- await Promise.all(filePaths.map(p => this._editTracker.completeEdit(p))); +- await Promise.all(filePaths.map(p => this._editTracker.completeEdit(p, input.toolArgs))); - } - } catch (error) { - this._logService.error(error, `[Copilot:${this.sessionId}] Failed in onPostToolUse: tool=${input.toolName}`); @@ -20638,8 +21575,6 @@ index 1a70b000302..00000000000 - - this._logService.info(`[Copilot:${sessionId}] System notification received: kind=${e.data.kind.type}`); - if (this._turnId) { -- // Later parent reasoning belongs after this notice; child reasoning keeps its own stream. -- this._currentTurn.value?.reasoningPartIds.delete(''); - this._emitAction({ - type: ActionType.ChatResponsePart, - turnId: this._turnId, @@ -20743,7 +21678,7 @@ index 1a70b000302..00000000000 - const state = this._getResponsePartState(contentParentToolCallId); - const isEmptyFinalAnswer = isLastMessageChunk && e.data.phase === 'final_answer' && !e.data.content && !e.data.toolRequests?.length; - // Fusion sends distinct complete chunks without preceding text deltas. -- if (e.data.content && (isFusionMessage || !state?.markdownPartIds.has(markdownScope))) { +- if (e.data.content && (isFusionMessage || (!state?.markdownPartIds.has(markdownScope) && !this._reasoningTiming.hasMarkdown(contentParentToolCallId)))) { - const partId = generateUuid(); - state?.markdownPartIds.set(markdownScope, partId); - this._emitAction({ @@ -20752,8 +21687,6 @@ index 1a70b000302..00000000000 - part: { kind: ResponsePartKind.Markdown, id: partId, content: e.data.content }, - }, contentParentToolCallId); - } else if (isEmptyFinalAnswer && !state?.markdownPartIds.has(markdownScope)) { -- // An empty final answer still ends the open thinking section; later text and reasoning start new parts. -- state?.reasoningPartIds.delete(markdownScope); - this._emitAction({ - type: ActionType.ChatResponsePart, - turnId: this._turnId, @@ -20781,6 +21714,12 @@ index 1a70b000302..00000000000 - // Wait for the full message boundary; clearing on an earlier tool delta would duplicate assembled markdown. - this._beginToolCallRound(contentParentToolCallId); - } +- if (isLastMessageChunk) { +- const recorded = this._reasoningTiming.completeMessage(e.id, e.data.reasoningText, e.data.content ?? '', contentParentToolCallId); +- if (!recorded && e.data.reasoningText) { +- this._logService.trace(`[Copilot:${this.sessionId}] No complete observed response layout for assistant message ${e.id}`); +- } +- } - }; - const renderCommittedFusionMessage = (e: SessionEventPayload<'assistant.message'>, phaseToolCallId: string): void => { - const call = this._pendingFusionMessageChunks.accept(e, phaseToolCallId); @@ -20821,9 +21760,9 @@ index 1a70b000302..00000000000 - // describe a subagent's model call, so subagent messages (mapped or dropped) are skipped. - if (!e.agentId && isModelResponse) { - const clientType = this._currentTurn.value?.clientType ?? AgentHostClientType.Unknown; -- void this._telemetryReporter.assistantMessageReceived(this.resourceUri.toString(), this._chatChannelUri.toString(), clientType, e.data.clientRequestId, this._appliedSnapshot.tools).catch(err => this._logService.trace(`[Copilot:${this.sessionId}] Telemetry emission failed: ${getErrorMessage(err)}`)); +- void this._telemetryReporter.assistantMessageReceived(this.resourceUri.toString(), this._chatChannelUri.toString(), clientType, e.data.clientRequestId, this._appliedSnapshot.tools, wrapper.sessionId).catch(err => this._logService.trace(`[Copilot:${this.sessionId}] Telemetry emission failed: ${getErrorMessage(err)}`)); - // Restricted `conversation.messageText` (source=model): the model's raw response text. -- void this._telemetryReporter.modelMessageText(this.resourceUri.toString(), this._chatChannelUri.toString(), clientType, e.data.content, this._turnOrdinal, e.data.clientRequestId).catch(err => this._logService.trace(`[Copilot:${this.sessionId}] Telemetry emission failed: ${getErrorMessage(err)}`)); +- void this._telemetryReporter.modelMessageText(this.resourceUri.toString(), this._chatChannelUri.toString(), clientType, e.data.content, this._turnOrdinal, e.data.clientRequestId, wrapper.sessionId).catch(err => this._logService.trace(`[Copilot:${this.sessionId}] Telemetry emission failed: ${getErrorMessage(err)}`)); - // Accumulate the per-turn tool-call aggregate for the restricted `toolCallDetails` event. - // Every main-agent `assistant.message` is one model-call round (matches the extension's - // `numRequests = toolCallRounds.length`, which counts the final tool-free response round @@ -20949,6 +21888,9 @@ index 1a70b000302..00000000000 - this._streamingToolCalls.set(e.data.toolCallId, streaming); - - const toolName = streaming.toolName; +- if (toolName && this._holdClientToolCall(toolName, e.data.toolCallId, e.agentId)) { +- return; +- } - if (!toolName || isHiddenTool(toolName) || isTaskCompleteTool(toolName) || this._clientToolNames.has(this._clientToolName(toolName))) { - return; - } @@ -20973,10 +21915,17 @@ index 1a70b000302..00000000000 - this._scheduleStreamingToolCallDisplay(e.data.toolCallId); - })); - -- const handleToolStart = (e: SessionEventPayload<'tool.execution_start'>): void => { +- const handleToolStart = (e: SessionEventPayload<'tool.execution_start'>, replacementFailure?: string): void => { - if (!e.agentId && this._shouldDropLateRootTurnEvent('tool.execution_start')) { - return; - } +- const held = replacementFailure === undefined ? this._holdClientToolCall(e.data.toolName, e.data.toolCallId, e.agentId) : undefined; +- if (held) { +- if (!held.completed) { +- held.start ??= failure => handleToolStart(e, failure); +- } +- return; +- } - const toolTitle = this._currentTurn.value?.toolTitles.get(e.data.toolCallId) ?? e.data.toolTitle; - this._currentTurn.value?.toolTitles.delete(e.data.toolCallId); - const intentionSummary = this._currentTurn.value?.toolIntentions.get(e.data.toolCallId); @@ -21002,7 +21951,7 @@ index 1a70b000302..00000000000 - const routingProxy = this._mcpRoutingProxies.get(e.data.toolName); - const mcpServerName = e.data.mcpServerName ?? routingProxy?.server.serverName; - const displayName = routingProxy -- ? localize('copilot.mcpRouting.connect', "Connect to {0}", routingProxy.server.serverName) +- ? localize('copilot.mcpRouting.connect', "Connect to {0}", routingProxy.server.displayName ?? routingProxy.server.serverName) - : getToolDisplayName(e.data.toolName, { toolTitle, mcpToolName: e.data.mcpToolName }); - const streamed = this._streamingToolCalls.get(e.data.toolCallId); - this._streamingToolDisplaySchedulers.deleteAndDispose(e.data.toolCallId); @@ -21020,9 +21969,9 @@ index 1a70b000302..00000000000 - } - const parentToolCallId = streamed?.parentToolCallId ?? this._parentToolCallIdForSubagentEvent(e) ?? this._fusionPhaseParentToolCallId(e.agentId, e.data.fusion); - const clientToolName = this._clientToolName(e.data.toolName); -- const isClientTool = this._clientToolNames.has(clientToolName); +- const isClientTool = replacementFailure !== undefined || this._clientToolNames.has(clientToolName); - const isToolSearch = this._isToolSearchActive() && e.data.toolName === RUNTIME_TOOL_SEARCH_TOOL_NAME; -- const contributor = this._getToolCallContributor(e.data.toolName, mcpServerName); +- const contributor = replacementFailure === undefined ? this._getToolCallContributor(e.data.toolName, mcpServerName) : undefined; - const intention = getToolIntention(e.data.toolName, parameters, intentionSummary); - this._activeToolCalls.set(e.data.toolCallId, { - turnId: this._turnId, @@ -21104,13 +22053,11 @@ index 1a70b000302..00000000000 - }, parentToolCallId); - } - -- // No client is connected to run this client tool. Fail it -- // immediately instead of leaving it pending until the -- // server-side disconnect timeout fires. We emit the completion -- // ourselves and drop the active-tool entry so the SDK's own -- // tool.execution_complete for this id is suppressed. -- if (isClientTool && !contributor && !isToolSearch) { -- this._logService.warn(`[Copilot:${sessionId}] Client tool '${e.data.toolName}' started with no connected client matching its advertised definition; failing it immediately.`); +- // Failed replacements and calls without a matching client must settle without client execution. +- if (replacementFailure !== undefined || (isClientTool && !contributor && !isToolSearch)) { +- this._logService.warn(replacementFailure +- ? `[Copilot:${sessionId}] Failing held client tool '${e.data.toolName}': ${replacementFailure}` +- : `[Copilot:${sessionId}] Client tool '${e.data.toolName}' started with no connected client matching its advertised definition; failing it immediately.`); - this._reportToolApprovalIfNoPermission(e.data.toolCallId); - this._toolApprovalRecords.delete(e.data.toolCallId); - this._activeToolCalls.delete(e.data.toolCallId); @@ -21132,13 +22079,13 @@ index 1a70b000302..00000000000 - result: { - success: false, - pastTenseMessage: `${displayName} failed`, -- error: { message: `No client with the advertised tool definition was connected to run ${displayName}` }, +- error: { message: replacementFailure ?? `No client with the advertised tool definition was connected to run ${displayName}` }, - }, - }, parentToolCallId); - this._pendingClientToolCalls.respondOrBuffer(e.data.toolCallId, { -- textResultForLlm: `No client with the advertised tool definition was connected to run ${displayName}.`, +- textResultForLlm: replacementFailure ?? `No client with the advertised tool definition was connected to run ${displayName}.`, - resultType: 'failure', -- error: 'No matching client connected', +- error: replacementFailure ?? 'No matching client connected', - }); - return; - } @@ -21147,9 +22094,11 @@ index 1a70b000302..00000000000 - if (isToolSearch && clientToolAutoApproved) { - meta.autoApproveBySetting = true; - } -- const sdkPolicy = getClientToolSdkPolicy(e.data.toolName); +- const definition = this._getClientToolDefinition(clientToolName); +- const sdkPolicy = getClientToolSdkPolicy(definition); +- const isTrustedCoordinationTool = readAgentToolOrigin(definition) !== 'mcpApp' && isAgentCoordinationTool(e.data.toolName); - const shouldWaitForClientToolReady = contributor?.kind === ToolCallContributorKind.Client -- && !isAgentCoordinationTool(e.data.toolName) +- && !isTrustedCoordinationTool - && (isToolSearch || (!sdkPolicy.skipPermission && !clientToolAutoApproved)); - if (shouldWaitForClientToolReady) { - return; @@ -21172,6 +22121,10 @@ index 1a70b000302..00000000000 - } else { - this._emitAction(ready, parentToolCallId); - } +- +- if (mcpServerName) { +- this._attachActiveMcpToolCallsToPendingAuthentication(mcpServerName); +- } - }; - this._surfaceProvisionalFusionToolStart = handleToolStart; - store.add(wrapper.onToolStart(e => { @@ -21185,6 +22138,17 @@ index 1a70b000302..00000000000 - - const handleToolComplete = (e: SessionEventPayload<'tool.execution_complete'>): void => { - this._approvedDuplicablePermissionSignatures.delete(e.data.toolCallId); +- const held = this._pendingClientToolReplacement?.calls.get(e.data.toolCallId); +- if (held) { +- if (!held.completed && this._isHeldClientToolCallCurrent(held)) { +- held.start?.(e.data.error?.message ?? localize('copilot.clientToolReplacementEnded', "Tool call ended before client tool configuration replacement completed.")); +- } +- held.completed = true; +- held.start = undefined; +- held.progress = undefined; +- held.ready.complete(false); +- return; +- } - const tracked = this._activeToolCalls.get(e.data.toolCallId); - if (!tracked) { - this._unroutableSubagentToolCallIds.delete(e.data.toolCallId); @@ -21344,6 +22308,7 @@ index 1a70b000302..00000000000 - pastTenseMessage: getPastTenseMessage(tracked.toolName, displayName, tracked.parameters, e.data.success, e.data.success ? toolOutput : undefined, path => this._resolveEditFilePath(path), this._resolveAgentName, tracked.meta?.[imageGenerationToolMetaKey]), - content: content.length > 0 ? content : undefined, - structuredContent: e.data.result?.structuredContent as Record | undefined, +- resultMetadata: getSdkMcpResultMeta(e.data.result), - error: e.data.error, - }, - _meta: tracked.meta ? toToolCallMeta(tracked.meta) : undefined, @@ -22170,6 +23135,10 @@ index 1a70b000302..00000000000 - }); - })); - +- // Attribute each compaction attempt to the turn that owned it when it started, so the +- // runtime's forwarded compaction telemetry can carry the same turn id as response telemetry. +- store.add(wrapper.onSessionCompactionStart(e => this._recordCompactionTurn(e))); +- - // Compaction (manual `/compact` or automatic) runs its own summarization model call, which the - // SDK bills on `session.compaction_complete` rather than as an `assistant.usage` event. - // @@ -22178,6 +23147,7 @@ index 1a70b000302..00000000000 - // only, rather than being carried onto whatever runs next and inflating an unrelated - // response footer by what is often the session's single most expensive call. - store.add(wrapper.onSessionCompactionComplete(async e => { +- this._recordCompactionTurn(e); - const observeUsage = () => { - const observedParentToolCallId = this._parentToolCallIdForSubagentEvent(e); - if ((!e.agentId || observedParentToolCallId) && !this._dropLateRootTurnEvents) { @@ -22479,6 +23449,51 @@ index 1a70b000302..00000000000 - } - } - +- private async _getMcpCommandServer(name: string): Promise { +- const { servers } = await this._wrapper.session.rpc.mcp.listConfigured(); +- const server = servers.find(server => server.name === name); +- if (!server) { +- return undefined; +- } +- const provenance = this._resolveMcpServerProvenance(name, this._mcpCustomizations.pluginMcpServerSources ?? new Map(), server); +- const source = provenance.source; +- const enabled = getEffectiveMcpServerEnablement(server.enabled, server.live.status); +- let configuration: ICopilotMcpCommandServer['configuration']; +- let configurationError: string | undefined; +- let sourceUri: string | undefined; +- if (source === 'user') { +- try { +- configuration = (await this._getUserMcpServers?.())?.[name]; +- } catch (error) { +- configurationError = getErrorMessage(error); +- this._logService.warn(`[Copilot:${this.sessionId}] Failed to read launch configuration for MCP server '${name}'`, error); +- } +- sourceUri = configuration?.sourcePath +- ? URI.file(configuration.sourcePath).toString() +- : provenance.sourceUri; +- } else if (source !== 'builtin' && source !== 'managed') { +- const pluginSource = this._mcpCustomizations.pluginMcpServerSources?.get(name); +- const definition = this._appliedSnapshot.plugins +- .filter(plugin => source !== 'plugin' || (pluginSource !== undefined && plugin.sourceUri?.toString() === pluginSource)) +- .flatMap(plugin => plugin.mcpServers.filter(candidate => source === 'plugin' || !plugin.disabledMcpServers?.includes(candidate.name))) +- .findLast(candidate => candidate.name === name && (source === 'plugin' || isMcpServerExplicitlyProjected(candidate))); +- if (definition) { +- configuration = toCopilotMcpServerConfiguration(definition.configuration, definition.configuration.type === 'stdio' +- ? resolveMcpServerWorkingDirectory(definition.configuration.cwd, definition.defaultCwd) : undefined); +- sourceUri = definition.uri.toString(); +- } else if (source !== 'plugin' && !this._launchPlan.isEphemeral) { +- const rootConfiguration = this._appliedSnapshot.mcpServers[name]; +- if (rootConfiguration) { +- configuration = toCopilotMcpServerConfiguration(rootConfiguration); +- } +- } +- } +- const tools = enabled && server.live.status === 'connected' && server.live.toolsLoaded !== false +- ? (await this._wrapper.session.rpc.mcp.listTools({ serverName: name })).tools +- : undefined; +- return { ...server, source, enabled, configuration, configurationError, sourceUri, tools }; +- } +- - private _noteMcpSdkStatus(serverName: string, status: SdkMcpServerStatus): void { - if (this._mcpSdkStatus.get(serverName) === 'connected' && status !== 'connected') { - this._mcpToolsLoaded.delete(serverName); @@ -22682,8 +23697,7 @@ index 1a70b000302..00000000000 - * Derives where a server is configured from the latest inventory, the user configuration file and the - * plugins in this session's client snapshot. Pure with respect to that state; never reads published output. - */ -- private _resolveMcpServerProvenance(serverName: string, pluginMcpServerSources: ReadonlyMap): IMcpServerProvenance { -- const reported = this._mcpInventoryProvenance.get(serverName); +- private _resolveMcpServerProvenance(serverName: string, pluginMcpServerSources: ReadonlyMap, reported = this._mcpInventoryProvenance.get(serverName)): IMcpServerProvenance { - const source = reported?.source - ?? (reported?.sourcePlugin || pluginMcpServerSources.has(serverName) ? 'plugin' - : Object.hasOwn(this._userMcpServers, serverName) ? 'user' @@ -23260,7 +24274,7 @@ index 1a70b000302..00000000000 - */ - private _surfaceUnobservedClientToolStart(invocation: ToolInvocation): void { - const { toolCallId } = invocation; -- if (this._activeToolCalls.has(toolCallId) || this._surfaceProvisionalFusionToolCall(toolCallId) || this._fusionProgress.runningPhaseToolCallId === undefined) { +- if (this._pendingClientToolCalls.hasBufferedResult(toolCallId) || this._activeToolCalls.has(toolCallId) || this._surfaceProvisionalFusionToolCall(toolCallId) || this._fusionProgress.runningPhaseToolCallId === undefined) { - return; - } - const args = invocation.arguments; @@ -23418,8 +24432,6 @@ index 1a70b000302..00000000000 - return; - } - this._reportedByokDroppedToolCount = droppedToolCount; -- // Later parent reasoning belongs after this notice. -- turn.reasoningPartIds.delete(''); - this._emitAction({ - type: ActionType.ChatResponsePart, - turnId: turn.id, @@ -23680,7 +24692,7 @@ index 1a70b000302..00000000000 - // matching `isSyntheticUserMessage`) and provider-initiated root requests so generated - // extension prompts are not reported as the user's text. - if (!e.agentId && !this._currentTurn.value?.providerInitiated && (!e.data.source || e.data.source.toLowerCase() === 'user')) { -- void this._telemetryReporter.userMessageText(this.resourceUri.toString(), this._chatChannelUri.toString(), this._currentTurn.value?.clientType ?? AgentHostClientType.Unknown, e.data.content, this._turnOrdinal).catch(err => this._logService.trace(`[Copilot:${this.sessionId}] Telemetry emission failed: ${getErrorMessage(err)}`)); +- void this._telemetryReporter.userMessageText(this.resourceUri.toString(), this._chatChannelUri.toString(), this._currentTurn.value?.clientType ?? AgentHostClientType.Unknown, e.data.content, this._turnOrdinal, wrapper.sessionId).catch(err => this._logService.trace(`[Copilot:${this.sessionId}] Telemetry emission failed: ${getErrorMessage(err)}`)); - } - })); - @@ -23852,8 +24864,15 @@ index 1a70b000302..00000000000 - appendShellOutput(e.data.toolCallId, () => this._nonPtyShellTerminals.appendChunk(e.data.toolCallId, e.data)); - })); - -- store.add(wrapper.onToolProgress(e => { +- const handleToolProgress = (e: SessionEventPayload<'tool.execution_progress'>): void => { - this._logService.trace(`[Copilot:${sessionId}] Tool progress: ${e.data.toolCallId} - ${e.data.progressMessage}`); +- const held = this._pendingClientToolReplacement?.calls.get(e.data.toolCallId); +- if (held) { +- if (!held.completed) { +- held.progress = () => handleToolProgress(e); +- } +- return; +- } - const tracked = this._activeToolCalls.get(e.data.toolCallId); - if (tracked?.toolName !== CopilotToolName.ImageGeneration) { - return; @@ -23876,7 +24895,8 @@ index 1a70b000302..00000000000 - content: tracked.content, - _meta: toToolCallMeta(tracked.meta), - }, parentToolCallId); -- })); +- }; +- store.add(wrapper.onToolProgress(handleToolProgress)); - - store.add(wrapper.onSkillInvoked(e => { - this._logService.trace(`[Copilot:${sessionId}] Skill invoked: ${e.data.name} (${e.data.path})`); @@ -24062,6 +25082,7 @@ index 1a70b000302..00000000000 - } - - private _cancelPendingClientToolCalls(): void { +- this._cancelHeldClientToolCalls(); - this._pendingClientToolCalls.denyAll({ textResultForLlm: 'Tool call cancelled: session ended', resultType: 'failure', error: 'Session ended' }); - } -} @@ -24161,10 +25182,10 @@ index 1a70b000302..00000000000 -} diff --git a/src/vs/platform/agentHost/node/copilot/copilotApprovalPolicy.ts b/src/vs/platform/agentHost/node/copilot/copilotApprovalPolicy.ts deleted file mode 100644 -index db7a95b95cc..00000000000 +index f9fafabf853..00000000000 --- a/src/vs/platform/agentHost/node/copilot/copilotApprovalPolicy.ts +++ /dev/null -@@ -1,132 +0,0 @@ +@@ -1,172 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -24172,6 +25193,9 @@ index db7a95b95cc..00000000000 - -import type { ManagedSettingsPermissions, PermissionMode, SessionEventPayload } from '@github/copilot-sdk'; -import { AutoApproveLevel, platformSessionSchema } from '../../common/agentHostSchema.js'; +-import { readAgentToolOrigin } from '../../common/meta/agentToolDefinitionMeta.js'; +-import { SEMANTIC_SEARCH_TOOL_NAME } from '../../common/semanticSearchConstants.js'; +-import type { ToolDefinition } from '../../common/state/protocol/state.js'; -import { ResolveSessionConfigResult } from '../../common/state/protocol/commands.js'; -import { localize } from '../../../../nls.js'; - @@ -24202,6 +25226,43 @@ index db7a95b95cc..00000000000 - return { available, defaultMode: available.includes(requestedDefault) ? requestedDefault : 'default', permissions }; -} - +-/** +- * Client tools that return workspace content without an SDK permission +- * request: the `semantic_search` override skips permission checks, and the +- * runtime cannot match a search query against managed `Read(...)` rules. +- */ +-export const MANAGED_WORKSPACE_RETRIEVAL_CLIENT_TOOLS: ReadonlySet = new Set([SEMANTIC_SEARCH_TOOL_NAME]); +- +-function hasManagedPermissionRules(permissions: unknown): boolean { +- return !!permissions && typeof permissions === 'object' && !Array.isArray(permissions) +- && ['deny', 'ask', 'allow'].some(key => { +- const rules = (permissions as Record)[key]; +- return Array.isArray(rules) && rules.length > 0; +- }); +-} +- +-/** +- * Whether a session must withhold the {@link MANAGED_WORKSPACE_RETRIEVAL_CLIENT_TOOLS} +- * under the enterprise managed policy it launches with. Once an enterprise +- * permission rule is active, the runtime requires approval for reads that no +- * rule matches, which these tools cannot obtain. So any deny, ask, or allow +- * rule, or a policy that fails closed, withholds them. +- */ +-export function isManagedWorkspaceRetrievalWithheld(resolved: SessionEventPayload<'session.managed_settings_resolved'>['data']): boolean { +- const settings = resolved.settings; +- const permissions = settings && typeof settings === 'object' && !Array.isArray(settings) ? settings.permissions : undefined; +- return resolved.failClosed +- || resolved.permissionsContext?.failClosed === true +- || resolved.permissionsAllowIntersected === true +- || hasManagedPermissionRules(permissions) +- || hasManagedPermissionRules(resolved.permissionsContext?.permissions); +-} +- +-/** Whether a client tool is withheld. MCP App tools keep normal SDK permission checks, so they never are. */ +-export function isWithheldClientTool(tool: ToolDefinition, withheldToolNames: ReadonlySet | undefined): boolean { +- return !!withheldToolNames?.has(tool.name) && readAgentToolOrigin(tool) !== 'mcpApp'; +-} +- -interface ICopilotApprovalInputs { - readonly requested: AutoApproveLevel | undefined; - readonly legacyRestricted: boolean; @@ -24557,10 +25618,10 @@ index 2e09292fc7b..9af280d31b0 100644 import { DeferredPromise, RunOnceScheduler } from '../../../../base/common/async.js'; diff --git a/src/vs/platform/agentHost/node/copilot/copilotConnectors.ts b/src/vs/platform/agentHost/node/copilot/copilotConnectors.ts deleted file mode 100644 -index 2ecb43e8728..00000000000 +index 92cf886109c..00000000000 --- a/src/vs/platform/agentHost/node/copilot/copilotConnectors.ts +++ /dev/null -@@ -1,123 +0,0 @@ +@@ -1,124 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -24674,7 +25735,8 @@ index 2ecb43e8728..00000000000 - }; -} - --function copilotConnectorServers(status: ConnectorStatus): ICopilotConnectorServers { +-/** Reads the Connector-owned runtime servers from the runtime's authoritative Connector state. */ +-export function copilotConnectorServers(status: ConnectorStatus): ICopilotConnectorServers { - const names = new Map(status.catalog?.connectors.map(connector => [connector.name, connector.displayName.trim() || connector.name])); - return { - servers: new Map(status.runtimeServers.map(server => [server.runtimeServerId, { @@ -24685,14 +25747,14 @@ index 2ecb43e8728..00000000000 - }; -} diff --git a/src/vs/platform/agentHost/node/copilot/copilotCustomizationCommandDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotCustomizationCommandDisplay.ts -index dcb59839c2c..81d2c42acc7 100644 +index f8ce15a42a7..59ceedbd9f5 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotCustomizationCommandDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotCustomizationCommandDisplay.ts @@ -6,3 +6,2 @@ import { appendEscapedMarkdownCodeBlockFence, appendEscapedMarkdownInlineCode, escapeMarkdownSyntaxTokens } from '../../../../base/common/htmlContent.js'; --import type { CopilotSession } from '@github/copilot-sdk'; - import { localize } from '../../../../nls.js'; -@@ -11,3 +10,21 @@ import type { CopilotSlashCommandOutput, CopilotSlashCommandResult, ICopilotSlas +-import type { CopilotClient, CopilotSession } from '@github/copilot-sdk'; + import { hasKey } from '../../../../base/common/types.js'; +@@ -12,3 +11,21 @@ import type { CopilotSlashCommandOutput, CopilotSlashCommandResult, ICopilotSlas -type CopilotPluginsApi = CopilotSession['rpc']['plugins']; +// `CopilotSession` came from '@github/copilot-sdk' (removed in !!APP_NAME!!); @@ -24715,6 +25777,23 @@ index dcb59839c2c..81d2c42acc7 100644 + }; +}; +@@ -24,4 +41,14 @@ export type ReloadCopilotPlugins = (reason: CopilotPluginReloadReason) => Promis + +-export type ICopilotMcpCommandServer = Awaited>['servers'][number] & { +- readonly configuration?: Awaited>['servers'][string]; ++// `CopilotSession['rpc']['mcp']['listConfigured']` servers and ++// `CopilotClient['rpc']['mcp']['config']['list']` configurations came from ++// '@github/copilot-sdk' (removed in !!APP_NAME!!); model the fields ++// invokeCopilotMcpCommand reads locally. ++interface ICopilotMcpConfiguredServer { name: string; enabled: boolean; source?: string; sourcePlugin?: string; live: { status: string; error?: string } } ++interface ICopilotMcpServerConfigBase { type?: string; tools?: string[]; timeout?: number } ++type CopilotMcpServerConfig = ++ | ICopilotMcpServerConfigBase & { command: string; args?: string[]; cwd?: string; env?: Record } ++ | ICopilotMcpServerConfigBase & { url: string; headers?: Record }; ++ ++export type ICopilotMcpCommandServer = ICopilotMcpConfiguredServer & { ++ readonly configuration?: CopilotMcpServerConfig; + readonly configurationError?: string; diff --git a/src/vs/platform/agentHost/node/copilot/copilotExtensionTools.ts b/src/vs/platform/agentHost/node/copilot/copilotExtensionTools.ts deleted file mode 100644 index 76f0cf4d789..00000000000 @@ -25613,10 +26692,10 @@ index ec5c474e0f0..00000000000 -} diff --git a/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts b/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts deleted file mode 100644 -index d8ee687f6f4..00000000000 +index 2473761f909..00000000000 --- a/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts +++ /dev/null -@@ -1,639 +0,0 @@ +@@ -1,687 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -25624,9 +26703,11 @@ index d8ee687f6f4..00000000000 - -import { spawn } from 'child_process'; -import type { CustomAgentConfig, MCPServerConfig, SessionHooks } from '@github/copilot-sdk'; +-import { StringSHA1 } from '../../../../base/common/hash.js'; -import { Schemas } from '../../../../base/common/network.js'; -import { dirname } from '../../../../base/common/path.js'; -import { OperatingSystem, OS } from '../../../../base/common/platform.js'; +-import { joinPath, relativePath } from '../../../../base/common/resources.js'; -import { escapeRegExpCharacters } from '../../../../base/common/strings.js'; -import { URI } from '../../../../base/common/uri.js'; -import { parseFrontMatter, type MarkdownNode, type YamlNode } from '../../../../base/common/yaml.js'; @@ -25646,6 +26727,7 @@ index d8ee687f6f4..00000000000 -type PostToolUseFailureHookInput = Parameters>[0]; -type PostToolUseFailureHookOutput = Exclude>>, void>; -type UserPromptSubmittedHookInput = Parameters>[0]; +-type UserPromptTransformedHookInput = Parameters>[0]; -type SessionStartHookInput = Parameters>[0]; -type SessionEndHookInput = Parameters>[0]; -type ErrorOccurredHookInput = Parameters>[0]; @@ -25717,6 +26799,21 @@ index d8ee687f6f4..00000000000 -// Custom agents -// --------------------------------------------------------------------------- - +-/** A forwarded definition with an optional SDK name resolved from its logical plugin source. */ +-export interface ISdkCustomAgentResource extends INamedPluginResource { +- readonly sdkName?: string; +-} +- +-/** Keeps forwarded SDK identities independent of authored names and materialized cache locations. */ +-export function getSdkCustomAgentName(uri: URI, plugin?: { readonly sourceUri?: URI; readonly pluginDir?: URI; readonly resourceDir?: URI }): string { +- const root = plugin?.pluginDir ?? plugin?.resourceDir; +- const relative = root && plugin?.sourceUri ? relativePath(root, uri) : undefined; +- const identity = relative !== undefined && plugin?.sourceUri ? joinPath(plugin.sourceUri, relative) : uri; +- const hash = new StringSHA1(); +- hash.update(identity.toString()); +- return `vscode-agent-${hash.digest()}`; +-} +- -const customAgentReasoningEfforts = ['low', 'medium', 'high', 'xhigh', 'max'] as const satisfies readonly NonNullable[]; -type CustomAgentReasoningEffort = (typeof customAgentReasoningEfforts)[number]; - @@ -25787,12 +26884,16 @@ index d8ee687f6f4..00000000000 - return hooks.length > 0 ? Object.fromEntries(hooks) : undefined; -} - +-function customAgentDescription(name: string, description?: string): string { +- return (description ? `${name}: ${description}` : name).replace(/\s+/g, ' ').trim(); +-} +- -/** - * Converts parsed plugin agents into the SDK's `customAgents` config. - * - * Each agent file is read and (when present) its YAML frontmatter is parsed: -- * - `name` falls back to the agent's resource name (filename stem). -- * - `description` is forwarded verbatim. +- * - `name` is derived from the resource URI; `displayName` retains the authored name. +- * - `description` includes the authored name on one line so the model can discover the agent. - * - Extension-qualified `tools` references are translated to their SDK registration names; - * an empty / missing array becomes `null` so the SDK grants the agent access to all tools. - * - `reasoning-effort` is forwarded when it is a supported runtime value. @@ -25800,7 +26901,7 @@ index d8ee687f6f4..00000000000 - * full file content when there is no frontmatter). - */ -export async function toSdkCustomAgents( -- agents: readonly INamedPluginResource[], +- agents: readonly ISdkCustomAgentResource[], - fileService: IFileService, - availableModelIds?: ReadonlySet, - clientToolNames?: ReadonlyMap, @@ -25813,15 +26914,13 @@ index d8ee687f6f4..00000000000 - const md = parseFrontMatter(raw); - if (!md) { - configs.push({ -- name: agent.name, +- name: agent.sdkName ?? getSdkCustomAgentName(agent.uri), +- displayName: agent.name, +- description: customAgentDescription(agent.name), - prompt: raw, - }); - } else { -- // Match `parseAgentFile`'s name derivation (trim + falsy fallback) so -- // the SDK config name equals the `resolvedAgentName` resolved from the -- // parsed plugin agent; otherwise a whitespace-padded frontmatter `name` -- // would make the SDK reject the session-start `agent:` as not found. -- const name = md.getStringValue('name')?.trim() || agent.name; +- const displayName = md.getStringValue('name')?.trim() || agent.name; - const description = md.getStringValue('description'); - const tools = md.getStringArrayValue('tools'); - const skills = md.getStringArrayValue('skills'); @@ -25837,8 +26936,9 @@ index d8ee687f6f4..00000000000 - const models = md.getStringArrayValue('model') ?? undefined; - const model = scalarModel ?? models?.find(model => availableModelIds?.has(model)) ?? models?.[0]; - configs.push({ -- name, -- ...(description ? { description } : {}), +- name: agent.sdkName ?? getSdkCustomAgentName(agent.uri), +- displayName, +- description: customAgentDescription(displayName, description), - ...(model ? { model } : {}), - ...(isCustomAgentReasoningEffort(reasoningEffort) ? { reasoningEffort } : {}), - tools: tools && tools.length > 0 ? tools : null, @@ -25866,7 +26966,7 @@ index d8ee687f6f4..00000000000 - -/** The agents contributed by a plugin. */ -export interface IPluginAgentsForSdk { -- readonly agents: readonly INamedPluginResource[]; +- readonly agents: readonly ISdkCustomAgentResource[]; -} - -/** @@ -26052,6 +27152,7 @@ index d8ee687f6f4..00000000000 - 'PreToolUse': 'onPreToolUse', - 'PostToolUse': 'onPostToolUse', - 'UserPromptSubmit': 'onUserPromptSubmitted', +- 'UserPromptTransformed': 'onUserPromptTransformed', - 'SessionStart': 'onSessionStart', - 'SessionEnd': 'onSessionEnd', - 'ErrorOccurred': 'onErrorOccurred', @@ -26154,6 +27255,28 @@ index d8ee687f6f4..00000000000 - }; - } - +- // Each hook sees the preceding hook's transformed prompt, while the +- // original user prompt remains unchanged. +- const transformedPromptCommands = commandsByKey.get('onUserPromptTransformed'); +- if (transformedPromptCommands?.length) { +- hooks.onUserPromptTransformed = async (input: UserPromptTransformedHookInput) => { +- let modifiedTransformedPrompt: string | undefined; +- +- for (const command of transformedPromptCommands) { +- const output: { modifiedTransformedPrompt?: unknown } | undefined = await runHookCommands([command], { +- ...input, +- transformedPrompt: modifiedTransformedPrompt ?? input.transformedPrompt, +- }); +- +- if (typeof output?.modifiedTransformedPrompt === 'string') { +- modifiedTransformedPrompt = output.modifiedTransformedPrompt; +- } +- } +- +- return modifiedTransformedPrompt === undefined ? undefined : { modifiedTransformedPrompt }; +- }; +- } +- - // Session-start handler - const startCommands = commandsByKey.get('onSessionStart'); - if (startCommands?.length) { @@ -26202,7 +27325,7 @@ index d8ee687f6f4..00000000000 - return hooks; -} - --type ComparablePlugin = IParsedPlugin & { readonly pluginDir?: URI; readonly resourceDir?: URI; readonly nonce?: string }; +-type ComparablePlugin = IParsedPlugin & { readonly sourceUri?: URI; readonly pluginDir?: URI; readonly resourceDir?: URI; readonly nonce?: string }; - -/** - * Compares SDK configuration, preserving plugin order. A plugin with a content nonce compares relative to its own @@ -26216,29 +27339,32 @@ index d8ee687f6f4..00000000000 - const rootPattern = roots.length - ? new RegExp(`(?:])`, 'g') - : undefined; -- return JSON.stringify({ -- format: p.format, -- nonce: p.nonce, -- hooks: p.hooks.map(h => ({ type: h.type, commands: h.commands.map(c => ({ command: c.command, windows: c.windows, linux: c.linux, osx: c.osx, cwd: c.cwd?.toString(), env: c.env, timeout: c.timeout })) })), -- mcpServers: p.mcpServers.map(m => ({ name: m.name, configuration: m.configuration, defaultCwd: m.defaultCwd?.toString() })), -- skills: p.skills.map(s => ({ -- uri: s.uri.toString(), -- name: s.name, -- disableModelInvocation: s.disableModelInvocation, -- disableUserInvocation: s.disableUserInvocation, -- })), -- agents: p.agents.map(a => ({ uri: a.uri.toString(), name: a.name })), -- instructions: p.instructions.map(i => ({ uri: i.uri.toString(), name: i.name })), -- }, (_key, value) => { -- // Tagged segments distinguish a relocated root from literal placeholder text. -- if (rootPattern && typeof value === 'string') { -- const segments = value.split(rootPattern); -- if (segments.length > 1) { -- return { pluginRootRelative: segments }; +- return { +- sourceUri: p.agents.length > 0 ? p.sourceUri?.toString() : undefined, +- configuration: JSON.stringify({ +- format: p.format, +- nonce: p.nonce, +- hooks: p.hooks.map(h => ({ type: h.type, commands: h.commands.map(c => ({ command: c.command, windows: c.windows, linux: c.linux, osx: c.osx, cwd: c.cwd?.toString(), env: c.env, timeout: c.timeout })) })), +- mcpServers: p.mcpServers.map(m => ({ name: m.name, configuration: m.configuration, defaultCwd: m.defaultCwd?.toString() })), +- skills: p.skills.map(s => ({ +- uri: s.uri.toString(), +- name: s.name, +- disableModelInvocation: s.disableModelInvocation, +- disableUserInvocation: s.disableUserInvocation, +- })), +- agents: p.agents.map(a => ({ uri: a.uri.toString(), name: a.name })), +- instructions: p.instructions.map(i => ({ uri: i.uri.toString(), name: i.name })), +- }, (_key, value) => { +- // Tagged segments distinguish a relocated root from literal placeholder text. +- if (rootPattern && typeof value === 'string') { +- const segments = value.split(rootPattern); +- if (segments.length > 1) { +- return { pluginRootRelative: segments }; +- } - } -- } -- return value; -- }); +- return value; +- }), +- }; - })); - }; - return serialize(a) === serialize(b); @@ -26251,7 +27377,8 @@ index d8ee687f6f4..00000000000 - // Applied and published sets can come from different chat anchors, so their plugin positions can differ. - const equivalent = index >= 0 ? applied.find(candidate => parsedPluginsEqual([candidate], [plugin])) : undefined; - if (equivalent) { -- return equivalent.agents[index]?.name; +- const agent = equivalent.agents[index]; +- return agent ? getSdkCustomAgentName(agent.uri, equivalent) : undefined; - } - } - return undefined; @@ -26695,10 +27822,10 @@ index f2da2e33b4a..c764bf16dd7 100644 import { isValidAssignmentContext } from '../../../telemetry/common/assignmentContext.js'; diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionHistory.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionHistory.ts deleted file mode 100644 -index 1fb86f99541..00000000000 +index 603b101ca7b..00000000000 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionHistory.ts +++ /dev/null -@@ -1,83 +0,0 @@ +@@ -1,113 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -26711,7 +27838,7 @@ index 1fb86f99541..00000000000 -import { localize } from '../../../../nls.js'; -import { buildNonPtyShellTerminalUri } from '../../common/nonPtyShellTerminalUri.js'; -import type { ISessionDatabase } from '../../common/sessionDataService.js'; --import { ResponsePartKind, ToolCallStatus, ToolResultContentType, type ToolResultTerminalContent, type Turn } from '../../common/state/sessionState.js'; +-import { ResponsePartKind, ToolCallStatus, ToolResultContentType, type ToolCallCompletedState, type ToolResultTerminalContent, type Turn } from '../../common/state/sessionState.js'; -import { parseSpilledShellCompletion } from './copilotNonPtyShellTerminals.js'; -import { isShellTool } from './copilotToolDisplay.js'; - @@ -26753,8 +27880,50 @@ index 1fb86f99541..00000000000 - return pages.reverse().flat(); -} - --/** Reattaches retained shell output to the exact restored chat, including history read without a live runtime. */ --export async function restoreStoredShellOutputsForChat(turns: readonly Turn[], storage: URI, session: URI, chat: URI, db: ISessionDatabase): Promise { +-/** The restored turns of one chat, either the parent chat or one of its subagent chats. */ +-export interface IRestoredChatTurns { +- readonly turns: readonly Turn[]; +- /** The chat that owns the turns, used to address each terminal resource. */ +- readonly chat: URI; +-} +- +-/** A restored non-PTY shell call whose full output may be stored in the session database. */ +-interface IStoredShellOutputCandidate { +- readonly toolCall: ToolCallCompletedState; +- readonly terminal: ToolResultTerminalContent | undefined; +- /** The chat that owns the tool call, used to address its terminal resource. */ +- readonly chat: URI; +-} +- +-/** Reattaches retained shell output to the exact restored chats, including history read without a live runtime. */ +-export async function restoreStoredShellOutputs(chats: readonly IRestoredChatTurns[], storage: URI, session: URI, db: ISessionDatabase): Promise { +- const candidates: IStoredShellOutputCandidate[] = []; +- for (const { turns, chat } of chats) { +- collectStoredShellOutputCandidates(turns, chat, candidates); +- } +- if (candidates.length === 0) { +- return; +- } +- // Look up every candidate at once; long sessions have thousands of shell calls. +- const storedSizes = await db.getTerminalOutputSizes(candidates.map(({ toolCall }) => toolCall.toolCallId)); +- for (const { toolCall, terminal, chat } of candidates) { +- if (!storedSizes.has(toolCall.toolCallId)) { +- continue; +- } +- const text = toolCall.content?.find(content => content.type === ToolResultContentType.Text)?.text; +- const terminalContent: ToolResultTerminalContent = { +- type: ToolResultContentType.Terminal, +- title: toolCall.displayName, +- isPty: false, +- result: parseSpilledShellCompletion(text) ?? { truncated: true, preview: '' }, +- ...terminal, +- resource: buildNonPtyShellTerminalUri(storage, session, chat, toolCall.toolCallId), +- }; +- toolCall.content = [...(toolCall.content ?? []).filter(content => content.type !== ToolResultContentType.Terminal), terminalContent]; +- } +-} +- +-function collectStoredShellOutputCandidates(turns: readonly Turn[], chat: URI, candidates: IStoredShellOutputCandidate[]): void { - for (const turn of turns) { - for (const part of turn.responseParts) { - if (part.kind !== ResponsePartKind.ToolCall @@ -26766,28 +27935,16 @@ index 1fb86f99541..00000000000 - if (terminal && terminal.isPty !== false) { - continue; - } -- if (await db.getTerminalOutputSize(part.toolCall.toolCallId) === undefined) { -- continue; -- } -- const text = part.toolCall.content?.find(content => content.type === ToolResultContentType.Text)?.text; -- const terminalContent: ToolResultTerminalContent = { -- type: ToolResultContentType.Terminal, -- title: part.toolCall.displayName, -- isPty: false, -- result: parseSpilledShellCompletion(text) ?? { truncated: true, preview: '' }, -- ...terminal, -- resource: buildNonPtyShellTerminalUri(storage, session, chat, part.toolCall.toolCallId), -- }; -- part.toolCall.content = [...(part.toolCall.content ?? []).filter(content => content.type !== ToolResultContentType.Terminal), terminalContent]; +- candidates.push({ toolCall: part.toolCall, terminal, chat }); - } - } -} diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts deleted file mode 100644 -index 9767e3f365b..00000000000 +index 85072371c58..00000000000 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ /dev/null -@@ -1,1613 +0,0 @@ +@@ -1,1743 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -26799,6 +27956,7 @@ index 9767e3f365b..00000000000 -import { CancellationError } from '../../../../base/common/errors.js'; -import { Schemas } from '../../../../base/common/network.js'; -import { equals as objectsEqual } from '../../../../base/common/objects.js'; +-import { basename, dirname, extUri, getComparisonKey, isEqual, joinPath } from '../../../../base/common/resources.js'; -import { isObject, isStringArray } from '../../../../base/common/types.js'; -import { StopWatch } from '../../../../base/common/stopwatch.js'; -import { URI } from '../../../../base/common/uri.js'; @@ -26809,13 +27967,14 @@ index 9767e3f365b..00000000000 -import { ILogService, LogLevel } from '../../../log/common/log.js'; -import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; -import { AgentSession, type IAgentModelInfo } from '../../common/agent.js'; +-import { AGENT_HOST_FILE_SCHEME } from '../../common/agentPluginManager.js'; -import type { IAgentProviderSendStageRecorder } from '../../common/agentHostTelemetry.js'; -import { getByokLmSelectionModelId, resolveByokLmEnablement, type IByokLmModelInfo } from '../../common/agentHostByokLm.js'; -import { LOCAL_MODEL_SENTINEL_PROVIDER_NAME } from '../../common/localModel.js'; -import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostCanvasesEnabledConfigKey, AgentHostMcpConnectorsEnabledConfigKey, AgentHostTelemetryLevelConfigKey, AgentHostWorkspaceTrustConfigKey, agentHostConfigValueToTelemetryLevel, platformRootSchema, type AgentHostMcpServers, type IAgentHostWorkspaceTrust } from '../../common/agentHostSchema.js'; -import { isAgentHostWorkspaceTrusted } from '../../common/agentHostWorkspaceTrust.js'; -import { isWorktreeUnderRepository } from '../../common/worktreePaths.js'; --import { getCopilotApprovalPolicy } from './copilotApprovalPolicy.js'; +-import { getCopilotApprovalPolicy, isManagedWorkspaceRetrievalWithheld, isWithheldClientTool, MANAGED_WORKSPACE_RETRIEVAL_CLIENT_TOOLS } from './copilotApprovalPolicy.js'; -import { AGENT_HOST_COPILOT_CLIENT_NAME, type CopilotManagedSettingsActivityReporter, type CopilotManagedSettingsResolver, resolveCopilotManagedSettings } from './copilotManagedSettings.js'; -import { omitCopilotManagedModelDefaults, type CopilotManagedModelPolicy } from './copilotManagedModelConfiguration.js'; -import { CopilotCliConfigKey, CopilotCliVSCodeAssignmentContextKey, copilotCliConfigSchema, normalizeModelFamilyAlias, normalizeToolSearchDeferThreshold, resolveModelCapabilityOverrideField } from '../../common/copilotCliConfig.js'; @@ -26824,7 +27983,7 @@ index 9767e3f365b..00000000000 -import { projectCopilotSandboxPolicy } from './copilotSandboxPolicy.js'; -import { defaultAutoModeTier, getSupportedAutoModeTiers, isAutoAdvertisedAsFusion, isSupportedAutoModeTier, normalizeAutoModeTier } from '../../common/autoModeTiers.js'; -import { SEMANTIC_SEARCH_TOOL_NAME } from '../../common/semanticSearchConstants.js'; --import type { ModelSelection, ToolDefinition } from '../../common/state/protocol/state.js'; +-import type { AgentCustomization, ModelSelection, ToolDefinition } from '../../common/state/protocol/state.js'; -import { ContextSizeConfigKey } from '../../common/agentModelConfiguration.js'; -import type { ActiveClientToolSet } from '../activeClientState.js'; -import { IAgentConfigurationService } from '../agentConfigurationService.js'; @@ -26836,7 +27995,7 @@ index 9767e3f365b..00000000000 -import type { ICopilotMcpServerInfo, ICopilotPluginInfo } from './copilotAgent.js'; -import { CopilotGitHubSessionCredentials } from './copilotGitHubCredentials.js'; -import { reconcileCopilotConnectors, type ICopilotConnectorServers } from './copilotConnectors.js'; --import { mapCopilotCustomAgentTools, toSdkClientToolName, toSdkCustomAgents, toSdkHooks, toSdkInstructionDirectories, toSdkMcpServers, toSdkMcpServersFromConfigMap, toSdkSkillDirectories } from './copilotPluginConverters.js'; +-import { getSdkCustomAgentName, mapCopilotCustomAgentTools, toSdkClientToolName, toSdkCustomAgents, toSdkHooks, toSdkInstructionDirectories, toSdkMcpServers, toSdkMcpServersFromConfigMap, toSdkSkillDirectories, type ISdkCustomAgentResource } from './copilotPluginConverters.js'; -import { buildCopilotBuiltinAgents, COPILOT_BUILTIN_AGENT_NAMES } from './copilotBuiltinAgents.js'; -import { CopilotSessionWrapper, type ICopilotByokSessionConfig } from './copilotSessionWrapper.js'; -import type { ILocalModelProvider, ILocalModelSessionConfigContribution } from './localModelBridge/localModelProvider.js'; @@ -26851,7 +28010,7 @@ index 9767e3f365b..00000000000 -import type { SandboxConfig } from './sandboxConfigForSdk.js'; -import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, agentHostModelSupportsToolSearch } from './toolSearchDeferral.js'; -import { createCopilotExtensionTools } from './copilotExtensionTools.js'; --import { readAgentToolFullReferenceName } from '../../common/meta/agentToolDefinitionMeta.js'; +-import { readAgentToolFullReferenceName, readAgentToolOrigin } from '../../common/meta/agentToolDefinitionMeta.js'; - -export const ThinkingLevelConfigKey = 'thinkingLevel'; -export const CopilotReasoningEffortConfigKey = 'reasoningEffort'; @@ -26965,7 +28124,11 @@ index 9767e3f365b..00000000000 - -export interface ICopilotAppliedCustomizations { - readonly plugins: readonly ICopilotPluginInfo[]; +- /** Host disable decisions for SDK-native servers; no native definitions are forwarded. */ +- readonly disabledNativeMcpServers?: readonly string[]; - readonly discoveredAgents: readonly IParsedAgent[]; +- /** SDK-native agents participate in selection, never in custom-agent registration. */ +- readonly nativeAgents?: readonly AgentCustomization[]; - readonly additionalRootInstructions: readonly IParsedRule[]; -} - @@ -26976,7 +28139,9 @@ index 9767e3f365b..00000000000 -export interface IActiveClientSnapshot { - readonly tools: readonly ToolDefinition[]; - readonly plugins: readonly ICopilotPluginInfo[]; +- readonly disabledNativeMcpServers?: readonly string[]; - readonly discoveredAgents: readonly IParsedAgent[]; +- readonly nativeAgents?: readonly AgentCustomization[]; - /** Primary/home instructions load automatically; only secondary roots need forwarding (https://github.com/github/copilot-agent-runtime/issues/28228). */ - readonly additionalRootInstructions: readonly IParsedRule[]; - readonly mcpServers: AgentHostMcpServers; @@ -26994,6 +28159,8 @@ index 9767e3f365b..00000000000 -export interface ICopilotClientToolConfiguration { - readonly toolSearchActive: boolean; - readonly hostToolNames: ReadonlySet; +- /** Client tools withheld from the model under the launch's managed permission rules; live tool updates keep them withheld. */ +- readonly withheldToolNames: ReadonlySet; - readonly sessionConfig: CopilotClientToolSessionConfig; - resolve(definitions: readonly ToolDefinition[], clientTools?: readonly Tool[]): ICopilotResolvedClientToolConfiguration; - replaceTools(session: CopilotSession, tools: Tool[]): Promise; @@ -27006,11 +28173,53 @@ index 9767e3f365b..00000000000 - })); -} - --export function getCopilotCustomAgents(customizations: ICopilotAppliedCustomizations): readonly IParsedAgent[] { +-export function getCopilotCustomAgents(customizations: ICopilotAppliedCustomizations): readonly (IParsedAgent & ISdkCustomAgentResource)[] { - return [ -- ...customizations.plugins.flatMap(plugin => plugin.agents), +- ...customizations.plugins.flatMap(plugin => plugin.agents.map(agent => ({ ...agent, sdkName: getSdkCustomAgentName(agent.uri, plugin) }))), - ...customizations.discoveredAgents, -- ]; +- ].filter(agent => !COPILOT_BUILTIN_AGENT_NAMES.has(agent.name)); +-} +- +-export function getCopilotAgentSelection(customizations: ICopilotAppliedCustomizations, uri: string, restoreLegacyFlatSelection = false): { readonly uri: string; readonly name: string } | undefined { +- const resource = URI.parse(uri); +- const native = customizations.nativeAgents?.find(agent => isEqual(URI.parse(agent.uri), resource)); +- if (native) { +- return { uri, name: native.id }; +- } +- const agents = getCopilotCustomAgents(customizations); +- const forwarded = agents.find(agent => isEqual(agent.uri, resource)); +- if (forwarded) { +- return { uri, name: forwarded.sdkName ?? getSdkCustomAgentName(forwarded.uri) }; +- } +- if (!restoreLegacyFlatSelection || resource.scheme !== Schemas.file || resource.query || resource.fragment) { +- return undefined; +- } +- const matches = new Map(); +- for (const plugin of customizations.plugins) { +- const materializedRoot = plugin.pluginDir ?? plugin.resourceDir; +- const root = materializedRoot && URI.parse(materializedRoot.toString()); +- if (!plugin.sourceUri || plugin.sourceUri.scheme === AGENT_HOST_FILE_SCHEME || !plugin.nonce || !root || root.scheme !== Schemas.file || basename(root) !== plugin.nonce +- || !extUri.isEqualOrParent(resource, dirname(root))) { +- continue; +- } +- // The host materializer owns this cache namespace; only restore a flat file in another revision of it. +- const parts = extUri.relativePath(dirname(root), resource)?.split('/'); +- if (parts?.length !== 3 || !parts[0] || parts[0] === '..' || parts[0] === '.' || parts[1] !== 'agents') { +- continue; +- } +- const current = joinPath(root, parts[1], parts[2]); +- const agent = agents.find(candidate => isEqual(candidate.uri, current)); +- if (agent) { +- matches.set(getComparisonKey(agent.uri), { +- uri: agent.uri.toString(), name: agent.sdkName ?? getSdkCustomAgentName(agent.uri), +- }); +- } +- } +- return matches.size === 1 ? matches.values().next().value : undefined; +-} +- +-export function getCopilotAgentSelectionName(customizations: ICopilotAppliedCustomizations, uri: string, restoreLegacyFlatSelection = false): string | undefined { +- return getCopilotAgentSelection(customizations, uri, restoreLegacyFlatSelection)?.name; -} - -/** @@ -27023,6 +28232,10 @@ index 9767e3f365b..00000000000 - return new Set(snapshot.tools.map(tool => tool.name)); -} - +-function trustedClientToolNames(tools: readonly ToolDefinition[]): ReadonlySet { +- return new Set(tools.filter(tool => readAgentToolOrigin(tool) !== 'mcpApp').map(tool => tool.name)); +-} +- -/** - * Narrows the names that gate prompt content so the system message never - * advertises a tool the filters disabled. Client tools are `custom:`-source even @@ -27079,9 +28292,9 @@ index 9767e3f365b..00000000000 - /** The owner session this launch is configured for; the chat's owner can change later, which relaunches at the next turn boundary. */ - readonly launchOwner: URI; - /** The sandbox policy to pass when the SDK session opens; `undefined` when the launch applies it another way. */ -- getLaunchSandboxConfig(): SandboxConfig | undefined; +- getLaunchSandboxConfig(isResume: boolean): SandboxConfig | undefined; - /** Applies the launch-time sandbox policy and diagnostics before the SDK session is attached. */ -- applyLaunchSandbox(session: CopilotSession): Promise; +- applyLaunchSandbox(session: CopilotSession, isResume: boolean): Promise; - handlePermissionRequest(request: PermissionRequest, invocation?: Parameters[1]): Promise; - handleExitPlanModeRequest(request: ExitPlanModeRequest, invocation: { sessionId: string }): Promise; - handleUserInputRequest(request: UserInputRequest, invocation: UserInputInvocation): Promise; @@ -27099,8 +28312,13 @@ index 9767e3f365b..00000000000 - handleUserPromptSubmitted(): { readonly additionalContext: string } | undefined; - /** Shows a launch-time wait, such as slow managed settings, as the activity of the chat that owns this session; `undefined` clears it. */ - readonly reportManagedSettingsActivity?: CopilotManagedSettingsActivityReporter; +- /** +- * Creates the SDK tools for the session's client tools. `hostToolNames` are +- * implemented by the host instead; `withheldToolNames` are not offered to +- * the model at all. +- */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- createClientSdkTools(toolSearchActive: boolean, hostToolNames?: ReadonlySet): Tool[]; +- createClientSdkTools(toolSearchActive: boolean, hostToolNames?: ReadonlySet, withheldToolNames?: ReadonlySet): Tool[]; - configureClientTools?(configuration: ICopilotClientToolConfiguration): void; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - createServerSdkTools(toolSearchActive?: boolean): Tool[]; @@ -27142,6 +28360,8 @@ index 9767e3f365b..00000000000 - * {@link IAgentCreateChatOptions.hasScopedEditSurface}. - */ - readonly hasScopedEditSurface?: boolean; +- /** Whether the owning chat surface is an inline chat. See {@link IAgentCreateChatOptions.isInlineChatSurface}. */ +- readonly isInlineChatSurface?: boolean; - readonly workingDirectory: URI | undefined; - /** - * The additional working directories beyond the primary process root @@ -27211,6 +28431,13 @@ index 9767e3f365b..00000000000 - -export type CopilotSessionLaunchPlan = ICopilotCreateSessionLaunchPlan | ICopilotResumeSessionLaunchPlan; - +-function getNativeAgentSelection(plan: CopilotSessionLaunchPlan): AgentCustomization | undefined { +- return plan.snapshot.nativeAgents?.find(agent => agent.id === plan.resolvedAgentName); +-} +- +-/** How the chat surface that created a chat's owning session scopes its runtime. */ +-export type CopilotChatSurface = Pick; +- -/** - * The trust a created SDK session grants its working directory, forwarding - * VS Code's Workspace Trust to the Copilot runtime so it loads the folder's @@ -27459,11 +28686,55 @@ index 9767e3f365b..00000000000 - return defaultAutoModeTier; -} - --/** Resolves the shared override first, then the picker preference. */ --export function resolveCopilotAutoTier(model: ModelSelection | undefined, configurationService: Pick, logService: ILogService, sessionId: string): string | undefined { +-/** +- * The tier inline chat, editor or terminal, runs Copilot Fusion at, whatever effort level the picker +- * holds, as latency matters more than routing depth there. Matches the Copilot extension's inline chat tier. +- */ +-const InlineChatFusionAutoTier = 'fast'; +- +-/** +- * The tier inline chat runs Copilot Fusion at, or `undefined` when Auto is not presented as Fusion or the +- * provider does not accept the tier. Advertised on Fusion's model, so clients hide its effort level in +- * inline chat only while the host pins it. +- */ +-export function getInlineChatFusionAutoTier(): string | undefined { +- return isAutoAdvertisedAsFusion() && isSupportedAutoModeTier(InlineChatFusionAutoTier) ? InlineChatFusionAutoTier : undefined; +-} +- +-/** Fusion's tier for inline chat, or `undefined` when the model is not Fusion or the provider does not accept the tier. */ +-function resolveInlineChatFusionAutoTier(model: ModelSelection | undefined, logService: ILogService, sessionId: string): string | undefined { +- if (!isAutoModel(model?.id) || !isAutoAdvertisedAsFusion()) { +- return undefined; +- } +- const tier = getInlineChatFusionAutoTier(); +- if (tier === undefined) { +- logService.warn(`[Copilot:${sessionId}] Not pinning inline chat to Fusion's '${InlineChatFusionAutoTier}' tier, which the provider does not accept; expected one of [${getSupportedAutoModeTiers().join(', ')}]`); +- return undefined; +- } +- logService.info(`[Copilot:${sessionId}] Using Fusion's '${tier}' tier for inline chat`); +- return tier; +-} +- +-/** +- * Resolves the tiers that take precedence over the picker: the shared override, then inline chat's +- * Fusion tier. `null` applies the override by leaving the tier to the runtime. +- */ +-function resolveForcedAutoTier(model: ModelSelection | undefined, configurationService: Pick, logService: ILogService, sessionId: string, isInlineChat: boolean): string | null | undefined { - const override = resolveConfiguredAutoTierOverride(model, configurationService, logService, sessionId); - if (override !== undefined) { -- return override ?? undefined; +- return override; +- } +- return isInlineChat ? resolveInlineChatFusionAutoTier(model, logService, sessionId) : undefined; +-} +- +-/** +- * Resolves the shared override first, then inline chat's Fusion tier, then the picker preference. +- * `isInlineChat` is the launch's {@link ICopilotSessionLaunchBase.isInlineChatSurface}, set for editor and terminal inline chat. +- */ +-export function resolveCopilotAutoTier(model: ModelSelection | undefined, configurationService: Pick, logService: ILogService, sessionId: string, isInlineChat = false): string | undefined { +- const forced = resolveForcedAutoTier(model, configurationService, logService, sessionId, isInlineChat); +- if (forced !== undefined) { +- return forced ?? undefined; - } - const tier = getCopilotAutoTier(model); - if (tier === undefined) { @@ -27662,19 +28933,22 @@ index 9767e3f365b..00000000000 - || (err instanceof Error && err.message.includes('Sandbox configuration update violates managed policy. Contact your administrator for more information.')); -} - --/** Applies sandbox configuration, returning false when the runtime retains its policy after a managed conflict. */ --export async function applySandboxConfig(session: CopilotSessionWrapper['session'], sandboxConfig: SandboxConfig, sessionId: string, logService: ILogService, rejectPolicyConflicts = false): Promise { +-/** Applies sandbox configuration, returning a reportable conflict when the runtime retains its policy. */ +-export async function applySandboxConfig(session: CopilotSessionWrapper['session'], sandboxConfig: SandboxConfig, sessionId: string, logService: ILogService, rejectPolicyConflicts = false, isSessionClosed?: () => boolean): Promise<{ applied: true } | { applied: false; message: string }> { - try { - const result = await session.rpc.options.update({ sandboxConfig }); - if (!result.success) { - throw new Error('Copilot SDK rejected sandbox config update'); - } - logService.info(`[Copilot:${sessionId}] Applied SDK sandboxConfig via session.options.update`); -- return true; +- return { applied: true }; - } catch (err) { +- if (isSessionClosed?.()) { +- return { applied: false, message: getErrorMessage(err) }; +- } - if (!rejectPolicyConflicts && isManagedSandboxPolicyConflict(err)) { - logService.warn(`[Copilot:${sessionId}] SDK sandboxConfig update conflicts with managed policy; continuing with the runtime's existing sandbox configuration`, err); -- return false; +- return { applied: false, message: getErrorMessage(err) }; - } - logService.warn(`[Copilot:${sessionId}] Failed to apply SDK sandboxConfig`, err); - throw err; @@ -27832,13 +29106,13 @@ index 9767e3f365b..00000000000 - } - const modelPolicy = built.modelPolicy; - // Applied during construction so MCP servers start sandboxed; the launch apply below still repeats it for runtimes without open-time support. -- const config: SandboxAwareSessionConfig = { ...built.config, sandboxConfig: runtime.getLaunchSandboxConfig() }; +- const config: SandboxAwareSessionConfig = { ...built.config, sandboxConfig: runtime.getLaunchSandboxConfig(plan.kind === 'resume') }; - plan.stageRecorder?.mark('create'); - const sandboxConfig = async (session: CopilotSessionWrapper['session']) => { - if (!managedSettingsResolved) { - this._logService.error(`[Copilot:${plan.sessionId}] Copilot runtime did not report its resolved managed settings; continuing with available sandbox configuration`); - } -- await runtime.applyLaunchSandbox(session); +- await runtime.applyLaunchSandbox(session, plan.kind === 'resume'); - }; - if (plan.kind === 'create') { - return this._createSession(plan, config, sandboxConfig, runtime, modelPolicy); @@ -27855,7 +29129,7 @@ index 9767e3f365b..00000000000 - const errCode = getCopilotSdkErrorCode(resumeError); - const errMsg = getErrorMessage(resumeError); - this._logService.warn(`[Copilot:${plan.sessionId}] SDK resumeSession failed: code=${errCode}, message=${errMsg}`); -- if (plan.resolvedAgentName && isCustomAgentNotFoundError(resumeError)) { +- if (plan.resolvedAgentName && !getNativeAgentSelection(plan) && isCustomAgentNotFoundError(resumeError)) { - fallbackPlan = { ...plan, resolvedAgentName: undefined }; - fallbackConfig = { ...config, agent: undefined }; - this._logService.warn(`[Copilot:${plan.sessionId}] Stored custom agent '${plan.resolvedAgentName}' was not found; retrying resume without a custom agent`); @@ -27939,8 +29213,7 @@ index 9767e3f365b..00000000000 - model: plan.model?.id, - reasoningEffort: resolveCopilotReasoningEffort(model, this._configurationService, this._logService, plan.sessionId), - contextTier: omittedContextDefault ? undefined : getCopilotContextTier(model, plan.longContextWindow, plan.freeLongContext), -- ...toSdkCapiSessionOptions(resolveCopilotAutoTier(model, this._configurationService, this._logService, plan.sessionId)), -- ...(plan.resolvedAgentName ? { agent: plan.resolvedAgentName } : {}), +- ...toSdkCapiSessionOptions(resolveCopilotAutoTier(model, this._configurationService, this._logService, plan.sessionId, plan.isInlineChatSurface === true)), - ...(trustWorkingDirectory ? { trustWorkingDirectory } : {}), - workingDirectory: plan.workingDirectory?.fsPath, - }))); @@ -27952,6 +29225,10 @@ index 9767e3f365b..00000000000 - try { - // Avoid unpublished lazy-discovery events blocking native OTel: github/copilot-agent-runtime#27963. - await raw.rpc.agent.list(); +- const nativeAgent = getNativeAgentSelection(plan); +- if (nativeAgent) { +- await raw.rpc.agent.select({ name: nativeAgent.id }); +- } - await this._applyScriptSafety(raw, plan.sessionId); - await sandboxConfig(raw); - await this._configureExecutionSubagentTool(raw, config); @@ -28109,7 +29386,6 @@ index 9767e3f365b..00000000000 - // Throwaway chat surfaces skip memory, like the other heavyweight features they omit. - const memoryEnabled = !plan.isEphemeral && this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.Memory) === true; - const localMemoryEnabled = memoryEnabled && this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.LocalMemory) === true; -- const tgrepEnabled = this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.Tgrep) === true; - const searchSubagentEnabled = this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.SearchSubagent) === true; - const copilotConnectorsEnabled = this._configurationService.getRootValue(platformRootSchema, AgentHostMcpConnectorsEnabledConfigKey) === true; - const stabilityOrderedPromptEnabled = this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.StabilityOrderedPrompt) === true; @@ -28118,7 +29394,7 @@ index 9767e3f365b..00000000000 - AUTO_APPROVAL: true, - ...catalogInstallationFeatureFlags, - CONNECTORS: copilotConnectorsEnabled, -- TGREP: tgrepEnabled, +- TGREP: true, - CONTENT_EXCLUSION: true, - // Attached long-lived services prevent the runtime from reaching session idle. - DETACH_LONG_LIVED_SERVICES: true, @@ -28232,7 +29508,7 @@ index 9767e3f365b..00000000000 - const toolSearchDeferThreshold = normalizeToolSearchDeferThreshold(this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.ToolSearchDeferThreshold)); - const resolveToolSearch = (names: ReadonlySet): NonNullable => - toolSearchEnabled && names.has(CLIENT_TOOL_SEARCH_REFERENCE_NAME) ? { enabled: true, deferThreshold: toolSearchDeferThreshold } : { enabled: false }; -- const toolSearchActive = resolveToolSearch(filterClientToolNames(clientToolNamesFromSnapshot(plan.snapshot), availableTools, excludedTools)).enabled === true; +- const toolSearchActive = resolveToolSearch(filterClientToolNames(trustedClientToolNames(plan.snapshot.tools), availableTools, excludedTools)).enabled === true; - const serverTools = runtime.createServerSdkTools(toolSearchActive); - const extensionTools = createCopilotExtensionTools(canvasRuntimeEnabled, () => runtime.reloadExtensions(), this._logService); - const hostToolNames = new Set([...shellTools, ...serverTools, ...extensionTools, ...computerUseTools].map(tool => tool.name)); @@ -28243,14 +29519,23 @@ index 9767e3f365b..00000000000 - const bridgedPermissions = this._managedSettingsService.permissions; - const resolved = await resolveCopilotSessionManagedSettings(plan, runtime.cancellationToken, runtime.reportManagedSettingsActivity); - runtime.setApprovalPolicy?.(resolved.resolved, bridgedPermissions); +- const withheldClientToolNames: ReadonlySet = isManagedWorkspaceRetrievalWithheld(resolved.resolved) +- ? MANAGED_WORKSPACE_RETRIEVAL_CLIENT_TOOLS +- : new Set(); +- if (withheldClientToolNames.size) { +- this._logService.info(`[Copilot:${plan.sessionId}] Managed permission rules are active; withholding client tools: ${[...withheldClientToolNames].join(', ')}`); +- } - const managedSettingsPermissions = getCopilotApprovalPolicy(resolved.resolved, - this._configurationService.getRootValue(platformRootSchema, AgentHostAutoApprovePolicyRestrictedConfigKey) === true, - bridgedPermissions).permissions; - const additionalDirectories = plan.additionalDirectories?.map(d => d.fsPath); -- const authoredCustomAgents = pluginCustomAgents.filter(agent => !COPILOT_BUILTIN_AGENT_NAMES.has(agent.name)); -- const resolveToolConfiguration = (definitions: readonly ToolDefinition[], clientTools: readonly Tool[] = []): ICopilotResolvedClientToolConfiguration => { +- const authoredCustomAgents = pluginCustomAgents.filter(agent => !COPILOT_BUILTIN_AGENT_NAMES.has(agent.displayName ?? agent.name)); +- const resolveToolConfiguration = (published: readonly ToolDefinition[], clientTools: readonly Tool[] = []): ICopilotResolvedClientToolConfiguration => { +- // Withheld tools are absent from every derived setting, so the runtime keeps its built-in replacement excluded. +- const definitions = withheldClientToolNames.size ? published.filter(tool => !isWithheldClientTool(tool, withheldClientToolNames)) : published; - const names = filterClientToolNames(new Set(definitions.map(tool => tool.name)), availableTools, excludedTools); -- const toolSearch = resolveToolSearch(names); +- const trustedNames = filterClientToolNames(trustedClientToolNames(definitions), availableTools, excludedTools); +- const toolSearch = resolveToolSearch(trustedNames); - const promptOverrides = applyToolOverrides([...shellTools, ...clientTools, ...serverTools, ...extensionTools, ...computerUseTools]); - const promptContext: IAgentHostPromptContext = { - getSetting: key => this._configurationService.getRootValue(copilotCliConfigSchema, key), @@ -28262,7 +29547,7 @@ index 9767e3f365b..00000000000 - tools: promptOverrides.tools, - sessionConfig: { - toolSearch, -- excludedTools: names.has(SEMANTIC_SEARCH_TOOL_NAME) +- excludedTools: trustedNames.has(SEMANTIC_SEARCH_TOOL_NAME) - ? configuredSdkExcludedTools - : [...new Set([...configuredSdkExcludedTools, `builtin:${SEMANTIC_SEARCH_TOOL_NAME}`])], - systemMessage: promptOverrides.systemPrompt !== undefined @@ -28275,10 +29560,11 @@ index 9767e3f365b..00000000000 - }, - }; - }; -- const toolConfiguration = resolveToolConfiguration(plan.snapshot.tools, runtime.createClientSdkTools(toolSearchActive, hostToolNames)); +- const toolConfiguration = resolveToolConfiguration(plan.snapshot.tools, runtime.createClientSdkTools(toolSearchActive, hostToolNames, withheldClientToolNames)); - runtime.configureClientTools?.({ - toolSearchActive: toolConfiguration.sessionConfig.toolSearch.enabled === true, - hostToolNames, +- withheldToolNames: withheldClientToolNames, - sessionConfig: toolConfiguration.sessionConfig, - resolve: resolveToolConfiguration, - replaceTools: async (session, tools) => { @@ -28343,7 +29629,7 @@ index 9767e3f365b..00000000000 - // while a resumed session keeps the effort the runtime journaled unless - // an override is configured (encoded like every other path). - ...(plan.kind === 'resume' ? { reasoningEffort: toRuntimeReasoningEffort(model, resolveConfiguredReasoningEffortOverride(model, this._configurationService, this._logService, plan.sessionId)) } : {}), -- ...(plan.kind === 'resume' ? toSdkCapiSessionOptions(resolveConfiguredAutoTierOverride(model, this._configurationService, this._logService, plan.sessionId) ?? undefined) : {}), +- ...(plan.kind === 'resume' ? toSdkCapiSessionOptions(resolveForcedAutoTier(model, this._configurationService, this._logService, plan.sessionId, plan.isInlineChatSurface === true) ?? undefined) : {}), - modelCapabilities, - enableMcpApps: true, - githubMcpToolConfig: { disableFormDeferral: true }, @@ -28371,10 +29657,11 @@ index 9767e3f365b..00000000000 - onExitPlanModeRequest: (request, invocation) => runtime.handleExitPlanModeRequest(request, invocation), - workingDirectory: plan.workingDirectory?.fsPath, - ...toolConfiguration.sessionConfig, -- agent: plan.resolvedAgentName, +- agent: getNativeAgentSelection(plan) ? undefined : plan.resolvedAgentName, - skillDirectories, - instructionDirectories, - additionalDirectories, +- coauthorEnabled: this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.IncludeCoAuthoredBy), - largeOutput: { - maxSizeBytes: 8 * 1024, - }, @@ -28403,10 +29690,10 @@ index 9767e3f365b..00000000000 -} diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionSandbox.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionSandbox.ts deleted file mode 100644 -index f2bbaed5c6e..00000000000 +index dde3c922235..00000000000 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionSandbox.ts +++ /dev/null -@@ -1,365 +0,0 @@ +@@ -1,456 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -28424,11 +29711,13 @@ index f2bbaed5c6e..00000000000 -import { ILogService } from '../../../log/common/log.js'; -import { normalizeSandboxFileSystemPath } from '../../../sandbox/common/settings.js'; -import type { IAgentPermissionResponseContext } from '../../common/agent.js'; +-import { getAttachedLocalPaths } from '../../common/attachedLocalPaths.js'; -import type { IAgentSandboxCertificateStatus } from '../../common/meta/agentSandboxCertificate.js'; -import { measureAgentProviderOperation } from '../../common/agentHostProviderTiming.js'; -import type { IAgentProviderSendStageRecorder } from '../../common/agentHostTelemetry.js'; -import { CopilotCliConfigKey, copilotCliConfigSchema } from '../../common/copilotCliConfig.js'; -import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +-import type { MessageAttachment } from '../../common/state/protocol/state.js'; -import { AgentHostSandboxConfigKey, AgentHostSandboxKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; -import type { ISandboxPathGrant } from '../../common/sandboxPathGrant.js'; -import { ISessionDataService } from '../../common/sessionDataService.js'; @@ -28441,7 +29730,7 @@ index f2bbaed5c6e..00000000000 -import { CopilotSandboxCertificateDiagnostics } from './copilotSandboxCertificateDiagnostics.js'; -import { applySandboxConfig } from './copilotSessionLauncher.js'; -import type { ShellManager } from './copilotShellTools.js'; --import { buildSandboxConfigForSdk, type SandboxConfig } from './sandboxConfigForSdk.js'; +-import { buildSandboxConfigForSdk, resolveAttachedPathGrants, toSandboxFileSystemPath, type ISandboxAttachedPathGrants, type ISandboxAttachedPaths, type SandboxConfig } from './sandboxConfigForSdk.js'; - -/** A config change of the chat this SDK session runs. */ -interface ISandboxConfigChange { @@ -28460,12 +29749,18 @@ index f2bbaed5c6e..00000000000 - readonly getConfigWriteTarget: () => IAgentChatConfigurationTarget; - readonly platform: NodeJS.Platform; - readonly getSession: () => CopilotSession | undefined; +- readonly isSessionClosed?: (session?: CopilotSession) => boolean; - readonly getHostSupport: () => ReturnType; - readonly getCertificateStatus: (config: SandboxConfig) => Promise; - readonly installCertificate: (getConfig: () => SandboxConfig, scopeId: string) => Promise; - readonly rotateCertificate: (getConfig: () => SandboxConfig, scopeId: string) => Promise; - readonly removeCertificate: (scopeId: string, validate: () => void) => Promise; - readonly getShellInitDirectory: () => URI | undefined; +- readonly getAppliedPluginDirectories: () => readonly URI[]; +- /** The working directory the runtime grants when `addCurrentWorkingDirectory` is on. */ +- readonly getWorkingDirectory: () => URI | undefined; +- /** Further working directories the SDK session was launched with; the runtime grants them read/write. */ +- readonly getAdditionalWorkingDirectories: () => readonly URI[]; - readonly shellManager: ShellManager | undefined; - readonly onDidChangeRuntimeConfig?: () => void; -} @@ -28474,6 +29769,8 @@ index f2bbaed5c6e..00000000000 -export class CopilotSessionSandbox extends Disposable { - private readonly _requests = new Map(); - private readonly _pendingPathGrants = new Map(); +- /** Local files and folders the user attached to this chat, by URI; read-only for the life of the SDK session, never saved to settings. */ +- private readonly _attachedPaths = new Map(); - private readonly _configSequencer = new Sequencer(); - private readonly _diagnostics: CopilotSandboxDiagnostics; - private readonly _certificateDiagnostics: CopilotSandboxCertificateDiagnostics; @@ -28556,13 +29853,15 @@ index f2bbaed5c6e..00000000000 - if (!context.sandboxPathGrantSaved) { - throw new Error(localize('copilot.permission.grant.unsupportedClient', "This client did not save the sandbox path grant. Update VS Code and retry.")); - } -- if (token.isCancellationRequested || !requestId || !grant || this._requests.get(toolCallId) !== requestId) { +- // The SDK session stops being available once it starts disconnecting. +- const session = this._options.getSession(); +- if (token.isCancellationRequested || !requestId || !grant || this._requests.get(toolCallId) !== requestId || !session) { - throw new Error(localize('copilot.permission.grant.expired', "The sandbox permission request is no longer pending.")); - } - const attributed = attributePermissionResult({ kind: 'approve-once' }, context.decisionSource); - // The grant changes the runtime's live policy outside `options.update`. - this._appliedConfig = undefined; -- const result = await this._options.getSession()!.rpc.sandbox.grantPathForRequest({ +- const result = await session.rpc.sandbox.grantPathForRequest({ - requestId, - ...(attributed.kind === 'attributed' ? { decisionContext: attributed.decisionContext } : {}), - }); @@ -28584,6 +29883,43 @@ index f2bbaed5c6e..00000000000 - } - } - +- /** +- * Records the local files and folders of a user message so the next applied +- * config grants them read-only. Returns whether the message attaches any +- * local path, new or not: a caller that sends before the next turn must then +- * await {@link applyConfig}, which also waits for an update still in flight. +- */ +- addAttachments(attachments: readonly MessageAttachment[] | undefined): boolean { +- const uris = getAttachedLocalPaths(attachments); +- for (const uri of uris) { +- this._attachedPaths.set(uri.toString(), uri); +- } +- return uris.length > 0; +- } +- +- /** The local attachments the sandbox can't make readable: those the effective policy blocks, and folders containing a writable folder. */ +- getUnreadableAttachments(attachments: readonly MessageAttachment[] | undefined): Pick { +- const paths = getAttachedLocalPaths(attachments); +- if (!paths.length || this._disabledForSession) { +- return { blocked: [], containsWritable: [] }; +- } +- const target = this._target(); +- const sandbox = getSessionSandboxConfig(this._configurationService, target, this._options.platform); +- const policy = this._configurationService.getSessionSandboxPolicy(target.session); +- const { blocked, containsWritable } = resolveAttachedPathGrants(this._options.platform, sandbox, policy, this._attachedPathsFor(paths)); +- return { blocked, containsWritable }; +- } +- +- /** Uses the session's platform rather than `URI.fsPath`, whose syntax follows the OS of the current process. */ +- private _attachedPathsFor(uris: readonly URI[]): ISandboxAttachedPaths { +- const workingDirectory = this._options.getWorkingDirectory(); +- return { +- paths: uris.map(uri => toSandboxFileSystemPath(uri, this._options.platform)), +- ...(workingDirectory ? { workingDirectory: toSandboxFileSystemPath(workingDirectory, this._options.platform) } : {}), +- additionalWorkingDirectories: this._options.getAdditionalWorkingDirectories().map(uri => toSandboxFileSystemPath(uri, this._options.platform)), +- }; +- } +- - /** Whether the Agent Host's own shell tools replace the SDK's built-in shell. */ - isCustomTerminalToolEnabled(): boolean { - return this._options.platform !== 'win32' && this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.EnableCustomTerminalTool) === true; @@ -28601,26 +29937,46 @@ index f2bbaed5c6e..00000000000 - return { session: this._options.ownerSession.toString(), chat: this._options.chat.toString() }; - } - -- applyLaunchConfig(session: CopilotSession): Promise { -- return this._configSequencer.queue(() => { +- private _getLaunchSelection(isResume: boolean): 'on' | 'off' | 'default' | undefined { +- if (this._pendingSelection !== undefined || !isResume) { +- return this._pendingSelection; +- } +- const target = this._target(); +- const policy = this._configurationService.getSessionSandboxPolicy(target.session); +- return policy?.enabled && policy.allowBypass === true +- && this._configurationService.getChatSandboxEnabled(target) === false +- && this._configurationService.getChatConfigValues(target)?.[SessionConfigKey.SandboxEnabled] === 'off' +- ? 'off' : undefined; +- } +- +- applyLaunchConfig(session: CopilotSession, isResume = false): Promise { +- return this._configSequencer.queue(async () => { - this._launchSession = session; -- const requestedSelection = this._pendingSelection; +- const requestedSelection = this._getLaunchSelection(isResume); - this._pendingSelection = undefined; - this._appliedConfig = undefined; -- return this._updateConfig(true, requestedSelection, undefined, session, false); +- if (isResume) { +- this._disabledForSession = false; +- } +- try { +- await this._updateConfig(true, requestedSelection, undefined, session, false, isResume); +- } catch (error) { +- this._launchSession = undefined; +- throw error; +- } - }); - } - - /** The config {@link applyLaunchConfig} pushes, so it can also be applied when the SDK session opens; `undefined` when the launch uses the session command or pushes nothing. */ -- getLaunchConfig(): SandboxConfig | undefined { -- return this._configForUpdate(this._pendingSelection, this._computeConfig()); +- getLaunchConfig(isResume = false): SandboxConfig | undefined { +- return this._configForUpdate(this._getLaunchSelection(isResume), this._computeConfig(), isResume); - } - -- private _configForUpdate(requestedSelection: 'on' | 'off' | 'default' | undefined, base: SandboxConfig | undefined): SandboxConfig | undefined { +- private _configForUpdate(requestedSelection: 'on' | 'off' | 'default' | undefined, base: SandboxConfig | undefined, isResume = false): SandboxConfig | undefined { - if (requestedSelection !== undefined && this._requiresSessionCommand()) { - return undefined; - } -- if (!base && this._disabledForSession) { +- if (!base && this._disabledForSession && !isResume) { - // A bare disabled options.update does not preserve the runtime's managed sandbox floor. - return undefined; - } @@ -28632,13 +29988,13 @@ index f2bbaed5c6e..00000000000 - const target = this._target(); - const sandbox = getSessionSandboxConfig(this._configurationService, target, this._options.platform); - const policy = this._configurationService.getSessionSandboxPolicy(target.session); -- const readonlyPaths = getVSCodeSandboxReadRoots({ +- const readonlyPaths = [...getVSCodeSandboxReadRoots({ - sessionDataDirectories: [this._options.ownerSession.toString(), this._options.storage.toString(), ...this._configurationService.getChatAttachmentScopes(target)] - .filter((scope, index, scopes) => scopes.indexOf(scope) === index) - .map(scope => this._sessionDataService.getSessionDataDir(URI.parse(scope))), - shellInitDirectory: this._options.getShellInitDirectory(), -- }).map(root => root.fsPath); -- return buildSandboxConfigForSdk(this._options.platform, sandbox, readonlyPaths, policy); +- }), ...this._options.getAppliedPluginDirectories()].map(root => root.fsPath); +- return buildSandboxConfigForSdk(this._options.platform, sandbox, readonlyPaths, policy, this._attachedPathsFor([...this._attachedPaths.values()])); - } - - updateDiagnostics(config?: SandboxConfig): Promise { @@ -28721,7 +30077,10 @@ index f2bbaed5c6e..00000000000 - return (policy?.enabled === true && policy.allowBypass === true) || this._disabledForSession; - } - -- private async _updateConfig(failOnError: boolean, requestedSelection: 'on' | 'off' | 'default' | undefined, recorder?: IAgentProviderSendStageRecorder, session = this._options.getSession() ?? this._launchSession, reportMutation = true): Promise { +- private async _updateConfig(failOnError: boolean, requestedSelection: 'on' | 'off' | 'default' | undefined, recorder?: IAgentProviderSendStageRecorder, session = this._options.getSession() ?? this._launchSession, reportMutation = true, isResume = false): Promise { +- if (this._store.isDisposed || this._options.isSessionClosed?.(session)) { +- return; +- } - if (!session) { - if (requestedSelection !== undefined) { - this._pendingSelection = requestedSelection; @@ -28729,12 +30088,14 @@ index f2bbaed5c6e..00000000000 - return; - } - // Saving settings must not replace the live policy before the runtime accepts the pending grant. -- if (this._pendingPathGrants.size && requestedSelection === undefined) { +- if (this._pendingPathGrants.size && requestedSelection === undefined && !isResume) { - this._configUpdateDeferred = true; - return; - } - const base = this._computeConfig(); - const target = this._target(); +- const writeTarget = this._options.getConfigWriteTarget(); +- const values = this._configurationService.getChatConfigValues(writeTarget); - if (requestedSelection !== undefined && this._requiresSessionCommand()) { - const enabled = requestedSelection === 'on' || (requestedSelection === 'default' && base?.enabled === true); - this._options.onDidChangeRuntimeConfig?.(); @@ -28748,7 +30109,7 @@ index f2bbaed5c6e..00000000000 - await measureAgentProviderOperation(recorder, 'sandboxDiagnostics', () => this.updateDiagnostics(enabled ? base : { enabled: false })); - return; - } -- const sandboxConfig = this._configForUpdate(requestedSelection, base); +- const sandboxConfig = this._configForUpdate(requestedSelection, base, isResume); - if (!sandboxConfig) { - return; - } @@ -28758,13 +30119,29 @@ index f2bbaed5c6e..00000000000 - if (!unchanged && reportMutation) { - this._options.onDidChangeRuntimeConfig?.(); - } -- if (unchanged || await measureAgentProviderOperation(recorder, 'sandboxRpc', () => applySandboxConfig(session, sandboxConfig, this._options.sessionId, this._logService, requestedSelection !== undefined))) { -- this._appliedConfig = serialized; -- this._disabledForSession = false; -- this._configurationService.setChatSandboxEnabled(target, sandboxConfig.enabled); -- await measureAgentProviderOperation(recorder, 'sandboxDiagnostics', () => this.updateDiagnostics(sandboxConfig)); +- if (!unchanged) { +- const result = await measureAgentProviderOperation(recorder, 'sandboxRpc', () => applySandboxConfig(session, sandboxConfig, this._options.sessionId, this._logService, isResume || requestedSelection !== undefined, () => !!this._options.isSessionClosed?.(session))); +- if (this._store.isDisposed || this._options.isSessionClosed?.(session)) { +- return; +- } +- if (!result.applied) { +- this._configurationService.reportChatSandboxConflict(target, result.message); +- return; +- } +- } +- this._appliedConfig = serialized; +- this._disabledForSession = false; +- this._configurationService.setChatSandboxEnabled(target, sandboxConfig.enabled); +- if (isResume && requestedSelection === undefined && sandboxConfig.enabled +- && values?.[SessionConfigKey.SandboxEnabled] === 'off' +- && this._configurationService.getChatConfigValues(writeTarget) === values) { +- this._configurationService.updateChatConfig(writeTarget, { [SessionConfigKey.SandboxEnabled]: 'on' }); - } +- await measureAgentProviderOperation(recorder, 'sandboxDiagnostics', () => this.updateDiagnostics(sandboxConfig)); - } catch (err) { +- if (this._store.isDisposed || this._options.isSessionClosed?.(session)) { +- return; +- } - if (failOnError) { - throw err; - } @@ -28772,6 +30149,7 @@ index f2bbaed5c6e..00000000000 - } - } -} +- diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts deleted file mode 100644 index d62814df20e..00000000000 @@ -30817,7 +32195,7 @@ index 9adf2ed598f..9cdd2097581 100644 + softAssertNever(kind as never); return undefined; diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts -index edc31dadbb1..88a624ba91c 100644 +index 6df4bd91086..4a329b3106d 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -5,3 +5,21 @@ @@ -30842,30 +32220,37 @@ index edc31dadbb1..88a624ba91c 100644 +type PermissionRequest = { [K in PermissionRequestKind]: { kind: K } & Record }[PermissionRequestKind]; +type SkillInvokedData = any; +/* eslint-enable @typescript-eslint/no-explicit-any */ - import { hasKey, isObject } from '../../../../base/common/types.js'; + import { assertNever } from '../../../../base/common/assert.js'; diff --git a/src/vs/platform/agentHost/node/copilot/customizations/copilotCustomizationInstallations.ts b/src/vs/platform/agentHost/node/copilot/customizations/copilotCustomizationInstallations.ts deleted file mode 100644 -index 7e7351d99f0..00000000000 +index 6257d4ac5d8..00000000000 --- a/src/vs/platform/agentHost/node/copilot/customizations/copilotCustomizationInstallations.ts +++ /dev/null -@@ -1,1233 +0,0 @@ +@@ -1,1315 +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 type { CopilotClient, InstallationConfirmationHandler, InstallationConfirmationRequest } from '@github/copilot-sdk'; --import { disposableTimeout } from '../../../../../base/common/async.js'; +-import { createHash } from 'crypto'; +-import { disposableTimeout, raceCancellationError } from '../../../../../base/common/async.js'; +-import { cancelOnDispose } from '../../../../../base/common/cancellation.js'; -import { structuralEquals } from '../../../../../base/common/equals.js'; -import { Disposable, DisposableMap, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; -import { joinPath } from '../../../../../base/common/resources.js'; -import { hasKey } from '../../../../../base/common/types.js'; +-import { stableStringify } from '../../../../../base/common/objects.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { generateUuid } from '../../../../../base/common/uuid.js'; -import { localize } from '../../../../../nls.js'; -import { IAgentCustomizationInstallation, IAgentCustomizationInstallationApplyResult, IAgentCustomizationInstallationCatalogue, IAgentCustomizationInstallationRequest, IAgentCustomizationInstallationReview, IAgentCustomizationMarketplaceSearchItem, IAgentCustomizationMarketplaceSearchRequest, IAgentCustomizationMarketplaceSearchResult } from '../../../common/agent.js'; --import { toCopilotMcpServerConfiguration } from '../../../../mcp/common/mcpCopilotConfiguration.js'; --import type { IMcpServerConfiguration } from '../../../../mcp/common/mcpPlatformTypes.js'; +-import { ICopilotMcpServerConfiguration, toCopilotMcpServerConfiguration } from '../../../../mcp/common/mcpCopilotConfiguration.js'; +-import { parseMcpGalleryServerConfiguration } from '../../../../mcp/common/mcpGalleryService.js'; +-import { TransportType } from '../../../../mcp/common/mcpManagement.js'; +-import { McpServerManifestParser } from '../../../../mcp/common/mcpManagementService.js'; +-import { IMcpServerConfiguration, McpServerType } from '../../../../mcp/common/mcpPlatformTypes.js'; +-import { IRequestService, isSuccess, readBoundedResponse } from '../../../../request/common/request.js'; - -const catalogContract = { - protocolVersion: 3, @@ -31030,6 +32415,7 @@ index 7e7351d99f0..00000000000 -} - -export class CopilotCustomizationInstallations extends Disposable { +- private readonly lifetimeToken = cancelOnDispose(this._store); - private readonly operations = this._register(new DisposableMap()); - private readonly catalogSelections = this._register(new DisposableMap()); - private readonly catalogCursors = this._register(new DisposableMap()); @@ -31060,6 +32446,7 @@ index 7e7351d99f0..00000000000 - constructor( - private readonly getClient: () => Promise, - private readonly createPolicySession: (client: ICopilotCustomizationInstallationClient, sessionId: string) => Promise, +- private readonly requestService?: IRequestService, - ) { - super(); - } @@ -31437,7 +32824,11 @@ index 7e7351d99f0..00000000000 - const planned = await client.rpc.mcp.planInstall({ - contract: { - ...catalogContract, -- requiredCapabilities: [...catalogContract.requiredCapabilities, 'mcp-install-planning', 'multiple-transport-choice'], +- requiredCapabilities: [ +- ...catalogContract.requiredCapabilities.filter(capability => source.kind === 'candidate' || capability !== 'catalog-search-session-bound'), +- 'mcp-install-planning', +- 'multiple-transport-choice', +- ], - }, - ...(source.kind === 'candidate' ? { policySessionId } : {}), - scope: 'user', @@ -31449,15 +32840,22 @@ index 7e7351d99f0..00000000000 - const recommended = planned.plan.recommendedTransportChoiceId - ? planned.plan.transportChoices.find(choice => choice.choiceId === planned.plan.recommendedTransportChoiceId) - : undefined; -- const choice = [recommended, ...planned.plan.transportChoices].find(choice => +- const choices = [recommended, ...planned.plan.transportChoices]; +- const choice = choices.find(choice => - choice?.installMethod === 'remote' && choice.secretPlaceholders.length === 0 -- ); -- if (!choice || choice.installMethod !== 'remote') { +- && (source.kind === 'candidate' || choice.requiredValues.length === 0) +- ) ?? (source.kind === 'card' ? choices.find(choice => +- choice?.installMethod === 'package' && choice.requiredValues.length === 0 && choice.secretPlaceholders.length === 0 +- ) : undefined); +- if (!choice) { - throw new Error(localize('copilot.customizationInstallation.mcpInputsRequired', "This MCP server requires an installation choice or additional values that Browse cannot collect yet.")); - } - const configuration = planned.plan.configurationChanges[planned.plan.transportChoices.indexOf(choice)]; - if (source.kind === 'card') { -- return this.prepareMcpCardConfiguration(client, request, planned.plan, choice, configuration); +- return this.prepareMcpCardConfiguration(client, request, card, planned.plan, choice, configuration); +- } +- if (choice.installMethod !== 'remote') { +- throw new Error(localize('copilot.customizationInstallation.mcpPackageUnsupported', "The SDK cannot prepare confirmed local MCP server installations.")); - } - const prepared = await client.rpc.mcp.prepareInstall({ - contract: mcpInstallationContract, @@ -31515,14 +32913,15 @@ index 7e7351d99f0..00000000000 - } - - /** Provisional direct-card fallback until https://github.com/github/copilot-agent-runtime/issues/26495 is available. */ -- private prepareMcpCardConfiguration( +- private async prepareMcpCardConfiguration( - client: ICopilotCustomizationInstallationClient, - request: IAgentCustomizationInstallationRequest, +- card: McpServerCardReference, - plan: Extract>, { readonly kind: 'planned' }>['plan'], -- choice: Extract, +- choice: typeof plan.transportChoices[number], - configuration: typeof plan.configurationChanges[number] | undefined, -- ): IAgentCustomizationInstallationReview { -- const supportedFields = new Set(['tools', 'type', 'url']); +- ): Promise { +- const supportedFields = new Set(choice.installMethod === 'package' ? ['tools', 'type', 'command', 'args', 'env'] : ['tools', 'type', 'url']); - if (plan.policy.decision === 'blocked') { - throw new Error(plan.policy.reason ?? localize('copilot.customizationInstallation.mcpPolicyBlocked', "Policy blocked this MCP server installation.")); - } @@ -31530,10 +32929,24 @@ index 7e7351d99f0..00000000000 - || choice.secretPlaceholders.length > 0 - || !configuration - || configuration.operation !== 'add' +- || configuration.scope !== 'user' +- || plan.target.scope !== 'user' +- || configuration.configKey !== plan.target.configKey +- || configuration.secretReferences.length > 0 - || configuration.changedFields.some(field => !supportedFields.has(field)) - ) { - throw new Error(localize('copilot.customizationInstallation.mcpProvisionalUnsupported', "This MCP server requires configuration that provisional Gallery installation cannot preserve.")); - } +- const config: ICopilotMcpServerConfiguration = choice.installMethod === 'package' +- ? await this.resolveMcpPackageConfiguration(card, plan, choice) +- : { +- type: choice.transport === 'sse' ? 'sse' : 'http', +- url: choice.endpoint, +- tools: ['*'], +- }; +- if (!structuralEquals(Object.keys(config).sort(), [...configuration.changedFields].sort())) { +- throw new Error(localize('copilot.customizationInstallation.mcpConfigurationChanged', "The MCP server configuration does not match the SDK installation plan. Refresh Browse and try again.")); +- } - const operationId = generateUuid(); - const review: IAgentCustomizationInstallationReview = { - operationId, @@ -31542,7 +32955,8 @@ index 7e7351d99f0..00000000000 - displayName: request.displayName, - serverName: plan.identity.serverName, - target: plan.target.configKey, -- endpoint: choice.endpoint, +- ...(choice.installMethod === 'remote' ? { endpoint: choice.endpoint } : {}), +- ...(config.type === 'stdio' ? { command: config.command, args: config.args, env: config.env } : {}), - configurationFields: configuration.changedFields, - }; - const expiresAt = Date.parse(plan.planHandleExpiresAt); @@ -31556,11 +32970,7 @@ index 7e7351d99f0..00000000000 - async () => { - await client.rpc.mcp.config.add({ - name: plan.identity.serverName, -- config: { -- type: choice.transport === 'sse' ? 'sse' : 'http', -- url: choice.endpoint, -- tools: ['*'], -- }, +- config, - }); - return undefined; - }, @@ -31570,6 +32980,56 @@ index 7e7351d99f0..00000000000 - return review; - } - +- private async resolveMcpPackageConfiguration( +- card: McpServerCardReference, +- plan: Extract>, { readonly kind: 'planned' }>['plan'], +- choice: Extract, +- ): Promise { +- if (card.kind !== 'url' || !this.requestService) { +- throw new Error(localize('copilot.customizationInstallation.mcpCardUnavailable', "The MCP server card is unavailable for local configuration.")); +- } +- const context = await this.requestService.request({ +- type: 'GET', +- url: card.url, +- followRedirects: 0, +- timeout: 30_000, +- logUrl: false, +- callSite: 'copilotCustomizationInstallations.resolveMcpPackageConfiguration', +- }, this.lifetimeToken); +- let data: unknown; +- try { +- if (!isSuccess(context)) { +- throw new Error(localize('copilot.customizationInstallation.mcpCardFetchFailed', "Could not read the MCP server card (HTTP {0}).", context.res.statusCode)); +- } +- const text = await raceCancellationError(readBoundedResponse(context, 5 * 1024 * 1024, +- () => new Error(localize('copilot.customizationInstallation.mcpCardTooLarge', "The MCP server card is too large to install."))), this.lifetimeToken); +- try { +- data = JSON.parse(text); +- } catch { +- throw new Error(localize('copilot.customizationInstallation.mcpCardInvalid', "The MCP server card contains invalid JSON.")); +- } +- } finally { +- context.stream.destroy(); +- } +- const digest = createHash('sha256').update(stableStringify(data)).digest('hex'); +- if (plan.provenance.cardDigest.algorithm !== 'sha256-rfc8785' || digest !== plan.provenance.cardDigest.value) { +- throw new Error(localize('copilot.customizationInstallation.mcpCardChanged', "The MCP server card changed after SDK planning. Refresh Browse and try again.")); +- } +- const server = parseMcpGalleryServerConfiguration(data); +- const packages = server?.configuration.packages?.filter(candidate => +- candidate.registryType === choice.packageType && candidate.identifier === choice.packageIdentifier +- && candidate.transport.type === TransportType.STDIO +- ); +- if (!server || server.name !== plan.identity.canonicalName || (plan.identity.version !== undefined && server.version !== plan.identity.version) || packages?.length !== 1) { +- throw new Error(localize('copilot.customizationInstallation.mcpPackageChanged', "The MCP package does not match the SDK installation plan. Refresh Browse and try again.")); +- } +- const parsed = new McpServerManifestParser().getMcpServerConfigurationFromManifest({ packages }, packages[0].registryType); +- if (parsed.notices.length || parsed.mcpServerConfiguration.inputs?.length || parsed.mcpServerConfiguration.config.type !== McpServerType.LOCAL) { +- throw new Error(localize('copilot.customizationInstallation.mcpPackageInputsRequired', "This MCP package requires additional configuration that Browse cannot collect yet.")); +- } +- return toCopilotMcpServerConfiguration(parsed.mcpServerConfiguration.config); +- } +- - private async prepareUninstall(client: ICopilotCustomizationInstallationClient, policySessionId: string, installationId: string): Promise { - if (installationId.startsWith(configuredMcpInstallationPrefix)) { - const serverName = decodeURIComponent(installationId.slice(configuredMcpInstallationPrefix.length)); @@ -32758,10 +34218,10 @@ index a0262d041d0..00000000000 -// #endregion diff --git a/src/vs/platform/agentHost/node/copilot/localModelBridge/shim/copilotRequestHandler.ts b/src/vs/platform/agentHost/node/copilot/localModelBridge/shim/copilotRequestHandler.ts deleted file mode 100644 -index 34f361d8c00..00000000000 +index 3cd73cc3fe0..00000000000 --- a/src/vs/platform/agentHost/node/copilot/localModelBridge/shim/copilotRequestHandler.ts +++ /dev/null -@@ -1,1158 +0,0 @@ +@@ -1,1185 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -32778,6 +34238,7 @@ index 34f361d8c00..00000000000 - adaptLocalRequestBody, - buildContextLengthExceededBody, - buildFallbackChatRequest, +- buildFallbackResponsesRequest, - buildInjectedAutoRequest, - classifyLocalModelRequest, - computeLocalModelLimits, @@ -32797,9 +34258,11 @@ index 34f361d8c00..00000000000 - readAutoSelectedModelId, - readLocalContextOverflow, - SseCommitScanner, +- toChatCompletionFromResponses, - toStreamingLocalRequestBody, - type ILocalContextOverflow, - type ILocalModelBufferedRequest, +- type ILocalModelFallbackModel, - type ILocalModelRouterEnv, - type ILocalPromptProgress, - type LocalModelFallbackReason, @@ -33495,9 +34958,33 @@ index 34f361d8c00..00000000000 - routing.ledger.record(ctx.sessionId, { kind: 'fallback', reason, fallbackModel: route.fallback.id, serving: route.serving }); - this._report(routing, ctx, 'fallback', { reason, tier: route.tier }); - // A 401 (expired token) goes back to the runtime, which renews `/auto`. +- if (route.fallback.endpoint === 'responses') { +- return this._sendResponsesFallback(request, ctx, body, route.fallback, token); +- } - return this._sendToCapi(buildFallbackChatRequest(request, body, route.fallback, token), ctx); - } - +- /** +- * Calls a fallback that serves only the Responses API with the runtime's request translated, and answers in chat +- * completions: a streaming request gets the whole answer as one chunk. A non-2xx answer goes back as CAPI sent it. +- */ +- private async _sendResponsesFallback(request: ILocalModelBufferedRequest, ctx: CopilotRequestContext, body: LocalModelJsonBody, fallback: ILocalModelFallbackModel, token: string): Promise { +- const response = await this._sendToCapi(buildFallbackResponsesRequest(request, body, fallback, token), ctx); +- if (!response.ok) { +- return response; +- } +- const stream = body.stream === true; +- const json = parseJsonObject(new Uint8Array(await response.arrayBuffer())); +- const answer = json ? toChatCompletionFromResponses(json, fallback.id, stream) : undefined; +- if (!answer) { +- this._logService.warn('[LocalModel] The Responses API fallback answered without a completed response'); +- return errorResponse(502, 'fallbackFailed', localize('localModel.fallbackFailed', "The cloud model could not finish its response. Try again.")); +- } +- return stream +- ? new Response(`data: ${JSON.stringify(answer)}\n\ndata: [DONE]\n\n`, { status: response.status, headers: responseHeaders(response.headers, 'text/event-stream') }) +- : new Response(encodeJsonBody(answer), { status: response.status, headers: responseHeaders(response.headers, 'application/json') }); +- } +- - // #endregion - - // #region `localDirect`: fail closed, no fallback @@ -39278,10 +40765,10 @@ index 03fde5a620b..00000000000 -// #endregion diff --git a/src/vs/platform/agentHost/node/copilot/localModelBridge/shim/requestRouter.ts b/src/vs/platform/agentHost/node/copilot/localModelBridge/shim/requestRouter.ts deleted file mode 100644 -index f4f8eb7189f..00000000000 +index 7b5706e5f9c..00000000000 --- a/src/vs/platform/agentHost/node/copilot/localModelBridge/shim/requestRouter.ts +++ /dev/null -@@ -1,1212 +0,0 @@ +@@ -1,1330 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -39621,9 +41108,16 @@ index f4f8eb7189f..00000000000 - -// #region `/auto` response handling - --/** The validated, chat-capable cloud fallback model `F` of an `/auto` answer. */ +-/** +- * The CAPI API a cloud fallback is called through: `chatCompletions` sends the runtime's request as it is; +- * `responses` (a fallback that serves only the Responses API) translates it and the answer. +- */ +-export type LocalModelFallbackEndpoint = 'chatCompletions' | 'responses'; +- +-/** The validated cloud fallback model `F` of an `/auto` answer. */ -export interface ILocalModelFallbackModel { - readonly id: string; +- readonly endpoint: LocalModelFallbackEndpoint; - readonly maxContextWindowTokens?: number; - readonly maxPromptTokens?: number; - readonly maxOutputTokens?: number; @@ -39663,12 +41157,14 @@ index f4f8eb7189f..00000000000 - return { maxContextWindowTokens, maxPromptTokens, maxOutputTokens }; -} - --/** Reads `F`; `undefined` unless it has a non-local string id and supports `/chat/completions`. */ +-/** Reads `F`; `undefined` unless it has a non-local string id and supports `/chat/completions` (preferred) or `/responses`. */ -export function readFallbackModel(value: unknown): ILocalModelFallbackModel | undefined { - if (!isObject(value) || typeof value.id !== 'string' || !value.id || isLocalCapiModelId(value.id)) { - return undefined; - } -- if (!Array.isArray(value.supported_endpoints) || !value.supported_endpoints.includes('/chat/completions')) { +- const endpoints = Array.isArray(value.supported_endpoints) ? value.supported_endpoints : []; +- const endpoint: LocalModelFallbackEndpoint | undefined = endpoints.includes('/chat/completions') ? 'chatCompletions' : endpoints.includes('/responses') ? 'responses' : undefined; +- if (!endpoint) { - return undefined; - } - const capabilities = isObject(value.capabilities) ? value.capabilities : undefined; @@ -39677,6 +41173,7 @@ index f4f8eb7189f..00000000000 - const efforts = Array.isArray(supports?.reasoning_effort) ? supports.reasoning_effort.filter((effort): effort is string => typeof effort === 'string') : undefined; - return { - id: value.id, +- endpoint, - maxContextWindowTokens: isPositiveNumber(limits?.max_context_window_tokens) ? Math.floor(limits.max_context_window_tokens) : undefined, - maxPromptTokens: isPositiveNumber(limits?.max_prompt_tokens) ? Math.floor(limits.max_prompt_tokens) : undefined, - maxOutputTokens: isPositiveNumber(limits?.max_output_tokens) ? Math.floor(limits.max_output_tokens) : undefined, @@ -39704,7 +41201,10 @@ index f4f8eb7189f..00000000000 - }; -} - --/** Why a local pick is answered with the cloud-only re-issue instead of a patch. */ +-/** +- * Why a local pick is answered with the cloud-only re-issue instead of a patch. `fallbackNotChat`: the fallback is +- * malformed, an on-device id, or serves neither `/chat/completions` nor `/responses`. +- */ -export type AutoReissueReason = 'noFallback' | 'fallbackNotChat' | 'noSessionToken' | 'limits' | 'otherLocalModel'; - -export type AutoResponseOutcome = @@ -39925,10 +41425,115 @@ index f4f8eb7189f..00000000000 - if (fallback.maxOutputTokens !== undefined && typeof next.max_tokens === 'number' && next.max_tokens > fallback.maxOutputTokens) { - next.max_tokens = fallback.maxOutputTokens; - } +- return { url: original.url, method: original.method, headers: fallbackHeaders(original, sessionToken), body: encodeJsonBody(next), redirect: original.redirect }; +-} +- +-/** +- * The cloud fallback request for a fallback that serves only the Responses API: the runtime's chat completions request +- * as a non-streaming request to the sibling `/responses` path, with `model := F.id`, `store: false`, function tools +- * only, `reasoning_effort` mapped or dropped, `max_output_tokens` clamped to F and the route's `Copilot-Session-Token`; +- * every other header is kept. Earlier reasoning is not replayed: nothing tells which model produced it. Throws for a +- * message role the Responses API has no item for. +- */ +-export function buildFallbackResponsesRequest(original: ILocalModelBufferedRequest, body: LocalModelJsonBody, fallback: ILocalModelFallbackModel, sessionToken: string): ILocalModelBufferedRequest { +- const next: LocalModelJsonBody = { model: fallback.id, input: toResponsesInput(body.messages), store: false, stream: false }; +- const tools = Array.isArray(body.tools) ? body.tools.flatMap((tool): LocalModelJsonBody[] => { +- const fn = isObject(tool) && tool.type === 'function' && isObject(tool.function) ? tool.function : undefined; +- return fn ? [{ type: 'function', name: fn.name, description: fn.description, parameters: fn.parameters ?? { type: 'object', properties: {} }, strict: fn.strict === true }] : []; +- }) : []; +- if (tools.length) { +- next.tools = tools; +- next.tool_choice = typeof body.tool_choice === 'string' ? body.tool_choice : undefined; +- next.parallel_tool_calls = body.parallel_tool_calls; +- } +- const maxTokens = body.max_completion_tokens ?? body.max_tokens; +- if (typeof maxTokens === 'number') { +- next.max_output_tokens = Math.min(maxTokens, fallback.maxOutputTokens ?? Infinity); +- } +- const effort = typeof body.reasoning_effort === 'string' ? mapReasoningEffort(body.reasoning_effort, fallback.reasoningEfforts) : undefined; +- if (effort) { +- next.reasoning = { effort }; +- } +- const url = new URL(original.url.href); +- url.pathname = url.pathname.replace(/\/chat\/completions$/, '/responses'); +- return { url, method: original.method, headers: fallbackHeaders(original, sessionToken), body: encodeJsonBody(next), redirect: original.redirect }; +-} +- +-function fallbackHeaders(original: ILocalModelBufferedRequest, sessionToken: string): Headers { - const headers = new Headers(original.headers); - headers.delete('content-length'); - headers.set('copilot-session-token', sessionToken); -- return { url: original.url, method: original.method, headers, body: encodeJsonBody(next), redirect: original.redirect }; +- return headers; +-} +- +-/** The text of chat message content: the string, or its text parts joined. */ +-function chatContentText(content: unknown): string { +- if (typeof content === 'string') { +- return content; +- } +- return Array.isArray(content) ? content.map(part => isObject(part) && part.type === 'text' && typeof part.text === 'string' ? part.text : '').join('') : ''; +-} +- +-/** Chat messages as Responses input items; text and image parts are the only content with an equivalent. */ +-function toResponsesInput(messages: unknown): LocalModelJsonBody[] { +- return (Array.isArray(messages) ? messages : []).flatMap((message): LocalModelJsonBody[] => { +- if (!isObject(message)) { +- return []; +- } +- switch (message.role) { +- case 'system': +- case 'developer': +- case 'user': { +- const content = Array.isArray(message.content) ? message.content.flatMap((part): LocalModelJsonBody[] => { +- if (isObject(part) && part.type === 'text') { +- return [{ type: 'input_text', text: part.text }]; +- } +- return isObject(part) && part.type === 'image_url' && isObject(part.image_url) ? [{ type: 'input_image', image_url: part.image_url.url, detail: part.image_url.detail ?? 'auto' }] : []; +- }) : [{ type: 'input_text', text: chatContentText(message.content) }]; +- return [{ type: 'message', role: message.role, content }]; +- } +- case 'assistant': { +- const text = chatContentText(message.content); +- const calls = (Array.isArray(message.tool_calls) ? message.tool_calls : []).flatMap((call): LocalModelJsonBody[] => +- isObject(call) && isObject(call.function) ? [{ type: 'function_call', call_id: call.id, name: call.function.name, arguments: call.function.arguments }] : []); +- return [...(text ? [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text }] }] : []), ...calls]; +- } +- case 'tool': +- return [{ type: 'function_call_output', call_id: message.tool_call_id, output: chatContentText(message.content) }]; +- default: +- throw new Error('A message role has no Responses API equivalent'); +- } +- }); +-} +- +-/** +- * The fallback's Responses API answer as a `chat.completion`, or as the single `chat.completion.chunk` a streaming +- * request gets: output text as `content`, function calls as `tool_calls`, and usage. `undefined` unless the response +- * completed or stopped early (`incomplete`, reported as `length`). Pure. +- */ +-export function toChatCompletionFromResponses(response: LocalModelJsonBody, fallbackModel: string, stream: boolean): LocalModelJsonBody | undefined { +- if (response.status !== 'completed' && response.status !== 'incomplete') { +- return undefined; +- } +- let content = ''; +- const toolCalls: LocalModelJsonBody[] = []; +- for (const item of Array.isArray(response.output) ? response.output : []) { +- if (isObject(item) && item.type === 'message' && Array.isArray(item.content)) { +- content += item.content.map(part => isObject(part) && part.type === 'output_text' && typeof part.text === 'string' ? part.text : '').join(''); +- } else if (isObject(item) && item.type === 'function_call') { +- toolCalls.push({ ...(stream ? { index: toolCalls.length } : {}), id: item.call_id, type: 'function', function: { name: item.name, arguments: item.arguments } }); +- } +- } +- const message = { role: 'assistant', content: content || (toolCalls.length ? null : ''), ...(toolCalls.length ? { tool_calls: toolCalls } : {}) }; +- const usage = isObject(response.usage) ? response.usage : undefined; +- return { +- id: response.id, +- object: stream ? 'chat.completion.chunk' : 'chat.completion', +- created: response.created_at, +- model: typeof response.model === 'string' ? response.model : fallbackModel, +- choices: [{ index: 0, [stream ? 'delta' : 'message']: message, finish_reason: response.status === 'incomplete' ? 'length' : toolCalls.length ? 'tool_calls' : 'stop' }], +- ...(usage ? { usage: { prompt_tokens: usage.input_tokens, completion_tokens: usage.output_tokens, total_tokens: usage.total_tokens } } : {}), +- }; -} - -// #endregion @@ -40496,7 +42101,7 @@ index f4f8eb7189f..00000000000 -// #endregion diff --git a/src/vs/platform/agentHost/node/copilot/localModelBridge/shim/routeTable.ts b/src/vs/platform/agentHost/node/copilot/localModelBridge/shim/routeTable.ts deleted file mode 100644 -index 03358b9b872..00000000000 +index 8f98b8d19ef..00000000000 --- a/src/vs/platform/agentHost/node/copilot/localModelBridge/shim/routeTable.ts +++ /dev/null @@ -1,154 +0,0 @@ @@ -40513,7 +42118,7 @@ index 03358b9b872..00000000000 - -/** What `/auto` patching records for one session token. */ -export interface IRouteRecordInit { -- /** The chat-capable cloud fallback `F`. */ +- /** The cloud fallback `F`. */ - readonly fallback: ILocalModelFallbackModel; - /** Serving identity captured at injection; the route is never served by another one. */ - readonly serving: IServingIdentity; @@ -48101,16 +49706,19 @@ index 3cb876a2335..a840e78b20b 100644 import { coalesce } from '../../../../../base/common/arrays.js'; diff --git a/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts b/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts deleted file mode 100644 -index c1b64db6208..00000000000 +index b565941f8dc..00000000000 --- a/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts +++ /dev/null -@@ -1,204 +0,0 @@ +@@ -1,358 +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 { homedir } from 'os'; +-import { posix, win32 } from '../../../../base/common/path.js'; -import { OperatingSystem } from '../../../../base/common/platform.js'; +-import type { URI } from '../../../../base/common/uri.js'; -import { AgentSandboxEnabledValue, type IAgentSandboxMaskedCredentials, normalizeSandboxFileSystemPath } from '../../../sandbox/common/settings.js'; -import { AgentHostSandboxKey, type ISandboxConfigValue } from '../../common/sandboxConfigSchema.js'; -import type { ISessionSandboxPolicy } from '../../common/meta/agentSandboxPolicyMeta.js'; @@ -48226,38 +49834,26 @@ index c1b64db6208..00000000000 - * Optional toggles are forwarded only when supplied; absent values use runtime defaults. - * Credential authentication defaults to enabled and uses the effective Copilot-only preferences. - * -- * `extraReadonlyPaths` grants read access to session attachments and generated -- * shell init scripts when the effective sandbox is applied before each turn, -- * unless a managed read-only list is present. +- * `extraReadonlyPaths` grants read access to session attachments, generated shell +- * init scripts and applied plugin resources, unless a managed read-only list is present. +- * +- * `attachedPaths` grants read-only access to the exact files and folders the +- * user attached to the chat; see {@link resolveAttachedPathGrants}. - */ -export function buildSandboxConfigForSdk( - platform: NodeJS.Platform, - sandbox: ISandboxConfigValue | undefined, - extraReadonlyPaths?: readonly string[], - managedPolicy?: ISessionSandboxPolicy, +- attachedPaths?: ISandboxAttachedPaths, -): SandboxConfig | undefined { - const enabledRaw = sandbox?.[AgentHostSandboxKey.Enabled]; - if (enabledRaw !== AgentSandboxEnabledValue.On) { - return undefined; - } - -- const fs = sandbox?.[AgentHostSandboxKey.UserConfiguredPaths]; -- const os = platform === 'win32' ? OperatingSystem.Windows : platform === 'darwin' ? OperatingSystem.Macintosh : OperatingSystem.Linux; -- const denied = new Set((fs?.deniedPaths ?? []).map(path => normalizeSandboxFileSystemPath(path, os))); -- const readonly = new Set(); -- const readwrite = new Set(); -- for (const path of fs?.readonlyPaths ?? []) { -- const p = normalizeSandboxFileSystemPath(path, os); -- if (!denied.has(p)) { -- readonly.add(p); -- } -- } -- for (const path of fs?.readwritePaths ?? []) { -- const p = normalizeSandboxFileSystemPath(path, os); -- if (!denied.has(p) && !readonly.has(p)) { -- readwrite.add(p); -- } -- } +- const os = toOperatingSystem(platform); +- const { denied, readonly, readwrite } = resolveUserPaths(sandbox, os); - // User denies win over host-generated read grants; existing read/write grants are preserved. - for (const path of managedPolicy?.readonlyPaths !== undefined ? [] : extraReadonlyPaths ?? []) { - const p = normalizeSandboxFileSystemPath(path, os); @@ -48265,6 +49861,9 @@ index c1b64db6208..00000000000 - readonly.add(p); - } - } +- for (const path of partitionAttachedPaths(os, sandbox, { denied, readonly, readwrite }, managedPolicy, attachedPaths).granted) { +- readonly.add(path); +- } - - const allowNetwork = sandbox?.[AgentHostSandboxKey.AllowNetwork]; - const allowLocalNetwork = sandbox?.[AgentHostSandboxKey.AllowLocalNetwork]; @@ -48309,11 +49908,171 @@ index c1b64db6208..00000000000 - }; - return sandboxConfig; -} +- +-/** Files and folders the user attached to a chat, as local paths of the executing Agent Host. */ +-export interface ISandboxAttachedPaths { +- /** The exact attached files and folders. */ +- readonly paths: readonly string[]; +- /** The working directory the runtime grants read/write when `addCurrentWorkingDirectory` is on. */ +- readonly workingDirectory?: string; +- /** Further working directories the session was launched with; the runtime grants them read/write. */ +- readonly additionalWorkingDirectories?: readonly string[]; +-} +- +-/** How the sandbox treats the paths of the user's attachments. */ +-export interface ISandboxAttachedPathGrants { +- /** Paths added to `readonlyPaths`. */ +- readonly granted: readonly string[]; +- /** Paths the effective policy keeps unreadable: under a denied path, or outside a managed read-only list. */ +- readonly blocked: readonly string[]; +- /** Folders not granted because they contain a writable folder, which a read-only grant could make read-only. */ +- readonly containsWritable: readonly string[]; +-} +- +-/** +- * Resolves read-only grants for user-attached files and folders. +- * +- * Attaching a file or folder is explicit consent to let the agent read exactly +- * that path, so it is granted read-only for the chat. The grant never widens +- * the policy beyond that: +- * - A path that is, or is inside, a denied path stays denied. +- * - When the managed policy defines a read-only list, attachments are not +- * added: the host must not widen an administrator's allowlist. +- * - A path already readable (inside a read-only or read/write grant, or the +- * granted working directory) is left alone, so a granted read-only entry +- * never downgrades a writable file. +- * - A folder that contains a writable folder (the working directory, an +- * additional working directory, or a read/write path) is not granted, so +- * the read-only entry can't make that folder read-only. +- * +- * Paths are compared after resolving `.` and `..` segments with `platform`'s +- * path rules, and granted in that normalized form. +- * +- * Returns no grants when sandboxing is off. +- */ +-export function resolveAttachedPathGrants( +- platform: NodeJS.Platform, +- sandbox: ISandboxConfigValue | undefined, +- managedPolicy: ISessionSandboxPolicy | undefined, +- attachedPaths: ISandboxAttachedPaths | undefined, +-): ISandboxAttachedPathGrants { +- if (sandbox?.[AgentHostSandboxKey.Enabled] !== AgentSandboxEnabledValue.On) { +- return { granted: [], blocked: [], containsWritable: [] }; +- } +- const os = toOperatingSystem(platform); +- return partitionAttachedPaths(os, sandbox, resolveUserPaths(sandbox, os), managedPolicy, attachedPaths); +-} +- +-interface IResolvedUserPaths { +- readonly denied: Set; +- readonly readonly: Set; +- readonly readwrite: Set; +-} +- +-function toOperatingSystem(platform: NodeJS.Platform): OperatingSystem { +- return platform === 'win32' ? OperatingSystem.Windows : platform === 'darwin' ? OperatingSystem.Macintosh : OperatingSystem.Linux; +-} +- +-function resolveUserPaths(sandbox: ISandboxConfigValue | undefined, os: OperatingSystem): IResolvedUserPaths { +- const fs = sandbox?.[AgentHostSandboxKey.UserConfiguredPaths]; +- const denied = new Set((fs?.deniedPaths ?? []).map(path => normalizeSandboxFileSystemPath(path, os))); +- const readonly = new Set(); +- const readwrite = new Set(); +- for (const path of fs?.readonlyPaths ?? []) { +- const p = normalizeSandboxFileSystemPath(path, os); +- if (!denied.has(p)) { +- readonly.add(p); +- } +- } +- for (const path of fs?.readwritePaths ?? []) { +- const p = normalizeSandboxFileSystemPath(path, os); +- if (!denied.has(p) && !readonly.has(p)) { +- readwrite.add(p); +- } +- } +- return { denied, readonly, readwrite }; +-} +- +-function partitionAttachedPaths( +- os: OperatingSystem, +- sandbox: ISandboxConfigValue | undefined, +- userPaths: IResolvedUserPaths, +- managedPolicy: ISessionSandboxPolicy | undefined, +- attachedPaths: ISandboxAttachedPaths | undefined, +-): ISandboxAttachedPathGrants { +- const granted: string[] = []; +- const blocked: string[] = []; +- const containsWritable: string[] = []; +- if (!attachedPaths?.paths.length) { +- return { granted, blocked, containsWritable }; +- } +- const workingDirectories = [ +- ...(attachedPaths.workingDirectory && (sandbox?.[AgentHostSandboxKey.AddCurrentWorkingDirectory] ?? true) ? [attachedPaths.workingDirectory] : []), +- ...attachedPaths.additionalWorkingDirectories ?? [], +- ].map(path => normalizeSandboxFileSystemPath(path, os)); +- const writable = [...userPaths.readwrite, ...workingDirectories]; +- const readable = [...userPaths.readonly, ...writable]; +- for (const path of new Set(attachedPaths.paths.map(path => normalizePathForPlatform(normalizeSandboxFileSystemPath(path, os), os)))) { +- if ([...userPaths.denied].some(denied => isEqualOrInside(path, denied, os))) { +- blocked.push(path); +- } else if (readable.some(root => isEqualOrInside(path, root, os))) { +- continue; +- } else if (writable.some(root => isEqualOrInside(root, path, os))) { +- containsWritable.push(path); +- } else if (managedPolicy?.readonlyPaths !== undefined) { +- blocked.push(path); +- } else { +- granted.push(path); +- } +- } +- return { granted, blocked, containsWritable }; +-} +- +-/** Resolves `.` and `..` segments and drops a trailing separator, using `os`'s path rules. */ +-function normalizePathForPlatform(path: string, os: OperatingSystem): string { +- const separator = os === OperatingSystem.Windows ? '\\' : '/'; +- let result = os === OperatingSystem.Windows ? win32.normalize(path) : posix.normalize(path); +- while (result.length > 1 && result.endsWith(separator) && !/^[a-zA-Z]:\\$/.test(result)) { +- result = result.slice(0, -1); +- } +- return result; +-} +- +-/** Whether `path` is `root` or inside it; `~` refers to the Agent Host user's home. */ +-function isEqualOrInside(path: string, root: string, os: OperatingSystem): boolean { +- const separator = os === OperatingSystem.Windows ? '\\' : '/'; +- const normalize = (value: string) => { +- const expanded = value === '~' || value.startsWith(`~${separator}`) ? normalizeSandboxFileSystemPath(homedir(), os) + value.slice(1) : value; +- const result = normalizePathForPlatform(expanded, os); +- return os === OperatingSystem.Linux ? result : result.toLowerCase(); +- }; +- const p = normalize(path); +- const r = normalize(root); +- return p === r || p.startsWith(r.endsWith(separator) ? r : r + separator); +-} +- +-/** +- * The file system path of a `file` URI in `platform`'s syntax, matching what +- * `URI.fsPath` returns when the process runs on that platform. Sandbox paths are +- * compared with `platform`'s rules, so they must not take their syntax from the +- * process's OS through `URI.fsPath`. +- */ +-export function toSandboxFileSystemPath(uri: URI, platform: NodeJS.Platform): string { +- let path: string; +- if (uri.authority && uri.path.length > 1) { +- path = `//${uri.authority}${uri.path}`; +- } else if (/^\/[a-zA-Z]:/.test(uri.path)) { +- path = uri.path[1].toLowerCase() + uri.path.slice(2); +- } else { +- path = uri.path; +- } +- return platform === 'win32' ? path.replace(/\//g, '\\') : path; +-} diff --git a/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts b/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts -index fcabf4cc9d0..5bd0cf19638 100644 +index bd75ff27224..ce8c48091fe 100644 --- a/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts +++ b/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts -@@ -5,3 +5,24 @@ +@@ -5,3 +5,30 @@ -import type { CopilotClient } from '@github/copilot-sdk'; +// `CopilotClient` came from '@github/copilot-sdk' (removed in !!APP_NAME!!). @@ -48335,16 +50094,22 @@ index fcabf4cc9d0..5bd0cf19638 100644 + discover(params: unknown): Promise<{ skills: any[] }>; + getDiscoveryPaths(params: unknown): Promise; + }; ++ hooks: { ++ discover(params: unknown): Promise<{ hooks: any[]; warnings: string[]; errors: string[] }>; ++ }; ++ mcp: { ++ discover(params: unknown): Promise<{ servers: any[] }>; ++ }; + }; +}; +/* eslint-enable @typescript-eslint/no-explicit-any */ import { appendFile, mkdir } from 'fs/promises'; -@@ -18,3 +39,3 @@ import { FileChangesEvent, FileChangeType, FileOperationResult, IFileService, IF +@@ -19,3 +46,3 @@ import { FileChangesEvent, FileChangeType, FileOperationResult, IFileService, IF import { ILogService } from '../../../log/common/log.js'; -import { detectPluginFormat, hasPluginManifest, parsePlugin, parseSkillFile, readPluginManifest, toSkillInvocationFlags } from '../../../agentPlugins/common/pluginParsers.js'; +import { detectPluginFormat, hasPluginManifest, parsePlugin, parseSkillFile, readPluginManifest, toSkillInvocationFlags, type IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js'; - import { AgentCustomization, ChildCustomization, CustomizationLoadStatus, CustomizationType, DirectoryCustomization, HookCustomization, PluginCustomization, RuleCustomization, SkillCustomization, customizationId } from '../../common/state/sessionState.js'; -@@ -24,3 +45,23 @@ import { withSkillArgumentHintMeta } from '../../common/meta/skillCustomizationM + import { AgentCustomization, ChildCustomization, CustomizationLoadStatus, CustomizationType, DirectoryCustomization, McpServerCustomization, PluginCustomization, RuleCustomization, SkillCustomization, customizationId } from '../../common/state/sessionState.js'; +@@ -25,3 +52,23 @@ import { withSkillArgumentHintMeta } from '../../common/meta/skillCustomizationM import { raceCancellationError } from '../../../../base/common/async.js'; -import { toChildCustomizations } from './copilotPluginConverters.js'; +// Inlined from './copilotPluginConverters.js', which !!APP_NAME!! deletes: that @@ -48368,7 +50133,7 @@ index fcabf4cc9d0..5bd0cf19638 100644 + } + return [...byId.values()]; +} - + import { AGENT_DISCOVERY_CUSTOMIZATION_SCHEME } from '../../common/agentHostCustomizationUri.js'; diff --git a/src/vs/platform/agentHost/node/localModelSetupChannel.ts b/src/vs/platform/agentHost/node/localModelSetupChannel.ts deleted file mode 100644 index bef19746fc3..00000000000 @@ -49181,33 +50946,35 @@ index f97b1ceb7f1..7a385a21770 100644 - test('createSession persists initial config values to the session DB', async () => { diff --git a/src/vs/platform/agentHost/test/node/buildSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/buildSessionEvents.test.ts -index e6648f0d100..a2d507fede6 100644 +index c429a801a53..97b1153839c 100644 --- a/src/vs/platform/agentHost/test/node/buildSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/buildSessionEvents.test.ts -@@ -13,3 +13,6 @@ import { buildSessionEventLogFromTurns, buildSessionEventsFromTurns, serializeSe +@@ -16,3 +16,6 @@ import { buildSessionEventLogFromTurns, buildSessionEventsFromTurns, serializeSe import { mapSessionEvents as mapSessionEventsWithRouting } from '../../node/copilot/mapSessionEvents.js'; -import type { SessionEvent } from '@github/copilot-sdk'; +// `SessionEvent` originally came from '@github/copilot-sdk' (removed in +// !!APP_NAME!!); alias it locally so this module keeps compiling. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type SessionEvent = any; - + import type { ISessionDatabase } from '../../common/sessionDataService.js'; diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts deleted file mode 100644 -index f24a97a6296..00000000000 +index 3fe9978da4b..00000000000 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ /dev/null -@@ -1,30882 +0,0 @@ +@@ -1,31087 +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 type { CopilotClient, CopilotClientOptions, CopilotSession, GitHubTelemetryNotification, PermissionMode, PermissionRequest, SessionConfig, SessionEvent, SessionEventHandler, SessionEventPayload, SessionEventType, TypedSessionEventHandler } from '@github/copilot-sdk'; +-import type { CopilotClient, CopilotClientOptions, CopilotSession, GitHubTelemetryNotification, PermissionMode, PermissionRequest, ResumeSessionConfig, SessionConfig, SessionEvent, SessionEventHandler, SessionEventPayload, SessionEventType, TypedSessionEventHandler } from '@github/copilot-sdk'; -import { mock } from '../../../../base/test/common/mock.js'; -import type Anthropic from '@anthropic-ai/sdk'; -import type { CCAModel } from '@vscode/copilot-api'; -import assert from 'assert'; +-import { X509Certificate } from 'crypto'; +-import { rootCertificates } from 'tls'; -import { agentEntitlementHintMeta } from '../../common/meta/agentEntitlementHintMeta.js'; -import { spy, stub, useFakeTimers } from 'sinon'; -import { workspacelessChatsRoot, workspacelessScratchDir } from '../../common/workspacelessScratchDir.js'; @@ -49225,9 +50992,9 @@ index f24a97a6296..00000000000 -import { Emitter, Event } from '../../../../base/common/event.js'; -import { Schemas } from '../../../../base/common/network.js'; -import { isEqual, isEqualOrParent } from '../../../../base/common/resources.js'; --import { autorun, constObservable, observableValue, waitForState } from '../../../../base/common/observable.js'; +-import { autorun, observableValue, waitForState } from '../../../../base/common/observable.js'; -import { URI } from '../../../../base/common/uri.js'; --import { isLinux } from '../../../../base/common/platform.js'; +-import { isLinux, isWindows } from '../../../../base/common/platform.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { runWithFakedTimers } from '../../../../base/test/common/virtualScheduling/index.js'; -import { PluginFormat, type IParsedAgent, type IParsedRule } from '../../../agentPlugins/common/pluginParsers.js'; @@ -49237,6 +51004,7 @@ index f24a97a6296..00000000000 -import { FileChangesEvent, FileChangeType, IFileService, type IFileSystemWatcher, type IStat } from '../../../files/common/files.js'; -import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; -import { DiskFileSystemProvider } from '../../../files/node/diskFileSystemProvider.js'; +-import { createInMemoryFileService } from '../../../files/test/common/inMemoryFileService.js'; -import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; -import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; -import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; @@ -49244,7 +51012,8 @@ index f24a97a6296..00000000000 -import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; -import product from '../../../product/common/product.js'; -import { IProductService } from '../../../product/common/productService.js'; --import { IAgentHostProxyResolver } from '../../node/agentHostProxyResolver.js'; +-import { IRequestService } from '../../../request/common/request.js'; +-import { AgentHostProxyResolver, IAgentHostProxyResolver } from '../../node/agentHostProxyResolver.js'; -import { AgentHostCustomizationEnablementService, IAgentHostCustomizationEnablementService, type IAgentHostCustomizationEnablementService as ICustomizationEnablementService } from '../../node/agentHostCustomizationEnablementService.js'; -import type { IAgentHostClientProxyConnection } from '../../common/agentHostClientProxyChannel.js'; -import type { IByokLmBridgeConnection, IByokLmModelInfo } from '../../common/agentHostByokLm.js'; @@ -49263,11 +51032,11 @@ index f24a97a6296..00000000000 -import { IAgentHostSessionOpenTelemetry } from '../../node/agentHostSessionOpenTelemetry.js'; -import { AgentHostDynamicAutoTiersEnabledSettingId, CopilotCliConfigKey, CopilotCliVSCodeAssignmentContextKey, COPILOT_HYDRA_FUSION_MODEL_ID } from '../../common/copilotCliConfig.js'; -import { agentModelCatalogStatusKey } from '../../common/agentModelCatalogStatus.js'; --import { isAgentModelFusion, readAgentModelAutoTierPromotionsMeta, readAgentModelAutoTiersMeta } from '../../common/meta/agentModelMeta.js'; +-import { isAgentModelFusion, readAgentModelAutoTierPromotionsMeta, readAgentModelAutoTiersMeta, readAgentModelFusionInlineChatTier } from '../../common/meta/agentModelMeta.js'; -import { TestExperimentTriggerTelemetryService } from '../../../telemetry/test/common/experimentTriggerTestUtils.js'; -import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js'; -import { buildAgentHostOTelEnv } from '../../common/agentService.js'; --import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostCanvasesEnabledConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostComputerUseEnabledConfigKey, AgentHostMcpConnectorsEnabledConfigKey, AgentHostMcpServersConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostProxyConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostTelemetryLevelConfigKey, AgentHostWorkspaceTrustConfigKey, platformSessionSchema, telemetryLevelToAgentHostConfigValue } from '../../common/agentHostSchema.js'; +-import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostCanvasesEnabledConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostComputerUseEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostMcpConnectorsEnabledConfigKey, AgentHostMcpServersConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostProxyConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostTelemetryLevelConfigKey, AgentHostWorkspaceTrustConfigKey, platformSessionSchema, telemetryLevelToAgentHostConfigValue } from '../../common/agentHostSchema.js'; -import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { toClientPluginMcpDefaultCwdsMeta, toClientPluginStandaloneMeta } from '../../common/meta/clientPluginCustomizationMeta.js'; -import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelation.js'; @@ -49275,7 +51044,7 @@ index f24a97a6296..00000000000 -import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; -import { subagentChatUri } from '../../common/agentHostChatMinting.js'; -import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, createUnknownAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; --import { ISessionDataService } from '../../common/sessionDataService.js'; +-import { ISessionDataService, type StoredAssistantResponsePart } from '../../common/sessionDataService.js'; -import { buildNonPtyShellTerminalUri } from '../../common/nonPtyShellTerminalUri.js'; -import { buildDefaultChatUri, buildChatUri, buildSubagentChatUri, buildSubagentSessionUri, CustomizationLoadStatus, MessageKind, readSessionEhcliAdoptable, readSessionWorkspaceless, ResponsePartKind, ToolResultContentType, TurnState, customizationId, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_EHCLI_LAST_TURN_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, type ClientPluginCustomization, type Customization, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js'; -import { ChatOriginKind, CustomizationEnablementKind, CustomizationType, McpServerStatus, SessionStatus, ToolCallContributorKind, type AgentSelection, type ModelSelection, type ProtectedResourceMetadata, type ToolDefinition } from '../../common/state/protocol/state.js'; @@ -49299,7 +51068,8 @@ index f24a97a6296..00000000000 -import { AgentHostStorageService } from '../../node/agentHostStorageService.js'; -import { AgentHostPromptCache, IAgentHostPromptCache } from '../../node/agentHostPromptCache.js'; -import { AgentHostSessionTitleSignal, IAgentHostSessionTitleSignal } from '../../node/agentHostSessionTitleSignal.js'; --import { IAgentHostGitService, type IAddWorktreeOptions, type IBranch, type IDefaultBranch } from '../../common/agentHostGitService.js'; +-import { IAgentHostGitService, type IAddWorktreeOptions } from '../../common/agentHostGitService.js'; +-import { TestAgentHostGitService as BaseTestAgentHostGitService } from '../common/testAgentHostGitService.js'; -import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; -import { IAgentHostOTelService, NullAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; -import { AgentHostCompletions, IAgentHostCompletions } from '../../node/agentHostCompletions.js'; @@ -49319,6 +51089,7 @@ index f24a97a6296..00000000000 -import { getCopilotHomePath, ICopilotHomeService } from '../../../environment/common/copilotHome.js'; -import { TestCopilotHomeService } from '../../../environment/test/common/testCopilotHomeService.js'; -import { readMcpServerSource, readMcpServerSourcePlugin } from '../../common/meta/mcpCustomizationMeta.js'; +-import { readCustomizationDiscoveryMeta } from '../../common/meta/customizationDiscoveryMeta.js'; -import { COMPUTER_USE_STATUS_KEY } from '../../common/computerUse.js'; -import { readComputerUseStatus } from '../../common/meta/computerUse.js'; -import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; @@ -49330,10 +51101,12 @@ index f24a97a6296..00000000000 -import { createNoopCustomizationEnablementService } from './testCustomizationEnablementService.js'; -import { CopilotAgentSession, type ICopilotCanvasHandoff } from '../../node/copilot/copilotAgentSession.js'; -import { ModelCallTurnCorrelation, type IModelCallTurnCorrelationResult } from '../../node/copilot/modelCallTurnCorrelation.js'; +-import type { CompactionTurnCorrelation, ICompactionTurnCorrelationResult } from '../../node/copilot/compactionTurnCorrelation.js'; -import { addCopilotSandboxPath, createCopilotCliEnvironment } from '../../node/copilot/copilotCliEnvironment.js'; -import { CopilotClientStartupConfigChangedError } from '../../node/copilot/copilotFailureTelemetry.js'; -import { AgentBranchNameGenerator, getAgentBranchNameHintFromMessage, normalizeAgentBranchName } from '../../node/shared/agentBranchNameGenerator.js'; --import { getCopilotAutoTier, getCopilotCustomAgents, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from '../../node/copilot/copilotSessionLauncher.js'; +-import { getCopilotAutoTier, getCopilotCustomAgents, getCopilotAgentSelectionName, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from '../../node/copilot/copilotSessionLauncher.js'; +-import { getSdkCustomAgentName } from '../../node/copilot/copilotPluginConverters.js'; -import { setAdvertisedAutoModeTiers } from '../../common/autoModeTiers.js'; -import { modelConfigurationSourceKey } from '../../common/agentModelConfiguration.js'; -import { ShellManager } from '../../node/copilot/copilotShellTools.js'; @@ -49488,8 +51261,8 @@ index f24a97a6296..00000000000 - return (agent as unknown as { _chatEntriesBySdkId: Map })._chatEntriesBySdkId; -} - --function chatBackings(agent: CopilotAgent): Map { -- return (agent as unknown as { _chatBackings: Map })._chatBackings; +-function chatBackings(agent: CopilotAgent): Map { +- return (agent as unknown as { _chatBackings: Map })._chatBackings; -} - -/** The owners the agent recorded per chat, read as their current values; `set` records a fixed owner. */ @@ -49604,9 +51377,7 @@ index f24a97a6296..00000000000 - } -} - --class TestAgentHostGitService implements IAgentHostGitService { -- declare readonly _serviceBrand: undefined; -- +-class TestAgentHostGitService extends BaseTestAgentHostGitService { - repositoryRoot: URI | undefined = undefined; - headCommit: string | undefined = '0'.repeat(40); - addedWorktrees: { repositoryRoot: URI; options: IAddWorktreeOptions }[] = []; @@ -49615,68 +51386,32 @@ index f24a97a6296..00000000000 - existingBranches = new Set(); - dirtyWorkingDirectories = new Set(); - -- async getCurrentBranch(): Promise { return undefined; } -- async getHEAD(): Promise { return undefined; } -- async getDefaultBranch(): Promise { return undefined; } -- async getDefaultBranch2(): Promise { return undefined; } -- async getBranch(): Promise { return undefined; } -- async getRefs(): Promise { return []; } -- async getBranches(): Promise { return []; } -- hasGitRoot() { return constObservable(undefined); } -- async getRepositoryRoot(): Promise { return this.repositoryRoot; } -- async getWorktreeRoots(): Promise { return []; } -- async addWorktree(repositoryRoot: URI, options: IAddWorktreeOptions): Promise { +- override async getRepositoryRoot(): Promise { return this.repositoryRoot; } +- override async addWorktree(repositoryRoot: URI, options: IAddWorktreeOptions): Promise { - this.addedWorktrees.push({ repositoryRoot, options }); - if (options.newBranchName) { - this.existingBranches.add(options.newBranchName); - } - } -- async copyWorktreeIncludeFiles(): Promise { } -- async symlinkWorktreeFolders(): Promise { return []; } -- async addExistingWorktree(repositoryRoot: URI, worktree: URI, branchName: string): Promise { +- override async addExistingWorktree(repositoryRoot: URI, worktree: URI, branchName: string): Promise { - this.addedExistingWorktrees.push({ repositoryRoot, worktree, branchName }); - } -- async isWorktreeRegistered(): Promise { return false; } -- async removeWorktree(repositoryRoot: URI, worktree: URI): Promise { +- override async isWorktreeRegistered(): Promise { return false; } +- override async removeWorktree(repositoryRoot: URI, worktree: URI): Promise { - this.removedWorktrees.push({ repositoryRoot, worktree }); - } -- async branchExists(_repositoryRoot: URI, branchName: string): Promise { +- override async branchExists(_repositoryRoot: URI, branchName: string): Promise { - return this.existingBranches.has(branchName); - } -- async createBranch(_workingDirectory: URI, branchName: string): Promise { +- override async createBranch(_workingDirectory: URI, branchName: string): Promise { - this.existingBranches.add(branchName); - } -- async checkout(): Promise { } -- async hasUncommittedChanges(workingDirectory: URI): Promise { +- override async hasUncommittedChanges(workingDirectory: URI): Promise { - return this.dirtyWorkingDirectories.has(workingDirectory.fsPath); - } -- async createStash(): Promise { } -- async commitAll(): Promise { } -- async mergeBranch(): Promise { return ''; } -- async restore(): Promise { } -- async hasUpstream(): Promise { return false; } -- async fetch(): Promise { } -- async pull(): Promise { } -- async push(): Promise { } -- async getSessionGitState(): Promise { return undefined; } -- async computeSessionFileDiffs(): Promise { return undefined; } -- async showBlob(): Promise { return undefined; } -- async captureWorkingTreeAsTree(): Promise { return undefined; } -- async commitTree(): Promise { return undefined; } -- async updateRef(): Promise { } -- async deleteRefs(): Promise { } -- async revParse(_repositoryRoot: URI, expression: string): Promise { +- override async revParse(_repositoryRoot: URI, expression: string): Promise { - return expression === 'HEAD' ? this.headCommit : undefined; - } -- async resolveBranchBaselineCommit(): Promise { return undefined; } -- async overlayPathIntoTree(): Promise { return undefined; } -- async diffTreePaths(): Promise { return undefined; } -- async computeFileDiffsBetweenRefs(): Promise { return undefined; } -- async getFetchRemoteUrls(): Promise { return undefined; } -- async getFetchRemotes(): Promise { return undefined; } -- async getUntrackedPaths(): Promise<[]> { return []; } -- async getBranchDiffSafetyInfo(): Promise { return undefined; } -- async getDiffPatchBetweenRefs(): Promise { return undefined; } -} - -class TestAgentHostTerminalManager implements IAgentHostTerminalManager { @@ -49936,6 +51671,7 @@ index f24a97a6296..00000000000 - readonly agents: CopilotAgentDiscovery; - readonly instructions: CopilotInstructionDiscovery; - readonly skills: CopilotSkillDiscovery; +- readonly hooks: Pick; - readonly sessions: { - readonly fork: CopilotClient['rpc']['sessions']['fork']; - readonly list: CopilotClient['rpc']['sessions']['list']; @@ -49951,7 +51687,7 @@ index f24a97a6296..00000000000 - readonly enable: CopilotExtensionsEnable; - readonly disable: CopilotExtensionsDisable; - }; -- readonly mcp: { readonly config: Pick }; +- readonly mcp: Pick & { readonly config: Pick }; - }; -} - @@ -50005,6 +51741,8 @@ index f24a97a6296..00000000000 - discoverInstructions: CopilotInstructionDiscovery['discover'] = async () => ({ sources: [] }); - getInstructionDiscoveryPaths: CopilotInstructionDiscovery['getDiscoveryPaths'] = async () => ({ paths: [] }); - discoverSkills: CopilotSkillDiscovery['discover'] = async () => ({ skills: [] }); +- discoverHooks: CopilotClient['rpc']['hooks']['discover'] = async () => ({ hooks: [], warnings: [], errors: [] }); +- discoverMcp: CopilotClient['rpc']['mcp']['discover'] = async () => ({ servers: [] }); - getSkillDiscoveryPaths: CopilotSkillDiscovery['getDiscoveryPaths'] = async () => ({ paths: [] }); - readonly agentDiscoveryRequests: Parameters[0][] = []; - readonly instructionDiscoveryRequests: Parameters[0][] = []; @@ -50014,7 +51752,8 @@ index f24a97a6296..00000000000 - readonly extensionDisableRequests: Parameters[0][] = []; - - readonly rpc: ITestCopilotClient['rpc'] = { -- mcp: { config: { list: async () => this.mcpConfigListResult } }, +- mcp: { config: { list: async () => this.mcpConfigListResult }, discover: async params => this.discoverMcp(params) }, +- hooks: { discover: async params => this.discoverHooks(params) }, - managedSettings: { - resolve: params => { - this.managedSettingsRequests.push(params); @@ -50577,6 +52316,16 @@ index f24a97a6296..00000000000 - -class TestProxyResolver implements IAgentHostProxyResolver { - declare readonly _serviceBrand: undefined; +- clientCertificates: readonly string[] = []; +- registerCertificateProvider(): IDisposable { return Disposable.None; } +- async loadClientCertificates(): Promise { return this.clientCertificates; } +- setClientCertificates(_clientId: string, certificates: readonly string[]): void { +- this.clientCertificates = certificates; +- this._onDidChangeConfiguration.fire(); +- } +- async loadCertificates(): Promise { +- return [...this.clientCertificates]; +- } - private readonly _onDidRegisterConnection = new Emitter(); - readonly onDidRegisterConnection = this._onDidRegisterConnection.event; - private readonly _onDidChangeConfiguration = new Emitter(); @@ -50599,6 +52348,18 @@ index f24a97a6296..00000000000 - }); - } - +- registerSharedNetworkClient(): IDisposable { +- return Disposable.None; +- } +- +- notifySharedNetworkClientChanged(): void { } +- +- readonly clientKerberosLookups: string[] = []; +- async lookupClientKerberosAuthorization(proxyUrl: string): Promise { +- this.clientKerberosLookups.push(proxyUrl); +- return 'Negotiate client-ticket'; +- } +- - getConfigurationValue(_key: string): T | undefined { - return undefined; - } @@ -50655,8 +52416,9 @@ index f24a97a6296..00000000000 - @ILocalModelSetupService localModelSetupService: ILocalModelSetupService, - @IAgentHostSessionStateStore sessionStateStore: IAgentHostSessionStateStore, - @ICopilotHomeService copilotHomeService: ICopilotHomeService, +- @IRequestService requestService: IRequestService, - ) { -- super(logService, instantiationService, sessionDataService, gitService, configurationService, sessionTitleSignal, managedSettingsService, gitHubEndpointService, otelService, completions, NULL_CHECKPOINT_SERVICE, NULL_REVIEW_SERVICE, customizationEnablementService, environmentService, productService, byokBridgeRegistry, telemetryService, copilotApiService, proxyResolver, fileService, worktreeIsolation, startupPerformance, byokLmProxyService, localModelSetupService, sessionStateStore, copilotHomeService); +- super(logService, instantiationService, sessionDataService, gitService, configurationService, sessionTitleSignal, managedSettingsService, gitHubEndpointService, otelService, completions, NULL_CHECKPOINT_SERVICE, NULL_REVIEW_SERVICE, customizationEnablementService, environmentService, productService, byokBridgeRegistry, telemetryService, copilotApiService, proxyResolver, fileService, worktreeIsolation, startupPerformance, byokLmProxyService, localModelSetupService, sessionStateStore, copilotHomeService, requestService); - } - - protected override _createCopilotClient(options: CopilotClientOptions): CopilotClient { @@ -50703,8 +52465,9 @@ index f24a97a6296..00000000000 - @ILocalModelSetupService localModelSetupService: ILocalModelSetupService, - @IAgentHostSessionStateStore sessionStateStore: IAgentHostSessionStateStore, - @ICopilotHomeService copilotHomeService: ICopilotHomeService, +- @IRequestService requestService: IRequestService, - ) { -- super(logService, instantiationService, sessionDataService, gitService, configurationService, sessionTitleSignal, managedSettingsService, gitHubEndpointService, otelService, completions, NULL_CHECKPOINT_SERVICE, NULL_REVIEW_SERVICE, customizationEnablementService, environmentService, productService, byokBridgeRegistry, telemetryService, copilotApiService, proxyResolver, fileService, worktreeIsolation, startupPerformance, byokLmProxyService, localModelSetupService, sessionStateStore, copilotHomeService); +- super(logService, instantiationService, sessionDataService, gitService, configurationService, sessionTitleSignal, managedSettingsService, gitHubEndpointService, otelService, completions, NULL_CHECKPOINT_SERVICE, NULL_REVIEW_SERVICE, customizationEnablementService, environmentService, productService, byokBridgeRegistry, telemetryService, copilotApiService, proxyResolver, fileService, worktreeIsolation, startupPerformance, byokLmProxyService, localModelSetupService, sessionStateStore, copilotHomeService, requestService); - this._now = now; - } - @@ -50776,7 +52539,7 @@ index f24a97a6296..00000000000 - return values.get('copilot.customizationDirectory'); -} - --function createTestAgentContext(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService; copilotApiService?: ICopilotApiService; gitHubEndpointService?: IAgentHostGitHubEndpointService; telemetryService?: ITelemetryService; userHome?: URI; copilotHome?: string; logService?: ILogService; proxyResolver?: IAgentHostProxyResolver; byokBridgeRegistry?: IByokLmBridgeRegistry; byokProxyService?: IByokLmProxyService; otelService?: IAgentHostOTelService; customizationEnablementService?: ICustomizationEnablementService; useRealCustomizationEnablementService?: boolean; worktreeIsolation?: IAgentHostWorktreeIsolation; rootConfig?: Record; now?: () => number; startupPerformance?: IAgentHostStartupPerformance; productService?: IProductService; localModelSetupService?: ILocalModelSetupService }): { agent: CopilotAgent; instantiationService: IInstantiationService; authenticationService: AgentHostAuthenticationService; configurationService: IAgentConfigurationService; worktreeIsolation: IAgentHostWorktreeIsolation; managedSettingsService: IAgentHostManagedSettingsService; fileService: FileService; stateManager: AgentHostStateManager } { +-function createTestAgentContext(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService; copilotApiService?: ICopilotApiService; gitHubEndpointService?: IAgentHostGitHubEndpointService; telemetryService?: ITelemetryService; userHome?: URI; copilotHome?: string; logService?: ILogService; proxyResolver?: IAgentHostProxyResolver; byokBridgeRegistry?: IByokLmBridgeRegistry; byokProxyService?: IByokLmProxyService; otelService?: IAgentHostOTelService; customizationEnablementService?: ICustomizationEnablementService; useRealCustomizationEnablementService?: boolean; worktreeIsolation?: IAgentHostWorktreeIsolation; rootConfig?: Record; now?: () => number; startupPerformance?: IAgentHostStartupPerformance; productService?: IProductService; localModelSetupService?: ILocalModelSetupService; appSettingsHome?: URI }): { agent: CopilotAgent; instantiationService: IInstantiationService; authenticationService: AgentHostAuthenticationService; configurationService: IAgentConfigurationService; worktreeIsolation: IAgentHostWorktreeIsolation; managedSettingsService: IAgentHostManagedSettingsService; fileService: FileService; stateManager: AgentHostStateManager } { - const services = new ServiceCollection(); - const logService = options?.logService ?? new NullLogService(); - const authenticationService = disposables.add(new AgentHostAuthenticationService(logService)); @@ -50787,6 +52550,7 @@ index f24a97a6296..00000000000 - const managedSettingsService = disposables.add(new AgentHostManagedSettingsService()); - const telemetryService = options?.telemetryService ?? NullTelemetryService; - services.set(ILogService, logService); +- services.set(IRequestService, new class extends mock() { }()); - services.set(IAgentHostAuthenticationService, authenticationService); - services.set(IFileService, fileService); - services.set(IAgentConfigurationService, configService); @@ -50853,6 +52617,7 @@ index f24a97a6296..00000000000 - userHome: options?.userHome ?? URI.from({ scheme: Schemas.inMemory, path: '/mock-home' }), - tmpDir: URI.from({ scheme: Schemas.inMemory, path: '/mock-tmp' }), - userDataPath: '/mock-userdata', +- ...(options?.appSettingsHome ? { appSettingsHome: options.appSettingsHome } : {}), - } as INativeEnvironmentService; - services.set(INativeEnvironmentService, environmentService); - } @@ -51002,8 +52767,34 @@ index f24a97a6296..00000000000 - await sessionStateStoresByAgent.get(agent)?.whenIdle(undefined); -} - +-/** +- * Registers a `teardown` that awaits the cleanups added during a test, most recently added +- * first, with the same error semantics as nested `finally` blocks. Call it before +- * `ensureNoDisposablesAreLeakedInTestSuite()`: hooks run in registration order, so the +- * cleanups complete before the leak check. +- */ +-function ensureCleanupAfterEachTest(): (cleanup: () => Promise) => void { +- const cleanups: (() => Promise)[] = []; +- const runCleanups = async (): Promise => { +- const cleanup = cleanups.pop(); +- if (cleanup) { +- try { +- await cleanup(); +- } finally { +- await runCleanups(); +- } +- } +- }; +- teardown(runCleanups); +- return cleanup => { +- cleanups.push(cleanup); +- }; +-} +- -suite('CopilotAgent', () => { - +- // Registered before the leak-check store so that cleanups complete before the leak check. +- const afterTest = ensureCleanupAfterEachTest(); - const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - const proxyEnvironmentKeys = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy', 'NO_PROXY', 'no_proxy'] as const; - const proxyEnvironmentKeyNames = new Set(proxyEnvironmentKeys.map(key => key.toLowerCase())); @@ -51297,17 +53088,14 @@ index f24a97a6296..00000000000 - test('uninstalls plugins through the SDK server API', async () => { - const client = new TestCopilotClient([]); - const agent = createTestAgent(disposables, { copilotClient: client }); -- try { -- await agent.uninstallPlugin({ name: 'spark', marketplace: 'copilot-plugins' }); -- await agent.uninstallPlugin({ name: 'direct', marketplace: '', directSourceId: 'source-id' }); +- afterTest(() => disposeAgent(agent)); +- await agent.uninstallPlugin({ name: 'spark', marketplace: 'copilot-plugins' }); +- await agent.uninstallPlugin({ name: 'direct', marketplace: '', directSourceId: 'source-id' }); - -- assert.deepStrictEqual(client.pluginUninstallRequests, [ -- { name: 'spark@copilot-plugins', directSourceId: undefined }, -- { name: 'direct', directSourceId: 'source-id' }, -- ]); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(client.pluginUninstallRequests, [ +- { name: 'spark@copilot-plugins', directSourceId: undefined }, +- { name: 'direct', directSourceId: 'source-id' }, +- ]); - }); - - test('toggles direct installations by source identity without changing same-name installations', async () => { @@ -51322,25 +53110,22 @@ index f24a97a6296..00000000000 - setPeerChatStub(agent, URI.parse(buildChatUri(session, 'peer')), { - reloadPlugins: async (reason: string) => { reloads.push(reason); }, - }, 'sdk-plugin-peer'); +- afterTest(() => disposeAgent(agent)); +- await agent.setPluginEnabled('source-id', true); +- const enabled = client.pluginInstallations.map(plugin => plugin.enabled); +- await agent.setPluginEnabled('source-id', false); +- assert.deepStrictEqual({ enabled, disabled: client.pluginInstallations.map(plugin => plugin.enabled), reloads }, { +- enabled: [true, false], +- disabled: [false, false], +- reloads: ['enablement', 'enablement'], +- }); +- +- await assert.rejects(agent.setPluginEnabled('canvas', true), /not installed/); +- const refuse = stub(client.rpc.plugins, 'enable').resolves(); - try { -- await agent.setPluginEnabled('source-id', true); -- const enabled = client.pluginInstallations.map(plugin => plugin.enabled); -- await agent.setPluginEnabled('source-id', false); -- assert.deepStrictEqual({ enabled, disabled: client.pluginInstallations.map(plugin => plugin.enabled), reloads }, { -- enabled: [true, false], -- disabled: [false, false], -- reloads: ['enablement', 'enablement'], -- }); -- -- await assert.rejects(agent.setPluginEnabled('canvas', true), /not installed/); -- const refuse = stub(client.rpc.plugins, 'enable').resolves(); -- try { -- await assert.rejects(agent.setPluginEnabled('source-id', true), /did not change enablement/); -- } finally { -- refuse.restore(); -- } +- await assert.rejects(agent.setPluginEnabled('source-id', true), /did not change enablement/); - } finally { -- await disposeAgent(agent); +- refuse.restore(); - } - }); - @@ -51354,15 +53139,12 @@ index f24a97a6296..00000000000 - reloadPlugins: async (reason: string) => { reloads.push(`${peer}:${reason}`); }, - }, `sdk-${peer}`); - } -- try { -- await agent.uninstallPlugin({ name: 'token-pacman', marketplace: '', directSourceId: 'opaque-source' }); -- assert.deepStrictEqual({ requests: client.pluginUninstallRequests, reloads: reloads.sort() }, { -- requests: [{ name: 'token-pacman', directSourceId: 'opaque-source' }], -- reloads: ['first:uninstall', 'second:uninstall'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.uninstallPlugin({ name: 'token-pacman', marketplace: '', directSourceId: 'opaque-source' }); +- assert.deepStrictEqual({ requests: client.pluginUninstallRequests, reloads: reloads.sort() }, { +- requests: [{ name: 'token-pacman', directSourceId: 'opaque-source' }], +- reloads: ['first:uninstall', 'second:uninstall'], +- }); - }); - - test('waits for peer reloads when the selected chat reload fails after plugin removal', async () => { @@ -51385,23 +53167,22 @@ index f24a97a6296..00000000000 - calls.push('peer reloaded'); - }, - }); -- try { -- let completed = false; -- const removal = agent.uninstallPlugin({ name: 'canvas', marketplace: '', directSourceId: 'source' }, session, selected); -- const result = assert.rejects(removal, /selected reload failed/).then(() => { completed = true; }); -- await peerReloadStarted.p; -- await timeout(0); -- const completedBeforePeerReload = completed; -- await finishPeerReload.complete(); -- await result; -- assert.deepStrictEqual({ completedBeforePeerReload, calls }, { -- completedBeforePeerReload: false, -- calls: ['uninstall', 'selected reload', 'peer reload', 'peer reloaded'], -- }); -- } finally { +- afterTest(async () => { - await finishPeerReload.complete(); - await disposeAgent(agent); -- } +- }); +- let completed = false; +- const removal = agent.uninstallPlugin({ name: 'canvas', marketplace: '', directSourceId: 'source' }, session, selected); +- const result = assert.rejects(removal, /selected reload failed/).then(() => { completed = true; }); +- await peerReloadStarted.p; +- await timeout(0); +- const completedBeforePeerReload = completed; +- await finishPeerReload.complete(); +- await result; +- assert.deepStrictEqual({ completedBeforePeerReload, calls }, { +- completedBeforePeerReload: false, +- calls: ['uninstall', 'selected reload', 'peer reload', 'peer reloaded'], +- }); - }); - - test('discovers extensions, persists preferences, and reconciles the selected live chat', async () => { @@ -51450,85 +53231,261 @@ index f24a97a6296..00000000000 - enabled: false, - }], - }, 'sdk-extension-peer'); -- try { -- const inventory = await agent.listAgentExtensions(); -- const scopedInventory = await agent.listAgentExtensions(session, chat); -- await agent.setAgentExtensionEnabled('user:preview', false); -- await agent.setAgentExtensionEnabled('user:preview', true, session); -- await agent.setAgentExtensionEnabled('plugin:dashboard', true, session, chat); -- -- assert.deepStrictEqual({ -- inventory: { -- mode: inventory.mode, -- extensions: inventory.extensions.map(extension => ({ -- id: extension.id, -- name: extension.name, -- resource: extension.resource?.toString(), -- source: extension.source, -- status: extension.status, -- pluginName: extension.pluginName, -- })), -- }, -- scopedInventory: scopedInventory.extensions.map(extension => ({ +- afterTest(() => disposeAgent(agent)); +- const inventory = await agent.listAgentExtensions(); +- const scopedInventory = await agent.listAgentExtensions(session, chat); +- await agent.setAgentExtensionEnabled('user:preview', false); +- await agent.setAgentExtensionEnabled('user:preview', true, session); +- await agent.setAgentExtensionEnabled('plugin:dashboard', true, session, chat); +- +- assert.deepStrictEqual({ +- inventory: { +- mode: inventory.mode, +- extensions: inventory.extensions.map(extension => ({ - id: extension.id, +- name: extension.name, - resource: extension.resource?.toString(), - source: extension.source, - status: extension.status, +- pluginName: extension.pluginName, - })), -- enableRequests: client.extensionEnableRequests, -- disableRequests: client.extensionDisableRequests, -- reconcileCalls, -- }, { -- inventory: { -- mode: 'load_and_augment', -- extensions: [{ -- id: 'user:preview', -- name: 'Preview', -- resource: 'file:///home/test/.copilot/extensions/preview/extension.mjs', -- source: 'user', -- status: 'unknown', -- pluginName: undefined, -- }, { -- id: 'plugin:dashboard', -- name: 'Dashboard', -- resource: 'file:///home/test/.copilot/plugins/dashboard/extensions/dashboard/extension.mjs', -- source: 'plugin', -- status: 'disabled', -- pluginName: 'dashboard', -- }, { -- id: 'plugin:microsoft-foundry:microsoft-foundry', -- name: 'microsoft-foundry', -- resource: 'file:///home/test/.copilot/plugins/microsoft-foundry/extensions/microsoft-foundry/extension.mjs', -- source: 'plugin', -- status: 'unknown', -- pluginName: 'microsoft-foundry', -- }], -- }, -- scopedInventory: [{ +- }, +- scopedInventory: scopedInventory.extensions.map(extension => ({ +- id: extension.id, +- resource: extension.resource?.toString(), +- source: extension.source, +- status: extension.status, +- })), +- enableRequests: client.extensionEnableRequests, +- disableRequests: client.extensionDisableRequests, +- reconcileCalls, +- }, { +- inventory: { +- mode: 'load_and_augment', +- extensions: [{ - id: 'user:preview', +- name: 'Preview', - resource: 'file:///home/test/.copilot/extensions/preview/extension.mjs', - source: 'user', -- status: 'running', -- }, { -- id: 'user:project-dashboard', -- resource: undefined, -- source: 'project', -- status: 'disabled', +- status: 'unknown', +- pluginName: undefined, - }, { - id: 'plugin:dashboard', +- name: 'Dashboard', - resource: 'file:///home/test/.copilot/plugins/dashboard/extensions/dashboard/extension.mjs', - source: 'plugin', - status: 'disabled', +- pluginName: 'dashboard', - }, { - id: 'plugin:microsoft-foundry:microsoft-foundry', +- name: 'microsoft-foundry', - resource: 'file:///home/test/.copilot/plugins/microsoft-foundry/extensions/microsoft-foundry/extension.mjs', - source: 'plugin', - status: 'unknown', +- pluginName: 'microsoft-foundry', - }], -- enableRequests: [{ ids: ['user:preview'] }, { ids: ['plugin:dashboard'] }], -- disableRequests: [{ ids: ['user:preview'] }], -- reconcileCalls: 1, +- }, +- scopedInventory: [{ +- id: 'user:preview', +- resource: 'file:///home/test/.copilot/extensions/preview/extension.mjs', +- source: 'user', +- status: 'running', +- }, { +- id: 'user:project-dashboard', +- resource: undefined, +- source: 'project', +- status: 'disabled', +- }, { +- id: 'plugin:dashboard', +- resource: 'file:///home/test/.copilot/plugins/dashboard/extensions/dashboard/extension.mjs', +- source: 'plugin', +- status: 'disabled', +- }, { +- id: 'plugin:microsoft-foundry:microsoft-foundry', +- resource: 'file:///home/test/.copilot/plugins/microsoft-foundry/extensions/microsoft-foundry/extension.mjs', +- source: 'plugin', +- status: 'unknown', +- }], +- enableRequests: [{ ids: ['user:preview'] }, { ids: ['plugin:dashboard'] }], +- disableRequests: [{ ids: ['user:preview'] }], +- reconcileCalls: 1, +- }); +- }); +- +- test('conservatively combines duplicate extension identities', async () => { +- const client = new TestCopilotClient([]); +- const installed = { +- id: 'plugin:foundry:canvas', +- name: 'canvas', +- path: '/plugins/foundry/canvas/extension.mjs', +- source: 'plugin' as const, +- enabled: true, +- plugin: { name: 'foundry' }, +- }; +- client.extensionInventory = { mode: 'load_and_augment', extensions: [installed, installed] }; +- const agent = createTestAgent(disposables, { copilotClient: client }); +- const session = AgentSession.uri('copilotcli', 'duplicate-extensions'); +- setDefaultSessionStub(agent, 'duplicate-extensions', { +- listExtensions: async () => [ +- { id: installed.id, name: 'foundry:canvas', source: 'plugin', status: 'disabled' }, +- { id: installed.id, name: 'foundry:canvas', source: 'plugin', status: 'disabled' }, +- { id: 'user:preview', name: 'Preview', source: 'user', status: 'running' }, +- { id: 'user:preview', name: 'Preview', source: 'user', status: 'disabled' }, +- { id: 'plugin:failed', name: 'Failed', source: 'plugin', status: 'running' }, +- { id: 'plugin:failed', name: 'Failed', source: 'plugin', status: 'failed' }, +- { id: 'plugin:starting', name: 'Starting', source: 'plugin', status: 'running' }, +- { id: 'plugin:starting', name: 'Starting', source: 'plugin', status: 'starting' }, +- ], +- }); +- afterTest(() => disposeAgent(agent)); +- const cold = await agent.listAgentExtensions(); +- const active = await agent.listAgentExtensions(session); +- assert.deepStrictEqual({ +- cold: cold.extensions.map(extension => [extension.id, extension.status]), +- active: active.extensions.map(extension => [extension.id, extension.status, extension.resource?.toString(), extension.pluginName]), +- }, { +- cold: [[installed.id, 'unknown']], +- active: [ +- [installed.id, 'disabled', URI.file(installed.path).toString(), 'foundry'], +- ['user:preview', 'unknown', undefined, undefined], +- ['plugin:failed', 'failed', undefined, undefined], +- ['plugin:starting', 'starting', undefined, undefined], +- ], +- }); +- }); +- +- for (const scenario of [ +- { name: 'disabled target and unrelated failure', enabled: false, statuses: ['disabled'], succeeds: false, expectedError: /After disabling 'preview', this chat couldn't finish updating canvas extensions because 'other' failed to start. This chat may not match your canvas extension settings/ }, +- { name: 'running target and unrelated failure', enabled: true, statuses: ['running'], succeeds: true, expectedError: undefined }, +- { name: 'target still running', enabled: false, statuses: ['running'], succeeds: false, expectedError: /After disabling 'preview', this chat couldn't finish updating canvas extensions because 'preview' couldn't be stopped/ }, +- { name: 'target still starting', enabled: true, statuses: ['starting'], succeeds: false, expectedError: /After enabling 'preview', this chat couldn't finish updating canvas extensions because 'other' failed to start. This chat may not match your canvas extension settings/ }, +- { name: 'target failed', enabled: true, statuses: ['failed'], succeeds: false, expectedError: /After enabling 'preview', this chat couldn't finish updating canvas extensions because 'preview' failed to start/ }, +- { name: 'target absent', enabled: false, statuses: [], succeeds: false, expectedError: /After disabling 'preview', this chat couldn't finish updating canvas extensions because 'preview' couldn't be stopped/ }, +- { name: 'conflicting duplicate target states', enabled: false, statuses: ['disabled', 'running'], succeeds: false, expectedError: /After disabling 'preview', this chat couldn't finish updating canvas extensions because 'preview' couldn't be stopped/ }, +- { name: 'conflicting duplicate target states while enabling', enabled: true, statuses: ['disabled', 'running'], succeeds: false, expectedError: /Canvas extension 'preview' was enabled in settings, but couldn't be started in this chat. unrelated extension did not become ready/ }, +- { name: 'unknown target state while enabling', enabled: true, statuses: ['unknown'], succeeds: false, expectedError: /Canvas extension 'preview' was enabled in settings, but couldn't be started in this chat. unrelated extension did not become ready/ }, +- { name: 'verification unavailable', enabled: false, statuses: ['disabled'], succeeds: false, expectedError: /Canvas extension 'preview' was disabled in settings/ }, +- { name: 'disposed during verification', enabled: true, statuses: ['running'], succeeds: false, expectedError: /Canvas extension 'preview' was enabled in settings/ }, +- { name: 'disposed during disable verification', enabled: false, statuses: ['disabled'], succeeds: false, expectedError: /Canvas extension 'preview' was disabled in settings/ }, +- ] as const) { +- test(`verifies extension enablement after reconciliation failure: ${scenario.name}`, async () => { +- const client = new TestCopilotClient([]); +- client.extensionInventory = { +- mode: 'load_and_augment', +- extensions: [{ id: 'user:preview', name: 'preview', path: '/extensions/preview.mjs', source: 'user', enabled: !scenario.enabled }], +- }; +- const logService = new NullLogService(); +- const warnings = spy(logService, 'warn'); +- const agent = createTestAgent(disposables, { copilotClient: client, logService }); +- const session = AgentSession.uri('copilotcli', 'partial-extension-reconcile'); +- let reconciled = false; +- let listedAfterReconcile = false; +- setDefaultSessionStub(agent, 'partial-extension-reconcile', { +- get isDisposed() { return listedAfterReconcile && (scenario.name === 'disposed during verification' || scenario.name === 'disposed during disable verification'); }, +- reconcileExtensions: async () => { +- reconciled = true; +- throw Object.assign(new Error('unrelated extension did not become ready'), { +- data: { code: 'extension_reconciliation_lifecycle_failed' }, +- }); +- }, +- listExtensions: async () => { +- if (reconciled) { +- listedAfterReconcile = true; +- if (scenario.name === 'verification unavailable') { +- throw new Error('inventory unavailable'); +- } +- } +- return [ +- ...scenario.statuses.map(status => ({ id: 'user:preview', name: 'preview', source: 'user', status })), +- { id: 'plugin:other', name: 'other', source: 'plugin', status: 'failed' }, +- ]; +- }, - }); +- try { +- const mutation = agent.setAgentExtensionEnabled('user:preview', scenario.enabled, session); +- if (scenario.succeeds) { +- await mutation; +- } else { +- await assert.rejects(mutation, scenario.expectedError); +- } +- assert.deepStrictEqual({ +- persisted: scenario.enabled ? client.extensionEnableRequests : client.extensionDisableRequests, +- warned: warnings.calledOnce, +- }, { +- persisted: [{ ids: ['user:preview'] }], +- warned: scenario.name !== 'verification unavailable' +- && scenario.name !== 'disposed during verification' +- && scenario.name !== 'disposed during disable verification' +- && scenario.name !== 'conflicting duplicate target states while enabling' +- && scenario.name !== 'unknown target state while enabling', +- }); +- } finally { +- warnings.restore(); +- await disposeAgent(agent); +- } +- }); +- } +- +- test('preserves non-lifecycle reconciliation failures without inspecting inventory', async () => { +- const client = new TestCopilotClient([]); +- client.extensionInventory = { +- mode: 'load_and_augment', +- extensions: [{ id: 'user:preview', name: 'preview', path: '/extensions/preview.mjs', source: 'user', enabled: true }], +- }; +- const agent = createTestAgent(disposables, { copilotClient: client }); +- const session = AgentSession.uri('copilotcli', 'non-lifecycle-extension-reconcile'); +- let reconciled = false; +- let verificationCalls = 0; +- setDefaultSessionStub(agent, 'non-lifecycle-extension-reconcile', { +- reconcileExtensions: async () => { +- reconciled = true; +- throw Object.assign(new Error('workspace unavailable'), { +- data: { code: 'extension_reconciliation_workspace_unavailable' }, +- }); +- }, +- listExtensions: async () => { +- if (reconciled) { +- verificationCalls++; +- } +- return [{ id: 'plugin:other', name: 'other', source: 'plugin', status: 'failed' }]; +- }, +- }); +- try { +- await assert.rejects(agent.setAgentExtensionEnabled('user:preview', false, session), /workspace unavailable/); +- assert.strictEqual(verificationCalls, 0); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('preserves a lifecycle reconciliation failure when a disable settled without a visible failing extension', async () => { +- const client = new TestCopilotClient([]); +- client.extensionInventory = { +- mode: 'load_and_augment', +- extensions: [{ id: 'user:preview', name: 'preview', path: '/extensions/preview.mjs', source: 'user', enabled: true }], +- }; +- const agent = createTestAgent(disposables, { copilotClient: client }); +- const session = AgentSession.uri('copilotcli', 'hidden-lifecycle-extension-reconcile'); +- let reconciled = false; +- setDefaultSessionStub(agent, 'hidden-lifecycle-extension-reconcile', { +- reconcileExtensions: async () => { +- reconciled = true; +- throw Object.assign(new Error('session extension did not become ready'), { +- data: { code: 'extension_reconciliation_lifecycle_failed' }, +- }); +- }, +- listExtensions: async () => reconciled +- ? [ +- { id: 'user:preview', name: 'preview', source: 'user', status: 'disabled' }, +- { id: 'plugin:other', name: 'other', source: 'plugin', status: 'starting' }, +- ] +- : [{ id: 'user:preview', name: 'preview', source: 'user', status: 'running' }], +- }); +- try { +- await assert.rejects( +- agent.setAgentExtensionEnabled('user:preview', false, session), +- /Canvas extension 'preview' was disabled in settings, but couldn't be stopped in this chat. session extension did not become ready/, +- ); - } finally { - await disposeAgent(agent); - } @@ -51581,81 +53538,78 @@ index f24a97a6296..00000000000 - status: 'unknown', - }], - }, 'sdk-forward-compatible-extensions'); -- try { -- const persisted = await agent.listAgentExtensions(); -- client.extensionInventory = { ...client.extensionInventory, mode: 'disabled' }; -- const disabled = await agent.listAgentExtensions(); -- client.extensionInventory = { ...client.extensionInventory, mode: 'load_only' }; -- const cold = await agent.listAgentExtensions(AgentSession.uri('copilotcli', 'cold-extensions'), URI.parse('vendor-chat:/cold')); -- const live = await agent.listAgentExtensions(session, chat); -- -- const toInventorySnapshot = (inventory: Awaited>) => ({ -- mode: inventory.mode, -- extensions: inventory.extensions.map(extension => ({ -- id: extension.id, -- resource: extension.resource?.toString(), -- source: extension.source, -- status: extension.status, -- pluginName: extension.pluginName, -- })), -- }); -- const expectedPersistedExtensions = [{ -- id: 'user:preview', -- resource: 'file:///home/test/.copilot/extensions/preview/extension.mjs', -- source: 'user', -- status: 'unknown', -- pluginName: undefined, -- }, { -- id: 'plugin:dashboard', -- resource: 'file:///home/test/.copilot/plugins/dashboard/extensions/dashboard/extension.mjs', -- source: 'plugin', -- status: 'disabled', -- pluginName: 'dashboard', -- }]; -- assert.deepStrictEqual({ -- persisted: toInventorySnapshot(persisted), -- disabled: toInventorySnapshot(disabled), -- cold: toInventorySnapshot(cold), -- live: toInventorySnapshot(live), -- }, { -- persisted: { -- mode: 'load_only', -- extensions: expectedPersistedExtensions, -- }, -- disabled: { -- mode: 'disabled', -- extensions: expectedPersistedExtensions, -- }, -- cold: { -- mode: 'load_only', -- extensions: expectedPersistedExtensions, -- }, -- live: { -- mode: 'load_only', -- extensions: [{ -- id: 'user:preview', -- resource: 'file:///home/test/.copilot/extensions/preview/extension.mjs', -- source: 'user', -- status: 'running', -- pluginName: undefined, -- }, { -- id: 'user:project-preview', -- resource: undefined, -- source: 'project', -- status: 'unknown', -- pluginName: undefined, -- }, { -- id: 'plugin:dashboard', -- resource: 'file:///home/test/.copilot/plugins/dashboard/extensions/dashboard/extension.mjs', -- source: 'plugin', -- status: 'disabled', -- pluginName: 'dashboard', -- }], -- }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const persisted = await agent.listAgentExtensions(); +- client.extensionInventory = { ...client.extensionInventory, mode: 'disabled' }; +- const disabled = await agent.listAgentExtensions(); +- client.extensionInventory = { ...client.extensionInventory, mode: 'load_only' }; +- const cold = await agent.listAgentExtensions(AgentSession.uri('copilotcli', 'cold-extensions'), URI.parse('vendor-chat:/cold')); +- const live = await agent.listAgentExtensions(session, chat); +- +- const toInventorySnapshot = (inventory: Awaited>) => ({ +- mode: inventory.mode, +- extensions: inventory.extensions.map(extension => ({ +- id: extension.id, +- resource: extension.resource?.toString(), +- source: extension.source, +- status: extension.status, +- pluginName: extension.pluginName, +- })), +- }); +- const expectedPersistedExtensions = [{ +- id: 'user:preview', +- resource: 'file:///home/test/.copilot/extensions/preview/extension.mjs', +- source: 'user', +- status: 'unknown', +- pluginName: undefined, +- }, { +- id: 'plugin:dashboard', +- resource: 'file:///home/test/.copilot/plugins/dashboard/extensions/dashboard/extension.mjs', +- source: 'plugin', +- status: 'disabled', +- pluginName: 'dashboard', +- }]; +- assert.deepStrictEqual({ +- persisted: toInventorySnapshot(persisted), +- disabled: toInventorySnapshot(disabled), +- cold: toInventorySnapshot(cold), +- live: toInventorySnapshot(live), +- }, { +- persisted: { +- mode: 'load_only', +- extensions: expectedPersistedExtensions, +- }, +- disabled: { +- mode: 'disabled', +- extensions: expectedPersistedExtensions, +- }, +- cold: { +- mode: 'load_only', +- extensions: expectedPersistedExtensions, +- }, +- live: { +- mode: 'load_only', +- extensions: [{ +- id: 'user:preview', +- resource: 'file:///home/test/.copilot/extensions/preview/extension.mjs', +- source: 'user', +- status: 'running', +- pluginName: undefined, +- }, { +- id: 'user:project-preview', +- resource: undefined, +- source: 'project', +- status: 'unknown', +- pluginName: undefined, +- }, { +- id: 'plugin:dashboard', +- resource: 'file:///home/test/.copilot/plugins/dashboard/extensions/dashboard/extension.mjs', +- source: 'plugin', +- status: 'disabled', +- pluginName: 'dashboard', +- }], +- }, +- }); - }); - - test('re-resolves session-only extension reconciliation after a queued replacement', async () => { @@ -51731,7 +53685,7 @@ index f24a97a6296..00000000000 - originalReconcileCalls, - replacementReconcileCalls, - }, { -- errorMessage: 'The extension preference was saved, but the selected chat could not be updated: The selected chat stopped or was replaced before its extension state could be updated.', +- errorMessage: 'After disabling \'Preview\', the canvas extension change couldn\'t be confirmed because the selected chat stopped or was replaced. \'Preview\' is disabled in settings.', - disableRequests: [{ ids: ['user:preview'] }], - originalReconcileCalls: 0, - replacementReconcileCalls: 0, @@ -51767,49 +53721,46 @@ index f24a97a6296..00000000000 - dispose() { }, - }); - chatBackings(agent).set(chat.toString(), { sdkSessionId: 'unrelated-live-session' }); -- try { -- const listed = await agent.listPluginMarketplaces(session, chat); -- const added = await agent.addPluginMarketplace(session, 'owner/repository', chat); -- const browsed = await agent.browsePluginMarketplace(session, 'marketplace', chat); -- const removed = await agent.removePluginMarketplace(session, 'marketplace', true, chat); -- const refreshed = await agent.refreshPluginMarketplaces(session, 'marketplace', chat); -- -- assert.deepStrictEqual({ -- listed, -- added, -- browsed, -- removed, -- refreshed, -- addRequests: client.pluginMarketplaceAddRequests, -- browseRequests: client.pluginMarketplaceBrowseRequests, -- removeRequests: client.pluginMarketplaceRemoveRequests, -- refreshRequests: client.pluginMarketplaceRefreshRequests, -- }, { -- listed: [{ -- name: 'marketplace', -- source: 'GitHub: owner/repository', -- isDefault: false, -- managed: true, -- available: true, -- }], -- added: { -- name: 'marketplace', -- source: 'GitHub: owner/repository', -- isDefault: false, -- managed: true, -- available: true, -- }, -- browsed: [{ name: 'plugin', description: 'Plugin description' }], -- removed: { removed: false, dependentPlugins: ['plugin'] }, -- refreshed: [{ name: 'marketplace', success: true, error: undefined }], -- addRequests: [{ source: 'owner/repository' }], -- browseRequests: [{ name: 'marketplace' }], -- removeRequests: [{ name: 'marketplace', force: true }], -- refreshRequests: [{ name: 'marketplace' }], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const listed = await agent.listPluginMarketplaces(session, chat); +- const added = await agent.addPluginMarketplace(session, 'owner/repository', chat); +- const browsed = await agent.browsePluginMarketplace(session, 'marketplace', chat); +- const removed = await agent.removePluginMarketplace(session, 'marketplace', true, chat); +- const refreshed = await agent.refreshPluginMarketplaces(session, 'marketplace', chat); +- +- assert.deepStrictEqual({ +- listed, +- added, +- browsed, +- removed, +- refreshed, +- addRequests: client.pluginMarketplaceAddRequests, +- browseRequests: client.pluginMarketplaceBrowseRequests, +- removeRequests: client.pluginMarketplaceRemoveRequests, +- refreshRequests: client.pluginMarketplaceRefreshRequests, +- }, { +- listed: [{ +- name: 'marketplace', +- source: 'GitHub: owner/repository', +- isDefault: false, +- managed: true, +- available: true, +- }], +- added: { +- name: 'marketplace', +- source: 'GitHub: owner/repository', +- isDefault: false, +- managed: true, +- available: true, +- }, +- browsed: [{ name: 'plugin', description: 'Plugin description' }], +- removed: { removed: false, dependentPlugins: ['plugin'] }, +- refreshed: [{ name: 'marketplace', success: true, error: undefined }], +- addRequests: [{ source: 'owner/repository' }], +- browseRequests: [{ name: 'marketplace' }], +- removeRequests: [{ name: 'marketplace', force: true }], +- refreshRequests: [{ name: 'marketplace' }], +- }); - }); - - test('lists persisted SDK plugin installations without session-only or uninstalled managed entries', async () => { @@ -51822,15 +53773,12 @@ index f24a97a6296..00000000000 - { name: 'builtin', marketplace: '', enabled: true, source: 'builtin' }, - ]; - const agent = createTestAgent(disposables, { copilotClient: client }); -- try { -- assert.deepStrictEqual(await agent.listPluginInstallations(), [ -- { name: 'disabled', marketplace: 'marketplace', directSourceId: undefined, version: '1.0', description: undefined, verifiedSource: undefined, iconUrl: undefined }, -- { name: 'direct', marketplace: '', directSourceId: 'opaque-source', version: undefined, description: 'Verified plugin', verifiedSource: { repository: 'owner/repo', revision: 'abc' }, iconUrl: 'https://example.test/icon.png' }, -- { name: 'live', marketplace: 'local', directSourceId: undefined, version: undefined, description: undefined, verifiedSource: undefined, iconUrl: undefined }, -- ]); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- assert.deepStrictEqual(await agent.listPluginInstallations(), [ +- { name: 'disabled', marketplace: 'marketplace', directSourceId: undefined, version: '1.0', description: undefined, verifiedSource: undefined, iconUrl: undefined }, +- { name: 'direct', marketplace: '', directSourceId: 'opaque-source', version: undefined, description: 'Verified plugin', verifiedSource: { repository: 'owner/repo', revision: 'abc' }, iconUrl: 'https://example.test/icon.png' }, +- { name: 'live', marketplace: 'local', directSourceId: undefined, version: undefined, description: undefined, verifiedSource: undefined, iconUrl: undefined }, +- ]); - }); - - test('prefers a live session for plugin marketplace operations', async () => { @@ -51871,69 +53819,66 @@ index f24a97a6296..00000000000 - const agent = createTestAgent(disposables, { copilotClient: client }); - chatEntriesBySdkId(agent).set('live-marketplace-peer', { chatSession: liveSession, dispose() { } }); - chatBackings(agent).set(chat.toString(), { sdkSessionId: 'live-marketplace-peer' }); -- try { -- await agent.installPlugin({ source: 'plugin@marketplace' }, session, chat); -- await agent.uninstallPlugin({ name: 'plugin', marketplace: 'marketplace' }, session, chat); -- const listed = await agent.listPluginMarketplaces(session, chat); -- const added = await agent.addPluginMarketplace(session, 'owner/repository', chat); -- const browsed = await agent.browsePluginMarketplace(session, 'marketplace', chat); -- const removed = await agent.removePluginMarketplace(session, 'marketplace', true, chat); -- const refreshed = await agent.refreshPluginMarketplaces(session, 'marketplace', chat); -- -- assert.deepStrictEqual({ -- listed, -- added, -- browsed, -- removed, -- refreshed, -- calls, -- clientRequests: { -- install: client.pluginInstallRequests, -- uninstall: client.pluginUninstallRequests, -- add: client.pluginMarketplaceAddRequests, -- browse: client.pluginMarketplaceBrowseRequests, -- remove: client.pluginMarketplaceRemoveRequests, -- refresh: client.pluginMarketplaceRefreshRequests, -- }, -- }, { -- listed: [{ -- name: 'marketplace', -- source: 'GitHub: owner/repository', -- isDefault: false, -- managed: false, -- available: true, -- }], -- added: { -- name: 'marketplace', -- source: 'GitHub: owner/repository', -- isDefault: false, -- managed: false, -- available: true, -- }, -- browsed: [{ name: 'plugin', description: 'Plugin description' }], -- removed: { removed: true, dependentPlugins: undefined }, -- refreshed: [{ name: 'marketplace', success: true, error: undefined }], -- calls: [ -- ['install', 'plugin@marketplace'], -- ['uninstall', 'plugin@marketplace', undefined], -- ['reload', 'uninstall'], -- ['add', 'owner/repository'], -- ['browse', 'marketplace'], -- ['remove', 'marketplace', true], -- ['refresh', 'marketplace'], -- ], -- clientRequests: { -- install: [], -- uninstall: [], -- add: [], -- browse: [], -- remove: [], -- refresh: [], -- }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.installPlugin({ source: 'plugin@marketplace' }, session, chat); +- await agent.uninstallPlugin({ name: 'plugin', marketplace: 'marketplace' }, session, chat); +- const listed = await agent.listPluginMarketplaces(session, chat); +- const added = await agent.addPluginMarketplace(session, 'owner/repository', chat); +- const browsed = await agent.browsePluginMarketplace(session, 'marketplace', chat); +- const removed = await agent.removePluginMarketplace(session, 'marketplace', true, chat); +- const refreshed = await agent.refreshPluginMarketplaces(session, 'marketplace', chat); +- +- assert.deepStrictEqual({ +- listed, +- added, +- browsed, +- removed, +- refreshed, +- calls, +- clientRequests: { +- install: client.pluginInstallRequests, +- uninstall: client.pluginUninstallRequests, +- add: client.pluginMarketplaceAddRequests, +- browse: client.pluginMarketplaceBrowseRequests, +- remove: client.pluginMarketplaceRemoveRequests, +- refresh: client.pluginMarketplaceRefreshRequests, +- }, +- }, { +- listed: [{ +- name: 'marketplace', +- source: 'GitHub: owner/repository', +- isDefault: false, +- managed: false, +- available: true, +- }], +- added: { +- name: 'marketplace', +- source: 'GitHub: owner/repository', +- isDefault: false, +- managed: false, +- available: true, +- }, +- browsed: [{ name: 'plugin', description: 'Plugin description' }], +- removed: { removed: true, dependentPlugins: undefined }, +- refreshed: [{ name: 'marketplace', success: true, error: undefined }], +- calls: [ +- ['install', 'plugin@marketplace'], +- ['uninstall', 'plugin@marketplace', undefined], +- ['reload', 'uninstall'], +- ['add', 'owner/repository'], +- ['browse', 'marketplace'], +- ['remove', 'marketplace', true], +- ['refresh', 'marketplace'], +- ], +- clientRequests: { +- install: [], +- uninstall: [], +- add: [], +- browse: [], +- remove: [], +- refresh: [], +- }, +- }); - }); - - test('retries managed plugins only in the exact live chat of the session', async () => { @@ -51951,18 +53896,15 @@ index f24a97a6296..00000000000 - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); - chatEntriesBySdkId(agent).set('live-retry-peer', { chatSession: liveSession, dispose() { } }); - chatBackings(agent).set(chat.toString(), { sdkSessionId: 'live-retry-peer' }); -- try { -- const plugins = await agent.retryManagedPlugins(session, chat); -- const otherSession = await agent.retryManagedPlugins(URI.parse('agent-host-copilotcli:/other-session'), chat).then(() => 'resolved', (error: Error) => error.message); +- afterTest(() => disposeAgent(agent)); +- const plugins = await agent.retryManagedPlugins(session, chat); +- const otherSession = await agent.retryManagedPlugins(URI.parse('agent-host-copilotcli:/other-session'), chat).then(() => 'resolved', (error: Error) => error.message); - -- assert.deepStrictEqual({ plugins, calls, otherSession }, { -- plugins: [{ spec: 'security-guard@contoso', status: 'installed' }], -- calls: ['agent-host-copilotcli:/retry-session#peer'], -- otherSession: 'This chat is not active. Send a message in it, then try again.', -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ plugins, calls, otherSession }, { +- plugins: [{ spec: 'security-guard@contoso', status: 'installed' }], +- calls: ['agent-host-copilotcli:/retry-session#peer'], +- otherSession: 'This chat is not active. Send a message in it, then try again.', +- }); - }); - - test('customization inventory uses the verified hidden policy session while a chat is live', async () => { @@ -52001,48 +53943,44 @@ index f24a97a6296..00000000000 - Object.assign(client.rpc.skills, { installations: skillInstallations }); - Object.assign(client.rpc.mcp, { installations: mcpInstallations, discover }); - const agent = createTestAgent(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService: disposables.add(new TestSessionDataService()) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.refreshModels(); -- await provisionSession(agent, { session, workingDirectories: [URI.file(workingDirectory)] }); -- await agent.chats.prepareTurn!(chat, 'turn-1', [URI.file(workingDirectory)], context); -- const visibleSessionId = agent['_findSessionChat'](session)?.sessionId; -- assert.ok(visibleSessionId); -- -- await agent.listCustomizationInstallations(session); -- -- const hiddenSessionIds = [...runtimes.keys()].filter(id => id !== visibleSessionId); -- assert.deepStrictEqual({ -- hiddenSessionIds: hiddenSessionIds.length, -- inventoryRequests, -- hiddenCredentialHosts: runtimes.get(hiddenSessionIds[0])?.gitHubCredentialUpdates.map(update => update.credentials.host), -- hiddenSessionIsDiscoverable: !agent['_customizationInstallations'].isPolicySessionId(hiddenSessionIds[0]), -- }, { -- hiddenSessionIds: 1, -- inventoryRequests: [`skills:${hiddenSessionIds[0]}`, `mcp:${hiddenSessionIds[0]}`], -- hiddenCredentialHosts: ['https://github.com'], -- hiddenSessionIsDiscoverable: false, -- }); -- } finally { +- afterTest(async () => { - await disposeAgent(agent); - await fs.rm(workingDirectory, { recursive: true, force: true }); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.refreshModels(); +- await provisionSession(agent, { session, workingDirectories: [URI.file(workingDirectory)] }); +- await agent.chats.prepareTurn!(chat, 'turn-1', [URI.file(workingDirectory)], context); +- const visibleSessionId = agent['_findSessionChat'](session)?.sessionId; +- assert.ok(visibleSessionId); +- +- await agent.listCustomizationInstallations(session); +- +- const hiddenSessionIds = [...runtimes.keys()].filter(id => id !== visibleSessionId); +- assert.deepStrictEqual({ +- hiddenSessionIds: hiddenSessionIds.length, +- inventoryRequests, +- hiddenCredentialHosts: runtimes.get(hiddenSessionIds[0])?.gitHubCredentialUpdates.map(update => update.credentials.host), +- hiddenSessionIsDiscoverable: !agent['_customizationInstallations'].isPolicySessionId(hiddenSessionIds[0]), +- }, { +- hiddenSessionIds: 1, +- inventoryRequests: [`skills:${hiddenSessionIds[0]}`, `mcp:${hiddenSessionIds[0]}`], +- hiddenCredentialHosts: ['https://github.com'], +- hiddenSessionIsDiscoverable: false, +- }); - }); - - test('a chat may set its own approvals, mode, tool permissions and sandbox', async () => { - const agent = createTestAgent(disposables); -- try { -- const { schema } = await agent.resolveChatConfig({}); -- assert.deepStrictEqual(Object.keys(schema.properties).filter(key => schema.properties[key].chatMutable).sort(), [ -- SessionConfigKey.AutoApprove, -- 'effectiveApprovalMode', -- SessionConfigKey.Mode, -- SessionConfigKey.Permissions, -- SessionConfigKey.SandboxEnabled, -- ].sort()); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const { schema } = await agent.resolveChatConfig({}); +- assert.deepStrictEqual(Object.keys(schema.properties).filter(key => schema.properties[key].chatMutable).sort(), [ +- SessionConfigKey.AutoApprove, +- 'effectiveApprovalMode', +- SessionConfigKey.Mode, +- SessionConfigKey.Permissions, +- SessionConfigKey.SandboxEnabled, +- ].sort()); - }); - - test('managed settings keep eager config resolution pending and apply restrictive approval modes', async () => { @@ -52095,6 +54033,60 @@ index f24a97a6296..00000000000 - } - }); - +- test('managed settings retry eager config on the restarted client when a resolved proxy restarts it', async () => { +- class DisposingConnectionClient extends TestCopilotClient { +- readonly connection = new DeferredPromise(); +- +- override async stop(): ReturnType { +- this.connection.error(new Error('Pending response rejected since connection got disposed')); +- return super.stop(); +- } +- } +- const proxy = 'http://client-proxy.example:8080'; +- const proxyResolver = new TestProxyResolver(); +- const proxyResolved = new DeferredPromise(); +- proxyResolver.resolvedProxy = proxy; +- proxyResolver.resolveProxyGate = proxyResolved.p; +- const client = new DisposingConnectionClient([]); +- const firstQueryStarted = new DeferredPromise(); +- const restricted: ManagedSettingsResolveResult = { +- ...client.managedSettingsResolution, +- resolved: { +- ...client.managedSettingsResolution.resolved, +- bypassPermissionsDisabled: true, +- settings: { permissions: { disableBypassPermissionsMode: 'disable', disableAssistedPermissionsMode: true } }, +- }, +- }; +- client.resolveManagedSettings = async () => { +- if (!firstQueryStarted.isSettled) { +- firstQueryStarted.complete(); +- return client.connection.p; +- } +- return restricted; +- }; +- const agent = createTestAgent(disposables, { copilotClient: client, proxyResolver }); +- try { +- const config = agent.resolveChatConfig({}); +- await firstQueryStarted.p; +- proxyResolved.complete(); +- assert.deepStrictEqual({ +- availableApprovalModes: (await config).values.availableApprovalModes, +- startCallCount: client.startCallCount, +- stopCallCount: client.stopCallCount, +- httpsProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTPS_PROXY'], +- }, { +- availableApprovalModes: ['default'], +- startCallCount: 2, +- stopCallCount: 1, +- httpsProxy: proxy, +- }); +- } finally { +- proxyResolved.complete(); +- client.connection.complete(client.managedSettingsResolution); +- await disposeAgent(agent); +- } +- }); +- - for (const kind of ['create', 'resume', 'peerResume', 'resumeWithSelections', 'peerResumeWithSelections'] as const) { - test(`Stop releases a ${kind} waiting on managed settings before the SDK starts`, async () => { - const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/copilot-policy-stop-`); @@ -52118,47 +54110,46 @@ index f24a97a6296..00000000000 - const agent = createTestAgent(disposables, { copilotClient: client, sessionDataService, useRealResumePath: true }); - const gate = new DeferredPromise(); - const started = new DeferredPromise(); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.refreshModels(); -- if (kind === 'create') { -- await provisionSession(agent, { session, workingDirectories: [URI.file(workingDirectory)] }); -- } else { -- const db = sessionDataService.openDatabase(session); -- await db.object.setMetadata('copilot.workingDirectory', URI.file(workingDirectory).toString()); -- db.dispose(); -- await agent.materializeChat(chat, context, JSON.stringify({ sdkSessionId: sessionId })); -- } -- client.resolveManagedSettings = params => { -- if (params.workingDirectory) { -- started.complete(); -- return gate.p; -- } -- return Promise.resolve(client.managedSettingsResolution); -- }; -- const selections = kind === 'resumeWithSelections' || kind === 'peerResumeWithSelections' -- ? [agent.chats.changeModel(chat, { id: 'model-x' }, context), agent.chats.changeAgent(chat, undefined, context)] -- : []; -- const settledSelections = Promise.allSettled(selections); -- const send = (async () => { -- await Promise.all(selections); -- await agent.chats.sendMessage(chat, 'cancel me', [URI.file(workingDirectory)], undefined, 'cancelled', undefined, undefined, context); -- })(); -- const completedSend = selections.length ? assert.rejects(send, isCancellationError) : send; -- await started.p; -- await agent.chats.abort(chat, context); -- await completedSend; -- assert.deepStrictEqual((await settledSelections).map(result => result.status), selections.map(() => 'rejected')); -- assert.deepStrictEqual({ startedSdkSessions: runtimes.length, pendingInitializations: agent['_sessionsPendingRegistration'].size }, { startedSdkSessions: 0, pendingInitializations: 0 }); -- const retry = agent.chats.sendMessage(chat, 'retry once', [URI.file(workingDirectory)], undefined, 'retry', undefined, undefined, context); -- await gate.complete(client.managedSettingsResolution); -- await retry; -- assert.deepStrictEqual(runtimes.map(runtime => runtime.sendCalls), [1]); -- } finally { +- afterTest(async () => { - await gate.complete(client.managedSettingsResolution); - await disposeAgent(agent); - await fs.rm(workingDirectory, { recursive: true, force: true }); +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.refreshModels(); +- if (kind === 'create') { +- await provisionSession(agent, { session, workingDirectories: [URI.file(workingDirectory)] }); +- } else { +- const db = sessionDataService.openDatabase(session); +- await db.object.setMetadata('copilot.workingDirectory', URI.file(workingDirectory).toString()); +- db.dispose(); +- await agent.materializeChat(chat, context, JSON.stringify({ sdkSessionId: sessionId })); - } +- client.resolveManagedSettings = params => { +- if (params.workingDirectory) { +- started.complete(); +- return gate.p; +- } +- return Promise.resolve(client.managedSettingsResolution); +- }; +- const selections = kind === 'resumeWithSelections' || kind === 'peerResumeWithSelections' +- ? [agent.chats.changeModel(chat, { id: 'model-x' }, context), agent.chats.changeAgent(chat, undefined, context)] +- : []; +- const settledSelections = Promise.allSettled(selections); +- const send = (async () => { +- await Promise.all(selections); +- await agent.chats.sendMessage(chat, 'cancel me', [URI.file(workingDirectory)], undefined, 'cancelled', undefined, undefined, context); +- })(); +- const completedSend = selections.length ? assert.rejects(send, isCancellationError) : send; +- await started.p; +- await agent.chats.abort(chat, context); +- await completedSend; +- assert.deepStrictEqual((await settledSelections).map(result => result.status), selections.map(() => 'rejected')); +- assert.deepStrictEqual({ startedSdkSessions: runtimes.length, pendingInitializations: agent['_sessionsPendingRegistration'].size }, { startedSdkSessions: 0, pendingInitializations: 0 }); +- const retry = agent.chats.sendMessage(chat, 'retry once', [URI.file(workingDirectory)], undefined, 'retry', undefined, undefined, context); +- await gate.complete(client.managedSettingsResolution); +- await retry; +- assert.deepStrictEqual(runtimes.map(runtime => runtime.sendCalls), [1]); - }); - } - @@ -52178,112 +54169,102 @@ index f24a97a6296..00000000000 - client.createSession = async () => createRuntime(); - client.resumeSession = async () => createRuntime(); - const agent = createTestAgent(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService: disposables.add(new TestSessionDataService()) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.refreshModels(); -- await provisionSession(agent, { session, workingDirectories: [URI.file(workingDirectory)] }); -- await agent.chats.prepareTurn!(chat, 'turn-1', [URI.file(workingDirectory)], context); -- const starts = client.startCallCount; -- let failed = false; -- client.resolveManagedSettings = async params => { -- if (params.workingDirectory && !failed) { -- failed = true; -- throw new Error('Connection is closed.'); -- } -- return client.managedSettingsResolution; -- }; -- await agent.chats.changeModel(chat, { id: 'model-x' }, context); -- assert.deepStrictEqual({ -- failed, restarted: client.startCallCount - starts, stopped: client.stopCallCount, -- models: runtimes.map(runtime => runtime.setModelCalls.map(([model]) => model)), -- }, { failed: true, restarted: 1, stopped: 1, models: [[], ['model-x']] }); -- } finally { +- afterTest(async () => { - await disposeAgent(agent); - await fs.rm(workingDirectory, { recursive: true, force: true }); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.refreshModels(); +- await provisionSession(agent, { session, workingDirectories: [URI.file(workingDirectory)] }); +- await agent.chats.prepareTurn!(chat, 'turn-1', [URI.file(workingDirectory)], context); +- const starts = client.startCallCount; +- let failed = false; +- client.resolveManagedSettings = async params => { +- if (params.workingDirectory && !failed) { +- failed = true; +- throw new Error('Connection is closed.'); +- } +- return client.managedSettingsResolution; +- }; +- await agent.chats.changeModel(chat, { id: 'model-x' }, context); +- assert.deepStrictEqual({ +- failed, restarted: client.startCallCount - starts, stopped: client.stopCallCount, +- models: runtimes.map(runtime => runtime.setModelCalls.map(([model]) => model)), +- }, { failed: true, restarted: 1, stopped: 1, models: [[], ['model-x']] }); - }); - - test('sandbox override survives config resolution but is not inherited by forks', async () => { - const agent = createTestAgent(disposables); -- try { -- const fresh = await agent.resolveChatConfig({}); -- const restored = await agent.resolveChatConfig({ config: { sandboxEnabled: 'off', autoApprove: 'default' } }); -- assert.deepStrictEqual({ -- fresh: fresh.values.sandboxEnabled, -- restored: restored.values.sandboxEnabled, -- fork: agent.getInheritedChatConfig(restored.values)?.sandboxEnabled, -- mutable: restored.schema.properties.sandboxEnabled.sessionMutable, -- }, { fresh: undefined, restored: 'off', fork: undefined, mutable: true }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const fresh = await agent.resolveChatConfig({}); +- const restored = await agent.resolveChatConfig({ config: { sandboxEnabled: 'off', autoApprove: 'default' } }); +- assert.deepStrictEqual({ +- fresh: fresh.values.sandboxEnabled, +- restored: restored.values.sandboxEnabled, +- fork: agent.getInheritedChatConfig(restored.values)?.sandboxEnabled, +- mutable: restored.schema.properties.sandboxEnabled.sessionMutable, +- }, { fresh: undefined, restored: 'off', fork: undefined, mutable: true }); - }); - - test('resolves the state and plan files of a restored default chat that is not resumed yet', async () => { - const { agent, fileService } = createTestAgentContext(disposables, { userHome: URI.file('/home/test') }); -- try { -- const session = AgentSession.uri('copilotcli', 'restored-session'); -- // A recreated session records its default chat with a generation query; restore materializes it under its session. -- const defaultChat = URI.parse(buildDefaultChatUri(session)).with({ query: 'generation=1' }); -- await agent.materializeChat(defaultChat, { sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(session), resource: session }, JSON.stringify({ sdkSessionId: 'restored-sdk-conversation' })); -- const provider = disposables.add(new InMemoryFileSystemProvider()); -- disposables.add(fileService.registerProvider(Schemas.file, provider)); -- await fileService.createFile(URI.file('/home/test/.copilot/session-state/restored-sdk-conversation/events.jsonl')); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'restored-session'); +- // A recreated session records its default chat with a generation query; restore materializes it under its session. +- const defaultChat = URI.parse(buildDefaultChatUri(session)).with({ query: 'generation=1' }); +- await agent.materializeChat(defaultChat, { sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(session), resource: session }, JSON.stringify({ sdkSessionId: 'restored-sdk-conversation' })); +- const provider = disposables.add(new InMemoryFileSystemProvider()); +- disposables.add(fileService.registerProvider(Schemas.file, provider)); +- await fileService.createFile(URI.file('/home/test/.copilot/session-state/restored-sdk-conversation/events.jsonl')); - -- assert.deepStrictEqual({ -- liveSessions: chatEntriesBySdkId(agent).size, -- sessionState: (await agent.getSessionStateFile(session))?.toString(), -- defaultChatState: (await agent.getSessionStateFile(session, defaultChat))?.toString(), -- defaultChatPlan: agent.getSessionPlanFile(session, defaultChat)?.toString(), -- }, { -- liveSessions: 0, -- sessionState: 'file:///home/test/.copilot/session-state/restored-sdk-conversation/events.jsonl', -- defaultChatState: 'file:///home/test/.copilot/session-state/restored-sdk-conversation/events.jsonl', -- defaultChatPlan: 'file:///home/test/.copilot/session-state/restored-sdk-conversation/plan.md', -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- liveSessions: chatEntriesBySdkId(agent).size, +- sessionState: (await agent.getSessionStateFile(session))?.toString(), +- defaultChatState: (await agent.getSessionStateFile(session, defaultChat))?.toString(), +- defaultChatPlan: agent.getSessionPlanFile(session, defaultChat)?.toString(), +- }, { +- liveSessions: 0, +- sessionState: 'file:///home/test/.copilot/session-state/restored-sdk-conversation/events.jsonl', +- defaultChatState: 'file:///home/test/.copilot/session-state/restored-sdk-conversation/events.jsonl', +- defaultChatPlan: 'file:///home/test/.copilot/session-state/restored-sdk-conversation/plan.md', +- }); - }); - - test('resolves state files from the default and peer chat SDK backings', async () => { - const { agent, fileService } = createTestAgentContext(disposables, { userHome: URI.file('/home/test') }); -- try { -- const session = AgentSession.uri('copilotcli', 'agent-host-session-id'); -- // The host stores the default chat under its session and a peer under itself. -- await agent.materializeChat(URI.parse(buildDefaultChatUri(session)), { sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(session), resource: session }, JSON.stringify({ sdkSessionId: 'sdk-conversation-id' })); -- const peerChat = URI.parse(buildChatUri(session, 'peer-1')); -- await agent.materializeChat(peerChat, { sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(session), resource: peerChat }, JSON.stringify({ sdkSessionId: 'peer-sdk-conversation-id' })); -- const defaultStateFile = URI.file('/home/test/.copilot/session-state/sdk-conversation-id/events.jsonl'); -- const peerStateFile = URI.file('/home/test/.copilot/session-state/peer-sdk-conversation-id/events.jsonl'); -- const provider = disposables.add(new InMemoryFileSystemProvider()); -- disposables.add(fileService.registerProvider(Schemas.file, provider)); -- const beforeCreate = await agent.getSessionStateFile(session); -- await fileService.createFile(defaultStateFile); -- await fileService.createFile(peerStateFile); -- -- assert.deepStrictEqual({ -- beforeCreate, -- defaultChat: (await agent.getSessionStateFile(session))?.toString(), -- explicitDefaultChat: (await agent.getSessionStateFile(session, URI.parse(buildDefaultChatUri(session))))?.toString(), -- peerChat: (await agent.getSessionStateFile(session, peerChat))?.toString(), -- defaultPlan: agent.getSessionPlanFile(session, URI.parse(buildDefaultChatUri(session)))?.toString(), -- peerPlan: agent.getSessionPlanFile(session, peerChat)?.toString(), -- unknownPeerPlan: agent.getSessionPlanFile(session, URI.parse(buildChatUri(session, 'unknown')))?.toString(), -- }, { -- beforeCreate: undefined, -- defaultChat: 'file:///home/test/.copilot/session-state/sdk-conversation-id/events.jsonl', -- explicitDefaultChat: 'file:///home/test/.copilot/session-state/sdk-conversation-id/events.jsonl', -- peerChat: 'file:///home/test/.copilot/session-state/peer-sdk-conversation-id/events.jsonl', -- defaultPlan: 'file:///home/test/.copilot/session-state/sdk-conversation-id/plan.md', -- peerPlan: 'file:///home/test/.copilot/session-state/peer-sdk-conversation-id/plan.md', -- unknownPeerPlan: undefined, -- }); -- chatBackings(agent).set(peerChat.toString(), { sdkSessionId: '../unrelated-session' }); -- assert.strictEqual(agent.getSessionPlanFile(session, peerChat), undefined); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'agent-host-session-id'); +- // The host stores the default chat under its session and a peer under itself. +- await agent.materializeChat(URI.parse(buildDefaultChatUri(session)), { sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(session), resource: session }, JSON.stringify({ sdkSessionId: 'sdk-conversation-id' })); +- const peerChat = URI.parse(buildChatUri(session, 'peer-1')); +- await agent.materializeChat(peerChat, { sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(session), resource: peerChat }, JSON.stringify({ sdkSessionId: 'peer-sdk-conversation-id' })); +- const defaultStateFile = URI.file('/home/test/.copilot/session-state/sdk-conversation-id/events.jsonl'); +- const peerStateFile = URI.file('/home/test/.copilot/session-state/peer-sdk-conversation-id/events.jsonl'); +- const provider = disposables.add(new InMemoryFileSystemProvider()); +- disposables.add(fileService.registerProvider(Schemas.file, provider)); +- const beforeCreate = await agent.getSessionStateFile(session); +- await fileService.createFile(defaultStateFile); +- await fileService.createFile(peerStateFile); +- +- assert.deepStrictEqual({ +- beforeCreate, +- defaultChat: (await agent.getSessionStateFile(session))?.toString(), +- explicitDefaultChat: (await agent.getSessionStateFile(session, URI.parse(buildDefaultChatUri(session))))?.toString(), +- peerChat: (await agent.getSessionStateFile(session, peerChat))?.toString(), +- defaultPlan: agent.getSessionPlanFile(session, URI.parse(buildDefaultChatUri(session)))?.toString(), +- peerPlan: agent.getSessionPlanFile(session, peerChat)?.toString(), +- unknownPeerPlan: agent.getSessionPlanFile(session, URI.parse(buildChatUri(session, 'unknown')))?.toString(), +- }, { +- beforeCreate: undefined, +- defaultChat: 'file:///home/test/.copilot/session-state/sdk-conversation-id/events.jsonl', +- explicitDefaultChat: 'file:///home/test/.copilot/session-state/sdk-conversation-id/events.jsonl', +- peerChat: 'file:///home/test/.copilot/session-state/peer-sdk-conversation-id/events.jsonl', +- defaultPlan: 'file:///home/test/.copilot/session-state/sdk-conversation-id/plan.md', +- peerPlan: 'file:///home/test/.copilot/session-state/peer-sdk-conversation-id/plan.md', +- unknownPeerPlan: undefined, +- }); +- chatBackings(agent).set(peerChat.toString(), { sdkSessionId: '../unrelated-session' }); +- assert.strictEqual(agent.getSessionPlanFile(session, peerChat), undefined); - }); - - test('initializes enablement before disabling the built-in GitHub MCP server at launch', async () => { @@ -52348,22 +54329,20 @@ index f24a97a6296..00000000000 - }, - }); - const session = URI.parse('ahp-session:/context7-launch'); -- try { -- await agent.authenticate('https://api.github.com', 'github-token'); -- const disabled = await agent['_disabledRootMcpServers'](session, { discoveredAgents: [], additionalRootInstructions: [], tools: [], plugins: [], mcpServers: {} }); -- assert.deepStrictEqual({ initializedSession, disabled }, { -- initializedSession: session.toString(), -- disabled: ['context7'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'github-token'); +- const disabled = await agent['_disabledRootMcpServers'](session, { discoveredAgents: [], additionalRootInstructions: [], tools: [], plugins: [], mcpServers: {} }); +- assert.deepStrictEqual({ initializedSession, disabled }, { +- initializedSession: session.toString(), +- disabled: ['context7'], +- }); - }); - } - - test('launch uses the owning AHP customization identity for session-scoped MCP disablement with an independent SDK backing', async () => { - const client = new TestCopilotClient([]); - client.mcpConfigListResult = { servers: { context7: { type: 'http', url: 'https://mcp.example.com/context7' } } }; +- client.discoverMcp = async () => ({ servers: [{ name: 'native', type: 'stdio', source: 'plugin', enabled: true }] }); - let launchConfig: SessionConfig | undefined; - client.createSession = async config => { - launchConfig = config; @@ -52384,35 +54363,477 @@ index f24a97a6296..00000000000 - defaultChat: buildDefaultChatUri(session.toString()), - }); - const enablementService = instantiationService.invokeFunction(accessor => accessor.get(IAgentHostCustomizationEnablementService)); -- try { -- await agent.authenticate('https://api.github.com', 'github-token'); -- await enablementService.initializeSession(session.toString()); -- enablementService.setEnablement(session.toString(), { -- id, type: CustomizationType.McpServer, name: 'context7', source: URI.parse(id), -- }, CustomizationEnablementKind.Session, false); -- await enablementService.whenIdle(); -- const chat = defaultChatUri(session); -- const result = await agent.chats.createChat(chat, exactChatContext(session, chat, session), { -- workingDirectories: [URI.file('/workspace')], -- deferBacking: false, +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'github-token'); +- await enablementService.initializeSession(session.toString()); +- enablementService.setEnablement(session.toString(), { +- id, type: CustomizationType.McpServer, name: 'context7', source: URI.parse(id), +- }, CustomizationEnablementKind.Session, false); +- enablementService.setEnablement(session.toString(), { +- id: 'mcp-top-level:copilotcli:context7-launch:native', type: CustomizationType.McpServer, +- name: 'native', source: URI.parse('agent-discovery://copilot/mcp#native'), defaultEnabled: true, +- }, CustomizationEnablementKind.Session, false); +- await enablementService.whenIdle(); +- const chat = defaultChatUri(session); +- const result = await agent.chats.createChat(chat, exactChatContext(session, chat, session), { +- workingDirectories: [URI.file('/workspace')], +- deferBacking: false, +- }); +- assert.ok(result); +- +- assert.deepStrictEqual({ +- session: session.toString(), +- separateBacking: result.backingSession !== undefined && AgentSession.id(result.backingSession) !== AgentSession.id(session), +- disabledMcpServers: launchConfig?.disabledMcpServers, +- }, { +- session: 'ahp-session:/context7-launch', +- separateBacking: true, +- disabledMcpServers: ['context7', 'native'], +- }); +- }); +- +- suite('discovered SDK customizations', () => { +- for (const scope of [CustomizationEnablementKind.Global, CustomizationEnablementKind.Workspace, CustomizationEnablementKind.Session]) { +- test(`runtime-first native MCP keeps real ${scope} disablement after ownership adoption`, async () => { +- const fileService = disposables.add(new FileService(new NullLogService())); +- disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); +- const workspace = URI.file('/workspace'); +- const plugin = URI.file('/sdk-installed/native'); +- const source = URI.joinPath(plugin, '.mcp.json'); +- await fileService.writeFile(URI.joinPath(plugin, '.plugin', 'plugin.json'), VSBuffer.fromString('{"name":"native"}')); +- await fileService.writeFile(source, VSBuffer.fromString('{"mcpServers":{"native":{"command":"native-server"}}}')); +- const client = new TestCopilotClient([]); +- let discovered = false; +- client.discoverMcp = async () => ({ +- servers: discovered ? [{ +- name: 'native', type: 'stdio', source: 'plugin', enabled: true, +- effectiveSource: { kind: 'plugin', id: 'opaque-source', editability: 'read-only', plugin: { id: 'opaque-plugin', name: 'native' }, file: { uri: source.toString() } }, +- }] : [], +- }); +- const { agent, instantiationService, stateManager } = createTestAgentContext(disposables, { +- fileService, copilotClient: client, sessionDataService: disposables.add(new TestSessionDataService()), +- useRealCustomizationEnablementService: true, +- rootConfig: { [AgentHostGitHubMcpServerEnabledConfigKey]: false }, +- }); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const sessionUri = AgentSession.uri('copilotcli', 'test-session-1'); +- await provisionSession(agent, { session: sessionUri, workingDirectories: [workspace] }); +- stateManager.createSession({ +- resource: sessionUri.toString(), provider: 'copilotcli', title: 'Test', status: SessionStatus.Idle, +- createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), workingDirectories: [workspace.toString()], +- defaultChat: buildDefaultChatUri(sessionUri.toString()), +- }); +- const enablement = instantiationService.invokeFunction(accessor => accessor.get(IAgentHostCustomizationEnablementService)); +- await enablement.initializeSession(sessionUri.toString()); +- disposables.add(agent.onDidChatProgress(signal => { +- if (signal.kind === 'action' && signal.resource.toString() === sessionUri.toString()) { +- stateManager.dispatchServerAction(sessionUri.toString(), signal.action as SessionAction); +- } +- })); +- const activeClient = agent['_getOrCreateActiveClient'](sessionUri, workspace); +- const { session } = createAgentSessionThroughAgent(agent, instantiationService, { workingDirectory: workspace }); +- agent['_registerLiveChat'](URI.parse(buildDefaultChatUri(sessionUri)), session, activeClient); +- await getDefaultChatCustomizations(agent, sessionUri); +- session['_mcpCustomizations'].applyOne({ name: 'native', state: { kind: McpServerStatus.Ready } }); +- const live = session.topLevelMcpCustomizations()[0]; +- enablement.setEnablement(sessionUri.toString(), { id: live.id, type: CustomizationType.McpServer, name: live.name, source: URI.parse(live.uri) }, scope, false); +- discovered = true; +- await activeClient.pluginController.refreshDiscovery(); +- const published = (await getDefaultChatCustomizations(agent, sessionUri)).filter(item => item.type === CustomizationType.McpServer); +- session['_mcpCustomizations'].applyOne({ name: 'native', state: { kind: McpServerStatus.Stopped } }); +- const updated = session.topLevelMcpCustomizations(); +- assert.deepStrictEqual([published, updated].map(servers => servers.map(server => ({ +- id: server.id, uri: server.uri, enabled: isCustomizationEnabled(server), decision: server.enablement?.find(entry => entry.kind === scope)?.enabled, +- }))), [ +- [{ id: live.id, uri: source.toString(), enabled: false, decision: false }], +- [{ id: live.id, uri: source.toString(), enabled: false, decision: false }], +- ]); - }); -- assert.ok(result); +- } - -- assert.deepStrictEqual({ -- session: session.toString(), -- separateBacking: result.backingSession !== undefined && AgentSession.id(result.backingSession) !== AgentSession.id(session), -- disabledMcpServers: launchConfig?.disabledMcpServers, -- }, { -- session: 'ahp-session:/context7-launch', -- separateBacking: true, -- disabledMcpServers: ['context7'], +- test('runtime-first native MCP inventory acquires late discovery metadata and plugin ownership', async () => { +- const fileService = createInMemoryFileService(disposables, Schemas.file); +- const workspace = URI.file('/workspace'); +- const plugin = URI.file('/sdk-installed/native'); +- const source = URI.joinPath(plugin, '.mcp.json'); +- await fileService.writeFile(URI.joinPath(plugin, '.plugin', 'plugin.json'), VSBuffer.fromString('{"name":"native"}')); +- await fileService.writeFile(source, VSBuffer.fromString('{"mcpServers":{"native":{"command":"native-server"}}}')); +- const client = new TestCopilotClient([]); +- let discovered = false; +- let hostDecision: boolean | undefined; +- client.discoverMcp = async () => ({ +- servers: discovered ? [{ +- name: 'native', type: 'stdio', source: 'plugin', enabled: false, +- effectiveSource: { kind: 'plugin', id: 'opaque-source', editability: 'read-only', plugin: { id: 'opaque-plugin', name: 'native' }, file: { uri: source.toString() } }, +- }] : [], +- }); +- const { agent, instantiationService, stateManager } = createTestAgentContext(disposables, { +- fileService, copilotClient: client, sessionDataService: disposables.add(new TestSessionDataService()), +- rootConfig: { [AgentHostGitHubMcpServerEnabledConfigKey]: false }, +- customizationEnablementService: { +- ...createNoopCustomizationEnablementService(), +- resolve: (_session, target) => ({ +- kind: 'resolved', workingDirectory: { kind: 'directory', uri: workspace }, +- enabled: hostDecision ?? target.defaultEnabled ?? true, +- enablement: (hostDecision ?? target.defaultEnabled) === undefined ? [] : [{ kind: CustomizationEnablementKind.Global, enabled: hostDecision ?? target.defaultEnabled! }], +- }), +- }, +- }); +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- const sessionUri = AgentSession.uri('copilotcli', 'test-session-1'); +- await provisionSession(agent, { session: sessionUri, workingDirectories: [workspace] }); +- stateManager.createSession({ +- resource: sessionUri.toString(), provider: 'copilotcli', title: 'Test', status: SessionStatus.Idle, +- createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), workingDirectories: [workspace.toString()], +- defaultChat: buildDefaultChatUri(sessionUri.toString()), +- }); +- disposables.add(agent.onDidChatProgress(signal => { +- if (signal.kind === 'action' && signal.resource.toString() === sessionUri.toString()) { +- stateManager.dispatchServerAction(sessionUri.toString(), signal.action as SessionAction); +- } +- })); +- const activeClient = agent['_getOrCreateActiveClient'](sessionUri, workspace); +- const { session } = createAgentSessionThroughAgent(agent, instantiationService, { workingDirectory: workspace }); +- agent['_registerLiveChat'](URI.parse(buildDefaultChatUri(sessionUri)), session, activeClient); +- await getDefaultChatCustomizations(agent, sessionUri); +- session['_mcpCustomizations'].applyOne({ name: 'native', state: { kind: McpServerStatus.Ready } }); +- const retainedId = session.topLevelMcpCustomizations()[0].id; +- discovered = true; +- await activeClient.pluginController.refreshDiscovery(); +- const summarize = (customizations: readonly Customization[]) => customizations.filter(item => item.type === CustomizationType.McpServer).map(item => ({ +- id: item.id, uri: item.uri, enabled: isCustomizationEnabled(item), state: item.state.kind, +- defaultEnabled: readCustomizationDiscoveryMeta(item)?.defaultEnabled, +- pluginId: readCustomizationDiscoveryMeta(item)?.pluginId, +- })); +- const published = summarize(await getDefaultChatCustomizations(agent, sessionUri)); +- session['_mcpCustomizations'].applyOne({ name: 'native', state: { kind: McpServerStatus.Stopped } }); +- assert.deepStrictEqual({ +- published, afterRuntimeUpdate: summarize(stateManager.getSessionState(sessionUri.toString())?.customizations ?? []), +- owner: session.mcpServerOwners()?.get('native'), +- }, { +- published: [{ id: retainedId, uri: source.toString(), enabled: false, state: McpServerStatus.Ready, defaultEnabled: false, pluginId: 'opaque-plugin' }], +- afterRuntimeUpdate: [{ id: retainedId, uri: source.toString(), enabled: false, state: McpServerStatus.Stopped, defaultEnabled: false, pluginId: 'opaque-plugin' }], +- owner: plugin.toString(), +- }); +- session['_mcpCustomizations'].applyOne({ name: 'native', state: { kind: McpServerStatus.Stopped }, enabled: true, defaultEnabled: true }); +- await activeClient.pluginController.refreshDiscovery(); +- const afterObservedDefault = await getDefaultChatCustomizations(agent, sessionUri); +- hostDecision = false; +- const withHostDecision = summarize(await getDefaultChatCustomizations(agent, sessionUri)); +- assert.deepStrictEqual({ +- inherited: summarize(afterObservedDefault), +- childDefaults: afterObservedDefault.flatMap(item => item.type === CustomizationType.Plugin ? item.children ?? [] : []).filter(child => child.type === CustomizationType.McpServer).map(child => readCustomizationDiscoveryMeta(child)?.defaultEnabled), +- withHostDecision, +- }, { +- inherited: [{ id: retainedId, uri: source.toString(), enabled: true, state: McpServerStatus.Stopped, defaultEnabled: true, pluginId: 'opaque-plugin' }], +- childDefaults: [true], +- withHostDecision: [{ id: retainedId, uri: source.toString(), enabled: false, state: McpServerStatus.Stopped, defaultEnabled: true, pluginId: 'opaque-plugin' }], +- }); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- for (const hasSkill of [false, true]) { +- test(`deduplicates nested native MCP declarations without discarding unrelated contributions (${hasSkill})`, async () => { +- const fileService = createInMemoryFileService(disposables, Schemas.file); +- const workspace = URI.file('/workspace'); +- const plugin = URI.file('/sdk-installed/native'); +- const source = URI.joinPath(plugin, '.mcp.json'); +- const skill = URI.joinPath(plugin, 'skills', 'native', 'SKILL.md'); +- await fileService.writeFile(URI.joinPath(workspace, '.mcp.json'), VSBuffer.fromString('{"mcpServers":{"native":{"command":"client-server"}}}')); +- await fileService.writeFile(URI.joinPath(plugin, '.plugin', 'plugin.json'), VSBuffer.fromString('{"name":"native"}')); +- await fileService.writeFile(source, VSBuffer.fromString('{"mcpServers":{"native":{"command":"native-server"}}}')); +- if (hasSkill) { +- await fileService.writeFile(skill, VSBuffer.fromString('---\nname: native\ndescription: Native skill\n---\n')); +- } +- const client = new TestCopilotClient([]); +- client.discoverMcp = async () => ({ +- servers: [{ +- name: 'native', type: 'stdio', source: 'plugin', enabled: true, +- effectiveSource: { kind: 'plugin', id: 'opaque', editability: 'read-only', file: { uri: source.toString() } }, +- }] +- }); +- client.discoverSkills = async () => ({ +- skills: hasSkill ? [{ +- name: 'native', description: 'Native skill', path: skill.fsPath, source: 'plugin', enabled: true, userInvocable: true, +- }] : [] +- }); +- const { agent } = createTestAgentContext(disposables, { fileService, copilotClient: client, rootConfig: { [AgentHostGitHubMcpServerEnabledConfigKey]: false } }); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', `dedup-${hasSkill}`); +- await provisionSession(agent, { session, workingDirectories: [workspace] }); +- const customizations = await getDefaultChatCustomizations(agent, session); +- assert.deepStrictEqual({ +- servers: customizations.flatMap(item => item.type === CustomizationType.McpServer ? [item.uri] : item.children?.filter(child => child.type === CustomizationType.McpServer).map(child => child.uri) ?? []), +- plugins: customizations.filter(item => item.type === CustomizationType.Plugin).map(item => item.children?.map(child => child.type)), +- }, { servers: [URI.joinPath(workspace, '.mcp.json').toString()], plugins: hasSkill ? [[CustomizationType.Skill]] : [] }); - }); -- } finally { -- await disposeAgent(agent); - } -- }); - -- suite('discovered SDK customizations', () => { +- test('refreshes SDK hooks and MCP inventory without forwarding definitions or manufacturing runtime intent', async () => { +- const fileService = createInMemoryFileService(disposables, Schemas.file); +- const workspace = URI.file('/remote-workspace'); +- const client = new TestCopilotClient([]); +- let installed = true; +- let enabled = false; +- let hostDecision: boolean | undefined; +- client.discoverHooks = async params => ({ +- hooks: installed ? [{ id: 'opaque-hook', hookType: 'sessionStart', origin: 'plugin', source: params.projectPaths?.[0], enabled }] : [], +- warnings: [], errors: [], +- }); +- client.discoverMcp = async () => ({ servers: installed ? [{ name: 'native', type: 'stdio', source: 'plugin', sourcePlugin: 'mcp-only', enabled }] : [] }); +- const { agent, instantiationService, stateManager } = createTestAgentContext(disposables, { +- fileService, copilotClient: client, rootConfig: { [AgentHostGitHubMcpServerEnabledConfigKey]: false }, +- sessionDataService: disposables.add(new TestSessionDataService()), +- customizationEnablementService: { +- ...createNoopCustomizationEnablementService(), +- resolve: (_session, target) => ({ +- kind: 'resolved', workingDirectory: { kind: 'directory', uri: workspace }, +- enabled: hostDecision ?? target.defaultEnabled ?? true, +- enablement: (hostDecision ?? target.defaultEnabled) === undefined ? [] : [{ kind: CustomizationEnablementKind.Global, enabled: hostDecision ?? target.defaultEnabled! }], +- }), +- }, +- }); +- const apply = stub(agent['_customizationInstallations'], 'apply').resolves(undefined); +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- const sessionUri = AgentSession.uri('copilotcli', 'test-session-1'); +- await provisionSession(agent, { session: sessionUri, workingDirectories: [workspace] }); +- stateManager.createSession({ +- resource: sessionUri.toString(), provider: 'copilotcli', title: 'Test', status: SessionStatus.Idle, +- createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), workingDirectories: [workspace.toString()], +- defaultChat: buildDefaultChatUri(sessionUri.toString()), +- }); +- const initial = await getDefaultChatCustomizations(agent, sessionUri); +- agent['_hostCustomizations'].set(sessionUri, initial); +- stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionCustomizationsChanged, customizations: [...initial] }); +- const { session } = createAgentSessionThroughAgent(agent, instantiationService, { workingDirectory: workspace }); +- disposables.add(session); +- const initialIntent = [...session['_getDesiredMcpServerEnablementByName']()]; +- const activeClient = agent['_getOrCreateActiveClient'](sessionUri, workspace); +- const inheritedDisabled = (await activeClient.snapshot(defaultChatUri(sessionUri).toString())).disabledNativeMcpServers; +- const summarize = (customizations: readonly Customization[]) => customizations.flatMap(item => { +- if (item.type === CustomizationType.McpServer) { +- return [{ name: item.name, enabled: isCustomizationEnabled(item) }]; +- } +- return (item.children ?? []).filter(child => child.type === CustomizationType.Hook).map(child => ({ name: child.name, enabled: child.enabled })); +- }); +- enabled = true; +- await agent.applyCustomizationInstallation('enable'); +- const refreshed = await getDefaultChatCustomizations(agent, sessionUri); +- agent['_hostCustomizations'].set(sessionUri, refreshed); +- stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionCustomizationsChanged, customizations: [...refreshed] }); +- const refreshedIntent = [...session['_getDesiredMcpServerEnablementByName']()]; +- hostDecision = false; +- const explicitIntent = [...session['_getDesiredMcpServerEnablementByName']()]; +- const disabledSnapshot = await activeClient.snapshot(defaultChatUri(sessionUri).toString()); +- const disabledAtLaunch = await agent['_disabledRootMcpServers'](sessionUri, disabledSnapshot); +- hostDecision = undefined; +- const snapshot = await agent['_getOrCreateActiveClient'](sessionUri, workspace).snapshot(defaultChatUri(sessionUri).toString()); +- installed = false; +- await agent.applyCustomizationInstallation('uninstall'); +- const removed = await getDefaultChatCustomizations(agent, sessionUri); +- assert.deepStrictEqual({ +- initial: summarize(initial), refreshed: summarize(refreshed), removed: summarize(removed), +- initialIntent, refreshedIntent, explicitIntent, inheritedDisabled, disabledAtLaunch, +- forwarded: { plugins: snapshot.plugins, mcpServers: snapshot.mcpServers }, +- }, { +- initial: [{ name: 'sessionStart', enabled: false }, { name: 'native', enabled: false }], +- refreshed: [{ name: 'sessionStart', enabled: true }, { name: 'native', enabled: true }], +- removed: [], initialIntent: [], refreshedIntent: [], explicitIntent: [['native', false]], +- inheritedDisabled: undefined, disabledAtLaunch: ['native'], +- forwarded: { plugins: [], mcpServers: {} }, +- }); +- } finally { +- apply.restore(); +- await disposeAgent(agent); +- } +- }); +- +- for (const operation of ['catalog', 'legacy', 'enablement'] as const) { +- test(`${operation} plugin mutations refresh and publish every session and chat anchor`, async () => { +- const fileService = createInMemoryFileService(disposables, Schemas.file); +- const plugin = URI.file('/sdk-plugins/session-timeline'); +- const skill = URI.joinPath(plugin, 'skills', 'session-timeline', 'SKILL.md'); +- await fileService.writeFile(URI.joinPath(plugin, '.plugin', 'plugin.json'), VSBuffer.fromString(JSON.stringify({ name: 'session-timeline' }))); +- await fileService.writeFile(skill, VSBuffer.fromString('---\nname: session-timeline\ndescription: Timeline\n---\n')); +- let installed = true; +- const client = new TestCopilotClient([]); +- client.discoverSkills = async () => ({ +- skills: installed ? [{ name: 'session-timeline', description: 'Timeline', path: skill.fsPath, source: 'plugin', enabled: true, userInvocable: true }] : [], +- }); +- const { agent } = createTestAgentContext(disposables, { copilotClient: client, fileService }); +- const names = (customizations: readonly Customization[]) => customizations +- .filter(customization => customization.type === CustomizationType.Plugin) +- .map(customization => customization.name); +- const published = new Map(); +- disposables.add(agent.onDidChatProgress(progress => { +- if (progress.kind === 'action' && progress.action.type === ActionType.SessionCustomizationsChanged) { +- published.set(progress.resource.toString(), names(progress.action.customizations)); +- } +- })); +- const apply = stub(agent['_customizationInstallations'], 'apply').callsFake(async operationId => { +- installed = operationId === 'install'; +- return undefined; +- }); +- const install = stub(client.rpc.plugins, 'install').callsFake(async () => { +- installed = true; +- return { plugin: { name: 'session-timeline', marketplace: 'test', version: '1.0.0', enabled: true }, skillsInstalled: 1 }; +- }); +- const uninstall = stub(client.rpc.plugins, 'uninstall').callsFake(async () => { +- installed = false; +- }); +- const list = stub(client.rpc.plugins, 'list').callsFake(async () => ({ +- plugins: [{ name: 'session-timeline', marketplace: 'test', version: '1.0.0', enabled: installed }], +- })); +- const enable = stub(client.rpc.plugins, 'enable').callsFake(async () => { installed = true; }); +- const disable = stub(client.rpc.plugins, 'disable').callsFake(async () => { installed = false; }); +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- const sessions = ['first', 'second'].map(id => AgentSession.uri('copilotcli', `plugin-refresh-${id}`)); +- const initial: string[][] = []; +- for (const session of sessions) { +- await provisionSession(agent, { session, workingDirectories: [URI.file(`/workspace/${AgentSession.id(session)}`)] }); +- initial.push(names(await getDefaultChatCustomizations(agent, session))); +- } +- const controller = agent['_activeClients'].get(sessions[0])!.pluginController; +- const peerAnchor = disposables.add(controller.acquireAnchor(URI.file('/peer-workspace'), [], false)); +- await peerAnchor.object.discoveredEntry()!.whenSettled(); +- const peerNames = () => names(peerAnchor.object.discoveredEntry()!.currentCustomizations()); +- assert.deepStrictEqual({ initial, peer: peerNames() }, { +- initial: [['session-timeline'], ['session-timeline']], +- peer: ['session-timeline'], +- }); +- const results: { published: (readonly string[] | undefined)[]; peer: string[] }[] = []; +- for (const action of ['uninstall', 'install'] as const) { +- published.clear(); +- if (operation === 'catalog') { +- await agent.applyCustomizationInstallation(action); +- } else if (operation === 'enablement') { +- await agent.setPluginEnabled('session-timeline@test', action === 'install'); +- } else if (action === 'uninstall') { +- await agent.uninstallPlugin({ name: 'session-timeline', marketplace: 'test' }); +- } else { +- await agent.installPlugin({ source: 'session-timeline@test' }); +- } +- results.push({ published: sessions.map(session => published.get(session.toString())), peer: peerNames() }); +- } +- assert.deepStrictEqual(results, [ +- { published: [[], []], peer: [] }, +- { published: [['session-timeline'], ['session-timeline']], peer: ['session-timeline'] }, +- ]); +- } finally { +- apply.restore(); +- install.restore(); +- uninstall.restore(); +- list.restore(); +- enable.restore(); +- disable.restore(); +- await disposeAgent(agent); +- } +- }); +- } +- +- test('plugin uninstall waits for superseding discovery scans to publish', async () => { +- const fileService = createInMemoryFileService(disposables, Schemas.file); +- const plugin = URI.file('/sdk-plugins/session-timeline'); +- const skill = URI.joinPath(plugin, 'skills', 'session-timeline', 'SKILL.md'); +- await fileService.writeFile(URI.joinPath(plugin, '.plugin', 'plugin.json'), VSBuffer.fromString(JSON.stringify({ name: 'session-timeline' }))); +- await fileService.writeFile(skill, VSBuffer.fromString('---\nname: session-timeline\ndescription: Timeline\n---\n')); +- const scans = Array.from({ length: 3 }, () => ({ +- started: new DeferredPromise(), +- release: new DeferredPromise(), +- })); +- let installed = true; +- let scanIndex = 0; +- const client = new TestCopilotClient([]); +- client.discoverSkills = async () => { +- if (installed) { +- return { skills: [{ name: 'session-timeline', description: 'Timeline', path: skill.fsPath, source: 'plugin', enabled: true, userInvocable: true }] }; +- } +- const scan = scans[scanIndex++]; +- scan.started.complete(); +- await scan.release.p; +- return { skills: [] }; +- }; +- const { agent } = createTestAgentContext(disposables, { copilotClient: client, fileService }); +- const apply = stub(agent['_customizationInstallations'], 'apply').callsFake(async () => { +- installed = false; +- return undefined; +- }); +- const published: string[][] = []; +- disposables.add(agent.onDidChatProgress(progress => { +- if (progress.kind === 'action' && progress.action.type === ActionType.SessionCustomizationsChanged) { +- published.push(progress.action.customizations.filter(item => item.type === CustomizationType.Plugin).map(item => item.name)); +- } +- })); +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'superseded-plugin-refresh'); +- const directory = URI.file('/workspace'); +- await provisionSession(agent, { session, workingDirectories: [directory] }); +- const initial = (await getDefaultChatCustomizations(agent, session)) +- .filter(item => item.type === CustomizationType.Plugin).map(item => item.name); +- const controller = agent['_activeClients'].get(session)!.pluginController; +- const anchor = disposables.add(controller.acquireAnchor(directory, [], false)); +- const entry = anchor.object.discoveredEntry()!; +- published.length = 0; +- let operationCompleted = false; +- let settledCompleted = false; +- const operation = agent.applyCustomizationInstallation('uninstall').then(() => { +- operationCompleted = true; +- return [...published]; +- }); +- await scans[0].started.p; +- const settled = entry.whenSettled().then(() => { settledCompleted = true; }); +- const replacements: Promise[] = []; +- const completionStates: boolean[][] = []; +- for (const scan of scans.slice(1)) { +- replacements.push(entry.refresh()); +- await scan.started.p; +- completionStates.push([operationCompleted, settledCompleted]); +- } +- await scans[2].release.complete(); +- const [publishedAtCompletion] = await Promise.all([operation, settled, ...replacements]); +- assert.deepStrictEqual({ initial, completionStates, publishedAtCompletion }, { +- initial: ['session-timeline'], +- completionStates: [[false, false], [false, false]], +- publishedAtCompletion: [[]], +- }); +- } finally { +- for (const scan of scans) { +- await scan.release.complete(); +- } +- apply.restore(); +- await disposeAgent(agent); +- } +- }); +- +- test('failed customization operations do not refresh discovery', async () => { +- const client = new TestCopilotClient([]); +- const agent = createTestAgent(disposables, { copilotClient: client }); +- const failure = new Error('SDK uninstall failed'); +- const apply = stub(agent['_customizationInstallations'], 'apply').rejects(failure); +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'failed-plugin-refresh'); +- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); +- await getDefaultChatCustomizations(agent, session); +- const requests = client.skillDiscoveryRequests.length; +- +- await assert.rejects(agent.applyCustomizationInstallation('uninstall'), error => error === failure); +- +- assert.strictEqual(client.skillDiscoveryRequests.length, requests); +- } finally { +- apply.restore(); +- await disposeAgent(agent); +- } +- }); +- - test('uses platform path casing for additional-root instructions after drive-letter normalization', () => { - const primary = URI.from({ scheme: Schemas.file, path: '/C:/workspace' }); - const secondary = URI.from({ scheme: Schemas.file, path: '/C:/additional' }); @@ -52625,360 +55046,321 @@ index f24a97a6296..00000000000 - for (const multiRoot of [false, true]) { - test(`a peer in the session's second folder gets that folder's agents, instructions and MCP servers, and the default chat is unchanged (multi-root: ${multiRoot})`, async () => { - const fixture = await createAnchorFixture(multiRoot); -- try { -- const { agent, f1, f2 } = fixture; -- const peer = await fixture.createPeer('peer-f2', f2); -- const multiFolderPeer = await fixture.createPeer('peer-f2-f1', f2); -- await agent.chats.sendMessage(multiFolderPeer, 'Work in both folders', [f2, f1], undefined, undefined, undefined, fixture.context(multiFolderPeer)); -- const name = (folder: URI) => basename(folder.fsPath); -- const folderView = (folders: readonly URI[]) => ({ -- agents: folders.map(folder => `${name(folder)}-agent`).sort(), -- instructions: folders.slice(1).map(folder => `${name(folder)}.instructions.md`).sort(), -- mcpServers: folders.map(folder => `${name(folder)}-server`).sort(), -- }); -- const sessionFolders = multiRoot ? [f1, f2] : [f1]; -- assert.deepStrictEqual({ -- peer: fixture.applied(peer), -- multiFolderPeer: fixture.applied(multiFolderPeer), -- main: fixture.applied(fixture.main), -- published: await fixture.published(), -- }, { -- peer: folderView([f2]), -- multiFolderPeer: folderView([f2, f1]), -- main: folderView(sessionFolders), -- published: { agents: folderView(sessionFolders).agents, mcpServers: folderView(sessionFolders).mcpServers }, -- }); -- } finally { -- await fixture.dispose(); -- } -- }); -- } -- -- test('the default chat follows a chat-only move to another folder, sharing a peer\'s discovery there, while older builds still get the shared folders', async () => { -- const fixture = await createAnchorFixture(false); -- try { -- const { agent, main, f1, f2 } = fixture; +- afterTest(() => fixture.dispose()); +- const { agent, f1, f2 } = fixture; - const peer = await fixture.createPeer('peer-f2', f2); -- const before = { main: fixture.applied(main), published: await fixture.published() }; -- await agent.setChatWorkingDirectory(main, fixture.context(main), f2); -- const publishedAfterMove = await fixture.published(); -- await agent.chats.sendMessage(main, 'Continue in the second folder', [f2], undefined, undefined, undefined, fixture.context(main)); -- const db = fixture.sessionDataService.openDatabase(fixture.session); -- let sharedRoots: string | undefined; -- try { -- sharedRoots = await db.object.getMetadata('copilot.sharedCustomizationDirectories'); -- } finally { -- db.dispose(); -- } +- const multiFolderPeer = await fixture.createPeer('peer-f2-f1', f2); +- await agent.chats.sendMessage(multiFolderPeer, 'Work in both folders', [f2, f1], undefined, undefined, undefined, fixture.context(multiFolderPeer)); +- const name = (folder: URI) => basename(folder.fsPath); +- const folderView = (folders: readonly URI[]) => ({ +- agents: folders.map(folder => `${name(folder)}-agent`).sort(), +- instructions: folders.slice(1).map(folder => `${name(folder)}.instructions.md`).sort(), +- mcpServers: folders.map(folder => `${name(folder)}-server`).sort(), +- }); +- const sessionFolders = multiRoot ? [f1, f2] : [f1]; - assert.deepStrictEqual({ -- before, -- publishedAfterMove, -- main: fixture.applied(main), - peer: fixture.applied(peer), +- multiFolderPeer: fixture.applied(multiFolderPeer), +- main: fixture.applied(fixture.main), - published: await fixture.published(), -- f2DiscoveryRuns: fixture.discoveryRuns([f2]), -- sharedRoots, - }, { -- before: { -- main: { agents: ['f1-agent'], instructions: [], mcpServers: ['f1-server'] }, -- published: { agents: ['f1-agent'], mcpServers: ['f1-server'] }, -- }, -- publishedAfterMove: { agents: ['f2-agent'], mcpServers: ['f2-server'] }, -- main: { agents: ['f2-agent'], instructions: [], mcpServers: ['f2-server'] }, -- peer: { agents: ['f2-agent'], instructions: [], mcpServers: ['f2-server'] }, -- published: { agents: ['f2-agent'], mcpServers: ['f2-server'] }, -- f2DiscoveryRuns: 1, -- sharedRoots: JSON.stringify([f1.toString()]), +- peer: folderView([f2]), +- multiFolderPeer: folderView([f2, f1]), +- main: folderView(sessionFolders), +- published: { agents: folderView(sessionFolders).agents, mcpServers: folderView(sessionFolders).mcpServers }, - }); +- }); +- } +- +- test('the default chat follows a chat-only move to another folder, sharing a peer\'s discovery there, while older builds still get the shared folders', async () => { +- const fixture = await createAnchorFixture(false); +- afterTest(() => fixture.dispose()); +- const { agent, main, f1, f2 } = fixture; +- const peer = await fixture.createPeer('peer-f2', f2); +- const before = { main: fixture.applied(main), published: await fixture.published() }; +- await agent.setChatWorkingDirectory(main, fixture.context(main), f2); +- const publishedAfterMove = await fixture.published(); +- await agent.chats.sendMessage(main, 'Continue in the second folder', [f2], undefined, undefined, undefined, fixture.context(main)); +- const db = fixture.sessionDataService.openDatabase(fixture.session); +- let sharedRoots: string | undefined; +- try { +- sharedRoots = await db.object.getMetadata('copilot.sharedCustomizationDirectories'); - } finally { -- await fixture.dispose(); +- db.dispose(); - } +- assert.deepStrictEqual({ +- before, +- publishedAfterMove, +- main: fixture.applied(main), +- peer: fixture.applied(peer), +- published: await fixture.published(), +- f2DiscoveryRuns: fixture.discoveryRuns([f2]), +- sharedRoots, +- }, { +- before: { +- main: { agents: ['f1-agent'], instructions: [], mcpServers: ['f1-server'] }, +- published: { agents: ['f1-agent'], mcpServers: ['f1-server'] }, +- }, +- publishedAfterMove: { agents: ['f2-agent'], mcpServers: ['f2-server'] }, +- main: { agents: ['f2-agent'], instructions: [], mcpServers: ['f2-server'] }, +- peer: { agents: ['f2-agent'], instructions: [], mcpServers: ['f2-server'] }, +- published: { agents: ['f2-agent'], mcpServers: ['f2-server'] }, +- f2DiscoveryRuns: 1, +- sharedRoots: JSON.stringify([f1.toString()]), +- }); - }); - - test('the published view stays the default chat\'s when a peer in another folder starts first after a restart', async () => { - const fixture = await createAnchorFixture(false); -- try { -- const { main, f1, f2 } = fixture; -- await fixture.restart(); -- const peer = await fixture.createPeer('peer-f2', f2, { sessionSettingsUnloaded: true }); -- await fixture.agent.chats.sendMessage(main, 'Continue in the first folder', [f1], undefined, undefined, undefined, fixture.context(main)); -- assert.deepStrictEqual({ peer: fixture.applied(peer).agents, main: fixture.applied(main).agents, published: await fixture.published() }, { -- peer: ['f2-agent'], -- main: ['f1-agent'], -- published: { agents: ['f1-agent'], mcpServers: ['f1-server'] }, -- }); -- } finally { -- await fixture.dispose(); -- } +- afterTest(() => fixture.dispose()); +- const { main, f1, f2 } = fixture; +- await fixture.restart(); +- const peer = await fixture.createPeer('peer-f2', f2, { sessionSettingsUnloaded: true }); +- await fixture.agent.chats.sendMessage(main, 'Continue in the first folder', [f1], undefined, undefined, undefined, fixture.context(main)); +- assert.deepStrictEqual({ peer: fixture.applied(peer).agents, main: fixture.applied(main).agents, published: await fixture.published() }, { +- peer: ['f2-agent'], +- main: ['f1-agent'], +- published: { agents: ['f1-agent'], mcpServers: ['f1-server'] }, +- }); - }); - - test('a peer selecting an agent the session\'s picker lists but its own folder lacks resolves and runs with it, keeping its own folder\'s instructions', async () => { - const fixture = await createAnchorFixture(false); -- try { -- const { agent, f1, f2 } = fixture; -- const peer = await fixture.createPeer('peer-f2', f2); -- // The picker lists the session's (default chat's) agents: here `f1-agent`. -- const listed = (await fixture.published()).agents; -- await agent.chats.changeAgent(peer, { uri: URI.joinPath(f1, '.github', 'agents', 'f1.agent.md').toString() }, fixture.context(peer)); -- await agent.chats.sendMessage(peer, 'Use the first folder\'s agent', [f2], undefined, undefined, undefined, fixture.context(peer)); -- const peerSdkSessionId = getPeerChatStub(agent, peer)!.sessionId; -- const launch = fixture.launches.filter(candidate => candidate.sdkSessionId === peerSdkSessionId).at(-1); -- const customAgents = launch?.customAgents.filter(name => !COPILOT_BUILTIN_AGENT_NAMES.has(name)); -- assert.deepStrictEqual({ listed, launch: { agent: launch?.agent, customAgents }, applied: fixture.applied(peer) }, { -- listed: ['f1-agent'], -- launch: { agent: 'f1-agent', customAgents: ['f1-agent', 'f2-agent'] }, -- applied: { agents: ['f1-agent', 'f2-agent'], instructions: [], mcpServers: ['f2-server'] }, -- }); -- } finally { -- await fixture.dispose(); -- } +- afterTest(() => fixture.dispose()); +- const { agent, f1, f2 } = fixture; +- const peer = await fixture.createPeer('peer-f2', f2); +- // The picker lists the session's (default chat's) agents: here `f1-agent`. +- const listed = (await fixture.published()).agents; +- await agent.chats.changeAgent(peer, { uri: URI.joinPath(f1, '.github', 'agents', 'f1.agent.md').toString() }, fixture.context(peer)); +- await agent.chats.sendMessage(peer, 'Use the first folder\'s agent', [f2], undefined, undefined, undefined, fixture.context(peer)); +- const peerSdkSessionId = getPeerChatStub(agent, peer)!.sessionId; +- const launch = fixture.launches.filter(candidate => candidate.sdkSessionId === peerSdkSessionId).at(-1); +- const customAgents = launch?.customAgents.filter(name => !COPILOT_BUILTIN_AGENT_NAMES.has(name)); +- assert.deepStrictEqual({ listed, launch: { agent: launch?.agent, customAgents }, applied: fixture.applied(peer) }, { +- listed: ['f1-agent'], +- launch: { +- agent: getSdkCustomAgentName(URI.joinPath(f1, '.github', 'agents', 'f1.agent.md')), +- customAgents: [ +- getSdkCustomAgentName(URI.joinPath(f1, '.github', 'agents', 'f1.agent.md')), +- getSdkCustomAgentName(URI.joinPath(f2, '.github', 'agents', 'f2.agent.md')), +- ].sort(), +- }, +- applied: { agents: ['f1-agent', 'f2-agent'], instructions: [], mcpServers: ['f2-server'] }, +- }); - }); - - test('the session list follows the default chat\'s chat-only move, and its new folder\'s changes publish without re-reading it', async () => { - const fixture = await createAnchorFixture(false); -- try { -- const { agent, session, main, f2, client } = fixture; -- const published = collectPublishedAgents(agent, session); -- await agent.setChatWorkingDirectory(main, fixture.context(main), f2); -- const afterMove = await waitUntil(agent, () => equals(published.at(-1), ['f2-agent'])); -- // A new agent appears in the new folder; nothing reads the session's customizations meanwhile. -- addAgentToFolder(agent, client, session, f2); -- const afterChange = await waitUntil(agent, () => equals(published.at(-1), ['f2-agent', 'f2-extra-agent'])); -- assert.deepStrictEqual({ afterMove, afterChange, last: published.at(-1) }, { afterMove: true, afterChange: true, last: ['f2-agent', 'f2-extra-agent'] }); -- } finally { -- await fixture.dispose(); -- } +- afterTest(() => fixture.dispose()); +- const { agent, session, main, f2, client } = fixture; +- const published = collectPublishedAgents(agent, session); +- await agent.setChatWorkingDirectory(main, fixture.context(main), f2); +- const afterMove = await waitUntil(agent, () => equals(published.at(-1), ['f2-agent'])); +- // A new agent appears in the new folder; nothing reads the session's customizations meanwhile. +- addAgentToFolder(agent, client, session, f2); +- const afterChange = await waitUntil(agent, () => equals(published.at(-1), ['f2-agent', 'f2-extra-agent'])); +- assert.deepStrictEqual({ afterMove, afterChange, last: published.at(-1) }, { afterMove: true, afterChange: true, last: ['f2-agent', 'f2-extra-agent'] }); - }); - - test('after a restart, changes in the resumed default chat\'s folder publish without the session list being re-read', async () => { - const fixture = await createAnchorFixture(false); -- try { -- const { main, f1, client, session } = fixture; -- await fixture.restart(); -- const agent = fixture.agent; -- await agent.chats.sendMessage(main, 'Continue after the restart', [f1], undefined, undefined, undefined, fixture.context(main)); -- const published = collectPublishedAgents(agent, session); -- addAgentToFolder(agent, client, session, f1); -- assert.deepStrictEqual(await waitUntil(agent, () => equals(published.at(-1), ['f1-agent', 'f1-extra-agent'])), true); -- } finally { -- await fixture.dispose(); -- } +- afterTest(() => fixture.dispose()); +- const { main, f1, client, session } = fixture; +- await fixture.restart(); +- const agent = fixture.agent; +- await agent.chats.sendMessage(main, 'Continue after the restart', [f1], undefined, undefined, undefined, fixture.context(main)); +- const published = collectPublishedAgents(agent, session); +- addAgentToFolder(agent, client, session, f1); +- assert.deepStrictEqual(await waitUntil(agent, () => equals(published.at(-1), ['f1-agent', 'f1-extra-agent'])), true); - }); - - test('an anchor set discovers workspace folders only for a chat that is not workspace-less', async () => { - const fixture = await createAnchorFixture(false); +- afterTest(() => fixture.dispose()); +- const { agent, session, f1, f2 } = fixture; +- const controller = (agent as unknown as AnchorInternals)._activeClients.get(session)!.pluginController; +- const workspaceless = controller.acquireAnchor(URI.file(join(dirname(f1.fsPath), 'scratch')), [], true); +- const foldered = controller.acquireAnchor(f2, [], false); - try { -- const { agent, session, f1, f2 } = fixture; -- const controller = (agent as unknown as AnchorInternals)._activeClients.get(session)!.pluginController; -- const workspaceless = controller.acquireAnchor(URI.file(join(dirname(f1.fsPath), 'scratch')), [], true); -- const foldered = controller.acquireAnchor(f2, [], false); -- try { -- const directoriesOf = (anchor: { readonly _workspaceDirectories: readonly URI[] }) => anchor._workspaceDirectories.map(directory => directory.toString()); -- assert.deepStrictEqual({ workspaceless: directoriesOf(workspaceless.object), foldered: directoriesOf(foldered.object) }, { workspaceless: [], foldered: [f2.toString()] }); -- } finally { -- workspaceless.dispose(); -- foldered.dispose(); -- } +- const directoriesOf = (anchor: { readonly _workspaceDirectories: readonly URI[] }) => anchor._workspaceDirectories.map(directory => directory.toString()); +- assert.deepStrictEqual({ workspaceless: directoriesOf(workspaceless.object), foldered: directoriesOf(foldered.object) }, { workspaceless: [], foldered: [f2.toString()] }); - } finally { -- await fixture.dispose(); +- workspaceless.dispose(); +- foldered.dispose(); - } - }); - - test('a read overlapping session teardown resolves without discovering', async () => { - const fixture = await createAnchorFixture(false); +- afterTest(() => fixture.dispose()); +- const { agent, session, main, f2 } = fixture; +- const activeClient = (agent as unknown as AnchorInternals)._activeClients.get(session)!; +- // A snapshot in flight holds its chat's anchor set while the last chat goes and the session finalizes. +- const anchor = activeClient.pluginController.acquireAnchor(f2, [], false); - try { -- const { agent, session, main, f2 } = fixture; -- const activeClient = (agent as unknown as AnchorInternals)._activeClients.get(session)!; -- // A snapshot in flight holds its chat's anchor set while the last chat goes and the session finalizes. -- const anchor = activeClient.pluginController.acquireAnchor(f2, [], false); -- try { -- await agent.chats.disposeChat(main, fixture.context(main)); -- const snapshot = await activeClient.snapshot(main.toString(), anchor.object); -- const published = await activeClient.pluginController.getCustomizationsSettled(); -- assert.deepStrictEqual({ snapshotAgents: getCopilotCustomAgents(snapshot).map(item => item.name), publishedAgents: listedAgents(published) }, { snapshotAgents: [], publishedAgents: [] }); -- } finally { -- anchor.dispose(); -- } +- await agent.chats.disposeChat(main, fixture.context(main)); +- const snapshot = await activeClient.snapshot(main.toString(), anchor.object); +- const published = await activeClient.pluginController.getCustomizationsSettled(); +- assert.deepStrictEqual({ snapshotAgents: getCopilotCustomAgents(snapshot).map(item => item.name), publishedAgents: listedAgents(published) }, { snapshotAgents: [], publishedAgents: [] }); - } finally { -- await fixture.dispose(); +- anchor.dispose(); - } - }); - - test('chats reaching the same folder through different spellings share one discovery of it', async () => { - const fixture = await createAnchorFixture(false); -- try { -- const { f1, f2 } = fixture; -- // The same folder through a symlink, as `/tmp` and `/private/tmp` on macOS. -- const alias = URI.file(join(dirname(f2.fsPath), 'f2-alias')); -- await fs.symlink(f2.fsPath, alias.fsPath, process.platform === 'win32' ? 'junction' : 'dir'); -- const viaFolder = await fixture.createPeer('via-folder', f2); -- const viaAlias = await fixture.createPeer('via-alias', alias); -- assert.deepStrictEqual({ -- viaFolder: fixture.applied(viaFolder).agents, -- viaAlias: fixture.applied(viaAlias).agents, -- launchedIn: getPeerChatStub(fixture.agent, viaAlias)?.workingDirectory?.fsPath, -- discoveryRuns: fixture.discoveryRuns([f2]) + fixture.discoveryRuns([alias]), -- liveAnchors: fixture.liveAnchorKeys().length, -- }, { -- viaFolder: ['f2-agent'], -- viaAlias: ['f2-agent'], -- // Only the key is normalized; the chat still runs in the folder it was given. -- launchedIn: alias.fsPath, -- discoveryRuns: 1, -- liveAnchors: [f1, f2].length, -- }); -- } finally { -- await fixture.dispose(); -- } +- afterTest(() => fixture.dispose()); +- const { f1, f2 } = fixture; +- // The same folder through a symlink, as `/tmp` and `/private/tmp` on macOS. +- const alias = URI.file(join(dirname(f2.fsPath), 'f2-alias')); +- await fs.symlink(f2.fsPath, alias.fsPath, process.platform === 'win32' ? 'junction' : 'dir'); +- const viaFolder = await fixture.createPeer('via-folder', f2); +- const viaAlias = await fixture.createPeer('via-alias', alias); +- assert.deepStrictEqual({ +- viaFolder: fixture.applied(viaFolder).agents, +- viaAlias: fixture.applied(viaAlias).agents, +- launchedIn: getPeerChatStub(fixture.agent, viaAlias)?.workingDirectory?.fsPath, +- discoveryRuns: fixture.discoveryRuns([f2]) + fixture.discoveryRuns([alias]), +- liveAnchors: fixture.liveAnchorKeys().length, +- }, { +- viaFolder: ['f2-agent'], +- viaAlias: ['f2-agent'], +- // Only the key is normalized; the chat still runs in the folder it was given. +- launchedIn: alias.fsPath, +- discoveryRuns: 1, +- liveAnchors: [f1, f2].length, +- }); - }); - - test('a folder published before its identity resolves shares its discovery with the chat that launches there through a symlink', async () => { - const fixture = await createAnchorFixture(false); -- try { -- const { agent, f2 } = fixture; -- const alias = URI.file(join(dirname(f2.fsPath), 'f2-early-alias')); -- await fs.symlink(f2.fsPath, alias.fsPath, process.platform === 'win32' ? 'junction' : 'dir'); -- const session = AgentSession.uri('copilotcli', 'anchors-early-publish'); -- const chat = defaultChatUri(session); -- await provisionSession(agent, { session, workingDirectories: [alias] }); -- // A client customization sync publishes the session's list before any chat launches. -- const activeClient = (agent as unknown as { _getOrCreateActiveClient(session: URI, directory: URI): { pluginController: IAnchorInternalsController & { getCustomizations(): readonly Customization[] } } })._getOrCreateActiveClient(session, alias); -- activeClient.pluginController.getCustomizations(); -- await agent.chats.sendMessage(chat, 'Launch through the symlink', [alias], undefined, undefined, undefined, exactChatContext(session, chat, session)); -- // The test discovery names an agent after the folder spelling it scanned: the published (alias) one. -- assert.deepStrictEqual({ -- applied: fixture.applied(chat).agents, -- discoveryRuns: fixture.discoveryRuns([alias]) + fixture.discoveryRuns([f2]), -- liveAnchors: activeClient.pluginController.liveAnchorKeys().length, -- }, { applied: ['f2-early-alias-agent'], discoveryRuns: 1, liveAnchors: 1 }); -- } finally { -- await fixture.dispose(); -- } +- afterTest(() => fixture.dispose()); +- const { agent, f2 } = fixture; +- const alias = URI.file(join(dirname(f2.fsPath), 'f2-early-alias')); +- await fs.symlink(f2.fsPath, alias.fsPath, process.platform === 'win32' ? 'junction' : 'dir'); +- const session = AgentSession.uri('copilotcli', 'anchors-early-publish'); +- const chat = defaultChatUri(session); +- await provisionSession(agent, { session, workingDirectories: [alias] }); +- // A client customization sync publishes the session's list before any chat launches. +- const activeClient = (agent as unknown as { _getOrCreateActiveClient(session: URI, directory: URI): { pluginController: IAnchorInternalsController & { getCustomizations(): readonly Customization[] } } })._getOrCreateActiveClient(session, alias); +- activeClient.pluginController.getCustomizations(); +- await agent.chats.sendMessage(chat, 'Launch through the symlink', [alias], undefined, undefined, undefined, exactChatContext(session, chat, session)); +- // The test discovery names an agent after the folder spelling it scanned: the published (alias) one. +- assert.deepStrictEqual({ +- applied: fixture.applied(chat).agents, +- discoveryRuns: fixture.discoveryRuns([alias]) + fixture.discoveryRuns([f2]), +- liveAnchors: activeClient.pluginController.liveAnchorKeys().length, +- }, { applied: ['f2-early-alias-agent'], discoveryRuns: 1, liveAnchors: 1 }); - }); - - test('repeated chat-only moves of the default chat keep the first move\'s roots for older builds', async () => { - const fixture = await createAnchorFixture(false); -- try { -- const { agent, main, f1, f2 } = fixture; -- const f3 = URI.file(join(dirname(f2.fsPath), 'f3')); -- await fs.mkdir(f3.fsPath); -- const sharedRoots = async () => { -- const db = fixture.sessionDataService.openDatabase(fixture.session); -- try { -- return await db.object.getMetadata('copilot.sharedCustomizationDirectories'); -- } finally { -- db.dispose(); -- } -- }; -- await agent.setChatWorkingDirectory(main, fixture.context(main), f2); -- const afterFirstMove = await sharedRoots(); -- await agent.chats.sendMessage(main, 'Continue in the second folder', [f2], undefined, undefined, undefined, fixture.context(main)); -- await agent.setChatWorkingDirectory(main, fixture.context(main), f3); -- assert.deepStrictEqual({ afterFirstMove, afterSecondMove: await sharedRoots() }, { -- afterFirstMove: JSON.stringify([f1.toString()]), -- afterSecondMove: JSON.stringify([f1.toString()]), -- }); -- } finally { -- await fixture.dispose(); -- } +- afterTest(() => fixture.dispose()); +- const { agent, main, f1, f2 } = fixture; +- const f3 = URI.file(join(dirname(f2.fsPath), 'f3')); +- await fs.mkdir(f3.fsPath); +- const sharedRoots = async () => { +- const db = fixture.sessionDataService.openDatabase(fixture.session); +- try { +- return await db.object.getMetadata('copilot.sharedCustomizationDirectories'); +- } finally { +- db.dispose(); +- } +- }; +- await agent.setChatWorkingDirectory(main, fixture.context(main), f2); +- const afterFirstMove = await sharedRoots(); +- await agent.chats.sendMessage(main, 'Continue in the second folder', [f2], undefined, undefined, undefined, fixture.context(main)); +- await agent.setChatWorkingDirectory(main, fixture.context(main), f3); +- assert.deepStrictEqual({ afterFirstMove, afterSecondMove: await sharedRoots() }, { +- afterFirstMove: JSON.stringify([f1.toString()]), +- afterSecondMove: JSON.stringify([f1.toString()]), +- }); - }); - - test('chats working in the same folder share one discovery of it', async () => { - const fixture = await createAnchorFixture(false); -- try { -- const { f1, f2 } = fixture; -- const first = await fixture.createPeer('first-f2', f2); -- const second = await fixture.createPeer('second-f2', f2); -- assert.deepStrictEqual({ -- first: fixture.applied(first).agents, -- second: fixture.applied(second).agents, -- f2DiscoveryRuns: fixture.discoveryRuns([f2]), -- liveAnchors: [...fixture.liveAnchorKeys()].sort(), -- }, { -- first: ['f2-agent'], -- second: ['f2-agent'], -- f2DiscoveryRuns: 1, -- liveAnchors: [f1.toString(), f2.toString()].sort(), -- }); -- } finally { -- await fixture.dispose(); -- } +- afterTest(() => fixture.dispose()); +- const { f1, f2 } = fixture; +- const first = await fixture.createPeer('first-f2', f2); +- const second = await fixture.createPeer('second-f2', f2); +- assert.deepStrictEqual({ +- first: fixture.applied(first).agents, +- second: fixture.applied(second).agents, +- f2DiscoveryRuns: fixture.discoveryRuns([f2]), +- liveAnchors: [...fixture.liveAnchorKeys()].sort(), +- }, { +- first: ['f2-agent'], +- second: ['f2-agent'], +- f2DiscoveryRuns: 1, +- liveAnchors: [f1.toString(), f2.toString()].sort(), +- }); - }); - - test('client tool updates reach every chat of the session without restarting, whichever folder it works in', async () => { - const fixture = await createAnchorFixture(false); -- try { -- const { agent, main, f2 } = fixture; -- const peer = await fixture.createPeer('peer-f2', f2); -- for (const chat of [main, peer]) { -- agent.getOrCreateActiveClient(chat, fixture.context(chat), { clientId: 'window' }).tools = [{ -- name: 'window_tool', -- description: 'A client tool', -- inputSchema: { type: 'object', properties: {} }, -- }]; -- } -- const mark = fixture.launches.length; -- await agent.chats.sendMessage(peer, 'Use the tool', [f2], undefined, undefined, undefined, fixture.context(peer)); -- await agent.chats.sendMessage(main, 'Use the tool', [fixture.f1], undefined, undefined, undefined, fixture.context(main)); -- assert.deepStrictEqual({ -- relaunches: fixture.launches.slice(mark).map(launch => launch.tools.includes('window_tool')), -- peer: getPeerChatStub(agent, peer)?.appliedSnapshot.tools.map(tool => tool.name), -- main: getPeerChatStub(agent, main)?.appliedSnapshot.tools.map(tool => tool.name), -- }, { -- relaunches: [], -- peer: ['window_tool'], -- main: ['window_tool'], -- }); -- } finally { -- await fixture.dispose(); +- afterTest(() => fixture.dispose()); +- const { agent, main, f2 } = fixture; +- const peer = await fixture.createPeer('peer-f2', f2); +- for (const chat of [main, peer]) { +- agent.getOrCreateActiveClient(chat, fixture.context(chat), { clientId: 'window' }).tools = [{ +- name: 'window_tool', +- description: 'A client tool', +- inputSchema: { type: 'object', properties: {} }, +- }]; - } +- const mark = fixture.launches.length; +- await agent.chats.sendMessage(peer, 'Use the tool', [f2], undefined, undefined, undefined, fixture.context(peer)); +- await agent.chats.sendMessage(main, 'Use the tool', [fixture.f1], undefined, undefined, undefined, fixture.context(main)); +- assert.deepStrictEqual({ +- relaunches: fixture.launches.slice(mark).map(launch => launch.tools.includes('window_tool')), +- peer: getPeerChatStub(agent, peer)?.appliedSnapshot.tools.map(tool => tool.name), +- main: getPeerChatStub(agent, main)?.appliedSnapshot.tools.map(tool => tool.name), +- }, { +- relaunches: [], +- peer: ['window_tool'], +- main: ['window_tool'], +- }); - }); - - test('a folder\'s discovery is released with the last chat using it, and every discovery with the session', async () => { - const fixture = await createAnchorFixture(false); -- try { -- const { agent, session, main, f1, f2 } = fixture; -- const peer = await fixture.createPeer('peer-f2', f2); -- await fixture.published(); -- const controller = (agent as unknown as AnchorInternals)._activeClients.get(session)!.pluginController; -- const withPeer = [...controller.liveAnchorKeys()].sort(); -- await agent.chats.disposeChat(peer, fixture.context(peer)); -- const afterPeer = [...controller.liveAnchorKeys()]; -- await agent.chats.disposeChat(main, fixture.context(main)); -- assert.deepStrictEqual({ withPeer, afterPeer, afterSession: controller.liveAnchorKeys(), finalized: (agent as unknown as AnchorInternals)._activeClients.get(session) }, { -- withPeer: [f1.toString(), f2.toString()].sort(), -- afterPeer: [f1.toString()], -- afterSession: [], -- finalized: undefined, -- }); -- } finally { -- await fixture.dispose(); -- } +- afterTest(() => fixture.dispose()); +- const { agent, session, main, f1, f2 } = fixture; +- const peer = await fixture.createPeer('peer-f2', f2); +- await fixture.published(); +- const controller = (agent as unknown as AnchorInternals)._activeClients.get(session)!.pluginController; +- const withPeer = [...controller.liveAnchorKeys()].sort(); +- await agent.chats.disposeChat(peer, fixture.context(peer)); +- const afterPeer = [...controller.liveAnchorKeys()]; +- await agent.chats.disposeChat(main, fixture.context(main)); +- assert.deepStrictEqual({ withPeer, afterPeer, afterSession: controller.liveAnchorKeys(), finalized: (agent as unknown as AnchorInternals)._activeClients.get(session) }, { +- withPeer: [f1.toString(), f2.toString()].sort(), +- afterPeer: [f1.toString()], +- afterSession: [], +- finalized: undefined, +- }); - }); - - test('a peer\'s folder-only MCP server still appears in the session\'s list (MCP runtime state is published by every chat)', async () => { - const fixture = await createAnchorFixture(false); -- try { -- const { agent, stateManager, session, f1, f2 } = fixture; -- const peer = await fixture.createPeer('peer-f2', f2); -- const now = new Date().toISOString(); -- stateManager.createSession({ -- resource: session.toString(), provider: 'copilotcli', title: 'Anchors', status: SessionStatus.Idle, -- createdAt: now, modifiedAt: now, workingDirectories: [f1.toString()], defaultChat: fixture.main.toString(), -- }); -- disposables.add(agent.onDidChatProgress(signal => { -- if (signal.kind === 'action' && signal.resource.toString() === session.toString()) { -- stateManager.dispatchServerAction(session.toString(), signal.action as SessionAction); -- } -- })); -- stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionCustomizationsChanged, customizations: [...await getDefaultChatCustomizations(agent, session)] }); -- const listed = () => (stateManager.getSessionState(session.toString())?.customizations ?? []).filter(item => item.type === CustomizationType.McpServer).map(item => item.name).sort(); -- const before = listed(); -- getPeerChatStub(agent, peer)!['_mcpCustomizations'].applyOne({ name: 'f2-server', state: { kind: McpServerStatus.Ready } }); -- assert.deepStrictEqual({ before, after: listed() }, { before: ['f1-server'], after: ['f1-server', 'f2-server'] }); -- } finally { -- await fixture.dispose(); -- } +- afterTest(() => fixture.dispose()); +- const { agent, stateManager, session, f1, f2 } = fixture; +- const peer = await fixture.createPeer('peer-f2', f2); +- const now = new Date().toISOString(); +- stateManager.createSession({ +- resource: session.toString(), provider: 'copilotcli', title: 'Anchors', status: SessionStatus.Idle, +- createdAt: now, modifiedAt: now, workingDirectories: [f1.toString()], defaultChat: fixture.main.toString(), +- }); +- disposables.add(agent.onDidChatProgress(signal => { +- if (signal.kind === 'action' && signal.resource.toString() === session.toString()) { +- stateManager.dispatchServerAction(session.toString(), signal.action as SessionAction); +- } +- })); +- stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionCustomizationsChanged, customizations: [...await getDefaultChatCustomizations(agent, session)] }); +- const listed = () => (stateManager.getSessionState(session.toString())?.customizations ?? []).filter(item => item.type === CustomizationType.McpServer).map(item => item.name).sort(); +- const before = listed(); +- getPeerChatStub(agent, peer)!['_mcpCustomizations'].applyOne({ name: 'f2-server', state: { kind: McpServerStatus.Ready } }); +- assert.deepStrictEqual({ before, after: listed() }, { before: ['f1-server'], after: ['f1-server', 'f2-server'] }); - }); - }); - @@ -52990,18 +55372,15 @@ index f24a97a6296..00000000000 - sessionDataService: disposables.add(new TestSessionDataService()), - }); - const session = URI.parse('ahp-session:/new-copilot'); -- try { -- await agent.authenticate('https://api.github.com', 'github-token'); -- const result = await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); -- assert.deepStrictEqual({ -- session: result.session.toString(), -- provisional: result.provisional, -- backingProvider: result.backingSession?.scheme, -- separateBacking: result.backingSession && AgentSession.id(result.backingSession) !== AgentSession.id(session), -- }, { session: session.toString(), provisional: true, backingProvider: 'copilotcli', separateBacking: true }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'github-token'); +- const result = await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); +- assert.deepStrictEqual({ +- session: result.session.toString(), +- provisional: result.provisional, +- backingProvider: result.backingSession?.scheme, +- separateBacking: result.backingSession && AgentSession.id(result.backingSession) !== AgentSession.id(session), +- }, { session: session.toString(), provisional: true, backingProvider: 'copilotcli', separateBacking: true }); - }); - - const authenticationParams = (scopes: readonly string[] = TEST_MCP_SCOPES): AuthenticateParams => ({ @@ -53023,20 +55402,17 @@ index f24a97a6296..00000000000 - }); - const session = AgentSession.uri('copilotcli', 'legacy-mcp-copilot-resource'); - const chat = defaultChatUri(session); -- try { -- await agent.authenticate(resource, 'github-token'); -- const creation = agent.chats.createChat(chat, exactChatContext(session, chat, session), { -- workingDirectories: [URI.file('/workspace')], deferBacking: false, -- }); -- await client.whenAuthenticationRequests(1); -- const handled = await agent.handleAuthenticationToken({ resource, scopes: TEST_MCP_SCOPES, token: 'mcp-token' }); -- await creation; -- assert.deepStrictEqual({ handled, responses: client.authenticationResponses }, { -- handled: true, responses: [{ kind: 'token', accessToken: 'mcp-token' }], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate(resource, 'github-token'); +- const creation = agent.chats.createChat(chat, exactChatContext(session, chat, session), { +- workingDirectories: [URI.file('/workspace')], deferBacking: false, +- }); +- await client.whenAuthenticationRequests(1); +- const handled = await agent.handleAuthenticationToken({ resource, scopes: TEST_MCP_SCOPES, token: 'mcp-token' }); +- await creation; +- assert.deepStrictEqual({ handled, responses: client.authenticationResponses }, { +- handled: true, responses: [{ kind: 'token', accessToken: 'mcp-token' }], +- }); - }); - - test('targeted tokens reach only their owning session during initialization and after registration', async () => { @@ -53057,37 +55433,34 @@ index f24a97a6296..00000000000 - })); - const resolver = spy(CopilotAgentSession.prototype, 'resolveMcpAuthentication'); - disposables.add(toDisposable(() => resolver.restore())); -- try { -- await agent.authenticate('https://api.github.com', 'github-token'); -- const creations = sessions.map(session => { -- const chat = defaultChatUri(session); -- return agent.chats.createChat(chat, exactChatContext(session, chat, session), { -- workingDirectories: [URI.file('/workspace')], deferBacking: false, -- }); -- }); -- await published.p; -- const authenticate = (index: number, token: string) => agent.handleAuthenticationToken({ -- ...authenticationParams(), token, -- target: { kind: AuthenticateTargetKind.McpServer, session: sessions[index].toString(), customizationId: ids.get(sessions[index].toString())! }, -- }); -- const first = await authenticate(0, 'token-a'); -- await creations[0]; -- const afterFirst = [...client.authenticationResponses]; -- const second = await authenticate(1, 'token-b'); -- await Promise.all(creations); -- const afterRegistration = await authenticate(0, 'token-a'); -- assert.deepStrictEqual({ -- first, second, afterRegistration, afterFirst, responses: client.authenticationResponses, -- deliveries: resolver.getCalls().map(call => (call.thisValue as CopilotAgentSession).ownerSessionUri.toString()), -- }, { -- first: true, second: true, afterRegistration: false, -- afterFirst: [{ kind: 'token', accessToken: 'token-a' }], -- responses: [{ kind: 'token', accessToken: 'token-a' }, { kind: 'token', accessToken: 'token-b' }], -- deliveries: [sessions[0].toString(), sessions[1].toString(), sessions[0].toString()], +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'github-token'); +- const creations = sessions.map(session => { +- const chat = defaultChatUri(session); +- return agent.chats.createChat(chat, exactChatContext(session, chat, session), { +- workingDirectories: [URI.file('/workspace')], deferBacking: false, - }); -- } finally { -- await disposeAgent(agent); -- } +- }); +- await published.p; +- const authenticate = (index: number, token: string) => agent.handleAuthenticationToken({ +- ...authenticationParams(), token, +- target: { kind: AuthenticateTargetKind.McpServer, session: sessions[index].toString(), customizationId: ids.get(sessions[index].toString())! }, +- }); +- const first = await authenticate(0, 'token-a'); +- await creations[0]; +- const afterFirst = [...client.authenticationResponses]; +- const second = await authenticate(1, 'token-b'); +- await Promise.all(creations); +- const afterRegistration = await authenticate(0, 'token-a'); +- assert.deepStrictEqual({ +- first, second, afterRegistration, afterFirst, responses: client.authenticationResponses, +- deliveries: resolver.getCalls().map(call => (call.thisValue as CopilotAgentSession).ownerSessionUri.toString()), +- }, { +- first: true, second: true, afterRegistration: false, +- afterFirst: [{ kind: 'token', accessToken: 'token-a' }], +- responses: [{ kind: 'token', accessToken: 'token-a' }, { kind: 'token', accessToken: 'token-b' }], +- deliveries: [sessions[0].toString(), sessions[1].toString(), sessions[0].toString()], +- }); - }); - - test('one token authenticates every concurrently initializing new session', async () => { @@ -53097,42 +55470,39 @@ index f24a97a6296..00000000000 - sessionDataService: disposables.add(new TestSessionDataService()), - }); - const sessions = ['mcp-auth-create-1', 'mcp-auth-create-2'].map(id => AgentSession.uri('copilotcli', id)); -- try { -- await agent.authenticate('https://api.github.com', 'github-token'); -- const creations = sessions.map(session => { -- const chat = defaultChatUri(session); -- return agent.chats.createChat(chat, exactChatContext(session, chat, session), { -- workingDirectories: [URI.file('/workspace')], -- deferBacking: false, -- }); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'github-token'); +- const creations = sessions.map(session => { +- const chat = defaultChatUri(session); +- return agent.chats.createChat(chat, exactChatContext(session, chat, session), { +- workingDirectories: [URI.file('/workspace')], +- deferBacking: false, - }); -- await client.whenAuthenticationRequests(2); +- }); +- await client.whenAuthenticationRequests(2); - -- const insufficientScopesHandled = await authenticateMcp(authenticationService, agent, []); -- const handled = await authenticateMcp(authenticationService, agent); -- await Promise.all(creations); +- const insufficientScopesHandled = await authenticateMcp(authenticationService, agent, []); +- const handled = await authenticateMcp(authenticationService, agent); +- await Promise.all(creations); - -- assert.deepStrictEqual({ -- insufficientScopesHandled, -- handled, -- requestedSessionCount: client.requestedSessionIds.length, -- uniqueRequestedSessionCount: new Set(client.requestedSessionIds).size, -- authenticationResponses: client.authenticationResponses, -- liveSessions: sessions.map(session => hasLiveChat(agent, defaultChatUri(session))), -- }, { -- insufficientScopesHandled: false, -- handled: true, -- requestedSessionCount: 2, -- uniqueRequestedSessionCount: 2, -- authenticationResponses: [ -- { kind: 'token', accessToken: 'mcp-token' }, -- { kind: 'token', accessToken: 'mcp-token' }, -- ], -- liveSessions: [true, true], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- insufficientScopesHandled, +- handled, +- requestedSessionCount: client.requestedSessionIds.length, +- uniqueRequestedSessionCount: new Set(client.requestedSessionIds).size, +- authenticationResponses: client.authenticationResponses, +- liveSessions: sessions.map(session => hasLiveChat(agent, defaultChatUri(session))), +- }, { +- insufficientScopesHandled: false, +- handled: true, +- requestedSessionCount: 2, +- uniqueRequestedSessionCount: 2, +- authenticationResponses: [ +- { kind: 'token', accessToken: 'mcp-token' }, +- { kind: 'token', accessToken: 'mcp-token' }, +- ], +- liveSessions: [true, true], +- }); - }); - - test('a later new session accepts a repeated delivery of the persisted token', async () => { @@ -53149,80 +55519,71 @@ index f24a97a6296..00000000000 - deferBacking: false, - }); - }; -- try { -- await agent.authenticate('https://api.github.com', 'github-token'); -- const firstCreation = create('mcp-auth-reuse-1'); -- await client.whenAuthenticationRequests(1); -- const firstHandled = await authenticateMcp(authenticationService, agent); -- await firstCreation; +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'github-token'); +- const firstCreation = create('mcp-auth-reuse-1'); +- await client.whenAuthenticationRequests(1); +- const firstHandled = await authenticateMcp(authenticationService, agent); +- await firstCreation; - -- const secondCreation = create('mcp-auth-reuse-2'); -- await client.whenAuthenticationRequests(2); -- const secondHandled = await authenticateMcp(authenticationService, agent); -- await secondCreation; +- const secondCreation = create('mcp-auth-reuse-2'); +- await client.whenAuthenticationRequests(2); +- const secondHandled = await authenticateMcp(authenticationService, agent); +- await secondCreation; - -- assert.deepStrictEqual({ -- firstHandled, -- secondHandled, -- requestedSessionCount: client.requestedSessionIds.length, -- authenticationResponses: client.authenticationResponses, -- }, { -- firstHandled: true, -- secondHandled: true, -- requestedSessionCount: 2, -- authenticationResponses: [ -- { kind: 'token', accessToken: 'mcp-token' }, -- { kind: 'token', accessToken: 'mcp-token' }, -- ], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- firstHandled, +- secondHandled, +- requestedSessionCount: client.requestedSessionIds.length, +- authenticationResponses: client.authenticationResponses, +- }, { +- firstHandled: true, +- secondHandled: true, +- requestedSessionCount: 2, +- authenticationResponses: [ +- { kind: 'token', accessToken: 'mcp-token' }, +- { kind: 'token', accessToken: 'mcp-token' }, +- ], +- }); - }); - - test('sends a chat\'s own mode to the SDK, not the session\'s', async () => { - const { agent, stateManager } = createTestAgentContext(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'per-chat-mode'); -- const defaultChat = defaultChatUri(session); -- const peer = URI.parse(buildChatUri(session, 'peer')); -- stateManager.createSession({ resource: session.toString(), provider: 'copilotcli', title: 't', status: SessionStatus.Idle, createdAt: '2026-01-01T00:00:00Z', modifiedAt: '2026-01-01T00:00:00Z', defaultChat: defaultChat.toString() }); -- const protocol = platformSessionSchema.toProtocol(); -- stateManager.setSessionConfig(session.toString(), { -- schema: { ...protocol, properties: { ...protocol.properties, [SessionConfigKey.Mode]: { ...protocol.properties[SessionConfigKey.Mode], chatMutable: true } } }, -- values: { [SessionConfigKey.Mode]: 'interactive' }, -- }); -- stateManager.addChat(session.toString(), peer.toString(), peer.toString(), { config: { values: { [SessionConfigKey.Mode]: 'plan' } } }); -- const resolve = (chat: URI) => (agent as unknown as { _resolveSdkMode(session: URI, chat: URI): string | undefined })._resolveSdkMode(session, chat); -- assert.deepStrictEqual([resolve(defaultChat), resolve(peer)], ['interactive', 'plan']); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'per-chat-mode'); +- const defaultChat = defaultChatUri(session); +- const peer = URI.parse(buildChatUri(session, 'peer')); +- stateManager.createSession({ resource: session.toString(), provider: 'copilotcli', title: 't', status: SessionStatus.Idle, createdAt: '2026-01-01T00:00:00Z', modifiedAt: '2026-01-01T00:00:00Z', defaultChat: defaultChat.toString() }); +- const protocol = platformSessionSchema.toProtocol(); +- stateManager.setSessionConfig(session.toString(), { +- schema: { ...protocol, properties: { ...protocol.properties, [SessionConfigKey.Mode]: { ...protocol.properties[SessionConfigKey.Mode], chatMutable: true } } }, +- values: { [SessionConfigKey.Mode]: 'interactive' }, +- }); +- stateManager.addChat(session.toString(), peer.toString(), peer.toString(), { config: { values: { [SessionConfigKey.Mode]: 'plan' } } }); +- const resolve = (chat: URI) => (agent as unknown as { _resolveSdkMode(session: URI, chat: URI): string | undefined })._resolveSdkMode(session, chat); +- assert.deepStrictEqual([resolve(defaultChat), resolve(peer)], ['interactive', 'plan']); - }); - - test('filters slash-command toggles by the completing chat\'s own mode and approvals', async () => { - const { agent, stateManager } = createTestAgentContext(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'per-chat-toggles'); -- const defaultChat = defaultChatUri(session); -- const peer = URI.parse(buildChatUri(session, 'peer')); -- stateManager.createSession({ resource: session.toString(), provider: 'copilotcli', title: 't', status: SessionStatus.Idle, createdAt: '2026-01-01T00:00:00Z', modifiedAt: '2026-01-01T00:00:00Z', defaultChat: defaultChat.toString() }); -- const protocol = platformSessionSchema.toProtocol(); -- const chatMutable = (key: string) => ({ ...protocol.properties[key], chatMutable: true }); -- stateManager.setSessionConfig(session.toString(), { -- schema: { ...protocol, properties: { ...protocol.properties, [SessionConfigKey.Mode]: chatMutable(SessionConfigKey.Mode), [SessionConfigKey.AutoApprove]: chatMutable(SessionConfigKey.AutoApprove) } }, -- values: { [SessionConfigKey.Mode]: 'interactive', [SessionConfigKey.AutoApprove]: 'default' }, -- }); -- stateManager.addChat(session.toString(), peer.toString(), peer.toString(), { config: { values: { [SessionConfigKey.Mode]: 'plan', [SessionConfigKey.AutoApprove]: 'autoApprove' } } }); -- const state = (chat: URI | undefined) => (agent as unknown as { _getSessionConfigState(sessionId: string, chat: string | undefined): unknown })._getSessionConfigState(AgentSession.id(session), chat?.toString()); -- assert.deepStrictEqual([state(defaultChat), state(peer), state(undefined)], [ -- { mode: 'interactive', autoApprove: 'default' }, -- { mode: 'plan', autoApprove: 'autoApprove' }, -- { mode: 'interactive', autoApprove: 'default' }, -- ]); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'per-chat-toggles'); +- const defaultChat = defaultChatUri(session); +- const peer = URI.parse(buildChatUri(session, 'peer')); +- stateManager.createSession({ resource: session.toString(), provider: 'copilotcli', title: 't', status: SessionStatus.Idle, createdAt: '2026-01-01T00:00:00Z', modifiedAt: '2026-01-01T00:00:00Z', defaultChat: defaultChat.toString() }); +- const protocol = platformSessionSchema.toProtocol(); +- const chatMutable = (key: string) => ({ ...protocol.properties[key], chatMutable: true }); +- stateManager.setSessionConfig(session.toString(), { +- schema: { ...protocol, properties: { ...protocol.properties, [SessionConfigKey.Mode]: chatMutable(SessionConfigKey.Mode), [SessionConfigKey.AutoApprove]: chatMutable(SessionConfigKey.AutoApprove) } }, +- values: { [SessionConfigKey.Mode]: 'interactive', [SessionConfigKey.AutoApprove]: 'default' }, +- }); +- stateManager.addChat(session.toString(), peer.toString(), peer.toString(), { config: { values: { [SessionConfigKey.Mode]: 'plan', [SessionConfigKey.AutoApprove]: 'autoApprove' } } }); +- const state = (chat: URI | undefined) => (agent as unknown as { _getSessionConfigState(sessionId: string, chat: string | undefined): unknown })._getSessionConfigState(AgentSession.id(session), chat?.toString()); +- assert.deepStrictEqual([state(defaultChat), state(peer), state(undefined)], [ +- { mode: 'interactive', autoApprove: 'default' }, +- { mode: 'plan', autoApprove: 'autoApprove' }, +- { mode: 'interactive', autoApprove: 'default' }, +- ]); - }); - - test('routes authentication while materializing a provisional session', async () => { @@ -53234,40 +55595,37 @@ index f24a97a6296..00000000000 - const session = AgentSession.uri('copilotcli', 'mcp-auth-materialize'); - const chat = defaultChatUri(session); - const workingDirectory = URI.file('/workspace'); -- try { -- await agent.authenticate('https://api.github.com', 'github-token'); -- const created = await provisionSession(agent, { -- session, -- workingDirectories: [workingDirectory], -- }); -- const send = agent.chats.sendMessage( -- chat, -- 'hello', -- [workingDirectory], -- undefined, -- 'turn-1', -- 'client-1', -- exactChatContext(session, chat, session), -- ); -- await client.whenAuthenticationRequests(1); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'github-token'); +- const created = await provisionSession(agent, { +- session, +- workingDirectories: [workingDirectory], +- }); +- const send = agent.chats.sendMessage( +- chat, +- 'hello', +- [workingDirectory], +- undefined, +- 'turn-1', +- 'client-1', +- exactChatContext(session, chat, session), +- ); +- await client.whenAuthenticationRequests(1); - -- const handled = await authenticateMcp(authenticationService, agent); -- await send; +- const handled = await authenticateMcp(authenticationService, agent); +- await send; - -- assert.deepStrictEqual({ -- provisionalBeforeSend: created.provisional, -- handled, -- authenticationResponses: client.authenticationResponses, -- live: hasLiveChat(agent, chat), -- }, { -- provisionalBeforeSend: true, -- handled: true, -- authenticationResponses: [{ kind: 'token', accessToken: 'mcp-token' }], -- live: true, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- provisionalBeforeSend: created.provisional, +- handled, +- authenticationResponses: client.authenticationResponses, +- live: hasLiveChat(agent, chat), +- }, { +- provisionalBeforeSend: true, +- handled: true, +- authenticationResponses: [{ kind: 'token', accessToken: 'mcp-token' }], +- live: true, +- }); - }); - - test('routes authentication while cold-resuming a persisted session', async () => { @@ -53283,31 +55641,30 @@ index f24a97a6296..00000000000 - }); - chatBackings(agent).set(chat.toString(), { sdkSessionId: sessionId }); - chatScopes(agent).set(chat.toString(), session); -- try { -- await agent.authenticate('https://api.github.com', 'github-token'); -- const messages = agent.chats.getMessages(chat, exactChatContext(session, chat, session)); -- await client.whenAuthenticationRequests(1); -- -- const handled = await authenticateMcp(authenticationService, agent); -- const turns = await messages; -- -- assert.deepStrictEqual({ -- handled, -- requestedSessionIds: client.requestedSessionIds, -- authenticationResponses: client.authenticationResponses, -- turns, -- live: hasLiveChat(agent, chat), -- }, { -- handled: true, -- requestedSessionIds: [sessionId], -- authenticationResponses: [{ kind: 'token', accessToken: 'mcp-token' }], -- turns: [], -- live: true, -- }); -- } finally { +- afterTest(async () => { - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'github-token'); +- const messages = agent.chats.getMessages(chat, exactChatContext(session, chat, session)); +- await client.whenAuthenticationRequests(1); +- +- const handled = await authenticateMcp(authenticationService, agent); +- const turns = await messages; +- +- assert.deepStrictEqual({ +- handled, +- requestedSessionIds: client.requestedSessionIds, +- authenticationResponses: client.authenticationResponses, +- turns, +- live: hasLiveChat(agent, chat), +- }, { +- handled: true, +- requestedSessionIds: [sessionId], +- authenticationResponses: [{ kind: 'token', accessToken: 'mcp-token' }], +- turns: [], +- live: true, +- }); - }); - - test('routes authentication while resuming a persisted peer chat', async () => { @@ -53330,31 +55687,30 @@ index f24a97a6296..00000000000 - }); - chatBackings(agent).set(chat.toString(), { sdkSessionId }); - chatScopes(agent).set(chat.toString(), session); -- try { -- await agent.authenticate('https://api.github.com', 'github-token'); -- const messages = agent.chats.getMessages(chat, { ...exactChatContext(session, chat), launchWorkingDirectories: [URI.file(workingDirectory)] }); -- await client.whenAuthenticationRequests(1); -- -- const handled = await authenticateMcp(authenticationService, agent); -- const turns = await messages; -- -- assert.deepStrictEqual({ -- handled, -- requestedSessionIds: client.requestedSessionIds, -- authenticationResponses: client.authenticationResponses, -- turns, -- live: hasLiveChat(agent, chat), -- }, { -- handled: true, -- requestedSessionIds: [sdkSessionId], -- authenticationResponses: [{ kind: 'token', accessToken: 'mcp-token' }], -- turns: [], -- live: true, -- }); -- } finally { +- afterTest(async () => { - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'github-token'); +- const messages = agent.chats.getMessages(chat, { ...exactChatContext(session, chat), launchWorkingDirectories: [URI.file(workingDirectory)] }); +- await client.whenAuthenticationRequests(1); +- +- const handled = await authenticateMcp(authenticationService, agent); +- const turns = await messages; +- +- assert.deepStrictEqual({ +- handled, +- requestedSessionIds: client.requestedSessionIds, +- authenticationResponses: client.authenticationResponses, +- turns, +- live: hasLiveChat(agent, chat), +- }, { +- handled: true, +- requestedSessionIds: [sdkSessionId], +- authenticationResponses: [{ kind: 'token', accessToken: 'mcp-token' }], +- turns: [], +- live: true, +- }); - }); - - test('shutdown cancels an MCP authentication-blocked initialization', async () => { @@ -53365,32 +55721,29 @@ index f24a97a6296..00000000000 - }); - const session = AgentSession.uri('copilotcli', 'mcp-auth-shutdown'); - const chat = defaultChatUri(session); -- try { -- await agent.authenticate('https://api.github.com', 'github-token'); -- const creation = assert.rejects( -- () => agent.chats.createChat(chat, exactChatContext(session, chat, session), { -- workingDirectories: [URI.file('/workspace')], -- deferBacking: false, -- }), -- (error: unknown) => isCancellationError(error), -- ); -- await client.whenAuthenticationRequests(1); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'github-token'); +- const creation = assert.rejects( +- () => agent.chats.createChat(chat, exactChatContext(session, chat, session), { +- workingDirectories: [URI.file('/workspace')], +- deferBacking: false, +- }), +- (error: unknown) => isCancellationError(error), +- ); +- await client.whenAuthenticationRequests(1); - -- await agent.shutdown(); -- await creation; +- await agent.shutdown(); +- await creation; - -- assert.deepStrictEqual({ -- authenticationResponses: client.authenticationResponses, -- live: hasLiveChat(agent, chat), -- stopCallCount: client.stopCallCount, -- }, { -- authenticationResponses: [{ kind: 'cancelled' }], -- live: false, -- stopCallCount: 1, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- authenticationResponses: client.authenticationResponses, +- live: hasLiveChat(agent, chat), +- stopCallCount: client.stopCallCount, +- }, { +- authenticationResponses: [{ kind: 'cancelled' }], +- live: false, +- stopCallCount: 1, +- }); - }); - - test('shutdown prevents registration after post-initialization metadata work', async () => { @@ -53505,43 +55858,37 @@ index f24a97a6296..00000000000 - - test('selects provider-native autonomous session config, preserves allow all, and respects policy', async () => { - const { agent, configurationService } = createTestAgentContext(disposables); -- try { -- const selected = agent.getAutonomousSessionConfig({}); -- const allowAll = agent.getAutonomousSessionConfig({ [SessionConfigKey.AutoApprove]: 'autoApprove' }); -- configurationService.updateRootConfig({ [AgentHostAutoApprovePolicyRestrictedConfigKey]: true }); -- const restricted = agent.getAutonomousSessionConfig({}); +- afterTest(() => disposeAgent(agent)); +- const selected = agent.getAutonomousSessionConfig({}); +- const allowAll = agent.getAutonomousSessionConfig({ [SessionConfigKey.AutoApprove]: 'autoApprove' }); +- configurationService.updateRootConfig({ [AgentHostAutoApprovePolicyRestrictedConfigKey]: true }); +- const restricted = agent.getAutonomousSessionConfig({}); - -- assert.deepStrictEqual({ selected, allowAll, restricted }, { -- selected: { [SessionConfigKey.Mode]: 'autopilot', [SessionConfigKey.AutoApprove]: 'assisted' }, -- allowAll: { [SessionConfigKey.Mode]: 'autopilot' }, -- restricted: { [SessionConfigKey.Mode]: 'autopilot' }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ selected, allowAll, restricted }, { +- selected: { [SessionConfigKey.Mode]: 'autopilot', [SessionConfigKey.AutoApprove]: 'assisted' }, +- allowAll: { [SessionConfigKey.Mode]: 'autopilot' }, +- restricted: { [SessionConfigKey.Mode]: 'autopilot' }, +- }); - }); - - test('installs client identity, Copilot home, and the GitHub telemetry callback in CopilotClientOptions', async () => { - const client = new TestCopilotClient([]); - const agent = createTestAgent(disposables, { copilotClient: client }) as TestableCopilotAgent; -- try { -- await agent.listChatsToMigrate(); -- const clientOptions = getCreatedClientOptions(agent).at(-1); -- assert.deepStrictEqual({ -- clientInfo: clientOptions?.clientInfo, -- copilotHome: clientOptions?.env?.['COPILOT_HOME'], -- onGitHubTelemetry: typeof clientOptions?.onGitHubTelemetry, -- }, { -- clientInfo: { -- applicationName: 'vscode-agent-host', -- applicationVersion: '1.2.3', -- }, -- copilotHome: join(URI.file('/mock-home').fsPath, '.copilot'), -- onGitHubTelemetry: 'function', -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- const clientOptions = getCreatedClientOptions(agent).at(-1); +- assert.deepStrictEqual({ +- clientInfo: clientOptions?.clientInfo, +- copilotHome: clientOptions?.env?.['COPILOT_HOME'], +- onGitHubTelemetry: typeof clientOptions?.onGitHubTelemetry, +- }, { +- clientInfo: { +- applicationName: 'vscode-agent-host', +- applicationVersion: '1.2.3', +- }, +- copilotHome: join(URI.file('/mock-home').fsPath, '.copilot'), +- onGitHubTelemetry: 'function', +- }); - }); - - test('uses injected Copilot homes for SDK startup and plan files despite an inherited override', async () => { @@ -53645,63 +55992,59 @@ index f24a97a6296..00000000000 - const effortLevel = { type: 'string', title: 'Effort Level', description: 'Controls how much reasoning effort the model uses.', default: 'medium', enum: ['low', 'medium', 'high'], enumLabels: ['Low', 'Medium', 'High'], enumDescriptions: ['Faster responses with less reasoning', 'Balanced reasoning and speed', 'Greater reasoning depth but slower'] }; - const notOptedIn = createTestAgentContext(disposables, { copilotClient: new TestCopilotClient([]), localModelSetupService }); - const { agent, instantiationService } = createTestAgentContext(disposables, { copilotClient: new TestCopilotClient([]), localModelSetupService, rootConfig: { [CopilotCliConfigKey.LocalModelEnabled]: true }, sessionDataService: disposables.add(new TestSessionDataService()) }); -- try { -- await notOptedIn.agent.listChatsToMigrate(); -- await agent.listChatsToMigrate(); -- const installed = localRows(agent); -- provider.setRows([maiRow]); -- const afterRemoval = localRows(agent); -- const { session, createOptions } = createAgentSessionThroughAgent(agent, instantiationService); -- setLiveChatStub(agent, session.sessionId, session); -- await session.initializeSession(); -- -- const maiInstalled = { id: 'vscode-on-device/mai-code-1.1-flash', name: 'Example Flash 1.0', maxContextWindow: 262144, maxPromptTokens: 180224, maxOutputTokens: 81920, thinkingLevel: effortLevel, _meta: { 'vscode.localModel': { familyId: 'mai-code-1.1-flash-local', contentId: 'mai-content-1', active: true } } }; -- assert.deepStrictEqual({ -- notOptedIn: localRows(notOptedIn.agent), -- installed, -- afterRemoval, -- autoSessionConfig: { providers: createOptions()?.providers, models: createOptions()?.models }, -- providerCalls: provider.calls.filter(call => !call.startsWith('updateLiveConfig')), -- }, { -- notOptedIn: [], -- installed: [ -- maiInstalled, -- // Off / On; `llama-server` thinks unless told otherwise. -- { id: 'vscode-on-device/example-coder', name: 'Example Coder', maxContextWindow: 131072, maxPromptTokens: 98304, maxOutputTokens: 32768, thinkingLevel: { type: 'string', title: 'Effort Level', description: 'Controls whether the model thinks before it answers.', default: 'medium', enum: ['none', 'medium'], enumLabels: ['Off', 'On'], enumDescriptions: ['Answers without thinking first', 'Thinks before it answers'] }, _meta: { 'vscode.localModel': { familyId: 'example-coder-local', contentId: 'example-content-1', active: false } } }, -- ], -- afterRemoval: [maiInstalled], -- autoSessionConfig: createSentinelContribution([maiRow]), -- providerCalls: ['onAutoSessionStarted', 'setSessionDirectFamily:test-session-1:-'], -- }); -- } finally { +- afterTest(async () => { - await disposeAgent(notOptedIn.agent); - await disposeAgent(agent); -- } +- }); +- await notOptedIn.agent.listChatsToMigrate(); +- await agent.listChatsToMigrate(); +- const installed = localRows(agent); +- provider.setRows([maiRow]); +- const afterRemoval = localRows(agent); +- const { session, createOptions } = createAgentSessionThroughAgent(agent, instantiationService); +- setLiveChatStub(agent, session.sessionId, session); +- await session.initializeSession(); +- +- const maiInstalled = { id: 'vscode-on-device/mai-code-1.1-flash', name: 'Example Flash 1.0', maxContextWindow: 262144, maxPromptTokens: 180224, maxOutputTokens: 81920, thinkingLevel: effortLevel, _meta: { 'vscode.localModel': { familyId: 'mai-code-1.1-flash-local', contentId: 'mai-content-1', active: true } } }; +- assert.deepStrictEqual({ +- notOptedIn: localRows(notOptedIn.agent), +- installed, +- afterRemoval, +- autoSessionConfig: { providers: createOptions()?.providers, models: createOptions()?.models }, +- providerCalls: provider.calls.filter(call => !call.startsWith('updateLiveConfig')), +- }, { +- notOptedIn: [], +- installed: [ +- maiInstalled, +- // Off / On; `llama-server` thinks unless told otherwise. +- { id: 'vscode-on-device/example-coder', name: 'Example Coder', maxContextWindow: 131072, maxPromptTokens: 98304, maxOutputTokens: 32768, thinkingLevel: { type: 'string', title: 'Effort Level', description: 'Controls whether the model thinks before it answers.', default: 'medium', enum: ['none', 'medium'], enumLabels: ['Off', 'On'], enumDescriptions: ['Answers without thinking first', 'Thinks before it answers'] }, _meta: { 'vscode.localModel': { familyId: 'example-coder-local', contentId: 'example-content-1', active: false } } }, +- ], +- afterRemoval: [maiInstalled], +- autoSessionConfig: createSentinelContribution([maiRow]), +- providerCalls: ['onAutoSessionStarted', 'setSessionDirectFamily:test-session-1:-'], +- }); - }); - - test('tells the on-device model provider when a different GitHub account replaces the signed-in one, not on renewal', async () => { - const provider = createAccountTrackingProvider(); - const copilotApiService = new TestCopilotApiService(); - const { agent } = createTestAgentContext(disposables, { copilotClient: new TestCopilotClient([]), localModelSetupService: createLocalModelSetupService(provider), copilotApiService, rootConfig: { [CopilotCliConfigKey.LocalModelEnabled]: true } }); -- try { -- await agent.listChatsToMigrate(); -- const accountChanges = () => provider.calls.filter(call => call === 'onAccountChanged').length; -- copilotApiService.userLogin = 'Alice'; -- await agent.authenticate('https://api.github.com', 'alice-token-1'); -- const signedIn = accountChanges(); -- copilotApiService.userLogin = 'alice'; -- await agent.authenticate('https://api.github.com', 'alice-token-2'); -- const renewed = accountChanges(); -- copilotApiService.userLogin = 'bob'; -- await agent.authenticate('https://api.github.com', 'bob-token'); -- const switched = accountChanges(); -- await agent.authenticate('https://api.github.com', ''); -- const signedOut = accountChanges(); -- -- assert.deepStrictEqual({ signedIn, renewed, switched, signedOut }, { signedIn: 0, renewed: 0, switched: 1, signedOut: 2 }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- const accountChanges = () => provider.calls.filter(call => call === 'onAccountChanged').length; +- copilotApiService.userLogin = 'Alice'; +- await agent.authenticate('https://api.github.com', 'alice-token-1'); +- const signedIn = accountChanges(); +- copilotApiService.userLogin = 'alice'; +- await agent.authenticate('https://api.github.com', 'alice-token-2'); +- const renewed = accountChanges(); +- copilotApiService.userLogin = 'bob'; +- await agent.authenticate('https://api.github.com', 'bob-token'); +- const switched = accountChanges(); +- await agent.authenticate('https://api.github.com', ''); +- const signedOut = accountChanges(); +- +- assert.deepStrictEqual({ signedIn, renewed, switched, signedOut }, { signedIn: 0, renewed: 0, switched: 1, signedOut: 2 }); - }); - - test('compares the first account switch after activation with the account signed in when the provider was applied', async () => { @@ -53709,81 +56052,75 @@ index f24a97a6296..00000000000 - const selections: LocalModelProviderSelection[] = []; - const copilotApiService = new TestCopilotApiService(); - const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: new TestCopilotClient([]), localModelSetupService: createLocalModelSetupService(provider, selections), copilotApiService }); -- try { -- const accountChanges = () => provider.calls.filter(call => call === 'onAccountChanged').length; -- await agent.listChatsToMigrate(); -- copilotApiService.userLogin = 'alice'; -- await agent.authenticate('https://api.github.com', 'alice-token'); -- -- // Turning the feature on restarts the client, which applies the provider while Alice is signed in. -- configurationService.updateRootConfig({ [CopilotCliConfigKey.LocalModelEnabled]: true }); -- await timeout(0); -- await agent.listChatsToMigrate(); -- const activated = accountChanges(); +- afterTest(() => disposeAgent(agent)); +- const accountChanges = () => provider.calls.filter(call => call === 'onAccountChanged').length; +- await agent.listChatsToMigrate(); +- copilotApiService.userLogin = 'alice'; +- await agent.authenticate('https://api.github.com', 'alice-token'); +- +- // Turning the feature on restarts the client, which applies the provider while Alice is signed in. +- configurationService.updateRootConfig({ [CopilotCliConfigKey.LocalModelEnabled]: true }); +- await timeout(0); +- await agent.listChatsToMigrate(); +- const activated = accountChanges(); - -- copilotApiService.userLogin = 'bob'; -- await agent.authenticate('https://api.github.com', 'bob-token'); -- const switched = accountChanges(); -- await agent.authenticate('https://api.github.com', 'bob-token-renewed'); -- const renewed = accountChanges(); +- copilotApiService.userLogin = 'bob'; +- await agent.authenticate('https://api.github.com', 'bob-token'); +- const switched = accountChanges(); +- await agent.authenticate('https://api.github.com', 'bob-token-renewed'); +- const renewed = accountChanges(); - -- assert.deepStrictEqual({ selections, activated, switched, renewed }, { selections: ['shim'], activated: 0, switched: 1, renewed: 1 }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ selections, activated, switched, renewed }, { selections: ['shim'], activated: 0, switched: 1, renewed: 1 }); - }); - - test('launches the runtime owned by the Copilot SDK platform package', async () => { - const client = new TestCopilotClient([]); - const agent = createTestAgent(disposables, { copilotClient: client }) as TestableCopilotAgent; -- try { -- await agent.listChatsToMigrate(); -- const clientOptions = getCreatedClientOptions(agent).at(-1); -- const connection = clientOptions?.connection; -- const runtimePath = connection?.kind === 'stdio' ? connection.path?.replaceAll('\\', '/') : undefined; -- const launch = await clientOptions?.extensionLaunchProvider?.resolve({ -- id: 'user:preview', -- name: 'preview', -- modulePath: '/user/extensions/preview/extension.mjs', -- source: 'user', -- }); -- const deniedProjectLaunch = await clientOptions?.extensionLaunchProvider?.resolve({ -- id: 'project:preview', -- name: 'preview', -- modulePath: '/workspace/.github/extensions/preview/extension.mjs', -- source: 'project', -- }); -- const deniedSessionLaunch = await clientOptions?.extensionLaunchProvider?.resolve({ -- id: 'session:session-id:preview', -- name: 'preview', -- modulePath: '/home/test/.copilot/session-state/session-id/extensions/preview/extension.mjs', -- source: 'session', -- }); -- assert.deepStrictEqual({ -- kind: connection?.kind, -- sdkRuntime: runtimePath?.includes('/node_modules/@github/copilot-sdk-'), -- runtimeExecutable: runtimePath?.endsWith(process.platform === 'win32' ? '/copilot-runtime.exe' : '/copilot-runtime'), -- extensionExecutable: launch?.launch?.executable, -- extensionBootstrap: launch?.launch?.args[0].replaceAll('\\', '/').endsWith('/preloads/extension_bootstrap.mjs'), -- extensionEnvironment: launch?.launch?.env, -- projectLaunchDenied: deniedProjectLaunch?.launch === undefined, -- sessionLaunchDenied: deniedSessionLaunch?.launch === undefined, -- }, { -- kind: 'stdio', -- sdkRuntime: true, -- runtimeExecutable: true, -- extensionExecutable: process.execPath, -- extensionBootstrap: true, -- extensionEnvironment: { -- EXTENSION_PATH: '/user/extensions/preview/extension.mjs', -- ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}), -- }, -- projectLaunchDenied: true, -- sessionLaunchDenied: true, -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- const clientOptions = getCreatedClientOptions(agent).at(-1); +- const connection = clientOptions?.connection; +- const runtimePath = connection?.kind === 'stdio' ? connection.path?.replaceAll('\\', '/') : undefined; +- const launch = await clientOptions?.extensionLaunchProvider?.resolve({ +- id: 'user:preview', +- name: 'preview', +- modulePath: '/user/extensions/preview/extension.mjs', +- source: 'user', +- }); +- const deniedProjectLaunch = await clientOptions?.extensionLaunchProvider?.resolve({ +- id: 'project:preview', +- name: 'preview', +- modulePath: '/workspace/.github/extensions/preview/extension.mjs', +- source: 'project', +- }); +- const deniedSessionLaunch = await clientOptions?.extensionLaunchProvider?.resolve({ +- id: 'session:session-id:preview', +- name: 'preview', +- modulePath: '/home/test/.copilot/session-state/session-id/extensions/preview/extension.mjs', +- source: 'session', +- }); +- assert.deepStrictEqual({ +- kind: connection?.kind, +- sdkRuntime: runtimePath?.includes('/node_modules/@github/copilot-sdk-'), +- runtimeExecutable: runtimePath?.endsWith(process.platform === 'win32' ? '/copilot-runtime.exe' : '/copilot-runtime'), +- extensionExecutable: launch?.launch?.executable, +- extensionBootstrap: launch?.launch?.args[0].replaceAll('\\', '/').endsWith('/preloads/extension_bootstrap.mjs'), +- extensionEnvironment: launch?.launch?.env, +- projectLaunchDenied: deniedProjectLaunch?.launch === undefined, +- sessionLaunchDenied: deniedSessionLaunch?.launch === undefined, +- }, { +- kind: 'stdio', +- sdkRuntime: true, +- runtimeExecutable: true, +- extensionExecutable: process.execPath, +- extensionBootstrap: true, +- extensionEnvironment: { +- EXTENSION_PATH: '/user/extensions/preview/extension.mjs', +- ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}), +- }, +- projectLaunchDenied: true, +- sessionLaunchDenied: true, +- }); - }); - - test('launches a configured local Copilot runtime executable', async () => { @@ -53798,21 +56135,20 @@ index f24a97a6296..00000000000 - copilotClient: client, - rootConfig: { [CopilotCliConfigKey.RuntimePath]: runtimePath }, - }); -- try { -- await agent.listChatsToMigrate(); -- const connection = getCreatedClientOptions(agent).at(-1)?.connection; -- -- assert.deepStrictEqual({ -- kind: connection?.kind, -- path: connection?.kind === 'stdio' ? connection.path : undefined, -- }, { -- kind: 'stdio', -- path: runtimePath, -- }); -- } finally { +- afterTest(async () => { - await disposeAgent(agent); - await fs.rm(directory, { recursive: true, force: true }); -- } +- }); +- await agent.listChatsToMigrate(); +- const connection = getCreatedClientOptions(agent).at(-1)?.connection; +- +- assert.deepStrictEqual({ +- kind: connection?.kind, +- path: connection?.kind === 'stdio' ? connection.path : undefined, +- }, { +- kind: 'stdio', +- path: runtimePath, +- }); - }); - - test('rejects an invalid configured Copilot runtime path without falling back', async () => { @@ -53822,27 +56158,26 @@ index f24a97a6296..00000000000 - copilotClient: client, - rootConfig: { [CopilotCliConfigKey.RuntimePath]: 'copilot-runtime' }, - }); -- try { -- await assert.rejects( -- () => agent.listChatsToMigrate(), -- /Invalid chat\.agentHost\.copilot\.runtimePath: expected an absolute path, got 'copilot-runtime'/, -- ); -- configurationService.updateRootConfig({ [CopilotCliConfigKey.RuntimePath]: directory }); -- await assert.rejects( -- () => agent.listChatsToMigrate(), -- /Invalid chat\.agentHost\.copilot\.runtimePath .*: expected a file/, -- ); -- assert.deepStrictEqual({ -- createdClients: getCreatedClientOptions(agent).length, -- clientStarts: client.startCallCount, -- }, { -- createdClients: 0, -- clientStarts: 0, -- }); -- } finally { +- afterTest(async () => { - await disposeAgent(agent); - await fs.rm(directory, { recursive: true, force: true }); -- } +- }); +- await assert.rejects( +- () => agent.listChatsToMigrate(), +- /Invalid chat\.agentHost\.copilot\.runtimePath: expected an absolute path, got 'copilot-runtime'/, +- ); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.RuntimePath]: directory }); +- await assert.rejects( +- () => agent.listChatsToMigrate(), +- /Invalid chat\.agentHost\.copilot\.runtimePath .*: expected a file/, +- ); +- assert.deepStrictEqual({ +- createdClients: getCreatedClientOptions(agent).length, +- clientStarts: client.startCallCount, +- }, { +- createdClients: 0, +- clientStarts: 0, +- }); - }); - - test('rejects a configured runtime file without execute permission', async () => { @@ -53858,22 +56193,21 @@ index f24a97a6296..00000000000 - copilotClient: client, - rootConfig: { [CopilotCliConfigKey.RuntimePath]: runtimePath }, - }); -- try { -- await assert.rejects( -- () => agent.listChatsToMigrate(), -- /Invalid chat\.agentHost\.copilot\.runtimePath .*: file is not executable/, -- ); -- assert.deepStrictEqual({ -- createdClients: getCreatedClientOptions(agent).length, -- clientStarts: client.startCallCount, -- }, { -- createdClients: 0, -- clientStarts: 0, -- }); -- } finally { +- afterTest(async () => { - await disposeAgent(agent); - await fs.rm(directory, { recursive: true, force: true }); -- } +- }); +- await assert.rejects( +- () => agent.listChatsToMigrate(), +- /Invalid chat\.agentHost\.copilot\.runtimePath .*: file is not executable/, +- ); +- assert.deepStrictEqual({ +- createdClients: getCreatedClientOptions(agent).length, +- clientStarts: client.startCallCount, +- }, { +- createdClients: 0, +- clientStarts: 0, +- }); - }); - - test('restarts the Copilot client when the configured runtime path changes', async () => { @@ -53895,53 +56229,49 @@ index f24a97a6296..00000000000 - copilotClient: client, - rootConfig: { [CopilotCliConfigKey.RuntimePath]: firstRuntimePath }, - }); -- try { -- await agent.listChatsToMigrate(); -- configurationService.updateRootConfig({ [CopilotCliConfigKey.RuntimePath]: secondRuntimePath }); -- await agent.listChatsToMigrate(); -- -- assert.deepStrictEqual({ -- runtimePaths: getCreatedClientOptions(agent).map(options => { -- const connection = options.connection; -- return connection?.kind === 'stdio' ? connection.path : undefined; -- }), -- stopCallCount: client.stopCallCount, -- }, { -- runtimePaths: [firstRuntimePath, secondRuntimePath], -- stopCallCount: 1, -- }); -- } finally { +- afterTest(async () => { - await disposeAgent(agent); - await fs.rm(directory, { recursive: true, force: true }); -- } +- }); +- await agent.listChatsToMigrate(); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.RuntimePath]: secondRuntimePath }); +- await agent.listChatsToMigrate(); +- +- assert.deepStrictEqual({ +- runtimePaths: getCreatedClientOptions(agent).map(options => { +- const connection = options.connection; +- return connection?.kind === 'stdio' ? connection.path : undefined; +- }), +- stopCallCount: client.stopCallCount, +- }, { +- runtimePaths: [firstRuntimePath, secondRuntimePath], +- stopCallCount: 1, +- }); - }); - - test('promotes the forwarded secondary assignment context to a telemetry-wide property', async () => { - const client = new TestCopilotClient([]); - const telemetryService = new RecordingTelemetryService(); - const agent = createTestAgent(disposables, { copilotClient: client, telemetryService }) as TestableCopilotAgent; -- try { -- await agent.listChatsToMigrate(); -- const forward = getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry; -- assert.ok(forward); -- -- await forward({ -- sessionId: 'session', -- restricted: false, -- event: { -- kind: 'response.success', -- properties: { secondary_assignment_context: 'secondary:1' }, -- metrics: {}, -- exp_assignment_context: 'primary:1', -- }, -- }); +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- const forward = getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry; +- assert.ok(forward); +- +- await forward({ +- sessionId: 'session', +- restricted: false, +- event: { +- kind: 'response.success', +- properties: { secondary_assignment_context: 'secondary:1' }, +- metrics: {}, +- exp_assignment_context: 'primary:1', +- }, +- }); - -- assert.deepStrictEqual(telemetryService.experimentProperties, { -- 'secondary.assignmentcontext': 'secondary:1', -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(telemetryService.experimentProperties, { +- 'secondary.assignmentcontext': 'secondary:1', +- }); - }); - - test('promotes the VS Code assignment context from root config to telemetry, sticky across a wipe', async () => { @@ -53955,20 +56285,17 @@ index f24a97a6296..00000000000 - } - }(); - const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client, telemetryService }); -- try { -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); - -- configurationService.updateRootConfig({ [CopilotCliVSCodeAssignmentContextKey]: 'experiment:1' }); -- configurationService.updateRootConfig({}, true); -- configurationService.updateRootConfig({ [CopilotCliVSCodeAssignmentContextKey]: '' }); +- configurationService.updateRootConfig({ [CopilotCliVSCodeAssignmentContextKey]: 'experiment:1' }); +- configurationService.updateRootConfig({}, true); +- configurationService.updateRootConfig({ [CopilotCliVSCodeAssignmentContextKey]: '' }); - -- assert.deepStrictEqual(telemetryService.experimentPropertyUpdates, [ -- { name: 'abexp.assignmentcontext', value: 'experiment:1' }, -- { name: 'abexp.assignmentcontext', value: '' }, -- ]); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(telemetryService.experimentPropertyUpdates, [ +- { name: 'abexp.assignmentcontext', value: 'experiment:1' }, +- { name: 'abexp.assignmentcontext', value: '' }, +- ]); - }); - - test('enriches non-response telemetry without changing its runtime turn identity', async () => { @@ -53979,45 +56306,42 @@ index f24a97a6296..00000000000 - } - }(); - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]), telemetryService }); -- try { -- await agent.listChatsToMigrate(); -- const forward = getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry; -- assert.ok(forward); -- let turnReads = 0; -- let correlationReads = 0; -- setLiveChatStub(agent, 'sdk-session', { -- get currentTurnId() { turnReads++; return 'host-turn'; }, -- get modelCallTurnCorrelation() { correlationReads++; return new ModelCallTurnCorrelation(); }, -- }); -- -- await forward({ -- sessionId: 'sdk-session', -- restricted: false, -- event: { -- kind: 'tool_call_executed', -- properties: { tool_name: 'grep', turnId: 'runtime-turn', modelCallId: 'call', initiatorType: 'agent' }, -- metrics: { duration_ms: 12 }, -- }, -- }); +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- const forward = getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry; +- assert.ok(forward); +- let turnReads = 0; +- let correlationReads = 0; +- setLiveChatStub(agent, 'sdk-session', { +- get currentTurnId() { turnReads++; return 'host-turn'; }, +- get modelCallTurnCorrelation() { correlationReads++; return new ModelCallTurnCorrelation(); }, +- }); +- +- await forward({ +- sessionId: 'sdk-session', +- restricted: false, +- event: { +- kind: 'tool_call_executed', +- properties: { tool_name: 'grep', turnId: 'runtime-turn', modelCallId: 'call', initiatorType: 'agent' }, +- metrics: { duration_ms: 12 }, +- }, +- }); - -- assert.deepStrictEqual({ -- turnReads, -- correlationReads, -- events: events.map(({ eventName, data }) => ({ -- eventName, -- turnId: data?.turnId, -- tool: data?.tool_name, -- duration: data?.duration_ms, -- diagnostics: Object.keys(data ?? {}).filter(key => key.startsWith('ah')), -- })), -- }, { -- turnReads: 0, -- correlationReads: 1, -- events: [{ eventName: 'copilotSdk/tool_call_executed', turnId: 'runtime-turn', tool: 'grep', duration: 12, diagnostics: [] }], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- turnReads, +- correlationReads, +- events: events.map(({ eventName, data }) => ({ +- eventName, +- turnId: data?.turnId, +- tool: data?.tool_name, +- duration: data?.duration_ms, +- diagnostics: Object.keys(data ?? {}).filter(key => key.startsWith('ah')), +- })), +- }, { +- turnReads: 0, +- correlationReads: 1, +- events: [{ eventName: 'copilotSdk/tool_call_executed', turnId: 'runtime-turn', tool: 'grep', duration: 12, diagnostics: [] }], +- }); - }); - - test('correlates forwarded response telemetry with active SDK session turns', async () => { @@ -54028,77 +56352,160 @@ index f24a97a6296..00000000000 - } - }(); - const agent = createTestAgent(disposables, { copilotClient: client, telemetryService }) as TestableCopilotAgent; -- try { -- await agent.listChatsToMigrate(); -- const forward = getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry; -- assert.ok(forward); +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- const forward = getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry; +- assert.ok(forward); +- +- const subagentCorrelation = new DeferredPromise(); +- const forwardedModelCallIds: string[] = []; +- const activeSession: Pick & { +- modelCallTurnCorrelation: Pick; +- } = { +- currentTurnId: 'turn-1', +- isDisposed: false, +- modelCallTurnCorrelation: { +- take: () => undefined, +- wait: modelCallId => modelCallId === 'unresolved-model-call' ? Promise.resolve({ turnId: undefined, outcome: 'waitExpired', waitMs: 100 }) : subagentCorrelation.p, +- markResponseForwarded: modelCallId => forwardedModelCallIds.push(modelCallId), +- getTelemetryContext: () => undefined, +- }, +- }; +- setLiveChatStub(agent, 'active-session', activeSession); +- setLiveChatStub(agent, 'second-active-session', { +- currentTurnId: 'turn-2', +- }); +- setLiveChatStub(agent, 'idle-session', { +- currentTurnId: undefined, +- }); +- const notification = (sessionId: string, turnId: string, modelCallId?: string, initiatorType?: string): GitHubTelemetryNotification => ({ +- sessionId, +- restricted: false, +- event: { +- kind: 'response.success', +- properties: { turnId, ...(modelCallId ? { modelCallId } : {}), ...(initiatorType ? { initiatorType } : {}) }, +- metrics: {}, +- }, +- }); - -- const subagentCorrelation = new DeferredPromise(); -- const forwardedModelCallIds: string[] = []; -- const activeSession: Pick & { -- modelCallTurnCorrelation: Pick; -- } = { -- currentTurnId: 'turn-1', -- isDisposed: false, -- modelCallTurnCorrelation: { -- take: () => undefined, -- wait: modelCallId => modelCallId === 'unresolved-model-call' ? Promise.resolve({ turnId: undefined, outcome: 'waitExpired', waitMs: 100 }) : subagentCorrelation.p, -- markResponseForwarded: modelCallId => forwardedModelCallIds.push(modelCallId), -- getTelemetryContext: () => undefined, -- }, -- }; -- setLiveChatStub(agent, 'active-session', activeSession); -- setLiveChatStub(agent, 'second-active-session', { -- currentTurnId: 'turn-2', -- }); -- setLiveChatStub(agent, 'idle-session', { -- currentTurnId: undefined, -- }); -- const notification = (sessionId: string, turnId: string, modelCallId?: string, initiatorType?: string): GitHubTelemetryNotification => ({ -- sessionId, -- restricted: false, -- event: { -- kind: 'response.success', -- properties: { turnId, ...(modelCallId ? { modelCallId } : {}), ...(initiatorType ? { initiatorType } : {}) }, -- metrics: {}, -- }, -- }); +- await forward(notification('active-session', 'runtime-active', 'root-model-call')); +- await forward(notification('active-session', 'runtime-subagent', 'subagent-model-call', 'agent')); +- subagentCorrelation.complete({ turnId: 'subagent-turn', outcome: 'mappingWaited', waitMs: 4 }); +- await timeout(0); +- await forward(notification('active-session', 'runtime-unresolved', 'unresolved-model-call', 'agent')); +- await timeout(0); +- await forward(notification('second-active-session', 'runtime-second-active')); +- await forward(notification('active-session', 'runtime-active-again')); +- await forward(notification('idle-session', 'runtime-idle')); +- await forward(notification('unknown-session', 'runtime-unknown')); - -- await forward(notification('active-session', 'runtime-active', 'root-model-call')); -- await forward(notification('active-session', 'runtime-subagent', 'subagent-model-call', 'agent')); -- subagentCorrelation.complete({ turnId: 'subagent-turn', outcome: 'mappingWaited', waitMs: 4 }); -- await timeout(0); -- await forward(notification('active-session', 'runtime-unresolved', 'unresolved-model-call', 'agent')); -- await timeout(0); -- await forward(notification('second-active-session', 'runtime-second-active')); -- await forward(notification('active-session', 'runtime-active-again')); -- await forward(notification('idle-session', 'runtime-idle')); -- await forward(notification('unknown-session', 'runtime-unknown')); +- assert.deepStrictEqual({ +- events: telemetryService.events.map(event => { +- const data = event.data as Record; +- return event.eventName === 'agentHost.copilotClientStartup' +- ? { eventName: event.eventName, outcome: data.outcome, durationMs: typeof data.durationMs, attemptNumber: data.attemptNumber } +- : { eventName: event.eventName, sessionId: data.sdk_session_id, turnId: data.turnId, diagnostics: Object.fromEntries(Object.entries(data).filter(([key]) => key.startsWith('ah'))) }; +- }), +- forwardedModelCallIds, +- }, { +- events: [ +- { eventName: 'agentHost.copilotClientStartup', outcome: 'success', durationMs: 'number', attemptNumber: 1 }, +- { eventName: 'copilotSdk/response.success', sessionId: 'active-session', turnId: 'turn-1', diagnostics: { ahCorrelationOutcome: 'activeTurnFallback' } }, +- { eventName: 'copilotSdk/response.success', sessionId: 'active-session', turnId: 'subagent-turn', diagnostics: { ahCorrelationOutcome: 'mappingWaited', ahCorrelationWaitMs: 4 } }, +- { eventName: 'copilotSdk/response.success', sessionId: 'active-session', turnId: undefined, diagnostics: { ahCorrelationOutcome: 'waitExpired', ahCorrelationWaitMs: 100, ahActiveRootTurnIdAtResponse: 'turn-1', ahSessionDisposedDuringWait: false } }, +- { eventName: 'copilotSdk/response.success', sessionId: 'second-active-session', turnId: 'turn-2', diagnostics: { ahCorrelationOutcome: 'activeTurnFallback' } }, +- { eventName: 'copilotSdk/response.success', sessionId: 'active-session', turnId: 'turn-1', diagnostics: { ahCorrelationOutcome: 'activeTurnFallback' } }, +- { eventName: 'copilotSdk/response.success', sessionId: 'idle-session', turnId: undefined, diagnostics: { ahCorrelationOutcome: 'noActiveTurn' } }, +- { eventName: 'copilotSdk/response.success', sessionId: 'unknown-session', turnId: undefined, diagnostics: { ahCorrelationOutcome: 'sessionNotFound' } }, +- ], +- forwardedModelCallIds: ['root-model-call'], +- }); +- }); - -- assert.deepStrictEqual({ -- events: telemetryService.events.map(event => { -- const data = event.data as Record; -- return event.eventName === 'agentHost.copilotClientStartup' -- ? { eventName: event.eventName, outcome: data.outcome, durationMs: typeof data.durationMs, attemptNumber: data.attemptNumber } -- : { eventName: event.eventName, sessionId: data.sdk_session_id, turnId: data.turnId, diagnostics: Object.fromEntries(Object.entries(data).filter(([key]) => key.startsWith('ah'))) }; -- }), -- forwardedModelCallIds, -- }, { -- events: [ -- { eventName: 'agentHost.copilotClientStartup', outcome: 'success', durationMs: 'number', attemptNumber: 1 }, -- { eventName: 'copilotSdk/response.success', sessionId: 'active-session', turnId: 'turn-1', diagnostics: { ahCorrelationOutcome: 'activeTurnFallback' } }, -- { eventName: 'copilotSdk/response.success', sessionId: 'active-session', turnId: 'subagent-turn', diagnostics: { ahCorrelationOutcome: 'mappingWaited', ahCorrelationWaitMs: 4 } }, -- { eventName: 'copilotSdk/response.success', sessionId: 'active-session', turnId: undefined, diagnostics: { ahCorrelationOutcome: 'waitExpired', ahCorrelationWaitMs: 100, ahActiveRootTurnIdAtResponse: 'turn-1', ahSessionDisposedDuringWait: false } }, -- { eventName: 'copilotSdk/response.success', sessionId: 'second-active-session', turnId: 'turn-2', diagnostics: { ahCorrelationOutcome: 'activeTurnFallback' } }, -- { eventName: 'copilotSdk/response.success', sessionId: 'active-session', turnId: 'turn-1', diagnostics: { ahCorrelationOutcome: 'activeTurnFallback' } }, -- { eventName: 'copilotSdk/response.success', sessionId: 'idle-session', turnId: undefined, diagnostics: { ahCorrelationOutcome: 'noActiveTurn' } }, -- { eventName: 'copilotSdk/response.success', sessionId: 'unknown-session', turnId: undefined, diagnostics: { ahCorrelationOutcome: 'sessionNotFound' } }, -- ], -- forwardedModelCallIds: ['root-model-call'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- test('correlates forwarded compaction telemetry with the turn that owned the compaction', async () => { +- const telemetryService = new class extends RecordingTelemetryService { +- override publicLog(eventName?: string, data?: unknown): void { +- this.events.push({ eventName: eventName ?? '', data }); +- } +- }(); +- const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]), telemetryService }) as TestableCopilotAgent; +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- const forward = getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry; +- assert.ok(forward); +- +- // The wait itself is covered with a virtual clock in compactionTurnCorrelation.test.ts; +- // here an unrecorded attempt reports an expired wait without real time passing. +- const owners = new Map([['attempt-in-turn', 'turn-1'], ['attempt-between-turns', null], ['attempt-from-earlier-turn', 'turn-0']]); +- const compactionTurnCorrelation: Pick = { +- resolve: async (attemptId): Promise => { +- const owner = owners.get(attemptId); +- return owner === undefined +- ? { turnId: undefined, outcome: 'waitExpired', waitMs: 100 } +- : owner === null ? { turnId: undefined, outcome: 'noOwningTurn' } : { turnId: owner, outcome: 'mappingAvailable' }; +- }, +- }; +- const accountContext = { copilotSku: 'sku-a', copilotTrackingId: 'analytics-a' }; +- let modelCallWaits = 0; +- setLiveChatStub(agent, 'active-session', { +- currentTurnId: 'turn-1', +- isDisposed: false, +- compactionTurnCorrelation, +- modelCallTurnCorrelation: { +- take: () => undefined, +- wait: () => { modelCallWaits++; return Promise.resolve({ turnId: undefined, outcome: 'waitExpired', waitMs: 100 }); }, +- markResponseForwarded: () => { }, +- getTelemetryContext: (modelCallId: string) => modelCallId === 'compaction-call' ? accountContext : undefined, +- }, +- }); +- const compaction = (sessionId: string, kind: string, attemptId?: string): GitHubTelemetryNotification => ({ +- sessionId, +- restricted: false, +- event: { kind, model_call_id: 'compaction-call', properties: { turnId: 'runtime-turn', ...(attemptId ? { compaction_attempt_id: attemptId } : {}) }, metrics: {} }, +- }); +- +- await forward(compaction('active-session', 'session_compaction_start', 'attempt-in-turn')); +- await forward(compaction('active-session', 'session_compaction_complete', 'attempt-between-turns')); +- await forward(compaction('active-session', 'session_compaction_complete', 'unrecorded-attempt')); +- await forward(compaction('active-session', 'session_compaction_complete')); +- await forward(compaction('unknown-session', 'session_compaction_complete', 'attempt-in-turn')); +- // A summarization call never completes a turn's model call, so its response row takes the +- // owner of its compaction attempt, even after a later turn became active. +- const compactionResponse = (kind: string, attemptId?: string): GitHubTelemetryNotification => ({ +- sessionId: 'active-session', +- restricted: false, +- event: { kind, properties: { modelCallId: 'compaction-call', initiatorType: 'agent', requestKind: 'conversation-compaction', ...(attemptId ? { compaction_attempt_id: attemptId } : {}) }, metrics: {} }, +- }); +- await forward(compactionResponse('response.success', 'attempt-from-earlier-turn')); +- await forward(compactionResponse('response.error', 'attempt-between-turns')); +- await forward(compactionResponse('response.success')); +- await timeout(0); +- +- assert.deepStrictEqual({ +- modelCallWaits, +- events: telemetryService.events.filter(event => event.eventName.startsWith('copilotSdk/')).map(event => { +- const data = event.data as Record; +- return { +- eventName: event.eventName, +- turnId: data.turnId, +- outcome: data.ahCompactionCorrelationOutcome ?? data.ahCorrelationOutcome, +- waited: data.ahCompactionCorrelationWaitMs !== undefined, +- copilotSku: data.copilotSku, +- }; +- }), +- }, { +- modelCallWaits: 0, +- events: [ +- { eventName: 'copilotSdk/session_compaction_start', turnId: 'turn-1', outcome: 'mappingAvailable', waited: false, copilotSku: 'sku-a' }, +- { eventName: 'copilotSdk/session_compaction_complete', turnId: undefined, outcome: 'noOwningTurn', waited: false, copilotSku: 'sku-a' }, +- { eventName: 'copilotSdk/session_compaction_complete', turnId: undefined, outcome: 'waitExpired', waited: true, copilotSku: 'sku-a' }, +- { eventName: 'copilotSdk/session_compaction_complete', turnId: undefined, outcome: 'missingAttemptId', waited: false, copilotSku: 'sku-a' }, +- { eventName: 'copilotSdk/session_compaction_complete', turnId: undefined, outcome: 'sessionNotFound', waited: false, copilotSku: undefined }, +- { eventName: 'copilotSdk/response.success', turnId: 'turn-0', outcome: 'mappingAvailable', waited: false, copilotSku: 'sku-a' }, +- { eventName: 'copilotSdk/response.error', turnId: undefined, outcome: 'noOwningTurn', waited: false, copilotSku: 'sku-a' }, +- { eventName: 'copilotSdk/response.success', turnId: undefined, outcome: 'missingAttemptId', waited: false, copilotSku: 'sku-a' }, +- ], +- }); - }); - - for (const disposeDuringWait of [false, true]) { @@ -54113,45 +56520,42 @@ index f24a97a6296..00000000000 - }(); - const sessionDataService = disposables.add(new TestSessionDataService()); - const { agent, instantiationService } = createTestAgentContext(disposables, { copilotClient: new TestCopilotClient([]), telemetryService, sessionDataService }); -- try { -- await agent.listChatsToMigrate(); -- const forward = getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry; -- assert.ok(forward); -- const { session } = createAgentSessionThroughAgent(agent, instantiationService); -- setLiveChatStub(agent, session.sessionId, session); -- await session.initializeSession(); -- session.resetTurnState('original-turn'); +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- const forward = getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry; +- assert.ok(forward); +- const { session } = createAgentSessionThroughAgent(agent, instantiationService); +- setLiveChatStub(agent, session.sessionId, session); +- await session.initializeSession(); +- session.resetTurnState('original-turn'); - -- await forward({ -- sessionId: session.sessionId, -- restricted: false, -- event: { kind: 'response.error', properties: { modelCallId: 'call', initiatorType: 'agent', turnId: 'sdk-turn' }, metrics: {} }, -- }); -- session.resetTurnState('replacement-turn'); -- if (disposeDuringWait) { -- session.dispose(); -- } -- const data = await response.p; -- assert.deepStrictEqual({ -- turn: data.turnId, -- outcome: data.ahCorrelationOutcome, -- activeRootAtCallback: data.ahActiveRootTurnIdAtResponse, -- disposedDuringWait: data.ahSessionDisposedDuringWait, -- measuredWait: typeof data.ahCorrelationWaitMs === 'number' && data.ahCorrelationWaitMs > 0, -- sessionDisposed: session.isDisposed, -- }, { -- turn: undefined, -- outcome: 'waitExpired', -- activeRootAtCallback: 'original-turn', -- disposedDuringWait: disposeDuringWait, -- measuredWait: true, -- sessionDisposed: disposeDuringWait, -- }); -- } finally { -- await disposeAgent(agent); -- } -- }); -- } +- await forward({ +- sessionId: session.sessionId, +- restricted: false, +- event: { kind: 'response.error', properties: { modelCallId: 'call', initiatorType: 'agent', turnId: 'sdk-turn' }, metrics: {} }, +- }); +- session.resetTurnState('replacement-turn'); +- if (disposeDuringWait) { +- session.dispose(); +- } +- const data = await response.p; +- assert.deepStrictEqual({ +- turn: data.turnId, +- outcome: data.ahCorrelationOutcome, +- activeRootAtCallback: data.ahActiveRootTurnIdAtResponse, +- disposedDuringWait: data.ahSessionDisposedDuringWait, +- measuredWait: typeof data.ahCorrelationWaitMs === 'number' && data.ahCorrelationWaitMs > 0, +- sessionDisposed: session.isDisposed, +- }, { +- turn: undefined, +- outcome: 'waitExpired', +- activeRootAtCallback: 'original-turn', +- disposedDuringWait: disposeDuringWait, +- measuredWait: true, +- sessionDisposed: disposeDuringWait, +- }); +- }); +- } - - test('does not relabel SDK responses when credentials change before notification arrival', async () => { - const events: ITelemetryData[] = []; @@ -54168,51 +56572,48 @@ index f24a97a6296..00000000000 - endpoints: { api: 'https://api.githubcopilot.com' }, access_type_sku: 'sku', analytics_tracking_id: 'analytics', - }), new NullLogService(), TEST_PRODUCT_SERVICE, createTestGitHubEndpointService(), NullTelemetryService)); - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]), telemetryService, copilotApiService }); -- try { -- await agent.authenticate('https://api.github.com', 'token-a'); -- await agent.refreshModels(); -- const forward = getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry; -- assert.ok(forward); -- const correlation = new ModelCallTurnCorrelation(); -- const context = agent.getTelemetryContext(); -- correlation.recordTelemetryContext('call-a', context); -- correlation.record('call-a', 'turn-a'); -- correlation.recordTelemetryContext('delayed-a', context); -- correlation.record('delayed-a', 'turn-a'); -- setLiveChatStub(agent, 'sdk-session', { -- sessionId: 'sdk-session', currentTurnId: 'turn-a', modelCallTurnCorrelation: correlation, isDisposed: false, -- }); -- const notification = (modelCallId: string, kind = 'response.success'): GitHubTelemetryNotification => ({ -- sessionId: 'sdk-session', restricted: false, -- event: { kind, model_call_id: modelCallId, properties: {}, metrics: {} }, -- }); -- await forward(notification('call-a')); -- await received.p; -- received = new DeferredPromise(); -- await forward(notification('call-a', 'tool_call_executed')); -- await received.p; -- received = new DeferredPromise(); -- await agent.authenticate('https://api.github.com', 'token-b'); -- await forward(notification('delayed-a')); -- await received.p; -- received = new DeferredPromise(); -- await forward(notification('unmatched')); -- await received.p; -- received = new DeferredPromise(); -- await forward(notification('delayed-a', 'tool_call_executed')); -- await received.p; -- assert.deepStrictEqual(events.map(data => ({ -- copilotSku: data.copilotSku, trackingId: data['common.copilotTrackingId'], -- })), [ -- { copilotSku: 'sku', trackingId: 'analytics' }, -- { copilotSku: 'sku', trackingId: 'analytics' }, -- { copilotSku: undefined, trackingId: undefined }, -- { copilotSku: undefined, trackingId: undefined }, -- { copilotSku: undefined, trackingId: undefined }, -- ]); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token-a'); +- await agent.refreshModels(); +- const forward = getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry; +- assert.ok(forward); +- const correlation = new ModelCallTurnCorrelation(); +- const context = agent.getTelemetryContext(); +- correlation.recordTelemetryContext('call-a', context); +- correlation.record('call-a', 'turn-a'); +- correlation.recordTelemetryContext('delayed-a', context); +- correlation.record('delayed-a', 'turn-a'); +- setLiveChatStub(agent, 'sdk-session', { +- sessionId: 'sdk-session', currentTurnId: 'turn-a', modelCallTurnCorrelation: correlation, isDisposed: false, +- }); +- const notification = (modelCallId: string, kind = 'response.success'): GitHubTelemetryNotification => ({ +- sessionId: 'sdk-session', restricted: false, +- event: { kind, model_call_id: modelCallId, properties: {}, metrics: {} }, +- }); +- await forward(notification('call-a')); +- await received.p; +- received = new DeferredPromise(); +- await forward(notification('call-a', 'tool_call_executed')); +- await received.p; +- received = new DeferredPromise(); +- await agent.authenticate('https://api.github.com', 'token-b'); +- await forward(notification('delayed-a')); +- await received.p; +- received = new DeferredPromise(); +- await forward(notification('unmatched')); +- await received.p; +- received = new DeferredPromise(); +- await forward(notification('delayed-a', 'tool_call_executed')); +- await received.p; +- assert.deepStrictEqual(events.map(data => ({ +- copilotSku: data.copilotSku, trackingId: data['common.copilotTrackingId'], +- })), [ +- { copilotSku: 'sku', trackingId: 'analytics' }, +- { copilotSku: 'sku', trackingId: 'analytics' }, +- { copilotSku: undefined, trackingId: undefined }, +- { copilotSku: undefined, trackingId: undefined }, +- { copilotSku: undefined, trackingId: undefined }, +- ]); - }); - - test('waits for originating account context for user and agent initiated SDK responses', async () => { @@ -54262,66 +56663,60 @@ index f24a97a6296..00000000000 - } - }(); - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]), telemetryService }); -- try { -- await agent.listChatsToMigrate(); -- const forward = getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry; -- assert.ok(forward); -- const chat = URI.parse(buildDefaultChatUri(AgentSession.uri('copilotcli', 'sdk-session'))); -- let activeTurn = 'root-turn'; -- setLiveChatStub(agent, 'sdk-session', { -- sessionId: 'sdk-session', -- get currentTurnId() { return activeTurn; }, -- modelCallTurnCorrelation: new ModelCallTurnCorrelation(), -- isDisposed: false, -- }, chat); -- const notification = (call: string): GitHubTelemetryNotification => ({ -- sessionId: 'sdk-session', restricted: false, -- event: { kind: 'response.success', model_call_id: call, properties: { initiatorType: 'agent' }, metrics: {} }, -- }); -- agent.recordModelCallTurnCorrelation(chat, 'cached-call', 'subagent-turn'); -- await forward(notification('cached-call')); -- await received.p; -- received = new DeferredPromise(); -- await forward(notification('delayed-call')); -- activeTurn = 'new-root-turn'; -- agent.recordModelCallTurnCorrelation(chat, 'delayed-call', 'original-logical-turn'); -- await received.p; -- -- assert.deepStrictEqual(events.map(data => ({ -- turn: data.turnId, -- diagnostics: Object.fromEntries(Object.entries(data).filter(([key]) => key.startsWith('ah')).map(([key, value]) => [key, key === 'ahCorrelationWaitMs' ? typeof value === 'number' && value >= 0 : value])), -- })), [ -- { turn: 'subagent-turn', diagnostics: { ahCorrelationOutcome: 'mappingAvailable' } }, -- { turn: 'original-logical-turn', diagnostics: { ahCorrelationOutcome: 'mappingWaited', ahCorrelationWaitMs: true } }, -- ]); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- const forward = getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry; +- assert.ok(forward); +- const chat = URI.parse(buildDefaultChatUri(AgentSession.uri('copilotcli', 'sdk-session'))); +- let activeTurn = 'root-turn'; +- setLiveChatStub(agent, 'sdk-session', { +- sessionId: 'sdk-session', +- get currentTurnId() { return activeTurn; }, +- modelCallTurnCorrelation: new ModelCallTurnCorrelation(), +- isDisposed: false, +- }, chat); +- const notification = (call: string): GitHubTelemetryNotification => ({ +- sessionId: 'sdk-session', restricted: false, +- event: { kind: 'response.success', model_call_id: call, properties: { initiatorType: 'agent' }, metrics: {} }, +- }); +- agent.recordModelCallTurnCorrelation(chat, 'cached-call', 'subagent-turn'); +- await forward(notification('cached-call')); +- await received.p; +- received = new DeferredPromise(); +- await forward(notification('delayed-call')); +- activeTurn = 'new-root-turn'; +- agent.recordModelCallTurnCorrelation(chat, 'delayed-call', 'original-logical-turn'); +- await received.p; +- +- assert.deepStrictEqual(events.map(data => ({ +- turn: data.turnId, +- diagnostics: Object.fromEntries(Object.entries(data).filter(([key]) => key.startsWith('ah')).map(([key, value]) => [key, key === 'ahCorrelationWaitMs' ? typeof value === 'number' && value >= 0 : value])), +- })), [ +- { turn: 'subagent-turn', diagnostics: { ahCorrelationOutcome: 'mappingAvailable' } }, +- { turn: 'original-logical-turn', diagnostics: { ahCorrelationOutcome: 'mappingWaited', ahCorrelationWaitMs: true } }, +- ]); - }); - - test('reads turn usage from the exact provider chat with explicit child ownership', async () => { - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); -- try { -- const chat = URI.parse('copilotcli:/host-session/chat'); -- const calls: { turnId: string; parentToolCallId: string | undefined }[] = []; -- const snapshot = { summaries: [] }; -- setLiveChatStub(agent, 'sdk-session', { -- getTurnTokenUsage: (turnId: string, parentToolCallId?: string) => { -- calls.push({ turnId, parentToolCallId }); -- return snapshot; -- }, -- }, chat); -- -- assert.strictEqual(agent.getTurnTokenUsage(chat, 'root-turn'), snapshot); -- assert.strictEqual(agent.getTurnTokenUsage(chat, 'child-turn', 'owning-tool'), snapshot); -- assert.strictEqual(agent.getTurnTokenUsage(URI.parse('copilotcli:/missing-chat'), 'root-turn'), undefined); -- assert.deepStrictEqual(calls, [ -- { turnId: 'root-turn', parentToolCallId: undefined }, -- { turnId: 'child-turn', parentToolCallId: 'owning-tool' }, -- ]); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const chat = URI.parse('copilotcli:/host-session/chat'); +- const calls: { turnId: string; parentToolCallId: string | undefined }[] = []; +- const snapshot = { summaries: [] }; +- setLiveChatStub(agent, 'sdk-session', { +- getTurnTokenUsage: (turnId: string, parentToolCallId?: string) => { +- calls.push({ turnId, parentToolCallId }); +- return snapshot; +- }, +- }, chat); +- +- assert.strictEqual(agent.getTurnTokenUsage(chat, 'root-turn'), snapshot); +- assert.strictEqual(agent.getTurnTokenUsage(chat, 'child-turn', 'owning-tool'), snapshot); +- assert.strictEqual(agent.getTurnTokenUsage(URI.parse('copilotcli:/missing-chat'), 'root-turn'), undefined); +- assert.deepStrictEqual(calls, [ +- { turnId: 'root-turn', parentToolCallId: undefined }, +- { turnId: 'child-turn', parentToolCallId: 'owning-tool' }, +- ]); - }); - - test('emits exact call ownership before or after forwarding without resending usage', async () => { @@ -54331,44 +56726,41 @@ index f24a97a6296..00000000000 - } - }(); - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]), telemetryService }); -- try { -- await agent.listChatsToMigrate(); -- const forward = getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry; -- assert.ok(forward); -- const chat = URI.parse('copilotcli:/host-session/chat'); -- setLiveChatStub(agent, 'sdk-session', { -- sessionId: 'sdk-session', -- currentTurnId: 'fallback-turn', -- modelCallTurnCorrelation: new ModelCallTurnCorrelation(), -- }, chat); -- const notification = (modelCallId: string): GitHubTelemetryNotification => ({ -- sessionId: 'sdk-session', -- restricted: false, -- event: { kind: 'response.success', model_call_id: modelCallId, properties: {}, metrics: { promptTokenCount: 42 } }, -- }); -- await forward(notification('late-call')); -- agent.recordModelCallTurnCorrelation(chat, 'late-call', 'child-turn'); -- agent.recordModelCallTurnCorrelation(chat, 'late-call', 'child-turn'); -- agent.recordModelCallTurnCorrelation(chat, 'late-call', 'conflicting-turn'); -- agent.recordModelCallTurnCorrelation(chat, 'early-call', 'exact-turn'); -- await forward(notification('early-call')); -- -- const mappings = telemetryService.events.filter(event => event.eventName === 'agentHost.modelCallTurnCorrelated').map(event => event.data); -- assert.deepStrictEqual(mappings, [ -- { sdkSessionId: 'sdk-session', modelCallId: 'late-call', turnId: 'child-turn', mappingStatus: 'late' }, -- { sdkSessionId: 'sdk-session', modelCallId: 'late-call', turnId: 'conflicting-turn', mappingStatus: 'conflict' }, -- { sdkSessionId: 'sdk-session', modelCallId: 'early-call', turnId: 'exact-turn', mappingStatus: 'recorded' }, -- ]); -- assert.deepStrictEqual(telemetryService.events.filter(event => event.eventName === 'copilotSdk/response.success').map(event => { -- const data = event.data as Record; -- return { turnId: data.turnId, outcome: data.ahCorrelationOutcome, promptTokenCount: data.promptTokenCount, usageStatus: data.usageStatus }; -- }), [ -- { turnId: 'fallback-turn', outcome: 'activeTurnFallback', promptTokenCount: 42, usageStatus: 'partial' }, -- { turnId: 'exact-turn', outcome: 'mappingAvailable', promptTokenCount: 42, usageStatus: 'partial' }, -- ]); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- const forward = getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry; +- assert.ok(forward); +- const chat = URI.parse('copilotcli:/host-session/chat'); +- setLiveChatStub(agent, 'sdk-session', { +- sessionId: 'sdk-session', +- currentTurnId: 'fallback-turn', +- modelCallTurnCorrelation: new ModelCallTurnCorrelation(), +- }, chat); +- const notification = (modelCallId: string): GitHubTelemetryNotification => ({ +- sessionId: 'sdk-session', +- restricted: false, +- event: { kind: 'response.success', model_call_id: modelCallId, properties: {}, metrics: { promptTokenCount: 42 } }, +- }); +- await forward(notification('late-call')); +- agent.recordModelCallTurnCorrelation(chat, 'late-call', 'child-turn'); +- agent.recordModelCallTurnCorrelation(chat, 'late-call', 'child-turn'); +- agent.recordModelCallTurnCorrelation(chat, 'late-call', 'conflicting-turn'); +- agent.recordModelCallTurnCorrelation(chat, 'early-call', 'exact-turn'); +- await forward(notification('early-call')); +- +- const mappings = telemetryService.events.filter(event => event.eventName === 'agentHost.modelCallTurnCorrelated').map(event => event.data); +- assert.deepStrictEqual(mappings, [ +- { sdkSessionId: 'sdk-session', modelCallId: 'late-call', turnId: 'child-turn', mappingStatus: 'late' }, +- { sdkSessionId: 'sdk-session', modelCallId: 'late-call', turnId: 'conflicting-turn', mappingStatus: 'conflict' }, +- { sdkSessionId: 'sdk-session', modelCallId: 'early-call', turnId: 'exact-turn', mappingStatus: 'recorded' }, +- ]); +- assert.deepStrictEqual(telemetryService.events.filter(event => event.eventName === 'copilotSdk/response.success').map(event => { +- const data = event.data as Record; +- return { turnId: data.turnId, outcome: data.ahCorrelationOutcome, promptTokenCount: data.promptTokenCount, usageStatus: data.usageStatus }; +- }), [ +- { turnId: 'fallback-turn', outcome: 'activeTurnFallback', promptTokenCount: 42, usageStatus: 'partial' }, +- { turnId: 'exact-turn', outcome: 'mappingAvailable', promptTokenCount: 42, usageStatus: 'partial' }, +- ]); - }); - - test('routes exact legacy targets exclusively and falls back to generic forwarding', async () => { @@ -54412,35 +56804,32 @@ index f24a97a6296..00000000000 - } - }(NullTelemetryService)); - const agent = createTestAgent(disposables, { copilotClient: client, copilotApiService, telemetryService }) as TestableCopilotAgent; -- try { -- await agent.authenticate('https://api.github.com', 'restricted-token'); -- await telemetryService.restrictedEnabled.p; -- await agent.listChatsToMigrate(); -- const forward = getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry; -- assert.ok(forward); -- -- const notification = (kind: string, restricted: boolean): GitHubTelemetryNotification => ({ -- sessionId: 'session-1', -- restricted, -- event: { kind, properties: {}, metrics: {} }, -- }); -- await forward(notification('engine.messages.length', true)); -- await forward(notification('engine.messages', false)); -- await forward(notification('unknown_restricted', true)); -- await forward(notification('tool_call_executed', false)); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'restricted-token'); +- await telemetryService.restrictedEnabled.p; +- await agent.listChatsToMigrate(); +- const forward = getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry; +- assert.ok(forward); +- +- const notification = (kind: string, restricted: boolean): GitHubTelemetryNotification => ({ +- sessionId: 'session-1', +- restricted, +- event: { kind, properties: {}, metrics: {} }, +- }); +- await forward(notification('engine.messages.length', true)); +- await forward(notification('engine.messages', false)); +- await forward(notification('unknown_restricted', true)); +- await forward(notification('tool_call_executed', false)); - -- assert.deepStrictEqual({ -- generic: telemetryService.genericEvents, -- enhanced: telemetryService.enhancedEvents, -- internal: telemetryService.internalEvents, -- }, { -- generic: ['copilotSdk/unknown_restricted', 'copilotSdk/tool_call_executed'], -- enhanced: ['engine.messages.length'], -- internal: ['engine.messages.length'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- generic: telemetryService.genericEvents, +- enhanced: telemetryService.enhancedEvents, +- internal: telemetryService.internalEvents, +- }, { +- generic: ['copilotSdk/unknown_restricted', 'copilotSdk/tool_call_executed'], +- enhanced: ['engine.messages.length'], +- internal: ['engine.messages.length'], +- }); - }); - - test('routes exact targets using the current auth token and reflects token changes', async () => { @@ -54473,50 +56862,47 @@ index f24a97a6296..00000000000 - } - }(NullTelemetryService)); - const agent = createTestAgent(disposables, { copilotClient: client, copilotApiService, telemetryService }) as TestableCopilotAgent; -- try { -- await agent.authenticate('https://api.github.com', 'token-a'); -- await agent.listChatsToMigrate(); -- const forward = getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry; -- assert.ok(forward); -- -- const notification: GitHubTelemetryNotification = { -- sessionId: 'session-a', -- restricted: true, -- event: { kind: 'engine.messages.length', properties: {}, metrics: {} }, -- }; -- await forward(notification); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token-a'); +- await agent.listChatsToMigrate(); +- const forward = getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry; +- assert.ok(forward); +- +- const notification: GitHubTelemetryNotification = { +- sessionId: 'session-a', +- restricted: true, +- event: { kind: 'engine.messages.length', properties: {}, metrics: {} }, +- }; +- await forward(notification); - -- // Swapping the current auth token to opted-out token-b: later events resolve context from it and emit nothing. -- await agent.authenticate('https://api.github.com', 'token-b'); -- await forward(notification); +- // Swapping the current auth token to opted-out token-b: later events resolve context from it and emit nothing. +- await agent.authenticate('https://api.github.com', 'token-b'); +- await forward(notification); - -- assert.deepStrictEqual({ -- enhancedContexts: telemetryService.enhancedContexts, -- internalContexts: telemetryService.internalContexts.map(context => ({ -- isInternal: context.isInternal, -- trackingId: context.trackingId, -- userName: context.userName, -- isVscodeTeamMember: context.isVscodeTeamMember, -- })), -- }, { -- enhancedContexts: [{ -- restrictedTelemetryEnabled: true, -- trackingId: 'token-a-tid', -- telemetryEndpoint: 'https://token-a.telemetry.example/telemetry', -- isInternal: true, -- userName: 'token-a-user', -- isVscodeTeamMember: true, -- }], -- internalContexts: [{ -- isInternal: true, -- trackingId: 'token-a-tid', -- userName: 'token-a-user', -- isVscodeTeamMember: true, -- }], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- enhancedContexts: telemetryService.enhancedContexts, +- internalContexts: telemetryService.internalContexts.map(context => ({ +- isInternal: context.isInternal, +- trackingId: context.trackingId, +- userName: context.userName, +- isVscodeTeamMember: context.isVscodeTeamMember, +- })), +- }, { +- enhancedContexts: [{ +- restrictedTelemetryEnabled: true, +- trackingId: 'token-a-tid', +- telemetryEndpoint: 'https://token-a.telemetry.example/telemetry', +- isInternal: true, +- userName: 'token-a-user', +- isVscodeTeamMember: true, +- }], +- internalContexts: [{ +- isInternal: true, +- trackingId: 'token-a-tid', +- userName: 'token-a-user', +- isVscodeTeamMember: true, +- }], +- }); - }); - - test('pushes the account internal signal to telemetry on authentication', async () => { @@ -54542,37 +56928,31 @@ index f24a97a6296..00000000000 - } - }(NullTelemetryService)); - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]), copilotApiService, telemetryService }) as TestableCopilotAgent; -- try { -- await agent.authenticate('https://api.github.com', 'internal-token'); -- await telemetryService.secondContextSet.p; +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'internal-token'); +- await telemetryService.secondContextSet.p; - -- assert.deepStrictEqual(telemetryService.internalContexts, [ -- undefined, // cleared until `/copilot_internal/user` resolves -- { isInternal: true, trackingId: 'internal-tid', userName: 'octocat', isVscodeTeamMember: true }, -- ]); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(telemetryService.internalContexts, [ +- undefined, // cleared until `/copilot_internal/user` resolves +- { isInternal: true, trackingId: 'internal-tid', userName: 'octocat', isVscodeTeamMember: true }, +- ]); - }); - - test('advertises Copilot as its display name', async () => { - const agent = createTestAgent(disposables); -- try { -- assert.deepStrictEqual({ -- descriptor: agent.getDescriptor(), -- agentHostCapabilities: agent.agentHostCapabilities, -- }, { -- descriptor: { -- provider: 'copilotcli', -- displayName: 'Copilot', -- description: 'Copilot SDK agent running in the local agent host process', -- capabilities: { multipleChats: { fork: true, sideChat: true }, multipleWorkingDirectories: { immutablePrimary: true } }, -- }, -- agentHostCapabilities: { workspaceConversion: true }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- assert.deepStrictEqual({ +- descriptor: agent.getDescriptor(), +- agentHostCapabilities: agent.agentHostCapabilities, +- }, { +- descriptor: { +- provider: 'copilotcli', +- displayName: 'Copilot', +- description: 'Copilot SDK agent running in the local agent host process', +- capabilities: { multipleChats: { fork: true, sideChat: true }, multipleWorkingDirectories: { immutablePrimary: true } }, +- }, +- agentHostCapabilities: { workspaceConversion: true }, +- }); - }); - - test('mock defaults advertise initial title support while custom capabilities remain unchanged', () => { @@ -54603,40 +56983,33 @@ index f24a97a6296..00000000000 - - test('advertises multipleWorkingDirectories by default', async () => { - const { agent } = createTestAgentContext(disposables); -- try { -- assert.deepStrictEqual(agent.getDescriptor().capabilities?.multipleWorkingDirectories, { immutablePrimary: true }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- assert.deepStrictEqual(agent.getDescriptor().capabilities?.multipleWorkingDirectories, { immutablePrimary: true }); - }); - - test('computeFolderPickerDecision hides the picker unless multiple folders carry .github/hooks', async () => { -- const fileService = disposables.add(new FileService(new NullLogService())); -- disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); +- const fileService = createInMemoryFileService(disposables, Schemas.inMemory); - const folder = (name: string) => URI.from({ scheme: Schemas.inMemory, path: `/${name}` }); - const seedHook = (name: string, file = 'hook.json') => fileService.writeFile(URI.joinPath(folder(name), '.github', 'hooks', file), VSBuffer.fromString('{}')); - const [a, b, c] = [folder('wsA'), folder('wsB'), folder('wsC')]; - - const { agent } = createTestAgentContext(disposables, { fileService }); -- try { -- await seedHook('wsB'); -- const soleHookFolder = await agent.computeFolderPickerDecision([a, b, c]); +- afterTest(() => disposeAgent(agent)); +- await seedHook('wsB'); +- const soleHookFolder = await agent.computeFolderPickerDecision([a, b, c]); - -- await seedHook('wsA', 'nested/other.json'); -- const multipleHookFolders = await agent.computeFolderPickerDecision([a, b, c]); +- await seedHook('wsA', 'nested/other.json'); +- const multipleHookFolders = await agent.computeFolderPickerDecision([a, b, c]); - -- const noHookFolders = await agent.computeFolderPickerDecision([folder('wsX'), folder('wsY')]); -- const singleWorkingDirectory = await agent.computeFolderPickerDecision([b]); +- const noHookFolders = await agent.computeFolderPickerDecision([folder('wsX'), folder('wsY')]); +- const singleWorkingDirectory = await agent.computeFolderPickerDecision([b]); - -- assert.deepStrictEqual({ soleHookFolder, multipleHookFolders, noHookFolders, singleWorkingDirectory }, { -- soleHookFolder: { hidden: true, primary: b.toString() }, -- multipleHookFolders: { hidden: false }, -- noHookFolders: { hidden: true }, -- singleWorkingDirectory: undefined, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ soleHookFolder, multipleHookFolders, noHookFolders, singleWorkingDirectory }, { +- soleHookFolder: { hidden: true, primary: b.toString() }, +- multipleHookFolders: { hidden: false }, +- noHookFolders: { hidden: true }, +- singleWorkingDirectory: undefined, +- }); - }); - - suite('spawned chat channel', () => { @@ -54648,71 +57021,65 @@ index f24a97a6296..00000000000 - const agent = createTestAgent(disposables); - const spawned: IAgentSpawnChatEvent[] = []; - disposables.add(agent.onDidSpawnChat(e => spawned.push(e))); -- try { -- const first = AgentSession.uri('copilotcli', 'spawn-first'); -- const second = AgentSession.uri('copilotcli', 'spawn-second'); -- const parentChat = URI.parse(buildDefaultChatUri(first.toString())); -- const owner = disposables.add(new TestChatOwnerRef(first, 'shaped')); -- agent['_rememberChatScope'](parentChat, owner, first); -- const start = (toolCallId: string) => fireSignal(agent, { kind: 'subagent_started', chat: parentChat, toolCallId, agentName: 'researcher', agentDisplayName: 'Researcher', agentDescription: 'Looks things up' }); -- -- start('before-change'); -- owner.set(second, 'opaque'); -- start('after-change'); -- -- assert.deepStrictEqual(spawned.map(e => ({ session: e.session.toString(), chat: e.chat.toString() })), [ -- { session: first.toString(), chat: buildSubagentChatUri(first, 'before-change') }, -- { session: second.toString(), chat: subagentChatUri('opaque', second.toString(), parentChat.toString(), 'after-change') }, -- ]); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const first = AgentSession.uri('copilotcli', 'spawn-first'); +- const second = AgentSession.uri('copilotcli', 'spawn-second'); +- const parentChat = URI.parse(buildDefaultChatUri(first.toString())); +- const owner = disposables.add(new TestChatOwnerRef(first, 'shaped')); +- agent['_rememberChatScope'](parentChat, owner, first); +- const start = (toolCallId: string) => fireSignal(agent, { kind: 'subagent_started', chat: parentChat, toolCallId, agentName: 'researcher', agentDisplayName: 'Researcher', agentDescription: 'Looks things up' }); +- +- start('before-change'); +- owner.set(second, 'opaque'); +- start('after-change'); +- +- assert.deepStrictEqual(spawned.map(e => ({ session: e.session.toString(), chat: e.chat.toString() })), [ +- { session: first.toString(), chat: buildSubagentChatUri(first, 'before-change') }, +- { session: second.toString(), chat: subagentChatUri('opaque', second.toString(), parentChat.toString(), 'after-change') }, +- ]); - }); - - test('mirrors subagent_started onto onDidSpawnChat; subagent_completed leaves the chat live', async () => { - const agent = createTestAgent(disposables); - const spawned: IAgentSpawnChatEvent[] = []; - disposables.add(agent.onDidSpawnChat(e => spawned.push(e))); -- try { -- const sessionUri = AgentSession.uri('copilotcli', 'spawn-session'); -- const parentChat = buildDefaultChatUri(sessionUri.toString()); -- const toolCallId = 'tool-42'; -- const expectedChat = buildSubagentChatUri(sessionUri, toolCallId); -- // The host records each chat's owning scope when it creates or materializes it. -- agent['_rememberChatScope'](URI.parse(parentChat), fixedChatOwnerRef(sessionUri), sessionUri); -- -- fireSignal(agent, { -- kind: 'subagent_started', -- chat: URI.parse(parentChat), -- toolCallId, -- agentName: 'researcher', -- agentDisplayName: 'Researcher', -- agentDescription: 'Looks things up', -- }); +- afterTest(() => disposeAgent(agent)); +- const sessionUri = AgentSession.uri('copilotcli', 'spawn-session'); +- const parentChat = buildDefaultChatUri(sessionUri.toString()); +- const toolCallId = 'tool-42'; +- const expectedChat = buildSubagentChatUri(sessionUri, toolCallId); +- // The host records each chat's owning scope when it creates or materializes it. +- agent['_rememberChatScope'](URI.parse(parentChat), fixedChatOwnerRef(sessionUri), sessionUri); +- +- fireSignal(agent, { +- kind: 'subagent_started', +- chat: URI.parse(parentChat), +- toolCallId, +- agentName: 'researcher', +- agentDisplayName: 'Researcher', +- agentDescription: 'Looks things up', +- }); - -- // Unrelated signals must not produce spawn events. -- fireSignal(agent, { kind: 'action', resource: sessionUri, action: { type: ActionType.SessionTitleChanged, title: 'x' } }); -- // A completed subagent chat stays live (removed only on session teardown). -- fireSignal(agent, { kind: 'subagent_completed', chat: URI.parse(parentChat), toolCallId }); +- // Unrelated signals must not produce spawn events. +- fireSignal(agent, { kind: 'action', resource: sessionUri, action: { type: ActionType.SessionTitleChanged, title: 'x' } }); +- // A completed subagent chat stays live (removed only on session teardown). +- fireSignal(agent, { kind: 'subagent_completed', chat: URI.parse(parentChat), toolCallId }); - -- assert.deepStrictEqual({ -- spawned: spawned.map(e => ({ -- session: e.session.toString(), -- chat: e.chat.toString(), -- parent: e.parent ? { chat: e.parent.chat.toString(), toolCallId: e.parent.toolCallId } : undefined, -- title: e.title, -- })), -- }, { -- spawned: [{ -- session: sessionUri.toString(), -- chat: expectedChat, -- parent: { chat: parentChat, toolCallId }, -- title: 'Researcher', -- }], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- spawned: spawned.map(e => ({ +- session: e.session.toString(), +- chat: e.chat.toString(), +- parent: e.parent ? { chat: e.parent.chat.toString(), toolCallId: e.parent.toolCallId } : undefined, +- title: e.title, +- })), +- }, { +- spawned: [{ +- session: sessionUri.toString(), +- chat: expectedChat, +- parent: { chat: parentChat, toolCallId }, +- title: 'Researcher', +- }], +- }); - }); - - test('getMessages resolves a canonical subagent chat through its exact parent chat', async () => { @@ -54732,16 +57099,13 @@ index f24a97a6296..00000000000 - dispose: () => { }, - }, parentChat); - -- try { -- const turns = await agent.chats.getMessages(childChat, { -- sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(session), -- resource: childChat, -- origin: { kind: ChatOriginKind.Tool, chat: parentChat.toString(), toolCallId: 'tool-1' }, -- }); -- assert.deepStrictEqual(turns, [turn]); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const turns = await agent.chats.getMessages(childChat, { +- sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(session), +- resource: childChat, +- origin: { kind: ChatOriginKind.Tool, chat: parentChat.toString(), toolCallId: 'tool-1' }, +- }); +- assert.deepStrictEqual(turns, [turn]); - }); - test('getMessages requires the host-supplied tool origin to reconstruct a subagent chat', async () => { - // Regression guard for the removed `chatId.startsWith('subagent/')` @@ -54767,23 +57131,20 @@ index f24a97a6296..00000000000 - dispose: () => { }, - }, parentChat); - -- try { -- assert.deepStrictEqual({ -- withoutOrigin: await agent.chats.getMessages(childChat, { sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(session), resource: childChat }), -- subagentCalls, -- withOrigin: await agent.chats.getMessages(childChat, { -- sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(session), -- resource: childChat, -- origin: { kind: ChatOriginKind.Tool, chat: parentChat.toString(), toolCallId: 'tool-1' }, -- }), -- }, { -- withoutOrigin: [], -- subagentCalls: ['tool-1'], -- withOrigin: [turn], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- assert.deepStrictEqual({ +- withoutOrigin: await agent.chats.getMessages(childChat, { sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(session), resource: childChat }), +- subagentCalls, +- withOrigin: await agent.chats.getMessages(childChat, { +- sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(session), +- resource: childChat, +- origin: { kind: ChatOriginKind.Tool, chat: parentChat.toString(), toolCallId: 'tool-1' }, +- }), +- }, { +- withoutOrigin: [], +- subagentCalls: ['tool-1'], +- withOrigin: [turn], +- }); - }); - - test('getMessages resumes a cold peer parent of a subagent with the parent\'s own storage', async () => { @@ -54807,16 +57168,13 @@ index f24a97a6296..00000000000 - return { getSubagentMessages: async (toolCallId: string) => toolCallId === 'tool-3' ? [turn] : [] }; - }; - -- try { -- const turns = await agent.chats.getMessages(childChat, { -- ...exactChatContext(session, childChat), -- origin: { kind: ChatOriginKind.Tool, chat: parentChat.toString(), toolCallId: 'tool-3' }, -- }); +- afterTest(() => disposeAgent(agent)); +- const turns = await agent.chats.getMessages(childChat, { +- ...exactChatContext(session, childChat), +- origin: { kind: ChatOriginKind.Tool, chat: parentChat.toString(), toolCallId: 'tool-3' }, +- }); - -- assert.deepStrictEqual({ turns, resumedWith }, { turns: [turn], resumedWith: [`${parentChat.toString()} ${parentChat.toString()}`] }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ turns, resumedWith }, { turns: [turn], resumedWith: [`${parentChat.toString()} ${parentChat.toString()}`] }); - }); - - test('a chat keeps one operation queue when its owner changes while an operation is in flight', async () => { @@ -54839,19 +57197,16 @@ index f24a97a6296..00000000000 - }, 'peer-queue-sdk'); - const context = { ...exactChatContext(session, peerChat), owner }; - -- try { -- const first = agent.chats.changeModel!(peerChat, { id: 'model-a' }, context); -- await timeout(0); -- owner.set(later); -- const second = agent.chats.changeModel!(peerChat, { id: 'model-b' }, context); -- await timeout(0); -- firstBlocked.complete(); -- await Promise.all([first, second]); +- afterTest(() => disposeAgent(agent)); +- const first = agent.chats.changeModel!(peerChat, { id: 'model-a' }, context); +- await timeout(0); +- owner.set(later); +- const second = agent.chats.changeModel!(peerChat, { id: 'model-b' }, context); +- await timeout(0); +- firstBlocked.complete(); +- await Promise.all([first, second]); - -- assert.deepStrictEqual(order, ['model-a start', 'model-a end', 'model-b start', 'model-b end']); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(order, ['model-a start', 'model-a end', 'model-b start', 'model-b end']); - }); - - test('a deferred chat keeps its reserved backing when its owner changes before its first send', async () => { @@ -54862,17 +57217,14 @@ index f24a97a6296..00000000000 - const owner = disposables.add(new TestChatOwnerRef(session)); - // The chat stores in the session it was created in, whichever session owns it. - const context = { ...exactChatContext(session, chat, session), owner }; -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.chats.createChat(chat, context, { workingDirectories: [URI.file('/workspace')], deferBacking: true }); -- owner.set(later); -- await agent.chats.changeModel!(chat, { id: 'model-after-move' }, context); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.chats.createChat(chat, context, { workingDirectories: [URI.file('/workspace')], deferBacking: true }); +- owner.set(later); +- await agent.chats.changeModel!(chat, { id: 'model-after-move' }, context); - -- const provisional = (agent as unknown as { _provisionalSessions: Map })._provisionalSessions.get(AgentSession.id(session)); -- assert.deepStrictEqual({ chat: provisional?.chat.toString(), model: provisional?.model?.id }, { chat: chat.toString(), model: 'model-after-move' }); -- } finally { -- await disposeAgent(agent); -- } +- const provisional = (agent as unknown as { _provisionalSessions: Map })._provisionalSessions.get(AgentSession.id(session)); +- assert.deepStrictEqual({ chat: provisional?.chat.toString(), model: provisional?.model?.id }, { chat: chat.toString(), model: 'model-after-move' }); - }); - - test('a moved default chat restored before its first run keeps its reservation where later operations find it', async () => { @@ -54886,17 +57238,14 @@ index f24a97a6296..00000000000 - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); - const chat = defaultChatUri(storage); - const context = exactChatContext(owner, chat, storage); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.materializeChat(chat, context, JSON.stringify({ sdkSessionId: 'deferred-restore-sdk' })); -- await agent.chats.changeModel!(chat, { id: 'model-after-restore' }, context); -- await agent.truncateChat(chat, undefined, context); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.materializeChat(chat, context, JSON.stringify({ sdkSessionId: 'deferred-restore-sdk' })); +- await agent.chats.changeModel!(chat, { id: 'model-after-restore' }, context); +- await agent.truncateChat(chat, undefined, context); - -- const provisional = (agent as unknown as { _provisionalSessions: Map })._provisionalSessions.get(AgentSession.id(storage)); -- assert.deepStrictEqual({ chat: provisional?.chat.toString(), model: provisional?.model?.id }, { chat: chat.toString(), model: 'model-after-restore' }); -- } finally { -- await disposeAgent(agent); -- } +- const provisional = (agent as unknown as { _provisionalSessions: Map })._provisionalSessions.get(AgentSession.id(storage)); +- assert.deepStrictEqual({ chat: provisional?.chat.toString(), model: provisional?.model?.id }, { chat: chat.toString(), model: 'model-after-restore' }); - }); - - test('getMessages never resumes an unknown subagent parent against its owner\'s database', async () => { @@ -54911,16 +57260,13 @@ index f24a97a6296..00000000000 - return undefined; - }; - -- try { -- const turns = await agent.chats.getMessages(childChat, { -- ...exactChatContext(session, childChat), -- origin: { kind: ChatOriginKind.Tool, chat: parentChat.toString(), toolCallId: 'tool-9' }, -- }); +- afterTest(() => disposeAgent(agent)); +- const turns = await agent.chats.getMessages(childChat, { +- ...exactChatContext(session, childChat), +- origin: { kind: ChatOriginKind.Tool, chat: parentChat.toString(), toolCallId: 'tool-9' }, +- }); - -- assert.deepStrictEqual({ turns, resumed }, { turns: [], resumed: [] }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ turns, resumed }, { turns: [], resumed: [] }); - }); - - test('getMessages reconstructs a legacy subagent session address from the origin the host stamps', async () => { @@ -54944,16 +57290,13 @@ index f24a97a6296..00000000000 - dispose: () => { }, - }, parentChat); - -- try { -- const turns = await agent.chats.getMessages(legacyChild, { -- sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(session), -- resource: legacyChild, -- origin: { kind: ChatOriginKind.Tool, chat: parentChat.toString(), toolCallId: 'tool-7' }, -- }); -- assert.deepStrictEqual(turns, [turn]); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const turns = await agent.chats.getMessages(legacyChild, { +- sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(session), +- resource: legacyChild, +- origin: { kind: ChatOriginKind.Tool, chat: parentChat.toString(), toolCallId: 'tool-7' }, +- }); +- assert.deepStrictEqual(turns, [turn]); - }); - }); - @@ -55123,22 +57466,19 @@ index f24a97a6296..00000000000 - copilotApiService.apiEndpoint = 'https://copilot.example.com'; - copilotApiService.userLogin = 'octocat'; - const agent = createTestAgent(disposables, { copilotApiService, gitHubEndpointService: endpointService }); -- try { -- await agent.authenticate(endpointService.getCopilotResource().resource, 'token'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate(endpointService.getCopilotResource().resource, 'token'); - -- assert.deepStrictEqual({ -- endpoints: await agent.getNetworkDiagnosticsEndpoints(), -- account: await agent.getNetworkDiagnosticsAccount(), -- }, { -- endpoints: [ -- { name: 'GitHub API', url: endpointService.getApiBaseUri() }, -- { name: 'Copilot API (CAPI)', url: 'https://copilot.example.com/_ping' }, -- ], -- account: 'octocat', -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- endpoints: await agent.getNetworkDiagnosticsEndpoints(), +- account: await agent.getNetworkDiagnosticsAccount(), +- }, { +- endpoints: [ +- { name: 'GitHub API', url: endpointService.getApiBaseUri() }, +- { name: 'Copilot API (CAPI)', url: 'https://copilot.example.com/_ping' }, +- ], +- account: 'octocat', +- }); - }); - - test('queries sessionless managed settings with the host token and client identity', async () => { @@ -55176,56 +57516,47 @@ index f24a97a6296..00000000000 - gitHubEndpointService: endpointService, - rootConfig: { [AgentHostProxyConfigKey.Proxy]: 'http://proxy.example.com:8080' }, - }); -- try { -- await agent.authenticate(endpointService.getCopilotResource().resource, 'token'); -- const snapshot = await agent.getManagedSettingsDiagnostics(); -- const options = getCreatedClientOptions(agent).at(-1); -- assert.deepStrictEqual({ -- snapshot, -- request: client.managedSettingsRequests.at(-1), -- host: options?.env?.['COPILOT_GH_HOST'], -- proxy: options?.env?.['HTTPS_PROXY'], -- noProxy: options?.env?.['NO_PROXY'], -- useLoggedInUser: options?.useLoggedInUser, -- }, { -- snapshot: { ...client.managedSettingsResolution.resolved, account: 'test-account', layers: client.managedSettingsResolution.layers, diagnostics: client.managedSettingsResolution.diagnostics }, -- request: { gitHubToken: 'token', clientName: 'vscode-agent-host' }, -- host: endpointService.getEnterpriseHost(), -- proxy: 'http://proxy.example.com:8080', -- noProxy: 'localhost,127.0.0.1,::1,::ffff:127.0.0.1', -- useLoggedInUser: false, -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate(endpointService.getCopilotResource().resource, 'token'); +- const snapshot = await agent.getManagedSettingsDiagnostics(); +- const options = getCreatedClientOptions(agent).at(-1); +- assert.deepStrictEqual({ +- snapshot, +- request: client.managedSettingsRequests.at(-1), +- host: options?.env?.['COPILOT_GH_HOST'], +- proxy: options?.env?.['HTTPS_PROXY'], +- noProxy: options?.env?.['NO_PROXY'], +- useLoggedInUser: options?.useLoggedInUser, +- }, { +- snapshot: { ...client.managedSettingsResolution.resolved, account: 'test-account', layers: client.managedSettingsResolution.layers, diagnostics: client.managedSettingsResolution.diagnostics }, +- request: { gitHubToken: 'token', clientName: 'vscode-agent-host' }, +- host: endpointService.getEnterpriseHost(), +- proxy: 'http://proxy.example.com:8080', +- noProxy: 'localhost,127.0.0.1,::1,::ffff:127.0.0.1', +- useLoggedInUser: false, +- }); - }); - - test('managed settings diagnostics work before authentication without creating a session', async () => { - const client = new TestCopilotClient([]); - const agent = createTestAgent(disposables, { copilotClient: client }); -- try { -- const first = await agent.getManagedSettingsDiagnostics(); -- const second = await agent.getManagedSettingsDiagnostics(); -- assert.deepStrictEqual({ first, second, starts: client.startCallCount, requests: client.managedSettingsRequests }, { -- first: { ...client.managedSettingsResolution.resolved, layers: [], diagnostics: [] }, -- second: first, -- starts: 1, -- requests: [{ clientName: 'vscode-agent-host' }, { clientName: 'vscode-agent-host' }], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const first = await agent.getManagedSettingsDiagnostics(); +- const second = await agent.getManagedSettingsDiagnostics(); +- assert.deepStrictEqual({ first, second, starts: client.startCallCount, requests: client.managedSettingsRequests }, { +- first: { ...client.managedSettingsResolution.resolved, layers: [], diagnostics: [] }, +- second: first, +- starts: 1, +- requests: [{ clientName: 'vscode-agent-host' }, { clientName: 'vscode-agent-host' }], +- }); - }); - - test('managed settings diagnostics surface resolver failures instead of an empty snapshot', async () => { - const client = new TestCopilotClient([]); - client.resolveManagedSettings = async () => { throw new Error('Policy resolution failed'); }; - const agent = createTestAgent(disposables, { copilotClient: client }); -- try { -- await assert.rejects(agent.getManagedSettingsDiagnostics(), /Policy resolution failed/); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await assert.rejects(agent.getManagedSettingsDiagnostics(), /Policy resolution failed/); - }); - - test('managed settings diagnostics reject an account change during resolution', async () => { @@ -55346,22 +57677,19 @@ index f24a97a6296..00000000000 - ownedDb.dispose(); - const client = new TestCopilotClient([sdkSession('owned-before-auth')]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); -- try { -- const catalog = await agent.listChatsToMigrate(); -- assert.deepStrictEqual({ -- models: agent.models.get(), -- sessions: catalog?.map(session => sessionIdOfChat(session.chat)), -- starts: client.startCallCount, -- listCalls: client.listSessionCallCount, -- }, { -- models: [], -- sessions: ['owned-before-auth'], -- starts: 1, -- listCalls: 1, -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const catalog = await agent.listChatsToMigrate(); +- assert.deepStrictEqual({ +- models: agent.models.get(), +- sessions: catalog?.map(session => sessionIdOfChat(session.chat)), +- starts: client.startCallCount, +- listCalls: client.listSessionCallCount, +- }, { +- models: [], +- sessions: ['owned-before-auth'], +- starts: 1, +- listCalls: 1, +- }); - }); - - test('bounds native session metadata resolution while listing legacy chats', async () => { @@ -55386,18 +57714,15 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new BlockingSessionDataService()); - const client = new TestCopilotClient(Array.from({ length: 8 }, (_, index) => sdkSession(`legacy-${index}`))); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); -- try { -- const listing = agent.listChatsToMigrate(); -- await sessionDataService.readsStarted.p; -- assert.strictEqual(sessionDataService.activeReads, 4); +- afterTest(() => disposeAgent(agent)); +- const listing = agent.listChatsToMigrate(); +- await sessionDataService.readsStarted.p; +- assert.strictEqual(sessionDataService.activeReads, 4); - -- sessionDataService.gate.complete(); -- await listing; +- sessionDataService.gate.complete(); +- await listing; - -- assert.strictEqual(sessionDataService.maxActiveReads, 4); -- } finally { -- await disposeAgent(agent); -- } +- assert.strictEqual(sessionDataService.maxActiveReads, 4); - }); - - test('starts the client and creates a provisional session before authentication', async () => { @@ -55405,25 +57730,22 @@ index f24a97a6296..00000000000 - const agent = createTestAgent(disposables, { copilotClient: client }); - const session = AgentSession.uri('copilotcli', 'unauth-create'); - const workingDirectory = URI.file('/workspace'); -- try { -- const result = await provisionSession(agent, { session, workingDirectories: [workingDirectory] }); -- assert.ok(result.resolvedWorkingDirectory); -- assert.deepStrictEqual({ -- session: result.session.toString(), -- workingDirectory: result.resolvedWorkingDirectory.toString(), -- provisional: result.provisional, -- starts: client.startCallCount, -- stops: client.stopCallCount, -- }, { -- session: session.toString(), -- workingDirectory: workingDirectory.toString(), -- provisional: true, -- starts: 1, -- stops: 0, -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const result = await provisionSession(agent, { session, workingDirectories: [workingDirectory] }); +- assert.ok(result.resolvedWorkingDirectory); +- assert.deepStrictEqual({ +- session: result.session.toString(), +- workingDirectory: result.resolvedWorkingDirectory.toString(), +- provisional: result.provisional, +- starts: client.startCallCount, +- stops: client.stopCallCount, +- }, { +- session: session.toString(), +- workingDirectory: workingDirectory.toString(), +- provisional: true, +- starts: 1, +- stops: 0, +- }); - }); - - test('passes the GitHub token when refreshing models', async () => { @@ -55432,14 +57754,11 @@ index f24a97a6296..00000000000 - name: 'GPT-4o', - }]); - const agent = createTestAgent(disposables, { copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'model-token'); -- await waitForState(agent.models, models => models.length > 0); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'model-token'); +- await waitForState(agent.models, models => models.length > 0); - -- assert.deepStrictEqual(client.modelListRequests, [{ gitHubToken: 'model-token' }]); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(client.modelListRequests, [{ gitHubToken: 'model-token' }]); - }); - - test('restarts an authenticated GitHub Enterprise client after a token change', async () => { @@ -55449,50 +57768,47 @@ index f24a97a6296..00000000000 - }]); - const endpointService = createTestGitHubEndpointService('https://example.ghe.com'); - const agent = createTestAgent(disposables, { copilotClient: client, gitHubEndpointService: endpointService }); -- try { -- await agent.authenticate(endpointService.getCopilotResource().resource, 'enterprise-model-token'); -- await waitForState(agent.models, models => models.length > 0); -- let hasActiveTurn = true; -- const liveSession = { -- get hasActiveTurn() { return hasActiveTurn; }, -- usesStaticGitHubToken: false, -- updateGitHubCredentials: async () => { throw new Error('unexpected credential update'); }, -- dispose() { }, -- } satisfies ICredentialUpdateSession; -- setDefaultSessionStub(agent, 'enterprise-active-turn', liveSession); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate(endpointService.getCopilotResource().resource, 'enterprise-model-token'); +- await waitForState(agent.models, models => models.length > 0); +- let hasActiveTurn = true; +- const liveSession = { +- get hasActiveTurn() { return hasActiveTurn; }, +- usesStaticGitHubToken: false, +- updateGitHubCredentials: async () => { throw new Error('unexpected credential update'); }, +- dispose() { }, +- } satisfies ICredentialUpdateSession; +- setDefaultSessionStub(agent, 'enterprise-active-turn', liveSession); - -- await agent.authenticate(endpointService.getCopilotResource().resource, 'rotated-enterprise-model-token'); -- const whileTurnActive = { -- clientStops: client.stopCallCount, -- modelListRequests: client.modelListRequests.length, -- }; -- hasActiveTurn = false; -- (agent as unknown as { _onChatTurnEnded(): void })._onChatTurnEnded(); -- await waitForState(agent.models, () => client.modelListRequests.length === 2); +- await agent.authenticate(endpointService.getCopilotResource().resource, 'rotated-enterprise-model-token'); +- const whileTurnActive = { +- clientStops: client.stopCallCount, +- modelListRequests: client.modelListRequests.length, +- }; +- hasActiveTurn = false; +- (agent as unknown as { _onChatTurnEnded(): void })._onChatTurnEnded(); +- await waitForState(agent.models, () => client.modelListRequests.length === 2); - -- assert.deepStrictEqual({ -- whileTurnActive, -- clientTokens: getCreatedClientOptions(agent).map(options => options.gitHubToken), -- enterpriseHosts: getCreatedClientOptions(agent).map(options => options.env?.['COPILOT_GH_HOST']), -- clientStarts: client.startCallCount, -- clientStops: client.stopCallCount, -- modelListRequests: client.modelListRequests, -- authenticationRequired: agent.authenticationRequired.get(), -- }, { -- whileTurnActive: { -- clientStops: 0, -- modelListRequests: 1, -- }, -- clientTokens: ['enterprise-model-token', 'rotated-enterprise-model-token'], -- enterpriseHosts: ['example.ghe.com', 'example.ghe.com'], -- clientStarts: 2, -- clientStops: 1, -- modelListRequests: [{}, {}], -- authenticationRequired: undefined, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- whileTurnActive, +- clientTokens: getCreatedClientOptions(agent).map(options => options.gitHubToken), +- enterpriseHosts: getCreatedClientOptions(agent).map(options => options.env?.['COPILOT_GH_HOST']), +- clientStarts: client.startCallCount, +- clientStops: client.stopCallCount, +- modelListRequests: client.modelListRequests, +- authenticationRequired: agent.authenticationRequired.get(), +- }, { +- whileTurnActive: { +- clientStops: 0, +- modelListRequests: 1, +- }, +- clientTokens: ['enterprise-model-token', 'rotated-enterprise-model-token'], +- enterpriseHosts: ['example.ghe.com', 'example.ghe.com'], +- clientStarts: 2, +- clientStops: 1, +- modelListRequests: [{}, {}], +- authenticationRequired: undefined, +- }); - }); - - test('revoking authentication clears the token and model catalog', async () => { @@ -55501,23 +57817,20 @@ index f24a97a6296..00000000000 - name: 'GPT-4o', - }]); - const agent = createTestAgent(disposables, { copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'model-token'); -- await waitForState(agent.models, models => models.length > 0); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'model-token'); +- await waitForState(agent.models, models => models.length > 0); - -- await agent.authenticate('https://api.github.com', ''); -- await waitForState(agent.models, models => models.length === 0); +- await agent.authenticate('https://api.github.com', ''); +- await waitForState(agent.models, models => models.length === 0); - -- assert.deepStrictEqual({ -- githubToken: agent['_githubCredentials'].token, -- models: agent.models.get(), -- }, { -- githubToken: undefined, -- models: [], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- githubToken: agent['_githubCredentials'].token, +- models: agent.models.get(), +- }, { +- githubToken: undefined, +- models: [], +- }); - }); - - test('clearing the shared enterprise endpoint challenges a previously working credential', async () => { @@ -55528,47 +57841,44 @@ index f24a97a6296..00000000000 - const endpoints = disposables.add(new AgentHostGitHubEndpointService(configuration, logService)); - const client = new TestCopilotClient([], [{ id: 'gpt-4o', name: 'GPT-4o' }]); - const agent = createTestAgent(disposables, { copilotClient: client, gitHubEndpointService: endpoints }); -- try { -- await agent.authenticate(endpoints.getCopilotResource().resource, 'enterprise-token'); -- await waitForState(agent.models, models => models.length > 0); -- const beforeEndpointChange = agent.authenticationRequired.get(); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate(endpoints.getCopilotResource().resource, 'enterprise-token'); +- await waitForState(agent.models, models => models.length > 0); +- const beforeEndpointChange = agent.authenticationRequired.get(); +- +- configuration.updateRootConfig({ [AgentHostConfigKey.GithubEnterpriseUri]: '' }); +- await waitForState(agent.models, models => models.length === 0); +- const afterEndpointChange = { +- token: agent['_githubCredentials'].token, +- requirement: agent.authenticationRequired.get(), +- models: agent.models.get().map(model => model.id), +- }; - -- configuration.updateRootConfig({ [AgentHostConfigKey.GithubEnterpriseUri]: '' }); -- await waitForState(agent.models, models => models.length === 0); -- const afterEndpointChange = { +- configuration.updateRootConfig({ [AgentHostConfigKey.GithubEnterpriseUri]: 'https://acme.ghe.com' }); +- await agent.authenticate(endpoints.getCopilotResource().resource, 'enterprise-token'); +- await waitForState(agent.models, models => models.length > 0); +- +- assert.deepStrictEqual({ +- beforeEndpointChange, +- afterEndpointChange, +- afterRestoringEndpoint: { - token: agent['_githubCredentials'].token, - requirement: agent.authenticationRequired.get(), - models: agent.models.get().map(model => model.id), -- }; -- -- configuration.updateRootConfig({ [AgentHostConfigKey.GithubEnterpriseUri]: 'https://acme.ghe.com' }); -- await agent.authenticate(endpoints.getCopilotResource().resource, 'enterprise-token'); -- await waitForState(agent.models, models => models.length > 0); -- -- assert.deepStrictEqual({ -- beforeEndpointChange, -- afterEndpointChange, -- afterRestoringEndpoint: { -- token: agent['_githubCredentials'].token, -- requirement: agent.authenticationRequired.get(), -- models: agent.models.get().map(model => model.id), -- }, -- }, { -- beforeEndpointChange: undefined, -- afterEndpointChange: { -- token: undefined, -- requirement: { resource: GITHUB_COPILOT_PROTECTED_RESOURCE, reason: AuthRequiredReason.Expired }, -- models: [], -- }, -- afterRestoringEndpoint: { -- token: 'enterprise-token', -- requirement: undefined, -- models: ['gpt-4o'], -- }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- }, +- }, { +- beforeEndpointChange: undefined, +- afterEndpointChange: { +- token: undefined, +- requirement: { resource: GITHUB_COPILOT_PROTECTED_RESOURCE, reason: AuthRequiredReason.Expired }, +- models: [], +- }, +- afterRestoringEndpoint: { +- token: 'enterprise-token', +- requirement: undefined, +- models: ['gpt-4o'], +- }, +- }); - }); - - test('keeps Codex SKU telemetry independent of concurrent Copilot authentication and clearing', async () => { @@ -55610,42 +57920,39 @@ index f24a97a6296..00000000000 - const codex = disposables.add(child.createInstance(CodexAgent)); - const reporter = new AgentHostTelemetryReporter(telemetryService); - const report = (provider: CodexAgent | CopilotAgent) => reporter.executionModeChanged(provider.id, AgentSession.uri(provider.id, 'sku-test').toString(), 'interactive', 'plan', 0); -- try { -- const authenticating = agent.authenticate('https://api.github.com', 'test-token-a'); -- await copilotDiscoveryStarted.p; -- await codex.authenticate('https://api.github.com', 'test-token-b'); -- report(codex); -- copilotDiscovery.complete(Response.json({ endpoints: { api: 'https://api.githubcopilot.com' }, access_type_sku: 'copilot-sku', analytics_tracking_id: 'copilot-analytics' })); -- await authenticating; -- report(codex); -- report(agent); -- await agent.authenticate('https://api.github.com', ''); -- report(codex); -- await codex.authenticate('https://api.github.com', ''); -- await agent.authenticate('https://api.github.com', 'test-token-a'); -- report(codex); -- await agent.refreshModels(); -- const forward = getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry; -- assert.ok(forward); -- await forward({ -- sessionId: 'sku-test', -- restricted: false, -- event: { kind: 'response.success', properties: {}, metrics: {}, exp_assignment_context: '' }, -- }); -- -- assert.deepStrictEqual({ events, sdkContexts: sdkEvents.map(event => ({ copilotSku: event.copilotSku, trackingId: event['common.copilotTrackingId'] })) }, { -- events: [ -- { provider: 'codex', copilotSku: 'codex-sku', trackingId: 'codex-analytics' }, -- { provider: 'codex', copilotSku: 'codex-sku', trackingId: 'codex-analytics' }, -- { provider: 'copilotcli', copilotSku: 'copilot-sku', trackingId: 'copilot-analytics' }, -- { provider: 'codex', copilotSku: 'codex-sku', trackingId: 'codex-analytics' }, -- { provider: 'codex', copilotSku: undefined, trackingId: undefined }, -- ], -- sdkContexts: [{ copilotSku: undefined, trackingId: undefined }], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const authenticating = agent.authenticate('https://api.github.com', 'test-token-a'); +- await copilotDiscoveryStarted.p; +- await codex.authenticate('https://api.github.com', 'test-token-b'); +- report(codex); +- copilotDiscovery.complete(Response.json({ endpoints: { api: 'https://api.githubcopilot.com' }, access_type_sku: 'copilot-sku', analytics_tracking_id: 'copilot-analytics' })); +- await authenticating; +- report(codex); +- report(agent); +- await agent.authenticate('https://api.github.com', ''); +- report(codex); +- await codex.authenticate('https://api.github.com', ''); +- await agent.authenticate('https://api.github.com', 'test-token-a'); +- report(codex); +- await agent.refreshModels(); +- const forward = getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry; +- assert.ok(forward); +- await forward({ +- sessionId: 'sku-test', +- restricted: false, +- event: { kind: 'response.success', properties: {}, metrics: {}, exp_assignment_context: '' }, +- }); +- +- assert.deepStrictEqual({ events, sdkContexts: sdkEvents.map(event => ({ copilotSku: event.copilotSku, trackingId: event['common.copilotTrackingId'] })) }, { +- events: [ +- { provider: 'codex', copilotSku: 'codex-sku', trackingId: 'codex-analytics' }, +- { provider: 'codex', copilotSku: 'codex-sku', trackingId: 'codex-analytics' }, +- { provider: 'copilotcli', copilotSku: 'copilot-sku', trackingId: 'copilot-analytics' }, +- { provider: 'codex', copilotSku: 'codex-sku', trackingId: 'codex-analytics' }, +- { provider: 'codex', copilotSku: undefined, trackingId: undefined }, +- ], +- sdkContexts: [{ copilotSku: undefined, trackingId: undefined }], +- }); - }); - - test('does not discover SKU on a new enterprise endpoint using an earlier authentication', async () => { @@ -55672,22 +57979,19 @@ index f24a97a6296..00000000000 - appenders: [{ log: (name, data) => { if (name === 'agentHost.executionModeChanged') { skus.push({ copilotSku: data.copilotSku }); } }, flush: async () => { } }], - }, TEST_PRODUCT_SERVICE))); - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]), copilotApiService: apiService, gitHubEndpointService: endpoints, telemetryService }); -- try { -- const oldAuthentication = agent.authenticate(endpoints.getCopilotResource().resource, 'test-token-a', undefined, agentEntitlementHintMeta({ trackingId: 'analytics', sku: 'free' })); -- await discoveryStarted.p; -- configuration.updateRootConfig({ [AgentHostConfigKey.GithubEnterpriseUri]: 'https://acme.ghe.com' }); -- const newAuthentication = agent.authenticate(endpoints.getCopilotResource().resource, 'test-token-b'); -- oldDiscovery.complete(Response.json({ endpoints: { api: 'https://api.githubcopilot.com' }, access_type_sku: 'old-sku' })); -- await Promise.all([oldAuthentication, newAuthentication]); -- new AgentHostTelemetryReporter(telemetryService).executionModeChanged(agent.id, AgentSession.uri(agent.id, 'sku-test').toString(), 'interactive', 'plan', 0); -- -- assert.deepStrictEqual({ oldCredentialReachedNewEndpoint, skus }, { -- oldCredentialReachedNewEndpoint: false, -- skus: [{ copilotSku: 'enterprise-sku' }], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const oldAuthentication = agent.authenticate(endpoints.getCopilotResource().resource, 'test-token-a', undefined, agentEntitlementHintMeta({ trackingId: 'analytics', sku: 'free' })); +- await discoveryStarted.p; +- configuration.updateRootConfig({ [AgentHostConfigKey.GithubEnterpriseUri]: 'https://acme.ghe.com' }); +- const newAuthentication = agent.authenticate(endpoints.getCopilotResource().resource, 'test-token-b'); +- oldDiscovery.complete(Response.json({ endpoints: { api: 'https://api.githubcopilot.com' }, access_type_sku: 'old-sku' })); +- await Promise.all([oldAuthentication, newAuthentication]); +- new AgentHostTelemetryReporter(telemetryService).executionModeChanged(agent.id, AgentSession.uri(agent.id, 'sku-test').toString(), 'interactive', 'plan', 0); +- +- assert.deepStrictEqual({ oldCredentialReachedNewEndpoint, skus }, { +- oldCredentialReachedNewEndpoint: false, +- skus: [{ copilotSku: 'enterprise-sku' }], +- }); - }); - - test('retries Copilot SKU discovery on authentication with an unchanged token', async () => { @@ -55696,20 +58000,17 @@ index f24a97a6296..00000000000 - ? Response.json({ endpoints: { api: 'https://api.githubcopilot.com' }, access_type_sku: 'recovered-sku' }) - : new Response('Unavailable', { status: 503 }), new NullLogService(), TEST_PRODUCT_SERVICE, createTestGitHubEndpointService(), NullTelemetryService)); - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]), copilotApiService }); -- try { -- await agent.authenticate('https://api.github.com', 'test-token-a'); -- await agent.refreshModels(); -- await assert.rejects(copilotApiService.resolveCopilotSku('test-token-a'), CopilotApiError); -- const unavailableSku = agent.getTelemetryContext().copilotSku; -- available = true; -- await agent.authenticate('https://api.github.com', 'test-token-a'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'test-token-a'); +- await agent.refreshModels(); +- await assert.rejects(copilotApiService.resolveCopilotSku('test-token-a'), CopilotApiError); +- const unavailableSku = agent.getTelemetryContext().copilotSku; +- available = true; +- await agent.authenticate('https://api.github.com', 'test-token-a'); - -- assert.deepStrictEqual({ unavailableSku, recoveredSku: agent.getTelemetryContext().copilotSku }, { -- unavailableSku: undefined, recoveredSku: 'recovered-sku', -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ unavailableSku, recoveredSku: agent.getTelemetryContext().copilotSku }, { +- unavailableSku: undefined, recoveredSku: 'recovered-sku', +- }); - }); - - test('refreshes same-token discovery for matching account entitlement hints without trusting their SKU', async () => { @@ -55799,29 +58100,26 @@ index f24a97a6296..00000000000 - test('discovers and caches models without a host token using only the caller credential', async () => { - const { agent, clients, options } = setup(); - const credentials = { getCredential: () => ({ resource: 'https://api.github.com', token: 'caller-token', expiresAt: undefined, revision: 1 }) }; -- try { -- const first = await agent.getModelsForCredentials(credentials); -- const second = await agent.getModelsForCredentials(credentials); -- assert.deepStrictEqual({ -- models: first.map(model => model.id), -- cached: first === second, -- sharedModels: agent.models.get(), -- tokens: options.map(option => option.gitHubToken), -- modelRequests: clients.flatMap(client => client.modelListRequests), -- policyTokens: clients.flatMap(client => client.managedSettingsRequests.map(request => request.gitHubToken)), -- stops: clients.map(client => client.stopCallCount), -- }, { -- models: ['gpt-4o'], -- cached: true, -- sharedModels: [], -- tokens: ['caller-token'], -- modelRequests: [{ gitHubToken: 'caller-token' }], -- policyTokens: ['caller-token'], -- stops: [1], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const first = await agent.getModelsForCredentials(credentials); +- const second = await agent.getModelsForCredentials(credentials); +- assert.deepStrictEqual({ +- models: first.map(model => model.id), +- cached: first === second, +- sharedModels: agent.models.get(), +- tokens: options.map(option => option.gitHubToken), +- modelRequests: clients.flatMap(client => client.modelListRequests), +- policyTokens: clients.flatMap(client => client.managedSettingsRequests.map(request => request.gitHubToken)), +- stops: clients.map(client => client.stopCallCount), +- }, { +- models: ['gpt-4o'], +- cached: true, +- sharedModels: [], +- tokens: ['caller-token'], +- modelRequests: [{ gitHubToken: 'caller-token' }], +- policyTokens: ['caller-token'], +- stops: [1], +- }); - }); - - test('scoped model catalogs isolate grants and refresh after rotation without changing host models', async () => { @@ -55835,41 +58133,35 @@ index f24a97a6296..00000000000 - let token = 'first-token'; - const first = { getCredential: () => ({ resource: 'https://api.github.com', token, expiresAt: undefined, revision: 1 }) }; - const second = { getCredential: () => ({ resource: 'https://api.github.com', token: 'second-token', expiresAt: undefined, revision: 1 }) }; -- try { -- const initial = await agent.getModelsForCredentials(first); -- const independent = await agent.getModelsForCredentials(second); -- token = 'rotated-token'; -- const rotated = await agent.getModelsForCredentials(first); -- assert.deepStrictEqual({ -- catalogs: [initial, independent, rotated].map(models => models.map(model => model.id)), -- shared: agent.models.get(), -- stops: clients.map(client => client.stopCallCount), -- }, { -- catalogs: [['first-token'], ['second-token'], ['rotated-token']], -- shared: [], -- stops: [1, 1, 1], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const initial = await agent.getModelsForCredentials(first); +- const independent = await agent.getModelsForCredentials(second); +- token = 'rotated-token'; +- const rotated = await agent.getModelsForCredentials(first); +- assert.deepStrictEqual({ +- catalogs: [initial, independent, rotated].map(models => models.map(model => model.id)), +- shared: agent.models.get(), +- stops: clients.map(client => client.stopCallCount), +- }, { +- catalogs: [['first-token'], ['second-token'], ['rotated-token']], +- shared: [], +- stops: [1, 1, 1], +- }); - }); - - test('scoped model discovery never borrows a host credential when its grant is missing or expired', async () => { - const { agent, options } = setup(); -- try { -- await agent.authenticate('https://api.github.com', 'host-token'); -- await agent.refreshModels(); -- const before = options.length; -- const missing = await agent.getModelsForCredentials({ getCredential: () => undefined }); -- const expired = await agent.getModelsForCredentials({ -- getCredential: () => ({ resource: 'https://api.github.com', token: 'expired-token', expiresAt: 1, revision: 1 }), -- }); -- assert.deepStrictEqual({ missing, expired, additionalClients: options.length - before }, { -- missing: [], expired: [], additionalClients: 0, -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'host-token'); +- await agent.refreshModels(); +- const before = options.length; +- const missing = await agent.getModelsForCredentials({ getCredential: () => undefined }); +- const expired = await agent.getModelsForCredentials({ +- getCredential: () => ({ resource: 'https://api.github.com', token: 'expired-token', expiresAt: 1, revision: 1 }), +- }); +- assert.deepStrictEqual({ missing, expired, additionalClients: options.length - before }, { +- missing: [], expired: [], additionalClients: 0, +- }); - }); - - test('scoped model discovery applies the caller managed defaults without publishing them globally', async () => { @@ -55883,16 +58175,13 @@ index f24a97a6296..00000000000 - }, - }); - const credentials = { getCredential: () => ({ resource: 'https://api.github.com', token: 'caller-token', expiresAt: undefined, revision: 1 }) }; -- try { -- const models = await agent.getModelsForCredentials(credentials); -- assert.deepStrictEqual({ -- defaultEffort: models[0].configSchema?.properties.thinkingLevel?.default, -- aliasChoices: models[0].configSchema?.properties.reasoningEffort?.enum, -- shared: agent.models.get(), -- }, { defaultEffort: 'high', aliasChoices: ['low', 'high'], shared: [] }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const models = await agent.getModelsForCredentials(credentials); +- assert.deepStrictEqual({ +- defaultEffort: models[0].configSchema?.properties.thinkingLevel?.default, +- aliasChoices: models[0].configSchema?.properties.reasoningEffort?.enum, +- shared: agent.models.get(), +- }, { defaultEffort: 'high', aliasChoices: ['low', 'high'], shared: [] }); - }); - - test('scoped model discovery offers Auto when the runtime returns an empty catalog', async () => { @@ -55900,14 +58189,11 @@ index f24a97a6296..00000000000 - onClientCreated: client => client.modelListResponses.push([]), - }); - const credentials = { getCredential: () => ({ resource: 'https://api.github.com', token: 'caller-token', expiresAt: undefined, revision: 1 }) }; -- try { -- const models = await agent.getModelsForCredentials(credentials); -- assert.deepStrictEqual({ models: models.map(model => model.id), shared: agent.models.get() }, { -- models: ['auto'], shared: [], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const models = await agent.getModelsForCredentials(credentials); +- assert.deepStrictEqual({ models: models.map(model => model.id), shared: agent.models.get() }, { +- models: ['auto'], shared: [], +- }); - }); - - test('scoped model discovery failures propagate and can be retried with the same credential', async () => { @@ -55919,15 +58205,12 @@ index f24a97a6296..00000000000 - }, - }); - const credentials = { getCredential: () => ({ resource: 'https://api.github.com', token: 'caller-token', expiresAt: undefined, revision: 1 }) }; -- try { -- await assert.rejects(agent.getModelsForCredentials(credentials), /model discovery unavailable/); -- const models = await agent.getModelsForCredentials(credentials); -- assert.deepStrictEqual({ models: models.map(model => model.id), stops: clients.map(client => client.stopCallCount) }, { -- models: ['gpt-4o'], stops: [1, 1], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await assert.rejects(agent.getModelsForCredentials(credentials), /model discovery unavailable/); +- const models = await agent.getModelsForCredentials(credentials); +- assert.deepStrictEqual({ models: models.map(model => model.id), stops: clients.map(client => client.stopCallCount) }, { +- models: ['gpt-4o'], stops: [1, 1], +- }); - }); - - test('cancelling scoped model discovery stops its runtime and does not populate the catalog', async () => { @@ -55969,17 +58252,14 @@ index f24a97a6296..00000000000 - const chat = defaultChatUri(session); - const credentials = { getCredential: () => ({ resource: 'https://api.github.com', token: 'caller-token', expiresAt: undefined, revision: 1 }) }; - const context = { ...exactChatContext(session, chat, session), credentials }; -- try { -- await agent.authenticate('https://api.github.com', 'host-token'); -- await agent.refreshModels(); -- await agent.getModelsForCredentials(credentials); -- await provisionSession(agent, { session, credentials, model: { id: 'caller-model' }, workingDirectories: [URI.file(process.cwd())] }); -- await agent.chats.changeModel(chat, { id: 'caller-model' }, context); -- await assert.rejects(agent.chats.changeModel(chat, { id: 'gpt-4o' }, context), /not available/); -- assert.deepStrictEqual(agent.models.get().map(model => model.id), ['gpt-4o']); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'host-token'); +- await agent.refreshModels(); +- await agent.getModelsForCredentials(credentials); +- await provisionSession(agent, { session, credentials, model: { id: 'caller-model' }, workingDirectories: [URI.file(process.cwd())] }); +- await agent.chats.changeModel(chat, { id: 'caller-model' }, context); +- await assert.rejects(agent.chats.changeModel(chat, { id: 'gpt-4o' }, context), /not available/); +- assert.deepStrictEqual(agent.models.get().map(model => model.id), ['gpt-4o']); - }); - - for (const rotate of [false, true]) { @@ -55991,49 +58271,116 @@ index f24a97a6296..00000000000 - let token = 'sponsor-a'; - const credentials = { getCredential: () => ({ resource: 'https://api.github.com', token, expiresAt: undefined, revision: 1 }) }; - const context = { ...exactChatContext(session, chat, session), credentials }; +- afterTest(() => disposeAgent(agent)); +- await provisionSession(agent, { session, credentials, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); +- const warmed = await agent.chats.warmChat!(chat, context); +- const afterWarm = { warmed, tokens: options.filter(option => option.gitHubToken !== undefined).map(option => option.gitHubToken), launches: launches.map(launch => launch.kind) }; +- if (rotate) { +- token = 'sponsor-b'; +- } +- +- await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, context); +- +- assert.deepStrictEqual({ +- afterWarm, +- tokens: options.filter(option => option.gitHubToken !== undefined).map(option => option.gitHubToken), +- stops: clients.filter((_client, index) => options[index].gitHubToken !== undefined).map(client => client.stopCallCount), +- launches: launches.map(launch => launch.kind), +- sends: launches.map(launch => launch.runtime.sendCalls), +- }, { +- afterWarm: { warmed: true, tokens: ['sponsor-a'], launches: ['create'] }, +- tokens: rotate ? ['sponsor-a', 'sponsor-b'] : ['sponsor-a'], +- stops: rotate ? [1, 0] : [0], +- launches: rotate ? ['create', 'create'] : ['create'], +- sends: rotate ? [0, 1] : [1], +- }); +- }); +- } +- +- test('prewarming does not borrow a host token when the caller credential is unavailable', async () => { +- const { agent, options, launches } = setup(); +- const session = AgentSession.uri('copilotcli', 'scoped-prewarm-unavailable'); +- const chat = defaultChatUri(session); +- let credential: IAgentHostScopedCredential | undefined = { resource: 'https://api.github.com', token: 'sponsor', expiresAt: undefined, revision: 1 }; +- const credentials = { getCredential: () => credential }; +- const context = { ...exactChatContext(session, chat, session), credentials }; +- afterTest(() => disposeAgent(agent)); +- await provisionSession(agent, { session, credentials, model: { id: 'gpt-4o' }, workingDirectories: [URI.file(process.cwd())] }); +- credential = undefined; +- const warmed = await agent.chats.warmChat!(chat, context); +- +- assert.deepStrictEqual({ warmed, clients: options.length, launches: launches.length }, { warmed: false, clients: 0, launches: 0 }); +- }); +- +- for (const kind of ['default', 'peer'] as const) { +- test(`a configuration restart keeps a ${kind} inline chat on Fusion's fast tier, resuming with the host's surface`, async () => { +- const { agent, launches } = setup(); +- const session = AgentSession.uri('copilotcli', `inline-fusion-restart-${kind}`); +- const chat = kind === 'default' ? defaultChatUri(session) : URI.parse(buildChatUri(session, 'inline-peer')); +- const directory = URI.file(process.cwd()); +- const model: ModelSelection = { id: 'auto', config: { tier: 'hydrafusion' } }; +- const context: IAgentChatContext = { ...exactChatContext(session, chat, kind === 'default' ? session : chat), chatSurface: 'terminal' }; +- setAdvertisedAutoModeTiers(['balance', 'hydrafusion', 'fast'], true); - try { -- await provisionSession(agent, { session, credentials, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); -- const warmed = await agent.chats.warmChat!(chat, context); -- const afterWarm = { warmed, tokens: options.filter(option => option.gitHubToken !== undefined).map(option => option.gitHubToken), launches: launches.map(launch => launch.kind) }; -- if (rotate) { -- token = 'sponsor-b'; +- if (kind === 'default') { +- await provisionSession(agent, { session, model, workingDirectories: [directory] }, { isInlineChatSurface: true }); +- } else { +- await provisionSession(agent, { session, workingDirectories: [directory] }); +- await agent.chats.createChat(chat, context, { deferBacking: true, model, workingDirectories: [directory], isInlineChatSurface: true }); - } -- - await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, context); +- const original = chatEntriesBySdkId(agent).get(launches[0].sessionId)!.chatSession; +- original.discardActiveTurn(); +- await agent.chats.changeModel(chat, model, context); +- original.markTelemetryRevocationRequiresRestart(); +- await agent.chats.sendMessage(chat, 'second', [directory], undefined, 'second', undefined, context); +- const replacement = chatEntriesBySdkId(agent).get(launches[1].sessionId)?.chatSession; - - assert.deepStrictEqual({ -- afterWarm, -- tokens: options.filter(option => option.gitHubToken !== undefined).map(option => option.gitHubToken), -- stops: clients.filter((_client, index) => options[index].gitHubToken !== undefined).map(client => client.stopCallCount), -- launches: launches.map(launch => launch.kind), -- sends: launches.map(launch => launch.runtime.sendCalls), +- launches: launches.map(launch => ({ kind: launch.kind, capi: launch.config.capi })), +- autoTiers: launches.map(launch => launch.runtime.setModelCalls.map(([, options]) => options?.autoTier)), +- surface: replacement?.surface, - }, { -- afterWarm: { warmed: true, tokens: ['sponsor-a'], launches: ['create'] }, -- tokens: rotate ? ['sponsor-a', 'sponsor-b'] : ['sponsor-a'], -- stops: rotate ? [1, 0] : [0], -- launches: rotate ? ['create', 'create'] : ['create'], -- sends: rotate ? [0, 1] : [1], +- launches: [{ kind: 'create', capi: { autoTier: 'fast' } }, { kind: 'resume', capi: { autoTier: 'fast' } }], +- autoTiers: [['fast'], ['fast']], +- surface: { isInlineChatSurface: true }, - }); - } finally { +- setAdvertisedAutoModeTiers(undefined); - await disposeAgent(agent); - } - }); - } - -- test('prewarming does not borrow a host token when the caller credential is unavailable', async () => { -- const { agent, options, launches } = setup(); -- const session = AgentSession.uri('copilotcli', 'scoped-prewarm-unavailable'); +- test('a configuration restart keeps a restored session-backed inline chat on Fusion\'s fast tier, resuming with the host\'s surface', async () => { +- const sessionId = 'inline-fusion-restart-session-backed'; +- const directory = URI.file(process.cwd()); +- const { agent, launches } = setup(undefined, [sdkSession(sessionId, directory.fsPath)]); +- const session = AgentSession.uri('copilotcli', sessionId); - const chat = defaultChatUri(session); -- let credential: IAgentHostScopedCredential | undefined = { resource: 'https://api.github.com', token: 'sponsor', expiresAt: undefined, revision: 1 }; -- const credentials = { getCredential: () => credential }; -- const context = { ...exactChatContext(session, chat, session), credentials }; +- const model: ModelSelection = { id: 'auto', config: { tier: 'hydrafusion' } }; +- const context: IAgentChatContext = { ...exactChatContext(session, chat, session), chatSurface: 'editorInline' }; +- setAdvertisedAutoModeTiers(['balance', 'hydrafusion', 'fast'], true); - try { -- await provisionSession(agent, { session, credentials, model: { id: 'gpt-4o' }, workingDirectories: [URI.file(process.cwd())] }); -- credential = undefined; -- const warmed = await agent.chats.warmChat!(chat, context); +- await agent.materializeChat(chat, context, JSON.stringify({ sdkSessionId: sessionId })); +- await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, context); +- const original = chatEntriesBySdkId(agent).get(sessionId)!.chatSession; +- original.discardActiveTurn(); +- await agent.chats.changeModel(chat, model, context); +- original.markTelemetryRevocationRequiresRestart(); +- await agent.chats.sendMessage(chat, 'second', [directory], undefined, 'second', undefined, context); - -- assert.deepStrictEqual({ warmed, clients: options.length, launches: launches.length }, { warmed: false, clients: 0, launches: 0 }); +- assert.deepStrictEqual({ +- launches: launches.map(launch => ({ kind: launch.kind, capi: launch.config.capi })), +- autoTiers: launches.map(launch => launch.runtime.setModelCalls.map(([, options]) => options?.autoTier)), +- surfaces: [original.surface, chatEntriesBySdkId(agent).get(sessionId)?.chatSession.surface], +- }, { +- launches: [{ kind: 'resume', capi: undefined }, { kind: 'resume', capi: { autoTier: 'fast' } }], +- autoTiers: [['fast'], ['fast']], +- surfaces: [{ hasScopedEditSurface: true, isInlineChatSurface: true }, { hasScopedEditSurface: true, isInlineChatSurface: true }], +- }); - } finally { +- setAdvertisedAutoModeTiers(undefined); - await disposeAgent(agent); - } - }); @@ -56045,24 +58392,21 @@ index f24a97a6296..00000000000 - const directory = URI.file(process.cwd()); - const credentials = { getCredential: () => ({ resource: 'https://api.github.com', token: 'sponsor', expiresAt: undefined, revision: 1 }) }; - const context = { ...exactChatContext(session, chat, session), credentials }; -- try { -- await provisionSession(agent, { session, credentials, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); -- await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, context); -- chatEntriesBySdkId(agent).get(launches[0].sessionId)!.chatSession.discardActiveTurn(); -- for (const client of clients) { -- client.managedSettingsRequests.length = 0; -- } +- afterTest(() => disposeAgent(agent)); +- await provisionSession(agent, { session, credentials, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); +- await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, context); +- chatEntriesBySdkId(agent).get(launches[0].sessionId)!.chatSession.discardActiveTurn(); +- for (const client of clients) { +- client.managedSettingsRequests.length = 0; +- } - -- await agent.chats.changeModel(chat, { id: 'gpt-4o' }, context); +- await agent.chats.changeModel(chat, { id: 'gpt-4o' }, context); - -- assert.deepStrictEqual({ -- clientTokens: options.filter((_option, index) => clients[index].managedSettingsRequests.length > 0).map(option => option.gitHubToken), -- policyTokens: [...new Set(clients.flatMap(client => client.managedSettingsRequests.map(request => request.gitHubToken)))], -- models: launches[0].runtime.setModelCalls.map(([model]) => model), -- }, { clientTokens: ['sponsor'], policyTokens: ['sponsor'], models: ['gpt-4o'] }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- clientTokens: options.filter((_option, index) => clients[index].managedSettingsRequests.length > 0).map(option => option.gitHubToken), +- policyTokens: [...new Set(clients.flatMap(client => client.managedSettingsRequests.map(request => request.gitHubToken)))], +- models: launches[0].runtime.setModelCalls.map(([model]) => model), +- }, { clientTokens: ['sponsor'], policyTokens: ['sponsor'], models: ['gpt-4o'] }); - }); - - for (const transition of ['legacy', 'rotated', 'expired'] as const) { @@ -56086,44 +58430,41 @@ index f24a97a6296..00000000000 - const context = exactChatContext(session, chat, session); - const firstContext = transition === 'legacy' ? context : { ...context, credentials: firstSource }; - const localContext = { ...context, credentials: localSource }; -- try { -- await provisionSession(agent, { session, credentials: firstContext.credentials, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); -- await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, firstContext); -- const original = launches[0]; -- chatEntriesBySdkId(agent).get(original.sessionId)!.chatSession.discardActiveTurn(); -- if (transition === 'expired') { -- clock.tick(60_001); -- } -- for (const client of clients) { -- client.managedSettingsRequests.length = 0; -- } +- afterTest(() => disposeAgent(agent)); +- await provisionSession(agent, { session, credentials: firstContext.credentials, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); +- await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, firstContext); +- const original = launches[0]; +- chatEntriesBySdkId(agent).get(original.sessionId)!.chatSession.discardActiveTurn(); +- if (transition === 'expired') { +- clock.tick(60_001); +- } +- for (const client of clients) { +- client.managedSettingsRequests.length = 0; +- } - -- const changeModel = () => agent.chats.changeModel(chat, { id: 'gpt-4o' }, localContext); -- const changeAgent = () => agent.chats.changeAgent(chat, undefined, localContext); -- await Promise.all(selection === 'model' ? [changeModel(), changeAgent()] : [changeAgent(), changeModel()]); -- await agent.chats.prepareTurn!(chat, 'continued', [directory], localContext); -- await agent.chats.sendMessage(chat, 'continued', [directory], undefined, 'continued', undefined, localContext); +- const changeModel = () => agent.chats.changeModel(chat, { id: 'gpt-4o' }, localContext); +- const changeAgent = () => agent.chats.changeAgent(chat, undefined, localContext); +- await Promise.all(selection === 'model' ? [changeModel(), changeAgent()] : [changeAgent(), changeModel()]); +- await agent.chats.prepareTurn!(chat, 'continued', [directory], localContext); +- await agent.chats.sendMessage(chat, 'continued', [directory], undefined, 'continued', undefined, localContext); - -- assert.deepStrictEqual({ -- tokens: launches.map(launch => launch.config.gitHubToken), -- backingPreserved: launches.every(launch => launch.sessionId === original.sessionId), -- sends: launches.map(launch => launch.runtime.sendCalls), -- models: launches.map(launch => launch.runtime.setModelCalls.map(([model]) => model)), -- agents: launches.map(launch => launch.runtime.agentSelectionCalls), -- policyTokens: [...new Set(clients.flatMap(client => client.managedSettingsRequests.map(request => request.gitHubToken)))], -- stops: clients.filter((_client, index) => options[index].gitHubToken === 'ios').map(client => client.stopCallCount), -- }, { -- tokens: [transition === 'legacy' ? undefined : 'ios', 'desktop'], -- backingPreserved: true, -- sends: [1, 1], -- models: [[], ['gpt-4o']], -- agents: [[], [undefined]], -- policyTokens: ['desktop'], -- stops: transition === 'legacy' ? [] : [1], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- tokens: launches.map(launch => launch.config.gitHubToken), +- backingPreserved: launches.every(launch => launch.sessionId === original.sessionId), +- sends: launches.map(launch => launch.runtime.sendCalls), +- models: launches.map(launch => launch.runtime.setModelCalls.map(([model]) => model)), +- agents: launches.map(launch => launch.runtime.agentSelectionCalls), +- policyTokens: [...new Set(clients.flatMap(client => client.managedSettingsRequests.map(request => request.gitHubToken)))], +- stops: clients.filter((_client, index) => options[index].gitHubToken === 'ios').map(client => client.stopCallCount), +- }, { +- tokens: [transition === 'legacy' ? undefined : 'ios', 'desktop'], +- backingPreserved: true, +- sends: [1, 1], +- models: [[], ['gpt-4o']], +- agents: [[], [undefined]], +- policyTokens: ['desktop'], +- stops: transition === 'legacy' ? [] : [1], +- }); - }); - } - } @@ -56136,28 +58477,25 @@ index f24a97a6296..00000000000 - const directory = URI.file(process.cwd()); - const firstSource = { getCredential: () => ({ resource: 'https://api.github.com', token: 'ios', expiresAt: undefined, revision: 1 }) }; - const context = { ...exactChatContext(session, chat, session), credentials: firstSource }; -- try { -- await provisionSession(agent, { session, credentials: firstSource, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); -- await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, context); -- chatEntriesBySdkId(agent).get(launches[0].sessionId)!.chatSession.discardActiveTurn(); -- for (const client of clients) { -- client.managedSettingsRequests.length = 0; -- } -- const unavailableContext = { ...context, credentials: { getCredential: () => undefined } }; +- afterTest(() => disposeAgent(agent)); +- await provisionSession(agent, { session, credentials: firstSource, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); +- await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, context); +- chatEntriesBySdkId(agent).get(launches[0].sessionId)!.chatSession.discardActiveTurn(); +- for (const client of clients) { +- client.managedSettingsRequests.length = 0; +- } +- const unavailableContext = { ...context, credentials: { getCredential: () => undefined } }; - -- await assert.rejects(selection === 'model' -- ? agent.chats.changeModel(chat, { id: 'gpt-4o' }, unavailableContext) -- : agent.chats.changeAgent(chat, undefined, unavailableContext), { code: AhpErrorCodes.AuthRequired }); +- await assert.rejects(selection === 'model' +- ? agent.chats.changeModel(chat, { id: 'gpt-4o' }, unavailableContext) +- : agent.chats.changeAgent(chat, undefined, unavailableContext), { code: AhpErrorCodes.AuthRequired }); - -- assert.deepStrictEqual({ -- launches: launches.length, -- models: launches[0].runtime.setModelCalls, -- agents: launches[0].runtime.agentSelectionCalls, -- policyRequests: clients.flatMap(client => client.managedSettingsRequests), -- }, { launches: 1, models: [], agents: [], policyRequests: [] }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- launches: launches.length, +- models: launches[0].runtime.setModelCalls, +- agents: launches[0].runtime.agentSelectionCalls, +- policyRequests: clients.flatMap(client => client.managedSettingsRequests), +- }, { launches: 1, models: [], agents: [], policyRequests: [] }); - }); - - test(`${selection} selection does not replace a busy runtime with another sponsor`, async () => { @@ -56167,27 +58505,24 @@ index f24a97a6296..00000000000 - const directory = URI.file(process.cwd()); - const firstSource = { getCredential: () => ({ resource: 'https://api.github.com', token: 'ios', expiresAt: undefined, revision: 1 }) }; - const context = { ...exactChatContext(session, chat, session), credentials: firstSource }; -- try { -- await provisionSession(agent, { session, credentials: firstSource, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); -- await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, context); -- const replacementContext = { -- ...context, -- credentials: { getCredential: () => ({ resource: 'https://api.github.com', token: 'desktop', expiresAt: undefined, revision: 2 }) }, -- }; +- afterTest(() => disposeAgent(agent)); +- await provisionSession(agent, { session, credentials: firstSource, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); +- await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, context); +- const replacementContext = { +- ...context, +- credentials: { getCredential: () => ({ resource: 'https://api.github.com', token: 'desktop', expiresAt: undefined, revision: 2 }) }, +- }; - -- await assert.rejects(selection === 'model' -- ? agent.chats.changeModel(chat, { id: 'gpt-4o' }, replacementContext) -- : agent.chats.changeAgent(chat, undefined, replacementContext), /active turn/i); +- await assert.rejects(selection === 'model' +- ? agent.chats.changeModel(chat, { id: 'gpt-4o' }, replacementContext) +- : agent.chats.changeAgent(chat, undefined, replacementContext), /active turn/i); - -- assert.deepStrictEqual({ -- tokens: launches.map(launch => launch.config.gitHubToken), -- stops: clients.map(client => client.stopCallCount), -- models: launches[0].runtime.setModelCalls, -- agents: launches[0].runtime.agentSelectionCalls, -- }, { tokens: ['ios'], stops: clients.map(() => 0), models: [], agents: [] }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- tokens: launches.map(launch => launch.config.gitHubToken), +- stops: clients.map(client => client.stopCallCount), +- models: launches[0].runtime.setModelCalls, +- agents: launches[0].runtime.agentSelectionCalls, +- }, { tokens: ['ios'], stops: clients.map(() => 0), models: [], agents: [] }); - }); - } - @@ -56197,27 +58532,24 @@ index f24a97a6296..00000000000 - const chat = defaultChatUri(session); - const directory = URI.file(process.cwd()); - const context = exactChatContext(session, chat, session); -- try { -- await provisionSession(agent, { session, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); -- await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, context); -- chatEntriesBySdkId(agent).get(launches[0].sessionId)!.chatSession.discardActiveTurn(); +- afterTest(() => disposeAgent(agent)); +- await provisionSession(agent, { session, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); +- await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, context); +- chatEntriesBySdkId(agent).get(launches[0].sessionId)!.chatSession.discardActiveTurn(); - -- await Promise.all([ -- agent.chats.changeModel(chat, { id: 'gpt-4o' }, context), -- agent.chats.changeAgent(chat, undefined, context), -- ]); -- await agent.chats.sendMessage(chat, 'continued', [directory], undefined, 'continued', undefined, context); +- await Promise.all([ +- agent.chats.changeModel(chat, { id: 'gpt-4o' }, context), +- agent.chats.changeAgent(chat, undefined, context), +- ]); +- await agent.chats.sendMessage(chat, 'continued', [directory], undefined, 'continued', undefined, context); - -- assert.deepStrictEqual({ -- tokens: launches.map(launch => launch.config.gitHubToken), -- scopedClients: options.filter(option => option.gitHubToken !== undefined).length, -- sends: launches.map(launch => launch.runtime.sendCalls), -- models: launches[0].runtime.setModelCalls.map(([model]) => model), -- agents: launches[0].runtime.agentSelectionCalls, -- }, { tokens: [undefined], scopedClients: 0, sends: [2], models: ['gpt-4o'], agents: [] }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- tokens: launches.map(launch => launch.config.gitHubToken), +- scopedClients: options.filter(option => option.gitHubToken !== undefined).length, +- sends: launches.map(launch => launch.runtime.sendCalls), +- models: launches[0].runtime.setModelCalls.map(([model]) => model), +- agents: launches[0].runtime.agentSelectionCalls, +- }, { tokens: [undefined], scopedClients: 0, sends: [2], models: ['gpt-4o'], agents: [] }); - }); - - test('defers stale configuration while a subagent is busy, but rejects a different sponsor', async () => { @@ -56228,32 +58560,29 @@ index f24a97a6296..00000000000 - let token = 'sponsor'; - const credentials = { getCredential: () => ({ resource: 'https://api.github.com', token, expiresAt: undefined, revision: 1 }) }; - const context = { ...exactChatContext(session, chat, session), credentials }; -- try { -- await provisionSession(agent, { session, credentials, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); -- await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, context); -- const target = chatEntriesBySdkId(agent).get(launches[0].sessionId)!.chatSession; -- target.discardActiveTurn(); -- const subagent = stub(target, 'hasActiveSubagents').get(() => true); -- disposables.add(toDisposable(() => subagent.restore())); -- configurationService.updateRootConfig({ [CopilotCliConfigKey.SkillCharBudget]: 40_000 }); -- await agent.chats.sendMessage(chat, 'same sponsor', [directory], undefined, 'second', undefined, context); -- target.discardActiveTurn(); -- const deferredStops = clients.filter((_client, index) => options[index].gitHubToken !== undefined).map(client => client.stopCallCount); -- token = 'other-sponsor'; -- await assert.rejects(agent.chats.sendMessage(chat, 'wrong sponsor', [directory], undefined, 'wrong', undefined, context), /busy|active|running/i); -- token = 'sponsor'; -- subagent.restore(); -- await agent.chats.sendMessage(chat, 'now idle', [directory], undefined, 'third', undefined, context); -- assert.deepStrictEqual({ -- deferredStops, -- stops: clients.filter((_client, index) => options[index].gitHubToken !== undefined).map(client => client.stopCallCount), -- kinds: launches.map(launch => launch.kind), -- oldSends: launches[0].runtime.sendCalls, -- newBudget: options.filter(options => options.gitHubToken !== undefined).at(-1)?.env?.SKILL_CHAR_BUDGET, -- }, { deferredStops: [0], stops: [1, 0], kinds: ['create', 'resume'], oldSends: 2, newBudget: '40000' }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await provisionSession(agent, { session, credentials, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); +- await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, context); +- const target = chatEntriesBySdkId(agent).get(launches[0].sessionId)!.chatSession; +- target.discardActiveTurn(); +- const subagent = stub(target, 'hasActiveSubagents').get(() => true); +- disposables.add(toDisposable(() => subagent.restore())); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.SkillCharBudget]: 40_000 }); +- await agent.chats.sendMessage(chat, 'same sponsor', [directory], undefined, 'second', undefined, context); +- target.discardActiveTurn(); +- const deferredStops = clients.filter((_client, index) => options[index].gitHubToken !== undefined).map(client => client.stopCallCount); +- token = 'other-sponsor'; +- await assert.rejects(agent.chats.sendMessage(chat, 'wrong sponsor', [directory], undefined, 'wrong', undefined, context), /busy|active|running/i); +- token = 'sponsor'; +- subagent.restore(); +- await agent.chats.sendMessage(chat, 'now idle', [directory], undefined, 'third', undefined, context); +- assert.deepStrictEqual({ +- deferredStops, +- stops: clients.filter((_client, index) => options[index].gitHubToken !== undefined).map(client => client.stopCallCount), +- kinds: launches.map(launch => launch.kind), +- oldSends: launches[0].runtime.sendCalls, +- newBudget: options.filter(options => options.gitHubToken !== undefined).at(-1)?.env?.SKILL_CHAR_BUDGET, +- }, { deferredStops: [0], stops: [1, 0], kinds: ['create', 'resume'], oldSends: 2, newBudget: '40000' }); - }); - - test('connector and index settings apply to a scoped runtime at the next idle boundary', async () => { @@ -56263,25 +58592,22 @@ index f24a97a6296..00000000000 - const directory = URI.file(process.cwd()); - const credentials = { getCredential: () => ({ resource: 'https://api.github.com', token: 'sponsor', expiresAt: undefined, revision: 1 }) }; - const context = { ...exactChatContext(session, chat, session), credentials }; -- try { -- await provisionSession(agent, { session, credentials, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); -- await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, context); -- chatEntriesBySdkId(agent).get(launches[0].sessionId)!.chatSession.discardActiveTurn(); -- configurationService.updateRootConfig({ -- [AgentHostMcpConnectorsEnabledConfigKey]: true, -- [CopilotCliConfigKey.LocalIndexEnabled]: true, -- }); -- await agent.chats.sendMessage(chat, 'same runtime', [directory], undefined, 'second', undefined, context); -- assert.deepStrictEqual({ -- scopedStarts: options.filter(options => options.gitHubToken !== undefined).length, -- scopedStops: clients.filter((_client, index) => options[index].gitHubToken !== undefined).map(client => client.stopCallCount), -- launches: launches.map(launch => launch.kind), -- sessionStore: launches.at(-1)?.config.enableSessionStore, -- connectors: launches.at(-1)?.config.featureFlags?.CONNECTORS, -- }, { scopedStarts: 2, scopedStops: [1, 0], launches: ['create', 'resume'], sessionStore: true, connectors: true }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await provisionSession(agent, { session, credentials, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); +- await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, context); +- chatEntriesBySdkId(agent).get(launches[0].sessionId)!.chatSession.discardActiveTurn(); +- configurationService.updateRootConfig({ +- [AgentHostMcpConnectorsEnabledConfigKey]: true, +- [CopilotCliConfigKey.LocalIndexEnabled]: true, +- }); +- await agent.chats.sendMessage(chat, 'same runtime', [directory], undefined, 'second', undefined, context); +- assert.deepStrictEqual({ +- scopedStarts: options.filter(options => options.gitHubToken !== undefined).length, +- scopedStops: clients.filter((_client, index) => options[index].gitHubToken !== undefined).map(client => client.stopCallCount), +- launches: launches.map(launch => launch.kind), +- sessionStore: launches.at(-1)?.config.enableSessionStore, +- connectors: launches.at(-1)?.config.featureFlags?.CONNECTORS, +- }, { scopedStarts: 2, scopedStops: [1, 0], launches: ['create', 'resume'], sessionStore: true, connectors: true }); - }); - - for (const profile of ['credential', 'storage'] as const) { @@ -56304,20 +58630,17 @@ index f24a97a6296..00000000000 - const start = () => (fixture.agent as unknown as { - _startClientWithConfigRetry(scope: { kind: 'storage' } | { kind: 'credential'; credential: IAgentHostScopedCredential }): Promise; - })._startClientWithConfigRetry(profile === 'storage' ? { kind: 'storage' } : { kind: 'credential', credential: { resource: 'https://api.github.com', token: 'fake-sponsor', expiresAt: undefined, revision: 1 } }); -- try { -- if (alwaysChanging) { -- await assert.rejects(start(), CopilotClientStartupConfigChangedError); -- } else { -- await (await start()).stop(); -- } -- assert.deepStrictEqual({ -- starts: fixture.clients.map(client => client.startCallCount), -- stops: fixture.clients.map(client => client.stopCallCount), -- retryLogs: info.getCalls().filter(call => typeof call.args[0] === 'string' && call.args[0].includes(`the ${profile} client`) && call.args[0].includes('re-acquiring')).length, -- }, { starts: [1, 1], stops: [1, 1], retryLogs: 1 }); -- } finally { -- await disposeAgent(fixture.agent); +- afterTest(() => disposeAgent(fixture.agent)); +- if (alwaysChanging) { +- await assert.rejects(start(), CopilotClientStartupConfigChangedError); +- } else { +- await (await start()).stop(); - } +- assert.deepStrictEqual({ +- starts: fixture.clients.map(client => client.startCallCount), +- stops: fixture.clients.map(client => client.stopCallCount), +- retryLogs: info.getCalls().filter(call => typeof call.args[0] === 'string' && call.args[0].includes(`the ${profile} client`) && call.args[0].includes('re-acquiring')).length, +- }, { starts: [1, 1], stops: [1, 1], retryLogs: 1 }); - }); - } - } @@ -56329,16 +58652,13 @@ index f24a97a6296..00000000000 - _startClientWithConfigRetry(scope: { kind: 'storage' }): Promise; - _ensureClient(): Promise; - }; -- try { -- await (await internal._startClientWithConfigRetry({ kind: 'storage' })).stop(); -- await internal._ensureClient(); -- assert.deepStrictEqual(telemetryService.events.filter(event => event.eventName === 'agentHost.copilotClientStartup').map(event => { -- const data = event.data as { attemptNumber: number; outcome: string }; -- return { attempt: data.attemptNumber, outcome: data.outcome }; -- }), [{ attempt: 1, outcome: 'success' }]); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await (await internal._startClientWithConfigRetry({ kind: 'storage' })).stop(); +- await internal._ensureClient(); +- assert.deepStrictEqual(telemetryService.events.filter(event => event.eventName === 'agentHost.copilotClientStartup').map(event => { +- const data = event.data as { attemptNumber: number; outcome: string }; +- return { attempt: data.attemptNumber, outcome: data.outcome }; +- }), [{ attempt: 1, outcome: 'success' }]); - }); - - for (const cold of [false, true]) { @@ -56363,26 +58683,23 @@ index f24a97a6296..00000000000 - current = setup(data); - await current.agent.materializeChat(chat, { ...context, credentials: missing }, created.providerData); - } -- try { -- const clientsBeforeDeletion = current.clients.length; -- await Promise.all([0, 1].map(() => current.agent.chats.disposeChat(chat, { ...context, credentials: missing }))); -- const storageOptions = current.options.at(-1)!; -- assert.deepStrictEqual({ -- deletionModes: current.options.slice(clientsBeforeDeletion).map(options => options.mode), -- storage: { token: storageOptions.gitHubToken, loggedInUser: storageOptions.useLoggedInUser }, -- deleted: current.clients.flatMap(client => client.deletedSessionIds), -- stops: current.clients.filter((_client, index) => current.options[index].mode === 'empty').map(client => client.stopCallCount), -- sameHome: !cold && current.options[0].baseDirectory === storageOptions.baseDirectory, -- backing: chatBackings(current.agent).get(chat.toString()), -- }, { -- deletionModes: ['empty'], -- storage: { token: undefined, loggedInUser: false }, -- deleted: [sdkId], stops: [1], -- sameHome: !cold, backing: undefined, -- }); -- } finally { -- await disposeAgent(current.agent); -- } +- afterTest(() => disposeAgent(current.agent)); +- const clientsBeforeDeletion = current.clients.length; +- await Promise.all([0, 1].map(() => current.agent.chats.disposeChat(chat, { ...context, credentials: missing }))); +- const storageOptions = current.options.at(-1)!; +- assert.deepStrictEqual({ +- deletionModes: current.options.slice(clientsBeforeDeletion).map(options => options.mode), +- storage: { token: storageOptions.gitHubToken, loggedInUser: storageOptions.useLoggedInUser }, +- deleted: current.clients.flatMap(client => client.deletedSessionIds), +- stops: current.clients.filter((_client, index) => current.options[index].mode === 'empty').map(client => client.stopCallCount), +- sameHome: !cold && current.options[0].baseDirectory === storageOptions.baseDirectory, +- backing: chatBackings(current.agent).get(chat.toString()), +- }, { +- deletionModes: ['empty'], +- storage: { token: undefined, loggedInUser: false }, +- deleted: [sdkId], stops: [1], +- sameHome: !cold, backing: undefined, +- }); - }); - } - @@ -56400,22 +58717,19 @@ index f24a97a6296..00000000000 - const restored = setup(data, [sdkSession(sdkId, directory.fsPath)]); - const missing = { getCredential: (): IAgentHostScopedCredential => { throw new Error('Storage must not request credentials'); } }; - const context = { ...exactChatContext(session, chat, session), credentials: missing }; -- try { -- await restored.agent.materializeChat(chat, context, created.providerData); -- await restored.agent.chats.disposeChat(chat, context); -- assert.deepStrictEqual({ -- options: restored.options.map(options => ({ mode: options.mode, token: options.gitHubToken })), -- metadata: restored.clients.flatMap(client => client.getSessionMetadataCalls), -- deleted: restored.clients.flatMap(client => client.deletedSessionIds), -- stops: restored.clients.map(client => client.stopCallCount), -- launches: restored.launches.length, -- }, { -- options: [0, 1].map(() => ({ mode: 'empty', token: undefined })), -- metadata: [sdkId], deleted: [sdkId], stops: [1, 1], launches: 0, -- }); -- } finally { -- await disposeAgent(restored.agent); -- } +- afterTest(() => disposeAgent(restored.agent)); +- await restored.agent.materializeChat(chat, context, created.providerData); +- await restored.agent.chats.disposeChat(chat, context); +- assert.deepStrictEqual({ +- options: restored.options.map(options => ({ mode: options.mode, token: options.gitHubToken })), +- metadata: restored.clients.flatMap(client => client.getSessionMetadataCalls), +- deleted: restored.clients.flatMap(client => client.deletedSessionIds), +- stops: restored.clients.map(client => client.stopCallCount), +- launches: restored.launches.length, +- }, { +- options: [0, 1].map(() => ({ mode: 'empty', token: undefined })), +- metadata: [sdkId], deleted: [sdkId], stops: [1, 1], launches: 0, +- }); - }); - - test('stops an unauthenticated storage client after a failed delete and preserves the backing for retry', async () => { @@ -56426,33 +58740,30 @@ index f24a97a6296..00000000000 - const directory = URI.file(process.cwd()); - const credentials = { getCredential: () => ({ resource: 'https://api.github.com', token: 'sponsor', expiresAt: undefined, revision: 1 }) }; - const context = { ...exactChatContext(session, chat, session), credentials }; -- try { -- await provisionSession(agent, { session, credentials, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); -- await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, context); -- const sdkId = launches[0].sessionId; -- metadata.push(sdkSession(sdkId, directory.fsPath)); -- chatEntriesBySdkId(agent).get(sdkId)!.chatSession.discardActiveTurn(); -- await agent.chats.releaseChat(chat, context); -- const beforeDelete = clients.length; -- const deletion = stub(TestCopilotClient.prototype, 'deleteSession').callThrough(); -- deletion.onFirstCall().rejects(new Error('Local delete failed')); -- disposables.add(toDisposable(() => deletion.restore())); -- await assert.rejects(agent.chats.disposeChat(chat, context), /Local delete failed/); -- const retained = chatBackings(agent).get(chat.toString())?.sdkSessionId; -- await agent.chats.disposeChat(chat, context); -- assert.deepStrictEqual({ -- retained, -- options: options.slice(beforeDelete).map(options => ({ mode: options.mode, token: options.gitHubToken })), -- stops: clients.slice(beforeDelete).map(client => client.stopCallCount), -- deleted: clients.slice(beforeDelete).flatMap(client => client.deletedSessionIds), -- backing: chatBackings(agent).get(chat.toString()), -- }, { -- retained: sdkId, options: [0, 1].map(() => ({ mode: 'empty', token: undefined })), -- stops: [1, 1], deleted: [sdkId], backing: undefined, -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await provisionSession(agent, { session, credentials, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); +- await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, context); +- const sdkId = launches[0].sessionId; +- metadata.push(sdkSession(sdkId, directory.fsPath)); +- chatEntriesBySdkId(agent).get(sdkId)!.chatSession.discardActiveTurn(); +- await agent.chats.releaseChat(chat, context); +- const beforeDelete = clients.length; +- const deletion = stub(TestCopilotClient.prototype, 'deleteSession').callThrough(); +- deletion.onFirstCall().rejects(new Error('Local delete failed')); +- disposables.add(toDisposable(() => deletion.restore())); +- await assert.rejects(agent.chats.disposeChat(chat, context), /Local delete failed/); +- const retained = chatBackings(agent).get(chat.toString())?.sdkSessionId; +- await agent.chats.disposeChat(chat, context); +- assert.deepStrictEqual({ +- retained, +- options: options.slice(beforeDelete).map(options => ({ mode: options.mode, token: options.gitHubToken })), +- stops: clients.slice(beforeDelete).map(client => client.stopCallCount), +- deleted: clients.slice(beforeDelete).flatMap(client => client.deletedSessionIds), +- backing: chatBackings(agent).get(chat.toString()), +- }, { +- retained: sdkId, options: [0, 1].map(() => ({ mode: 'empty', token: undefined })), +- stops: [1, 1], deleted: [sdkId], backing: undefined, +- }); - }); - - for (const setting of [CopilotCliConfigKey.SkillCharBudget, AgentHostProxyConfigKey.NoProxy]) { @@ -56463,31 +58774,28 @@ index f24a97a6296..00000000000 - const directory = URI.file(process.cwd()); - const credentials = { getCredential: () => ({ resource: 'https://api.github.com', token: 'sponsor', expiresAt: undefined, revision: 1 }) }; - const context = { ...exactChatContext(session, chat, session), credentials }; -- try { -- await provisionSession(agent, { session, credentials, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); -- await agent.chats.prepareTurn!(chat, 'first', [directory], context); -- configurationService.updateRootConfig({ [setting]: setting === CopilotCliConfigKey.SkillCharBudget ? 40_000 : ['new.example'] }); -- await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, context); -- await agent.chats.sendMessage(chat, 'busy', [directory], undefined, 'busy', undefined, context); -- const beforeIdle = clients.filter((_client, index) => options[index].gitHubToken !== undefined).map(client => client.stopCallCount); -- chatEntriesBySdkId(agent).get(launches[0].sessionId)!.chatSession.discardActiveTurn(); -- await agent.chats.prepareTurn!(chat, 'second', [directory], context); -- await agent.chats.sendMessage(chat, 'second', [directory], undefined, 'second', undefined, context); -- const scopedOptions = options.filter(options => options.gitHubToken !== undefined); -- assert.deepStrictEqual({ -- beforeIdle, -- modes: scopedOptions.map(options => options.mode), -- stops: clients.filter((_client, index) => options[index].gitHubToken !== undefined).map(client => client.stopCallCount), -- launches: launches.map(launch => ({ kind: launch.kind, id: launch.sessionId, token: launch.config.gitHubToken })), -- environment: scopedOptions[1].env?.[setting === CopilotCliConfigKey.SkillCharBudget ? 'SKILL_CHAR_BUDGET' : 'NO_PROXY'], -- }, { -- beforeIdle: [0], modes: [undefined, undefined], stops: [1, 0], -- launches: [{ kind: 'create', id: launches[0].sessionId, token: 'sponsor' }, { kind: 'resume', id: launches[0].sessionId, token: 'sponsor' }], -- environment: setting === CopilotCliConfigKey.SkillCharBudget ? '40000' : 'new.example', -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await provisionSession(agent, { session, credentials, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); +- await agent.chats.prepareTurn!(chat, 'first', [directory], context); +- configurationService.updateRootConfig({ [setting]: setting === CopilotCliConfigKey.SkillCharBudget ? 40_000 : ['new.example'] }); +- await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, context); +- await agent.chats.sendMessage(chat, 'busy', [directory], undefined, 'busy', undefined, context); +- const beforeIdle = clients.filter((_client, index) => options[index].gitHubToken !== undefined).map(client => client.stopCallCount); +- chatEntriesBySdkId(agent).get(launches[0].sessionId)!.chatSession.discardActiveTurn(); +- await agent.chats.prepareTurn!(chat, 'second', [directory], context); +- await agent.chats.sendMessage(chat, 'second', [directory], undefined, 'second', undefined, context); +- const scopedOptions = options.filter(options => options.gitHubToken !== undefined); +- assert.deepStrictEqual({ +- beforeIdle, +- modes: scopedOptions.map(options => options.mode), +- stops: clients.filter((_client, index) => options[index].gitHubToken !== undefined).map(client => client.stopCallCount), +- launches: launches.map(launch => ({ kind: launch.kind, id: launch.sessionId, token: launch.config.gitHubToken })), +- environment: scopedOptions[1].env?.[setting === CopilotCliConfigKey.SkillCharBudget ? 'SKILL_CHAR_BUDGET' : 'NO_PROXY'], +- }, { +- beforeIdle: [0], modes: [undefined, undefined], stops: [1, 0], +- launches: [{ kind: 'create', id: launches[0].sessionId, token: 'sponsor' }, { kind: 'resume', id: launches[0].sessionId, token: 'sponsor' }], +- environment: setting === CopilotCliConfigKey.SkillCharBudget ? '40000' : 'new.example', +- }); - }); - } - @@ -56497,36 +58805,33 @@ index f24a97a6296..00000000000 - [AgentHostProxyConfigKey.NoProxy]: ['legacy.example'], - [AgentHostProxyConfigKey.ProxyKerberosServicePrincipal]: 'HTTP/legacy.example', - }); -- const watermark = agent as unknown as { _appliedProxy: string; _appliedNoProxy: string; _appliedProxyKerberosSpn: string }; +- const watermark = agent as unknown as { _appliedProxyState: { proxy: string; noProxy: string; kerberosSpn: string; certificates: readonly string[] } }; - const session = AgentSession.uri('copilotcli', 'scoped-proxy-watermark'); - const chat = defaultChatUri(session); - const directory = URI.file(process.cwd()); - const credentials = { getCredential: () => ({ resource: 'https://api.github.com', token: 'sponsor', expiresAt: undefined, revision: 1 }) }; -- try { -- const legacy = AgentSession.uri('copilotcli', 'legacy-proxy-watermark'); -- const legacyChat = defaultChatUri(legacy); -- await provisionSession(agent, { session: legacy, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); -- await agent.chats.sendMessage(legacyChat, 'legacy running', [directory], undefined, 'legacy', undefined, exactChatContext(legacy, legacyChat, legacy)); -- configurationService.updateRootConfig({ -- [AgentHostProxyConfigKey.Proxy]: 'http://scoped-proxy.example', -- [AgentHostProxyConfigKey.NoProxy]: ['scoped.example'], -- [AgentHostProxyConfigKey.ProxyKerberosServicePrincipal]: 'HTTP/scoped.example', -- }); -- await provisionSession(agent, { session, credentials, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); -- await agent.chats.prepareTurn!(chat, 'turn', [directory], { ...exactChatContext(session, chat, session), credentials }); -- const scopedOptions = options.find(options => options.gitHubToken !== undefined)!; -- assert.deepStrictEqual({ -- proxy: watermark._appliedProxy, noProxy: watermark._appliedNoProxy, kerberos: watermark._appliedProxyKerberosSpn, -- scopedProxy: scopedOptions.env?.HTTPS_PROXY, scopedNoProxy: scopedOptions.env?.NO_PROXY, -- legacyStops: clients[0].stopCallCount, -- }, { -- proxy: 'http://legacy-proxy.example', noProxy: 'legacy.example', kerberos: 'HTTP/legacy.example', -- scopedProxy: 'http://scoped-proxy.example', scopedNoProxy: 'scoped.example', -- legacyStops: 0, -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const legacy = AgentSession.uri('copilotcli', 'legacy-proxy-watermark'); +- const legacyChat = defaultChatUri(legacy); +- await provisionSession(agent, { session: legacy, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); +- await agent.chats.sendMessage(legacyChat, 'legacy running', [directory], undefined, 'legacy', undefined, exactChatContext(legacy, legacyChat, legacy)); +- configurationService.updateRootConfig({ +- [AgentHostProxyConfigKey.Proxy]: 'http://scoped-proxy.example', +- [AgentHostProxyConfigKey.NoProxy]: ['scoped.example'], +- [AgentHostProxyConfigKey.ProxyKerberosServicePrincipal]: 'HTTP/scoped.example', +- }); +- await provisionSession(agent, { session, credentials, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); +- await agent.chats.prepareTurn!(chat, 'turn', [directory], { ...exactChatContext(session, chat, session), credentials }); +- const scopedOptions = options.find(options => options.gitHubToken !== undefined)!; +- assert.deepStrictEqual({ +- proxy: watermark._appliedProxyState.proxy, noProxy: watermark._appliedProxyState.noProxy, kerberos: watermark._appliedProxyState.kerberosSpn, +- scopedProxy: scopedOptions.env?.HTTPS_PROXY, scopedNoProxy: scopedOptions.env?.NO_PROXY, +- legacyStops: clients[0].stopCallCount, +- }, { +- proxy: 'http://legacy-proxy.example', noProxy: 'legacy.example', kerberos: 'HTTP/legacy.example', +- scopedProxy: 'http://scoped-proxy.example', scopedNoProxy: 'scoped.example', +- legacyStops: 0, +- }); - }); - - for (const scoped of [false, true]) { @@ -56543,13 +58848,10 @@ index f24a97a6296..00000000000 - usesScopedGitHubCredentials: scoped, - sendSteering: async () => { throw new Error('Steering failed'); }, - } as unknown as CopilotAgentSession; -- try { -- internals._deliverPendingSteering(chat, target); -- await timeout(0); -- assert.deepStrictEqual([...internals._pendingSteeringMessages.values()], scoped ? [pending] : []); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- internals._deliverPendingSteering(chat, target); +- await timeout(0); +- assert.deepStrictEqual([...internals._pendingSteeringMessages.values()], scoped ? [pending] : []); - }); - } - @@ -56564,15 +58866,12 @@ index f24a97a6296..00000000000 - { resource: 'https://api.github.com', token: 'invalid-deadline', expiresAt: NaN, revision: 1 }, - { resource: 'https://other.example', token: 'wrong-resource', expiresAt: undefined, revision: 1 }, - ]; -- try { -- for (const credential of invalid) { -- const context = { ...exactChatContext(session, chat, session), credentials: { getCredential: () => credential } }; -- await assert.rejects(agent.chats.createChat(chat, context, { deferBacking: true, workingDirectories: [URI.file(process.cwd())] }), { code: AhpErrorCodes.AuthRequired }); -- } -- assert.deepStrictEqual({ options, backing: chatBackings(agent).get(chat.toString()) }, { options: [], backing: undefined }); -- } finally { -- await disposeAgent(agent); +- afterTest(() => disposeAgent(agent)); +- for (const credential of invalid) { +- const context = { ...exactChatContext(session, chat, session), credentials: { getCredential: () => credential } }; +- await assert.rejects(agent.chats.createChat(chat, context, { deferBacking: true, workingDirectories: [URI.file(process.cwd())] }), { code: AhpErrorCodes.AuthRequired }); - } +- assert.deepStrictEqual({ options, backing: chatBackings(agent).get(chat.toString()) }, { options: [], backing: undefined }); - }); - - for (const read of ['metadata', 'materialization', 'history'] as const) { @@ -56586,38 +58885,35 @@ index f24a97a6296..00000000000 - const providerData = JSON.stringify({ sdkSessionId: sdkId }); - const unavailable = { getCredential: () => { throw new ProtocolError(AhpErrorCodes.AuthRequired, 'An isolated session requires an authenticated sponsor'); } }; - const observing = { ...context, credentials: unavailable }; -- try { -- if (read !== 'materialization') { -- await agent.getChatMetadata(chat, observing, providerData); -- } -- if (read !== 'metadata') { -- await agent.materializeChat(chat, observing, providerData); -- } -- if (read === 'history') { -- await agent.chats.getMessages(chat, observing); -- } -- const beforeExecution = { -- modes: options.map(option => option.mode), -- launches: launches.length, -- }; -- await agent.chats.releaseChat(chat, observing); -- await agent.materializeChat(chat, context, providerData); -- await Promise.all([ -- agent.chats.changeModel(chat, { id: 'gpt-4o' }, context), -- agent.chats.changeAgent(chat, undefined, context), -- ]); -- await agent.chats.sendMessage(chat, 'Continue locally', [directory], undefined, 'local-turn', undefined, context); -- -- assert.deepStrictEqual({ -- beforeExecution, -- launches: launches.map(launch => ({ kind: launch.kind, id: launch.sessionId, token: launch.config.gitHubToken, sends: launch.runtime.sendCalls })), -- }, { -- beforeExecution: { modes: Array(read === 'materialization' ? 0 : read === 'history' ? 2 : 1).fill('empty'), launches: 0 }, -- launches: [{ kind: 'resume', id: sdkId, token: undefined, sends: 1 }], -- }); -- } finally { -- await disposeAgent(agent); +- afterTest(() => disposeAgent(agent)); +- if (read !== 'materialization') { +- await agent.getChatMetadata(chat, observing, providerData); +- } +- if (read !== 'metadata') { +- await agent.materializeChat(chat, observing, providerData); +- } +- if (read === 'history') { +- await agent.chats.getMessages(chat, observing); - } +- const beforeExecution = { +- modes: options.map(option => option.mode), +- launches: launches.length, +- }; +- await agent.chats.releaseChat(chat, observing); +- await agent.materializeChat(chat, context, providerData); +- await Promise.all([ +- agent.chats.changeModel(chat, { id: 'gpt-4o' }, context), +- agent.chats.changeAgent(chat, undefined, context), +- ]); +- await agent.chats.sendMessage(chat, 'Continue locally', [directory], undefined, 'local-turn', undefined, context); +- +- assert.deepStrictEqual({ +- beforeExecution, +- launches: launches.map(launch => ({ kind: launch.kind, id: launch.sessionId, token: launch.config.gitHubToken, sends: launch.runtime.sendCalls })), +- }, { +- beforeExecution: { modes: Array(read === 'materialization' ? 0 : read === 'history' ? 2 : 1).fill('empty'), launches: 0 }, +- launches: [{ kind: 'resume', id: sdkId, token: undefined, sends: 1 }], +- }); - }); - } - @@ -56629,20 +58925,17 @@ index f24a97a6296..00000000000 - const credentials = { getCredential: () => ({ resource: 'https://api.github.com', token: 'execution-sponsor', expiresAt: undefined, revision: 1 }) }; - const context = { ...exactChatContext(session, chat, session), credentials }; - const unavailable = { getCredential: () => { throw new ProtocolError(AhpErrorCodes.AuthRequired, 'Observation cannot sponsor execution'); } }; -- try { -- const created = await provisionSession(agent, { session, credentials, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); -- await agent.chats.sendMessage(chat, 'First', [directory], undefined, 'first', undefined, context); -- chatEntriesBySdkId(agent).get(launches[0].sessionId)!.chatSession.discardActiveTurn(); -- await agent.getChatMetadata(chat, { ...context, credentials: unavailable }, created.providerData); -- await agent.chats.sendMessage(chat, 'Continue', [directory], undefined, 'second'); +- afterTest(() => disposeAgent(agent)); +- const created = await provisionSession(agent, { session, credentials, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); +- await agent.chats.sendMessage(chat, 'First', [directory], undefined, 'first', undefined, context); +- chatEntriesBySdkId(agent).get(launches[0].sessionId)!.chatSession.discardActiveTurn(); +- await agent.getChatMetadata(chat, { ...context, credentials: unavailable }, created.providerData); +- await agent.chats.sendMessage(chat, 'Continue', [directory], undefined, 'second'); - -- assert.deepStrictEqual({ -- tokens: options.filter(option => option.gitHubToken !== undefined).map(option => option.gitHubToken), -- sends: launches.map(launch => launch.runtime.sendCalls), -- }, { tokens: ['execution-sponsor'], sends: [2] }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- tokens: options.filter(option => option.gitHubToken !== undefined).map(option => option.gitHubToken), +- sends: launches.map(launch => launch.runtime.sendCalls), +- }, { tokens: ['execution-sponsor'], sends: [2] }); - }); - - test('reads isolated history and lists canvases without a sponsor, then resumes execution with a fresh sponsor', async () => { @@ -56669,40 +58962,37 @@ index f24a97a6296..00000000000 - const restored = setup(data, [sdkSession(sdkId, directory.fsPath)], history, { [AgentHostCanvasesEnabledConfigKey]: true }); - const missing = { getCredential: () => { throw new ProtocolError(AhpErrorCodes.AuthRequired, 'Fresh authentication required'); } }; - const fresh = { getCredential: () => ({ resource: 'https://api.github.com', token: 'fresh', expiresAt: undefined, revision: 1 }) }; -- try { -- const observing = { ...exactChatContext(session, chat, session), credentials: missing }; -- await restored.agent.materializeChat(chat, observing, reserved.providerData); -- const turns = await restored.agent.chats.getMessages(chat, observing); -- const canvases = await restored.agent.listSessionCanvases(session, chat, observing); -- const beforeSend = { -- launches: restored.launches.length, -- canvases, -- clients: restored.options.map(options => ({ mode: options.mode, token: options.gitHubToken })), -- stops: restored.clients.map(client => client.stopCallCount), -- }; -- await assert.rejects(restored.agent.chats.sendMessage(chat, 'Unavailable', [directory], undefined, 'missing-turn', undefined, observing), /Fresh authentication required/); -- const context = { ...exactChatContext(session, chat, session), credentials: fresh }; -- await restored.agent.openSessionCanvas(session, { canvasId: 'preview', instanceId: 'preview' }, chat, context); -- await restored.agent.chats.sendMessage(chat, 'Continue', [directory], undefined, 'fresh-turn', undefined, context); -- restored.launches[0].runtime.canvases.push({ -- canvasId: 'preview', extensionId: 'project:preview', displayName: 'Preview', description: 'Preview generated content.', -- }); -- const liveCanvases = await restored.agent.listSessionCanvases(session, chat, context); -- assert.deepStrictEqual({ -- beforeSend, -- identity: restored.launches[0]?.sessionId, -- token: restored.launches[0]?.config.gitHubToken, -- question: turns[0]?.message.text, -- sends: restored.launches[0]?.runtime.sendCalls, -- liveCanvases: liveCanvases?.map(canvas => canvas.canvasId), -- }, { -- beforeSend: { launches: 0, canvases: undefined, clients: [0, 1].map(() => ({ mode: 'empty', token: undefined })), stops: [1, 1] }, -- identity: sdkId, token: 'fresh', question: 'Remember the isolated conversation', sends: 1, -- liveCanvases: ['preview'], -- }); -- } finally { -- await disposeAgent(restored.agent); -- } +- afterTest(() => disposeAgent(restored.agent)); +- const observing = { ...exactChatContext(session, chat, session), credentials: missing }; +- await restored.agent.materializeChat(chat, observing, reserved.providerData); +- const turns = await restored.agent.chats.getMessages(chat, observing); +- const canvases = await restored.agent.listSessionCanvases(session, chat, observing); +- const beforeSend = { +- launches: restored.launches.length, +- canvases, +- clients: restored.options.map(options => ({ mode: options.mode, token: options.gitHubToken })), +- stops: restored.clients.map(client => client.stopCallCount), +- }; +- await assert.rejects(restored.agent.chats.sendMessage(chat, 'Unavailable', [directory], undefined, 'missing-turn', undefined, observing), /Fresh authentication required/); +- const context = { ...exactChatContext(session, chat, session), credentials: fresh }; +- await restored.agent.openSessionCanvas(session, { canvasId: 'preview', instanceId: 'preview' }, chat, context); +- await restored.agent.chats.sendMessage(chat, 'Continue', [directory], undefined, 'fresh-turn', undefined, context); +- restored.launches[0].runtime.canvases.push({ +- canvasId: 'preview', extensionId: 'project:preview', displayName: 'Preview', description: 'Preview generated content.', +- }); +- const liveCanvases = await restored.agent.listSessionCanvases(session, chat, context); +- assert.deepStrictEqual({ +- beforeSend, +- identity: restored.launches[0]?.sessionId, +- token: restored.launches[0]?.config.gitHubToken, +- question: turns[0]?.message.text, +- sends: restored.launches[0]?.runtime.sendCalls, +- liveCanvases: liveCanvases?.map(canvas => canvas.canvasId), +- }, { +- beforeSend: { launches: 0, canvases: undefined, clients: [0, 1].map(() => ({ mode: 'empty', token: undefined })), stops: [1, 1] }, +- identity: sdkId, token: 'fresh', question: 'Remember the isolated conversation', sends: 1, +- liveCanvases: ['preview'], +- }); - }); - - test('live registration refreshes a cached isolated canvas inventory after an early registry event without dropping open canvases', async () => { @@ -56805,6 +59095,9 @@ index f24a97a6296..00000000000 - try { - await database.object.createTurn('question'); - await database.object.storeTerminalOutput('question', 'shell', VSBuffer.fromString('retained output').buffer); +- const batchedLookups = spy(database.object, 'getTerminalOutputSizes'); +- const singleLookups = spy(database.object, 'getTerminalOutputSize'); +- disposables.add(toDisposable(() => { batchedLookups.restore(); singleLookups.restore(); })); - await agent.materializeChat(chat, context, undefined); - const parentRead = agent.chats.getMessages(chat, context); - await readStarted.p; @@ -56828,6 +59121,7 @@ index f24a97a6296..00000000000 - sibling: { question: siblingTurns[0].message.text, markdown: markdown(siblingTurns) }, - childAgain: markdown(childAgain), - terminal: shell?.kind === ResponsePartKind.ToolCall && shell.toolCall.status === 'completed' ? shell.toolCall.content?.find(content => content.type === ToolResultContentType.Terminal)?.resource : undefined, +- storedOutputLookups: { batched: batchedLookups.callCount, single: singleLookups.callCount }, - launches: launches.length, - clients: options.map(option => option.mode), - }, { @@ -56837,6 +59131,7 @@ index f24a97a6296..00000000000 - sibling: { question: 'Sibling question', markdown: ['Sibling answer'] }, - childAgain: ['Child answer'], - terminal: buildNonPtyShellTerminalUri(storage, owner, chat, 'shell'), +- storedOutputLookups: { batched: 1, single: 0 }, - launches: 0, clients: ['empty'], - }); - await agent.chats.releaseChat(chat, context); @@ -56878,20 +59173,17 @@ index f24a97a6296..00000000000 - const chat = defaultChatUri(session); - const credentials = { getCredential: () => { assert.fail('History must not request a credential'); } }; - const context = { ...exactChatContext(session, chat, session), credentials }; -- try { -- await agent.materializeChat(chat, context, undefined); -- await assert.rejects(agent.chats.getMessages(chat, context), failure === 'rpc' ? /Journal unavailable/ : failure === 'expired' ? /history changed/ : /invalid history cursor/); -- failed = true; -- const retried = await agent.chats.getMessages(chat, context); -- assert.deepStrictEqual({ -- clients: options.map(options => ({ mode: options.mode, token: options.gitHubToken })), -- stops: clients.map(client => client.stopCallCount), -- launches: launches.length, -- retried, -- }, { clients: [0, 1].map(() => ({ mode: 'empty', token: undefined })), stops: [1, 1], launches: 0, retried: [] }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.materializeChat(chat, context, undefined); +- await assert.rejects(agent.chats.getMessages(chat, context), failure === 'rpc' ? /Journal unavailable/ : failure === 'expired' ? /history changed/ : /invalid history cursor/); +- failed = true; +- const retried = await agent.chats.getMessages(chat, context); +- assert.deepStrictEqual({ +- clients: options.map(options => ({ mode: options.mode, token: options.gitHubToken })), +- stops: clients.map(client => client.stopCallCount), +- launches: launches.length, +- retried, +- }, { clients: [0, 1].map(() => ({ mode: 'empty', token: undefined })), stops: [1, 1], launches: 0, retried: [] }); - }); - } - @@ -56905,54 +59197,51 @@ index f24a97a6296..00000000000 - }; - const credentials = { getCredential: () => credential }; - const context = { ...exactChatContext(session, chat, session), credentials }; -- try { -- await agent.authenticate('https://api.github.com', 'desktop'); -- await agent.refreshModels(); -- await provisionSession(agent, { session, credentials, workingDirectories: [directory] }); -- const afterReservation = options.length; -- await agent.chats.prepareTurn!(chat, 'turn-a', [directory], context); -- await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'turn-a', undefined, context); -- const first = launches[0]; -- await agent.authenticate('https://api.github.com', 'desktop-rotated', 7200); -- await agent.refreshModels(); -- await (agent as unknown as { _handleClientOperationFailure(error: Error, operation: string): Promise })._handleClientOperationFailure(new Error('Connection is closed.'), 'modelRefresh'); -- const afterGlobalAuth = { live: hasLiveChat(agent, chat), scopedStops: clients[1].stopCallCount, updates: first.runtime.gitHubCredentialUpdates }; -- credential = { ...credential!, token: 'scoped-b', revision: 2 }; -- await assert.rejects(agent.chats.sendMessage(chat, 'busy', [directory], undefined, 'turn-b', undefined, context), /busy|in-flight|active|running/i); -- const oldToken = first.config.gitHubToken; -- chatEntriesBySdkId(agent).get(first.sessionId)!.chatSession.discardActiveTurn(); -- await agent.chats.sendMessage(chat, 'second', [directory], undefined, 'turn-b', undefined, context); -- const second = launches[1]; -- const newToken = second.config.gitHubToken; -- chatEntriesBySdkId(agent).get(second.sessionId)!.chatSession.discardActiveTurn(); -- const detachedWork = stub(chatEntriesBySdkId(agent).get(second.sessionId)!.chatSession, 'hasActiveSubagents').get(() => true); -- credential = { ...credential, token: 'scoped-c', expiresAt: undefined, revision: 3 }; -- await assert.rejects(agent.chats.resumeTurn!(chat, 'turn-c', context), /active turn/i); -- detachedWork.restore(); -- await agent.chats.resumeTurn!(chat, 'turn-c', context); -- assert.deepStrictEqual({ -- afterReservation, afterGlobalAuth, -- oldToken, newToken, -- hasTokenProviders: launches.map(launch => launch.config.gitHubTokenProvider !== undefined), -- kinds: launches.map(launch => launch.kind), -- sameIdentity: launches.every(launch => launch.sessionId === first.sessionId) && chatBackings(agent).get(chat.toString())?.sdkSessionId === first.sessionId, -- resumedStaticToken: launches[2].config.gitHubToken, -- scopedOptions: options.filter(option => option.gitHubToken !== undefined).map(option => ({ token: option.gitHubToken, useLoggedInUser: option.useLoggedInUser })), -- stoppedOldScopedRuntime: clients[1].stopCallCount, -- }, { -- afterReservation: 1, afterGlobalAuth: { live: true, scopedStops: 0, updates: [] }, -- oldToken: 'scoped-a', newToken: 'scoped-b', kinds: ['create', 'resume', 'resume'], sameIdentity: true, resumedStaticToken: 'scoped-c', -- hasTokenProviders: [false, false, false], -- scopedOptions: [ -- { token: 'scoped-a', useLoggedInUser: false }, -- { token: 'scoped-b', useLoggedInUser: false }, -- { token: 'scoped-c', useLoggedInUser: false }, -- ], -- stoppedOldScopedRuntime: 1, -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'desktop'); +- await agent.refreshModels(); +- await provisionSession(agent, { session, credentials, workingDirectories: [directory] }); +- const afterReservation = options.length; +- await agent.chats.prepareTurn!(chat, 'turn-a', [directory], context); +- await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'turn-a', undefined, context); +- const first = launches[0]; +- await agent.authenticate('https://api.github.com', 'desktop-rotated', 7200); +- await agent.refreshModels(); +- await (agent as unknown as { _handleClientOperationFailure(error: Error, operation: string): Promise })._handleClientOperationFailure(new Error('Connection is closed.'), 'modelRefresh'); +- const afterGlobalAuth = { live: hasLiveChat(agent, chat), scopedStops: clients[1].stopCallCount, updates: first.runtime.gitHubCredentialUpdates }; +- credential = { ...credential!, token: 'scoped-b', revision: 2 }; +- await assert.rejects(agent.chats.sendMessage(chat, 'busy', [directory], undefined, 'turn-b', undefined, context), /busy|in-flight|active|running/i); +- const oldToken = first.config.gitHubToken; +- chatEntriesBySdkId(agent).get(first.sessionId)!.chatSession.discardActiveTurn(); +- await agent.chats.sendMessage(chat, 'second', [directory], undefined, 'turn-b', undefined, context); +- const second = launches[1]; +- const newToken = second.config.gitHubToken; +- chatEntriesBySdkId(agent).get(second.sessionId)!.chatSession.discardActiveTurn(); +- const detachedWork = stub(chatEntriesBySdkId(agent).get(second.sessionId)!.chatSession, 'hasActiveSubagents').get(() => true); +- credential = { ...credential, token: 'scoped-c', expiresAt: undefined, revision: 3 }; +- await assert.rejects(agent.chats.resumeTurn!(chat, 'turn-c', context), /active turn/i); +- detachedWork.restore(); +- await agent.chats.resumeTurn!(chat, 'turn-c', context); +- assert.deepStrictEqual({ +- afterReservation, afterGlobalAuth, +- oldToken, newToken, +- hasTokenProviders: launches.map(launch => launch.config.gitHubTokenProvider !== undefined), +- kinds: launches.map(launch => launch.kind), +- sameIdentity: launches.every(launch => launch.sessionId === first.sessionId) && chatBackings(agent).get(chat.toString())?.sdkSessionId === first.sessionId, +- resumedStaticToken: launches[2].config.gitHubToken, +- scopedOptions: options.filter(option => option.gitHubToken !== undefined).map(option => ({ token: option.gitHubToken, useLoggedInUser: option.useLoggedInUser })), +- stoppedOldScopedRuntime: clients[1].stopCallCount, +- }, { +- afterReservation: 1, afterGlobalAuth: { live: true, scopedStops: 0, updates: [] }, +- oldToken: 'scoped-a', newToken: 'scoped-b', kinds: ['create', 'resume', 'resume'], sameIdentity: true, resumedStaticToken: 'scoped-c', +- hasTokenProviders: [false, false, false], +- scopedOptions: [ +- { token: 'scoped-a', useLoggedInUser: false }, +- { token: 'scoped-b', useLoggedInUser: false }, +- { token: 'scoped-c', useLoggedInUser: false }, +- ], +- stoppedOldScopedRuntime: 1, +- }); - }); - - test('isolates peer runtimes, preserves local adoption identity and refuses revoked prepared work', async () => { @@ -56964,28 +59253,25 @@ index f24a97a6296..00000000000 - let available = true; - const source = { getCredential: () => available ? { resource: 'https://api.github.com', token: 'sponsor', expiresAt: undefined, revision: 1 } : undefined }; - const context = { ...exactChatContext(session, chat, session), credentials: source }; -- try { -- await provisionSession(agent, { session, workingDirectories: [directory] }); -- await agent.chats.prepareTurn!(chat, 'local', [directory], exactChatContext(session, chat, session)); -- const localId = launches[0].sessionId; -- await agent.chats.prepareTurn!(chat, 'adopted', [directory], context); -- const adopted = launches[1]; -- await agent.chats.createChat(peer, { ...exactChatContext(session, peer), credentials: { getCredential: () => ({ resource: 'https://api.github.com', token: 'peer', expiresAt: undefined, revision: 1 }) } }, { workingDirectories: [directory] }); -- available = false; -- await assert.rejects(agent.chats.sendMessage(chat, 'revoked', [directory], undefined, 'adopted', undefined, context), { code: AhpErrorCodes.AuthRequired }); -- assert.deepStrictEqual({ -- localIdPreserved: adopted.sessionId === localId, -- tokens: launches.map(launch => launch.config.gitHubToken), -- modes: options.map(option => option.mode ?? 'copilot-cli'), -- adoptedSends: adopted.runtime.sendCalls, -- preservesSdkStorage: options[1].baseDirectory === options[2].baseDirectory && options[1].baseDirectory === URI.joinPath(URI.file('/mock-home'), '.copilot').fsPath, -- }, { -- localIdPreserved: true, tokens: [undefined, 'sponsor', 'peer'], -- modes: ['copilot-cli', 'copilot-cli', 'copilot-cli'], adoptedSends: 0, preservesSdkStorage: true, -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await provisionSession(agent, { session, workingDirectories: [directory] }); +- await agent.chats.prepareTurn!(chat, 'local', [directory], exactChatContext(session, chat, session)); +- const localId = launches[0].sessionId; +- await agent.chats.prepareTurn!(chat, 'adopted', [directory], context); +- const adopted = launches[1]; +- await agent.chats.createChat(peer, { ...exactChatContext(session, peer), credentials: { getCredential: () => ({ resource: 'https://api.github.com', token: 'peer', expiresAt: undefined, revision: 1 }) } }, { workingDirectories: [directory] }); +- available = false; +- await assert.rejects(agent.chats.sendMessage(chat, 'revoked', [directory], undefined, 'adopted', undefined, context), { code: AhpErrorCodes.AuthRequired }); +- assert.deepStrictEqual({ +- localIdPreserved: adopted.sessionId === localId, +- tokens: launches.map(launch => launch.config.gitHubToken), +- modes: options.map(option => option.mode ?? 'copilot-cli'), +- adoptedSends: adopted.runtime.sendCalls, +- preservesSdkStorage: options[1].baseDirectory === options[2].baseDirectory && options[1].baseDirectory === URI.joinPath(URI.file('/mock-home'), '.copilot').fsPath, +- }, { +- localIdPreserved: true, tokens: [undefined, 'sponsor', 'peer'], +- modes: ['copilot-cli', 'copilot-cli', 'copilot-cli'], adoptedSends: 0, preservesSdkStorage: true, +- }); - }); - - test('scrubs ambient authentication and stops failed scoped startup without falling back', async () => { @@ -57007,21 +59293,18 @@ index f24a97a6296..00000000000 - disposables.add(toDisposable(() => environment.restore())); - const start = stub(TestCopilotClient.prototype, 'start').callsFake(async () => { throw new Error('Scoped startup unavailable'); }); - disposables.add(toDisposable(() => start.restore())); -- try { -- await agent.chats.createChat(chat, context, { deferBacking: true, workingDirectories: [directory] }); -- await assert.rejects(agent.chats.prepareTurn!(chat, 'turn', [directory], context), /Scoped startup unavailable/); -- assert.deepStrictEqual({ -- modes: options.map(option => option.mode), -- token: options[0].gitHubToken, -- ambientKeys: Object.keys(options[0].env ?? {}).filter(key => /TOKEN|API_KEY|ACCESS_KEY/.test(key) && key !== 'GITHUB_COPILOT_GITHUB_TOKEN'), -- shellToken: options[0].env?.GITHUB_COPILOT_GITHUB_TOKEN, -- host: options[0].env?.COPILOT_GH_HOST, -- stops: clients.map(client => client.stopCallCount), -- live: hasLiveChat(agent, chat), -- }, { modes: [undefined], token: 'scoped', shellToken: 'scoped', ambientKeys: [], host: 'github.com', stops: [1], live: false }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.chats.createChat(chat, context, { deferBacking: true, workingDirectories: [directory] }); +- await assert.rejects(agent.chats.prepareTurn!(chat, 'turn', [directory], context), /Scoped startup unavailable/); +- assert.deepStrictEqual({ +- modes: options.map(option => option.mode), +- token: options[0].gitHubToken, +- ambientKeys: Object.keys(options[0].env ?? {}).filter(key => /TOKEN|API_KEY|ACCESS_KEY/.test(key) && key !== 'GITHUB_COPILOT_GITHUB_TOKEN'), +- shellToken: options[0].env?.GITHUB_COPILOT_GITHUB_TOKEN, +- host: options[0].env?.COPILOT_GH_HOST, +- stops: clients.map(client => client.stopCallCount), +- live: hasLiveChat(agent, chat), +- }, { modes: [undefined], token: 'scoped', shellToken: 'scoped', ambientKeys: [], host: 'github.com', stops: [1], live: false }); - }); - - test('preserves host-configured runtime flags and Kerberos proxy settings in scoped startup', async () => { @@ -57035,26 +59318,23 @@ index f24a97a6296..00000000000 - ...exactChatContext(session, chat, session), - credentials: { getCredential: () => ({ resource: 'https://api.github.com', token: 'scoped', expiresAt: undefined, revision: 1 }) }, - }; -- try { -- await agent.chats.createChat(chat, context, { deferBacking: true, workingDirectories: [URI.file(process.cwd())] }); -- await agent.chats.prepareTurn!(chat, 'turn', [URI.file(process.cwd())], context); -- const env = options[0].env; -- assert.deepStrictEqual({ -- mode: options[0].mode, -- runAsNode: env?.COPILOT_CLI_RUN_AS_NODE, -- mcpApps: env?.COPILOT_MCP_APPS, -- budget: env?.SKILL_CHAR_BUDGET, -- advisor: env?.ANTHROPIC_ADVISOR, -- kerberosSpn: env?.COPILOT_PROXY_KERBEROS_SPN, -- integration: env?.GITHUB_COPILOT_INTEGRATION_ID, -- host: env?.COPILOT_GH_HOST, -- }, { -- mode: undefined, runAsNode: '1', mcpApps: 'true', budget: '30000', advisor: 'true', -- kerberosSpn: 'HTTP/proxy.example', integration: COPILOT_INTEGRATION_ID, host: 'github.com', -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.chats.createChat(chat, context, { deferBacking: true, workingDirectories: [URI.file(process.cwd())] }); +- await agent.chats.prepareTurn!(chat, 'turn', [URI.file(process.cwd())], context); +- const env = options[0].env; +- assert.deepStrictEqual({ +- mode: options[0].mode, +- runAsNode: env?.COPILOT_CLI_RUN_AS_NODE, +- mcpApps: env?.COPILOT_MCP_APPS, +- budget: env?.SKILL_CHAR_BUDGET, +- advisor: env?.ANTHROPIC_ADVISOR, +- kerberosSpn: env?.COPILOT_PROXY_KERBEROS_SPN, +- integration: env?.GITHUB_COPILOT_INTEGRATION_ID, +- host: env?.COPILOT_GH_HOST, +- }, { +- mode: undefined, runAsNode: '1', mcpApps: 'true', budget: '30000', advisor: 'true', +- kerberosSpn: 'HTTP/proxy.example', integration: COPILOT_INTEGRATION_ID, host: 'github.com', +- }); - }); - - test('same-owner steering keeps the active turn credential and the next turn uses its own credential', async () => { @@ -57067,34 +59347,31 @@ index f24a97a6296..00000000000 - const secondSource = { principal, getCredential: () => ({ resource: principal.apiOrigin, token: 'second', expiresAt: undefined, revision: 2 }) }; - const context = { ...exactChatContext(session, chat, session), credentials: firstSource }; - const sender = { clientId: 'steering-client', clientContext: createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow) }; -- try { -- await provisionSession(agent, { session, credentials: firstSource, workingDirectories: [directory] }); -- await agent.chats.sendMessage(chat, 'first turn', [directory], undefined, 'first-turn', undefined, context); -- agent.setPendingMessages(chat, { id: 'same-source', message: { text: 'same source', origin: { kind: MessageKind.User } } }, [], { ...sender, credentials: firstSource }); -- await timeout(0); -- const sameSourceSends = launches[0].runtime.sendCalls; -- const readCredential = spy(secondSource, 'getCredential'); -- agent.setPendingMessages(chat, { id: 'different-source', message: { text: 'different source', origin: { kind: MessageKind.User } } }, [], { ...sender, credentials: secondSource }); -- await timeout(0); -- const differentSourceSends = launches[0].runtime.sendCalls; -- const steeringCredentialReads = readCredential.callCount; -- agent.setPendingMessages(chat, { id: 'missing-source', message: { text: 'missing source', origin: { kind: MessageKind.User } } }, [], sender); -- agent.setPendingMessages(chat, { id: 'different-owner', message: { text: 'different owner', origin: { kind: MessageKind.User } } }, [], { -- ...sender, credentials: { ...secondSource, principal: { ...principal, userId: '2' } }, -- }); -- await timeout(0); -- const localSteeringSends = launches[0].runtime.sendCalls; -- agent.setPendingMessages(chat, undefined, []); -- chatEntriesBySdkId(agent).get(launches[0].sessionId)!.chatSession.discardActiveTurn(); -- await agent.chats.sendMessage(chat, 'second turn', [directory], undefined, 'second-turn', undefined, { ...context, credentials: secondSource }); -- await timeout(0); -- assert.deepStrictEqual({ -- sameSourceSends, differentSourceSends, localSteeringSends, steeringCredentialReads, secondSourceSends: launches[1].runtime.sendCalls, -- tokens: launches.map(launch => launch.config.gitHubToken), -- }, { sameSourceSends: 2, differentSourceSends: 3, localSteeringSends: 4, steeringCredentialReads: 0, secondSourceSends: 1, tokens: ['first', 'second'] }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await provisionSession(agent, { session, credentials: firstSource, workingDirectories: [directory] }); +- await agent.chats.sendMessage(chat, 'first turn', [directory], undefined, 'first-turn', undefined, context); +- agent.setPendingMessages(chat, { id: 'same-source', message: { text: 'same source', origin: { kind: MessageKind.User } } }, [], { ...sender, credentials: firstSource }); +- await timeout(0); +- const sameSourceSends = launches[0].runtime.sendCalls; +- const readCredential = spy(secondSource, 'getCredential'); +- agent.setPendingMessages(chat, { id: 'different-source', message: { text: 'different source', origin: { kind: MessageKind.User } } }, [], { ...sender, credentials: secondSource }); +- await timeout(0); +- const differentSourceSends = launches[0].runtime.sendCalls; +- const steeringCredentialReads = readCredential.callCount; +- agent.setPendingMessages(chat, { id: 'missing-source', message: { text: 'missing source', origin: { kind: MessageKind.User } } }, [], sender); +- agent.setPendingMessages(chat, { id: 'different-owner', message: { text: 'different owner', origin: { kind: MessageKind.User } } }, [], { +- ...sender, credentials: { ...secondSource, principal: { ...principal, userId: '2' } }, +- }); +- await timeout(0); +- const localSteeringSends = launches[0].runtime.sendCalls; +- agent.setPendingMessages(chat, undefined, []); +- chatEntriesBySdkId(agent).get(launches[0].sessionId)!.chatSession.discardActiveTurn(); +- await agent.chats.sendMessage(chat, 'second turn', [directory], undefined, 'second-turn', undefined, { ...context, credentials: secondSource }); +- await timeout(0); +- assert.deepStrictEqual({ +- sameSourceSends, differentSourceSends, localSteeringSends, steeringCredentialReads, secondSourceSends: launches[1].runtime.sendCalls, +- tokens: launches.map(launch => launch.config.gitHubToken), +- }, { sameSourceSends: 2, differentSourceSends: 3, localSteeringSends: 4, steeringCredentialReads: 0, secondSourceSends: 1, tokens: ['first', 'second'] }); - }); - - test('remote steering does not replace an already-running legacy runtime', async () => { @@ -57102,21 +59379,18 @@ index f24a97a6296..00000000000 - const session = AgentSession.uri('copilotcli', 'legacy-remote-steering'); - const chat = defaultChatUri(session); - const directory = URI.file(process.cwd()); -- try { -- await provisionSession(agent, { session, workingDirectories: [directory] }); -- await agent.chats.sendMessage(chat, 'local turn', [directory], undefined, 'local-turn', undefined, exactChatContext(session, chat, session)); -- agent.setPendingMessages(chat, { id: 'remote-steering', message: { text: 'Remote steering', origin: { kind: MessageKind.User } } }, [], { -- clientId: 'remote', clientContext: createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), -- credentials: { -- principal: { apiOrigin: 'https://api.github.com', userId: '1' }, -- getCredential: () => { throw new Error('Steering must not request a new runtime credential'); }, -- }, -- }); -- await timeout(0); -- assert.deepStrictEqual({ runtimes: launches.length, sends: launches[0].runtime.sendCalls }, { runtimes: 1, sends: 2 }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await provisionSession(agent, { session, workingDirectories: [directory] }); +- await agent.chats.sendMessage(chat, 'local turn', [directory], undefined, 'local-turn', undefined, exactChatContext(session, chat, session)); +- agent.setPendingMessages(chat, { id: 'remote-steering', message: { text: 'Remote steering', origin: { kind: MessageKind.User } } }, [], { +- clientId: 'remote', clientContext: createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), +- credentials: { +- principal: { apiOrigin: 'https://api.github.com', userId: '1' }, +- getCredential: () => { throw new Error('Steering must not request a new runtime credential'); }, +- }, +- }); +- await timeout(0); +- assert.deepStrictEqual({ runtimes: launches.length, sends: launches[0].runtime.sendCalls }, { runtimes: 1, sends: 2 }); - }); - - test('completes a scoped MCP challenge without capturing or replaying the MCP token', async () => { @@ -57130,26 +59404,23 @@ index f24a97a6296..00000000000 - await authenticationService.authenticate({ resource: 'https://api.github.com', token: 'scoped' }, [agent], scope); - const session = AgentSession.uri('copilotcli', 'scoped-mcp'); - const chat = defaultChatUri(session); -- try { -- const creation = agent.chats.createChat(chat, { ...exactChatContext(session, chat, session), credentials: scope }, { -- workingDirectories: [URI.file(process.cwd())], deferBacking: false, -- }); -- await client.whenAuthenticationRequests(1); -- const handled = await authenticationService.authenticate({ -- resource: TEST_MCP_RESOURCE, scopes: TEST_MCP_SCOPES, token: 'mcp-token', -- }, [agent], scope); -- await creation; -- const collision = await agent.handleAuthenticationToken({ resource: 'https://api.github.com', token: 'agent-token' }); -- assert.deepStrictEqual({ -- handled: handled.authenticated, -- responses: client.authenticationResponses, -- collision, -- legacy: authenticationService.getAuthToken({ resource: TEST_MCP_RESOURCE }), -- scoped: authenticationService.getAuthToken({ resource: TEST_MCP_RESOURCE }, scope), -- }, { handled: true, responses: [{ kind: 'token', accessToken: 'mcp-token' }], collision: false, legacy: undefined, scoped: undefined }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const creation = agent.chats.createChat(chat, { ...exactChatContext(session, chat, session), credentials: scope }, { +- workingDirectories: [URI.file(process.cwd())], deferBacking: false, +- }); +- await client.whenAuthenticationRequests(1); +- const handled = await authenticationService.authenticate({ +- resource: TEST_MCP_RESOURCE, scopes: TEST_MCP_SCOPES, token: 'mcp-token', +- }, [agent], scope); +- await creation; +- const collision = await agent.handleAuthenticationToken({ resource: 'https://api.github.com', token: 'agent-token' }); +- assert.deepStrictEqual({ +- handled: handled.authenticated, +- responses: client.authenticationResponses, +- collision, +- legacy: authenticationService.getAuthToken({ resource: TEST_MCP_RESOURCE }), +- scoped: authenticationService.getAuthToken({ resource: TEST_MCP_RESOURCE }, scope), +- }, { handled: true, responses: [{ kind: 'token', accessToken: 'mcp-token' }], collision: false, legacy: undefined, scoped: undefined }); - }); - }); - @@ -57179,30 +59450,27 @@ index f24a97a6296..00000000000 - }, - dispose() { }, - } satisfies ICredentialUpdateSession & { updates: Array<{ host: string; token: string }> }; -- try { -- await agent.listChatsToMigrate(); -- setDefaultSessionStub(agent, 'first', first); -- setDefaultSessionStub(agent, 'second', second); -- await agent.authenticate('https://api.github.com', 'model-token-a'); -- await agent.authenticate('https://api.github.com', 'model-token-a'); -- await agent.authenticate('https://api.github.com', 'model-token-a', 7200); +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- setDefaultSessionStub(agent, 'first', first); +- setDefaultSessionStub(agent, 'second', second); +- await agent.authenticate('https://api.github.com', 'model-token-a'); +- await agent.authenticate('https://api.github.com', 'model-token-a'); +- await agent.authenticate('https://api.github.com', 'model-token-a', 7200); - -- assert.deepStrictEqual({ -- firstUpdates: first.updates, -- secondUpdates: second.updates, -- stops: client.stopCallCount, -- }, { -- firstUpdates: [ -- { host: 'https://github.com', token: 'model-token-a' }, -- ], -- secondUpdates: [ -- { host: 'https://github.com', token: 'model-token-a' }, -- ], -- stops: 1, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- firstUpdates: first.updates, +- secondUpdates: second.updates, +- stops: client.stopCallCount, +- }, { +- firstUpdates: [ +- { host: 'https://github.com', token: 'model-token-a' }, +- ], +- secondUpdates: [ +- { host: 'https://github.com', token: 'model-token-a' }, +- ], +- stops: 1, +- }); - }); - - test('defers a proxy-change restart until credential updates finish', async () => { @@ -57283,21 +59551,18 @@ index f24a97a6296..00000000000 - async updateGitHubCredentials() { return { success: true }; }, - dispose() { this.disposed = true; }, - } satisfies ICredentialUpdateSession & { disposed: boolean }; -- try { -- await agent.listChatsToMigrate(); -- setDefaultSessionStub(agent, 'proxy-change', session); -- proxyResolver.resolvedProxy = 'http://new-proxy:8080'; -- await agent.authenticate('https://api.github.com', 'fresh-token'); -- const duringTurn = { stops: client.stopCallCount, disposed: session.disposed, proxyResolutions: proxyResolver.resolveProxyCalls }; -- -- session.hasActiveTurn = false; -- (agent as unknown as { _onChatTurnEnded(): void })._onChatTurnEnded(); -- await timeout(0); -- assert.deepStrictEqual(duringTurn, { stops: 0, disposed: false, proxyResolutions: 2 }); -- assert.deepStrictEqual({ stops: client.stopCallCount, disposed: session.disposed }, { stops: 1, disposed: true }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- setDefaultSessionStub(agent, 'proxy-change', session); +- proxyResolver.resolvedProxy = 'http://new-proxy:8080'; +- await agent.authenticate('https://api.github.com', 'fresh-token'); +- const duringTurn = { stops: client.stopCallCount, disposed: session.disposed, proxyResolutions: proxyResolver.resolveProxyCalls }; +- +- session.hasActiveTurn = false; +- (agent as unknown as { _onChatTurnEnded(): void })._onChatTurnEnded(); +- await timeout(0); +- assert.deepStrictEqual(duringTurn, { stops: 0, disposed: false, proxyResolutions: 2 }); +- assert.deepStrictEqual({ stops: client.stopCallCount, disposed: session.disposed }, { stops: 1, disposed: true }); - }); - - test('serializes concurrent changed auth tokens so the final session credentials use the latest token', async () => { @@ -57318,20 +59583,17 @@ index f24a97a6296..00000000000 - }, - dispose() { }, - } satisfies ICredentialUpdateSession & { appliedTokens: string[] }; -- try { -- await agent.listChatsToMigrate(); -- setDefaultSessionStub(agent, 'concurrent-auth', session); +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- setDefaultSessionStub(agent, 'concurrent-auth', session); - -- const authA = agent.authenticate('https://api.github.com', 'token-a'); -- await firstUpdateStarted.p; -- const authB = agent.authenticate('https://api.github.com', 'token-b'); -- firstUpdateGate.complete(); -- await Promise.all([authA, authB]); +- const authA = agent.authenticate('https://api.github.com', 'token-a'); +- await firstUpdateStarted.p; +- const authB = agent.authenticate('https://api.github.com', 'token-b'); +- firstUpdateGate.complete(); +- await Promise.all([authA, authB]); - -- assert.deepStrictEqual(session.appliedTokens, ['token-a', 'token-b']); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(session.appliedTokens, ['token-a', 'token-b']); - }); - - test('defers a changed-token fallback restart until an active turn ends', async () => { @@ -57349,28 +59611,25 @@ index f24a97a6296..00000000000 - dispose() { this.disposed = true; }, - async updateGitHubCredentials() { throw new Error('runtime unavailable'); }, - } satisfies ICredentialUpdateSession & { disposed: boolean }; -- try { -- await agent.listChatsToMigrate(); -- setDefaultSessionStub(agent, 'rejected', rejected); -- setDefaultSessionStub(agent, 'failed', failed); +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- setDefaultSessionStub(agent, 'rejected', rejected); +- setDefaultSessionStub(agent, 'failed', failed); - -- await agent.authenticate('https://api.github.com', 'fresh-token'); -- const duringTurn = { stopCount: client.stopCallCount, rejectedDisposed: rejected.disposed, failedDisposed: failed.disposed }; +- await agent.authenticate('https://api.github.com', 'fresh-token'); +- const duringTurn = { stopCount: client.stopCallCount, rejectedDisposed: rejected.disposed, failedDisposed: failed.disposed }; - -- rejected.hasActiveTurn = false; -- (agent as unknown as { _onChatTurnEnded(): void })._onChatTurnEnded(); -- await timeout(0); +- rejected.hasActiveTurn = false; +- (agent as unknown as { _onChatTurnEnded(): void })._onChatTurnEnded(); +- await timeout(0); - -- assert.deepStrictEqual({ -- duringTurn, -- afterTurn: { stopCount: client.stopCallCount, rejectedDisposed: rejected.disposed, failedDisposed: failed.disposed }, -- }, { -- duringTurn: { stopCount: 0, rejectedDisposed: false, failedDisposed: false }, -- afterTurn: { stopCount: 1, rejectedDisposed: true, failedDisposed: true }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- duringTurn, +- afterTurn: { stopCount: client.stopCallCount, rejectedDisposed: rejected.disposed, failedDisposed: failed.disposed }, +- }, { +- duringTurn: { stopCount: 0, rejectedDisposed: false, failedDisposed: false }, +- afterTurn: { stopCount: 1, rejectedDisposed: true, failedDisposed: true }, +- }); - }); - - test('defers a credential-mode restart until an in-flight subagent settles after its root turn', async () => { @@ -57388,46 +59647,40 @@ index f24a97a6296..00000000000 - async updateGitHubCredentials() { return { success: true }; }, - dispose() { this.disposed = true; }, - } satisfies ICredentialUpdateSession & { hasActiveSubagents: boolean; disposed: boolean }; -- try { -- await agent.authenticate('https://api.github.com', 'expiring-token', 8 * 3600); -- await agent.listChatsToMigrate(); -- setDefaultSessionStub(agent, 'background-subagent', session); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'expiring-token', 8 * 3600); +- await agent.listChatsToMigrate(); +- setDefaultSessionStub(agent, 'background-subagent', session); - -- await agent.authenticate('https://api.github.com', 'second-window-token'); -- const duringSubagent = { stops: client.stopCallCount, disposed: session.disposed }; +- await agent.authenticate('https://api.github.com', 'second-window-token'); +- const duringSubagent = { stops: client.stopCallCount, disposed: session.disposed }; - -- session.hasActiveSubagents = false; -- (agent as unknown as { _onChatTurnEnded(): void })._onChatTurnEnded(); -- await timeout(0); +- session.hasActiveSubagents = false; +- (agent as unknown as { _onChatTurnEnded(): void })._onChatTurnEnded(); +- await timeout(0); - -- assert.deepStrictEqual({ -- duringSubagent, -- afterSubagent: { stops: client.stopCallCount, disposed: session.disposed }, -- }, { -- duringSubagent: { stops: 0, disposed: false }, -- afterSubagent: { stops: 1, disposed: true }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- duringSubagent, +- afterSubagent: { stops: client.stopCallCount, disposed: session.disposed }, +- }, { +- duringSubagent: { stops: 0, disposed: false }, +- afterSubagent: { stops: 1, disposed: true }, +- }); - }); - - test('ignores Connector refresh while the feature is disabled', async () => { - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); - const calls: string[] = []; -- try { -- setDefaultSessionStub(agent, 'connector-refresh-disabled', { -- isDisposed: false, -- markConnectorConfigurationChanged() { calls.push('refresh'); }, -- dispose() { }, -- }); +- afterTest(() => disposeAgent(agent)); +- setDefaultSessionStub(agent, 'connector-refresh-disabled', { +- isDisposed: false, +- markConnectorConfigurationChanged() { calls.push('refresh'); }, +- dispose() { }, +- }); - -- await agent.refreshConnectorSessions(); +- await agent.refreshConnectorSessions(); - -- assert.deepStrictEqual(calls, []); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(calls, []); - }); - - test('queues Connector refresh behind in-flight chat work', async () => { @@ -57472,33 +59725,30 @@ index f24a97a6296..00000000000 - const calls: string[] = []; - let defaultPending = false; - let peerPending = false; -- try { -- setDefaultSessionStub(agent, AgentSession.id(session), { -- isDisposed: false, -- get hasPendingConnectorRefresh() { return defaultPending; }, -- markConnectorConfigurationChanged(name?: string) { calls.push(`default:${name}`); defaultPending = true; }, -- async refreshConnectors() { calls.push('default:reconciled'); defaultPending = false; }, -- dispose() { }, -- } satisfies Pick); -- setPeerChatStub(agent, URI.parse(buildChatUri(session, 'healthy')), { -- isDisposed: false, -- get hasPendingConnectorRefresh() { return peerPending; }, -- markConnectorConfigurationChanged(name?: string) { calls.push(`peer:${name}`); peerPending = true; }, -- async refreshConnectors() { calls.push('peer:reconciled'); peerPending = false; }, -- dispose() { }, -- } satisfies Pick, 'connector-healthy'); -- setPeerChatStub(agent, URI.parse(buildChatUri(session, 'disposed')), { -- isDisposed: true, -- markConnectorConfigurationChanged() { calls.push('disposed'); }, -- dispose() { }, -- }, 'connector-disposed'); +- afterTest(() => disposeAgent(agent)); +- setDefaultSessionStub(agent, AgentSession.id(session), { +- isDisposed: false, +- get hasPendingConnectorRefresh() { return defaultPending; }, +- markConnectorConfigurationChanged(name?: string) { calls.push(`default:${name}`); defaultPending = true; }, +- async refreshConnectors() { calls.push('default:reconciled'); defaultPending = false; }, +- dispose() { }, +- } satisfies Pick); +- setPeerChatStub(agent, URI.parse(buildChatUri(session, 'healthy')), { +- isDisposed: false, +- get hasPendingConnectorRefresh() { return peerPending; }, +- markConnectorConfigurationChanged(name?: string) { calls.push(`peer:${name}`); peerPending = true; }, +- async refreshConnectors() { calls.push('peer:reconciled'); peerPending = false; }, +- dispose() { }, +- } satisfies Pick, 'connector-healthy'); +- setPeerChatStub(agent, URI.parse(buildChatUri(session, 'disposed')), { +- isDisposed: true, +- markConnectorConfigurationChanged() { calls.push('disposed'); }, +- dispose() { }, +- }, 'connector-disposed'); - -- await agent.refreshConnectorSessions('mail'); +- await agent.refreshConnectorSessions('mail'); - -- assert.deepStrictEqual(calls.sort(), ['default:mail', 'default:reconciled', 'peer:mail', 'peer:reconciled']); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(calls.sort(), ['default:mail', 'default:reconciled', 'peer:mail', 'peer:reconciled']); - }); - - test('marks sessions when a Connector changes while they initialize', async () => { @@ -57570,24 +59820,21 @@ index f24a97a6296..00000000000 - ...busy, hasActiveTurn: false, hasActiveSubagents: false, - async refreshConnectors() { calls.push('idle'); this.hasPendingConnectorRefresh = false; }, - }; -- try { -- setDefaultSessionStub(agent, 'busy-connector', busy); -- setDefaultSessionStub(agent, 'idle-connector', idle); -- await agent.refreshConnectorSessions(); -- const duringTurn = [...calls]; -- busy.hasActiveTurn = false; -- (agent as unknown as { _onChatTurnEnded(): void })._onChatTurnEnded(); -- await timeout(0); -- const duringSubagent = [...calls]; -- busy.hasActiveSubagents = false; -- (agent as unknown as { _onChatTurnEnded(): void })._onChatTurnEnded(); -- await timeout(0); -- assert.deepStrictEqual({ duringTurn, duringSubagent, afterIdle: calls }, { -- duringTurn: ['idle'], duringSubagent: ['idle'], afterIdle: ['idle', 'busy'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- setDefaultSessionStub(agent, 'busy-connector', busy); +- setDefaultSessionStub(agent, 'idle-connector', idle); +- await agent.refreshConnectorSessions(); +- const duringTurn = [...calls]; +- busy.hasActiveTurn = false; +- (agent as unknown as { _onChatTurnEnded(): void })._onChatTurnEnded(); +- await timeout(0); +- const duringSubagent = [...calls]; +- busy.hasActiveSubagents = false; +- (agent as unknown as { _onChatTurnEnded(): void })._onChatTurnEnded(); +- await timeout(0); +- assert.deepStrictEqual({ duringTurn, duringSubagent, afterIdle: calls }, { +- duringTurn: ['idle'], duringSubagent: ['idle'], afterIdle: ['idle', 'busy'], +- }); - }); - - test('coalesces Connector refreshes waiting in the same chat queue', async () => { @@ -57631,15 +59878,12 @@ index f24a97a6296..00000000000 - }, - dispose() { }, - } satisfies ICredentialUpdateSession & { updates: number }; -- try { -- await agent.listChatsToMigrate(); -- setDefaultSessionStub(agent, 'degraded-metadata', session); -- await agent.authenticate('https://api.github.com', 'fresh-token'); +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- setDefaultSessionStub(agent, 'degraded-metadata', session); +- await agent.authenticate('https://api.github.com', 'fresh-token'); - -- assert.deepStrictEqual({ updates: session.updates, stops: client.stopCallCount }, { updates: 1, stops: 0 }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ updates: session.updates, stops: client.stopCallCount }, { updates: 1, stops: 0 }); - }); - - test('rearms expired Copilot auth notifications after every authenticate call', async () => { @@ -57657,44 +59901,41 @@ index f24a97a6296..00000000000 - authRequests.push(requirement); - } - })); -- try { -- await createdSession.session.initializeSession(); -- (agent as unknown as { -- _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; -- })._registerLiveChat(createdSession.session.chatChannelUri, createdSession.session, createdSession.activeClient); -- -- const authError = (errorType: 'authentication' | 'authorization', statusCode = 401) => mockSession.emit({ -- type: 'session.error', -- data: { errorType, message: 'token rejected', statusCode }, -- } as SessionEventPayload<'session.error'>); -- authError('authentication'); -- authError('authorization'); -- authError('authentication', 403); -- -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'fresh-token'); -- const authenticatedSku = agent.getTelemetryContext().copilotSku; -- const contextBeforeRejection = agent.getTelemetryContext(); -- authError('authorization'); -- const rejectedSku = agent.getTelemetryContext().copilotSku; -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'fresh-token'); -- const refreshedSku = agent.getTelemetryContext().copilotSku; -- const rejectedContextAfterRefresh = contextBeforeRejection.copilotSku; -- authError('authentication'); -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'new-token'); -- authError('authentication'); -- -- assert.deepStrictEqual({ authRequests, authenticatedSku, rejectedSku, refreshedSku, rejectedContextAfterRefresh }, { -- authRequests: [ -- { resource: GITHUB_COPILOT_PROTECTED_RESOURCE, reason: 'expired' }, -- { resource: GITHUB_COPILOT_PROTECTED_RESOURCE, reason: 'expired' }, -- { resource: GITHUB_COPILOT_PROTECTED_RESOURCE, reason: 'expired' }, -- { resource: GITHUB_COPILOT_PROTECTED_RESOURCE, reason: 'expired' }, -- ], -- authenticatedSku: 'sku-a', rejectedSku: undefined, refreshedSku: 'sku-a', rejectedContextAfterRefresh: undefined, -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await createdSession.session.initializeSession(); +- (agent as unknown as { +- _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; +- })._registerLiveChat(createdSession.session.chatChannelUri, createdSession.session, createdSession.activeClient); +- +- const authError = (errorType: 'authentication' | 'authorization', statusCode = 401) => mockSession.emit({ +- type: 'session.error', +- data: { errorType, message: 'token rejected', statusCode }, +- } as SessionEventPayload<'session.error'>); +- authError('authentication'); +- authError('authorization'); +- authError('authentication', 403); +- +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'fresh-token'); +- const authenticatedSku = agent.getTelemetryContext().copilotSku; +- const contextBeforeRejection = agent.getTelemetryContext(); +- authError('authorization'); +- const rejectedSku = agent.getTelemetryContext().copilotSku; +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'fresh-token'); +- const refreshedSku = agent.getTelemetryContext().copilotSku; +- const rejectedContextAfterRefresh = contextBeforeRejection.copilotSku; +- authError('authentication'); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'new-token'); +- authError('authentication'); +- +- assert.deepStrictEqual({ authRequests, authenticatedSku, rejectedSku, refreshedSku, rejectedContextAfterRefresh }, { +- authRequests: [ +- { resource: GITHUB_COPILOT_PROTECTED_RESOURCE, reason: 'expired' }, +- { resource: GITHUB_COPILOT_PROTECTED_RESOURCE, reason: 'expired' }, +- { resource: GITHUB_COPILOT_PROTECTED_RESOURCE, reason: 'expired' }, +- { resource: GITHUB_COPILOT_PROTECTED_RESOURCE, reason: 'expired' }, +- ], +- authenticatedSku: 'sku-a', rejectedSku: undefined, refreshedSku: 'sku-a', rejectedContextAfterRefresh: undefined, +- }); - }); - - test('requests reauthentication when the credential is cleared', async () => { @@ -57710,41 +59951,35 @@ index f24a97a6296..00000000000 - authRequests.push(requirement); - } - })); -- try { -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- await waitForState(agent.models, models => models.length > 0); -- // Another client revoked the shared credential; the resulting SDK failure -- // is a local InvalidArg rather than a 401, so the cleared token itself has -- // to advertise the requirement or no client is ever asked to re-supply one. -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, ''); -- await waitForState(agent.models, models => models.length === 0); -- -- assert.deepStrictEqual(authRequests, [{ -- resource: GITHUB_COPILOT_PROTECTED_RESOURCE, -- reason: 'expired', -- }]); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); +- await waitForState(agent.models, models => models.length > 0); +- // Another client revoked the shared credential; the resulting SDK failure +- // is a local InvalidArg rather than a 401, so the cleared token itself has +- // to advertise the requirement or no client is ever asked to re-supply one. +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, ''); +- await waitForState(agent.models, models => models.length === 0); +- +- assert.deepStrictEqual(authRequests, [{ +- resource: GITHUB_COPILOT_PROTECTED_RESOURCE, +- reason: 'expired', +- }]); - }); - - test('Automation readiness waits for applied credentials and follows revocation', async () => { - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([], []) }); -- try { -- const initial = agent.isReadyForAutomation(undefined); -- const authentication = agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'automation-token'); -- const whileApplying = agent.isReadyForAutomation(undefined); -- await authentication; -- const authenticated = agent.isReadyForAutomation(undefined); -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, ''); -- const revoked = agent.isReadyForAutomation(undefined); -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'replacement-token'); -- assert.deepStrictEqual({ initial, whileApplying, authenticated, revoked, restored: agent.isReadyForAutomation(undefined) }, { -- initial: false, whileApplying: false, authenticated: true, revoked: false, restored: true, -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const initial = agent.isReadyForAutomation(undefined); +- const authentication = agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'automation-token'); +- const whileApplying = agent.isReadyForAutomation(undefined); +- await authentication; +- const authenticated = agent.isReadyForAutomation(undefined); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, ''); +- const revoked = agent.isReadyForAutomation(undefined); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'replacement-token'); +- assert.deepStrictEqual({ initial, whileApplying, authenticated, revoked, restored: agent.isReadyForAutomation(undefined) }, { +- initial: false, whileApplying: false, authenticated: true, revoked: false, restored: true, +- }); - }); - - test('keeps the requirement raised when a second revocation arrives while tokenless', async () => { @@ -57753,21 +59988,18 @@ index f24a97a6296..00000000000 - name: 'GPT-4o', - }]); - const agent = createTestAgent(disposables, { copilotClient: client }); -- try { -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- await waitForState(agent.models, models => models.length > 0); -- // The host forwards every revocation to every provider, so a second -- // client revoking must not retract the outstanding requirement. -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, ''); -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, ''); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); +- await waitForState(agent.models, models => models.length > 0); +- // The host forwards every revocation to every provider, so a second +- // client revoking must not retract the outstanding requirement. +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, ''); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, ''); - -- assert.deepStrictEqual(agent.authenticationRequired.get(), { -- resource: GITHUB_COPILOT_PROTECTED_RESOURCE, -- reason: 'expired', -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(agent.authenticationRequired.get(), { +- resource: GITHUB_COPILOT_PROTECTED_RESOURCE, +- reason: 'expired', +- }); - }); - - test('retries refreshing models after a transient failure', async () => { @@ -57777,20 +60009,17 @@ index f24a97a6296..00000000000 - }]); - client.modelListErrors.push(new Error('429 "too many requests"')); - const agent = createTestAgent(disposables, { copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const models = await waitForState(agent.models, m => m.length > 0); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const models = await waitForState(agent.models, m => m.length > 0); - -- assert.deepStrictEqual({ -- modelNames: models.map(m => m.name), -- requestCount: client.modelListRequests.length, -- }, { -- modelNames: ['GPT-4o'], -- requestCount: 2, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- modelNames: models.map(m => m.name), +- requestCount: client.modelListRequests.length, +- }, { +- modelNames: ['GPT-4o'], +- requestCount: 2, +- }); - }); - - test('requests reauthentication when refreshing models returns unauthorized', async () => { @@ -57807,17 +60036,14 @@ index f24a97a6296..00000000000 - authRequests.push(requirement); - } - })); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await waitForState(agent.models, models => models.length > 0); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await waitForState(agent.models, models => models.length > 0); - -- assert.deepStrictEqual(authRequests, [{ -- resource: GITHUB_COPILOT_PROTECTED_RESOURCE, -- reason: 'expired', -- }]); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(authRequests, [{ +- resource: GITHUB_COPILOT_PROTECTED_RESOURCE, +- reason: 'expired', +- }]); - }); - - test('recovers the client and reports telemetry when the SDK connection is closed', async () => { @@ -57828,57 +60054,54 @@ index f24a97a6296..00000000000 - client.modelListErrors.push(new Error('Connection is closed.')); - const telemetryService = new RecordingTelemetryService(); - const agent = createTestAgent(disposables, { copilotClient: client, telemetryService }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const models = await waitForState(agent.models, m => m.length > 0); -- const failure = telemetryService.errorEvents[0].data as Record; -- const recovery = telemetryService.events.find(event => event.eventName === 'agentHost.copilotClientRecovery')?.data as Record; +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const models = await waitForState(agent.models, m => m.length > 0); +- const failure = telemetryService.errorEvents[0].data as Record; +- const recovery = telemetryService.events.find(event => event.eventName === 'agentHost.copilotClientRecovery')?.data as Record; - -- assert.deepStrictEqual({ -- modelNames: models.map(model => model.name), -- startCount: client.startCallCount, -- stopCount: client.stopCallCount, -- requestCount: client.modelListRequests.length, -- failure: { -- ...failure, -- clientFailureId: typeof failure.clientFailureId, -- callstack: typeof failure.callstack, -- }, -- recovery: { -- ...recovery, -- clientFailureIdMatches: recovery.clientFailureId === failure.clientFailureId, -- clientFailureId: typeof recovery.clientFailureId, -- durationMs: typeof recovery.durationMs, -- }, -- }, { -- modelNames: ['GPT-4o'], -- startCount: 2, -- stopCount: 1, -- requestCount: 2, -- failure: { -- clientFailureId: 'string', -- failureKind: 'connectionClosed', -- operation: 'modelRefresh', -- activeTurnCount: 0, -- recoveryStarted: true, -- errorName: 'Error', -- errorCode: undefined, -- msg: 'Connection is closed.', -- callstack: 'string', -- }, -- recovery: { -- clientFailureId: 'string', -- failureKind: 'connectionClosed', -- durationMs: 'number', -- failedTurnCount: 0, -- stopSucceeded: true, -- clientFailureIdMatches: true, -- }, -- }); +- assert.deepStrictEqual({ +- modelNames: models.map(model => model.name), +- startCount: client.startCallCount, +- stopCount: client.stopCallCount, +- requestCount: client.modelListRequests.length, +- failure: { +- ...failure, +- clientFailureId: typeof failure.clientFailureId, +- callstack: typeof failure.callstack, +- }, +- recovery: { +- ...recovery, +- clientFailureIdMatches: recovery.clientFailureId === failure.clientFailureId, +- clientFailureId: typeof recovery.clientFailureId, +- durationMs: typeof recovery.durationMs, +- }, +- }, { +- modelNames: ['GPT-4o'], +- startCount: 2, +- stopCount: 1, +- requestCount: 2, +- failure: { +- clientFailureId: 'string', +- failureKind: 'connectionClosed', +- operation: 'modelRefresh', +- activeTurnCount: 0, +- recoveryStarted: true, +- errorName: 'Error', +- errorCode: undefined, +- msg: 'Connection is closed.', +- callstack: 'string', +- }, +- recovery: { +- clientFailureId: 'string', +- failureKind: 'connectionClosed', +- durationMs: 'number', +- failedTurnCount: 0, +- stopSucceeded: true, +- clientFailureIdMatches: true, +- }, +- }); - -- } finally { -- await disposeAgent(agent); -- } - }); - - test('reports one successful Copilot client startup outcome for concurrent callers', async () => { @@ -58135,96 +60358,93 @@ index f24a97a6296..00000000000 - }, - dispose: () => calls.failed.dispose++, - }); -- try { -- await agent.listChatsToMigrate(); -- const abort = agent.chats.abort(cancelledChat, exactChatContext(AgentSession.uri('copilotcli', 'cancelled'), cancelledChat)); -- await cancelledDiscarded.p; -- const internals = agent as unknown as RecoveryInternals; -- const second = internals._handleClientOperationFailure(new Error('Connection is closed.'), 'modelRefresh'); -- client.stopGate.complete(); -- const [, secondResult] = await Promise.all([abort, second]); -- const failures = telemetryService.errorEvents -- .filter(event => event.eventName === 'agentHost.copilotClientFailure') -- .map(event => event.data as Record); -- const recoveryTurns = telemetryService.errorEvents -- .filter(event => event.eventName === 'agentHost.copilotClientRecoveryTurnFailed') -- .map(event => event.data as Record); -- const recovery = telemetryService.events.find(event => event.eventName === 'agentHost.copilotClientRecovery')?.data as Record; +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- const abort = agent.chats.abort(cancelledChat, exactChatContext(AgentSession.uri('copilotcli', 'cancelled'), cancelledChat)); +- await cancelledDiscarded.p; +- const internals = agent as unknown as RecoveryInternals; +- const second = internals._handleClientOperationFailure(new Error('Connection is closed.'), 'modelRefresh'); +- client.stopGate.complete(); +- const [, secondResult] = await Promise.all([abort, second]); +- const failures = telemetryService.errorEvents +- .filter(event => event.eventName === 'agentHost.copilotClientFailure') +- .map(event => event.data as Record); +- const recoveryTurns = telemetryService.errorEvents +- .filter(event => event.eventName === 'agentHost.copilotClientRecoveryTurnFailed') +- .map(event => event.data as Record); +- const recovery = telemetryService.events.find(event => event.eventName === 'agentHost.copilotClientRecovery')?.data as Record; - -- assert.deepStrictEqual({ -- calls, -- secondFailedTurnIds: [...(secondResult?.failedTurnIds ?? [])], -- stopCount: client.stopCallCount, -- remainingSessions: chatEntriesBySdkId(agent).size, -- failures: failures.map(failure => ({ -- ...failure, -- clientFailureId: typeof failure.clientFailureId, -- callstack: typeof failure.callstack, -- })), -- recoveryTurns: recoveryTurns.map(recoveryTurn => ({ -- ...recoveryTurn, -- clientFailureIdMatches: recoveryTurn.clientFailureId === recovery.clientFailureId, -- clientFailureId: typeof recoveryTurn.clientFailureId, -- })), -- recovery: { -- ...recovery, -- clientFailureIdMatches: failures.every(failure => failure.clientFailureId === recovery.clientFailureId), -- clientFailureId: typeof recovery.clientFailureId, -- durationMs: typeof recovery.durationMs, -- }, +- assert.deepStrictEqual({ +- calls, +- secondFailedTurnIds: [...(secondResult?.failedTurnIds ?? [])], +- stopCount: client.stopCallCount, +- remainingSessions: chatEntriesBySdkId(agent).size, +- failures: failures.map(failure => ({ +- ...failure, +- clientFailureId: typeof failure.clientFailureId, +- callstack: typeof failure.callstack, +- })), +- recoveryTurns: recoveryTurns.map(recoveryTurn => ({ +- ...recoveryTurn, +- clientFailureIdMatches: recoveryTurn.clientFailureId === recovery.clientFailureId, +- clientFailureId: typeof recoveryTurn.clientFailureId, +- })), +- recovery: { +- ...recovery, +- clientFailureIdMatches: failures.every(failure => failure.clientFailureId === recovery.clientFailureId), +- clientFailureId: typeof recovery.clientFailureId, +- durationMs: typeof recovery.durationMs, +- }, +- }, { +- calls: { +- cancelled: { discard: 1, fail: 0, dispose: 1 }, +- failed: { discard: 0, fail: 1, dispose: 1 }, +- }, +- secondFailedTurnIds: ['failed-turn'], +- stopCount: 1, +- remainingSessions: 0, +- failures: [{ +- clientFailureId: 'string', +- failureKind: 'connectionClosed', +- operation: 'abort', +- agentSessionId: 'cancelled', +- chatSessionId: getTelemetryChatSessionId(cancelledChat), +- turnId: undefined, +- sdkSessionId: 'cancelled', +- activeTurnCount: 1, +- recoveryStarted: true, +- errorName: 'Error', +- errorCode: undefined, +- msg: 'Connection is closed.', +- callstack: 'string', - }, { -- calls: { -- cancelled: { discard: 1, fail: 0, dispose: 1 }, -- failed: { discard: 0, fail: 1, dispose: 1 }, -- }, -- secondFailedTurnIds: ['failed-turn'], -- stopCount: 1, -- remainingSessions: 0, -- failures: [{ -- clientFailureId: 'string', -- failureKind: 'connectionClosed', -- operation: 'abort', -- agentSessionId: 'cancelled', -- chatSessionId: getTelemetryChatSessionId(cancelledChat), -- turnId: undefined, -- sdkSessionId: 'cancelled', -- activeTurnCount: 1, -- recoveryStarted: true, -- errorName: 'Error', -- errorCode: undefined, -- msg: 'Connection is closed.', -- callstack: 'string', -- }, { -- clientFailureId: 'string', -- failureKind: 'connectionClosed', -- operation: 'modelRefresh', -- activeTurnCount: 0, -- recoveryStarted: false, -- errorName: 'Error', -- errorCode: undefined, -- msg: 'Connection is closed.', -- callstack: 'string', -- }], -- recoveryTurns: [{ -- clientFailureId: 'string', -- agentSessionId: 'failed', -- chatSessionId: getTelemetryChatSessionId(failedChat), -- turnId: 'failed-turn', -- sdkSessionId: 'failed', -- clientFailureIdMatches: true, -- }], -- recovery: { -- clientFailureId: 'string', -- failureKind: 'connectionClosed', -- durationMs: 'number', -- failedTurnCount: 1, -- stopSucceeded: true, -- clientFailureIdMatches: true, -- }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- clientFailureId: 'string', +- failureKind: 'connectionClosed', +- operation: 'modelRefresh', +- activeTurnCount: 0, +- recoveryStarted: false, +- errorName: 'Error', +- errorCode: undefined, +- msg: 'Connection is closed.', +- callstack: 'string', +- }], +- recoveryTurns: [{ +- clientFailureId: 'string', +- agentSessionId: 'failed', +- chatSessionId: getTelemetryChatSessionId(failedChat), +- turnId: 'failed-turn', +- sdkSessionId: 'failed', +- clientFailureIdMatches: true, +- }], +- recovery: { +- clientFailureId: 'string', +- failureKind: 'connectionClosed', +- durationMs: 'number', +- failedTurnCount: 1, +- stopSucceeded: true, +- clientFailureIdMatches: true, +- }, +- }); - }); - - test('recovers a closed connection while resuming without duplicating the turn failure', async () => { @@ -58260,26 +60480,23 @@ index f24a97a6296..00000000000 - }, - dispose: () => { }, - }, chat); -- try { -- await agent.listChatsToMigrate(); -- await agent.chats.resumeTurn!(chat, 'turn-1', { ...exactChatContext(session, chat), sendStageRecorder: recorder }); +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- await agent.chats.resumeTurn!(chat, 'turn-1', { ...exactChatContext(session, chat), sendStageRecorder: recorder }); - -- assert.deepStrictEqual({ -- resumeCalls, -- recorderForwarded, -- failureCalls, -- remainingSessions: chatEntriesBySdkId(agent).size, -- operation: (telemetryService.errorEvents.find(event => event.eventName === 'agentHost.copilotClientFailure')?.data as Record | undefined)?.operation, -- }, { -- resumeCalls: 1, -- recorderForwarded: true, -- failureCalls: 1, -- remainingSessions: 0, -- operation: 'resumeTurn', -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- resumeCalls, +- recorderForwarded, +- failureCalls, +- remainingSessions: chatEntriesBySdkId(agent).size, +- operation: (telemetryService.errorEvents.find(event => event.eventName === 'agentHost.copilotClientFailure')?.data as Record | undefined)?.operation, +- }, { +- resumeCalls: 1, +- recorderForwarded: true, +- failureCalls: 1, +- remainingSessions: 0, +- operation: 'resumeTurn', +- }); - }); - - test('reports but does not recover or discard for another classified abort failure', async () => { @@ -58295,55 +60512,52 @@ index f24a97a6296..00000000000 - discardActiveTurn: () => discardCount++, - dispose: () => { }, - }); -- try { -- await assert.rejects(agent.chats.abort(chat, { -- ...exactChatContext(AgentSession.uri('copilotcli', 'abort-failure'), chat), -- clientTelemetryContext: { -- clientType: AgentHostClientType.EditorWindow, -- connectionKind: AgentHostClientConnectionKind.RemoteExtensionHost, -- transportKind: AgentHostTransportKind.MessagePort, -- hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, -- machineId: 'client-machine-id', -- devDeviceId: 'client-dev-device-id', -- }, -- }), /Client not connected/); -- const failure = telemetryService.errorEvents[0].data as Record; -- assert.deepStrictEqual({ -- discardCount, -- remainingSessions: chatEntriesBySdkId(agent).size, -- failure: { -- ...failure, -- clientFailureId: typeof failure.clientFailureId, -- callstack: typeof failure.callstack, -- }, -- }, { -- discardCount: 0, -- remainingSessions: 1, -- failure: { -- clientFailureId: 'string', -- failureKind: 'clientNotConnected', -- operation: 'abort', -- initiatorClientType: 'editor_window', -- initiatorConnectionKind: 'remote_extension_host', -- initiatorTransportKind: 'message_port', -- hostLaunchKind: 'vscode_main_process', -- initiatorMachineId: 'client-machine-id', -- initiatorDevDeviceId: 'client-dev-device-id', -- agentSessionId: 'abort-failure', -- chatSessionId: getTelemetryChatSessionId(chat), -- turnId: undefined, -- sdkSessionId: 'abort-failure', -- activeTurnCount: 1, -- recoveryStarted: false, -- errorName: 'Error', -- errorCode: undefined, -- msg: 'Client not connected', -- callstack: 'string', -- }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await assert.rejects(agent.chats.abort(chat, { +- ...exactChatContext(AgentSession.uri('copilotcli', 'abort-failure'), chat), +- clientTelemetryContext: { +- clientType: AgentHostClientType.EditorWindow, +- connectionKind: AgentHostClientConnectionKind.RemoteExtensionHost, +- transportKind: AgentHostTransportKind.MessagePort, +- hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, +- machineId: 'client-machine-id', +- devDeviceId: 'client-dev-device-id', +- }, +- }), /Client not connected/); +- const failure = telemetryService.errorEvents[0].data as Record; +- assert.deepStrictEqual({ +- discardCount, +- remainingSessions: chatEntriesBySdkId(agent).size, +- failure: { +- ...failure, +- clientFailureId: typeof failure.clientFailureId, +- callstack: typeof failure.callstack, +- }, +- }, { +- discardCount: 0, +- remainingSessions: 1, +- failure: { +- clientFailureId: 'string', +- failureKind: 'clientNotConnected', +- operation: 'abort', +- initiatorClientType: 'editor_window', +- initiatorConnectionKind: 'remote_extension_host', +- initiatorTransportKind: 'message_port', +- hostLaunchKind: 'vscode_main_process', +- initiatorMachineId: 'client-machine-id', +- initiatorDevDeviceId: 'client-dev-device-id', +- agentSessionId: 'abort-failure', +- chatSessionId: getTelemetryChatSessionId(chat), +- turnId: undefined, +- sdkSessionId: 'abort-failure', +- activeTurnCount: 1, +- recoveryStarted: false, +- errorName: 'Error', +- errorCode: undefined, +- msg: 'Client not connected', +- callstack: 'string', +- }, +- }); - }); - - test('stops refreshing models after the maximum number of attempts', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -58558,29 +60772,26 @@ index f24a97a6296..00000000000 - name: 'GPT-4o', - }]); - const agent = createTestAgent(disposables, { copilotClient: client }); -- try { -- // Block the first request in flight so the second caller has -- // something to coalesce onto: an auth-triggered refresh landing on -- // top of a periodic scheduler tick must not double-hit the service. -- const gate = new DeferredPromise(); -- client.modelListGate = gate.p; -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(() => disposeAgent(agent)); +- // Block the first request in flight so the second caller has +- // something to coalesce onto: an auth-triggered refresh landing on +- // top of a periodic scheduler tick must not double-hit the service. +- const gate = new DeferredPromise(); +- client.modelListGate = gate.p; +- await agent.authenticate('https://api.github.com', 'token'); - -- const first = agent.refreshModels(); -- const second = agent.refreshModels(); -- gate.complete(); -- await Promise.all([first, second]); +- const first = agent.refreshModels(); +- const second = agent.refreshModels(); +- gate.complete(); +- await Promise.all([first, second]); - -- assert.deepStrictEqual({ -- requests: client.modelListRequests, -- modelNames: agent.models.get().map(m => m.name), -- }, { -- requests: [{ gitHubToken: 'token' }], -- modelNames: ['GPT-4o'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- requests: client.modelListRequests, +- modelNames: agent.models.get().map(m => m.name), +- }, { +- requests: [{ gitHubToken: 'token' }], +- modelNames: ['GPT-4o'], +- }); - }); - - test('does not schedule a model refresh retry when the agent is torn down while a refresh failure is handled', async () => { @@ -58616,29 +60827,26 @@ index f24a97a6296..00000000000 - name: 'GPT-4o', - }]); - const agent = createTestAgent(disposables, { copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await waitForState(agent.models, m => m.length > 0); -- await agent.shutdown(); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await waitForState(agent.models, m => m.length > 0); +- await agent.shutdown(); - -- const startsAfterShutdown = client.startCallCount; -- const requestsAfterShutdown = client.modelListRequests.length; +- const startsAfterShutdown = client.startCallCount; +- const requestsAfterShutdown = client.modelListRequests.length; - -- // Simulate a queued model-refresh retry timer firing after shutdown. -- // It must bail out rather than call `_ensureClient()` and spawn a -- // fresh SDK client for an agent that is already torn down. -- await (agent as unknown as { _refreshModels(attempt?: number): Promise })._refreshModels(1); +- // Simulate a queued model-refresh retry timer firing after shutdown. +- // It must bail out rather than call `_ensureClient()` and spawn a +- // fresh SDK client for an agent that is already torn down. +- await (agent as unknown as { _refreshModels(attempt?: number): Promise })._refreshModels(1); - -- assert.deepStrictEqual({ -- starts: client.startCallCount, -- requests: client.modelListRequests.length, -- }, { -- starts: startsAfterShutdown, -- requests: requestsAfterShutdown, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- starts: client.startCallCount, +- requests: client.modelListRequests.length, +- }, { +- starts: startsAfterShutdown, +- requests: requestsAfterShutdown, +- }); - }); - - test('does not publish an in-flight model refresh after shutdown', async () => { @@ -58647,27 +60855,24 @@ index f24a97a6296..00000000000 - name: 'Initial', - }]); - const agent = createTestAgent(disposables, { copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await waitForState(agent.models, models => models.some(model => model.id === 'initial')); -- await Promise.resolve(); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await waitForState(agent.models, models => models.some(model => model.id === 'initial')); +- await Promise.resolve(); - -- const gate = new DeferredPromise(); -- client.modelListGates.push(gate.p); -- client.modelListResponses.push([{ id: 'late', name: 'Late' }]); -- const requestsBefore = client.modelListRequests.length; -- const refresh = agent.refreshModels(); -- await client.waitForCalls(() => client.modelListRequests.length > requestsBefore); -- assert.strictEqual(client.modelListRequests.length, requestsBefore + 1, 'expected the gated model request to start'); +- const gate = new DeferredPromise(); +- client.modelListGates.push(gate.p); +- client.modelListResponses.push([{ id: 'late', name: 'Late' }]); +- const requestsBefore = client.modelListRequests.length; +- const refresh = agent.refreshModels(); +- await client.waitForCalls(() => client.modelListRequests.length > requestsBefore); +- assert.strictEqual(client.modelListRequests.length, requestsBefore + 1, 'expected the gated model request to start'); - -- await agent.shutdown(); -- gate.complete(); -- await refresh; +- await agent.shutdown(); +- gate.complete(); +- await refresh; - -- assert.deepStrictEqual(agent.models.get().map(model => model.id), ['initial']); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(agent.models.get().map(model => model.id), ['initial']); - }); - - test('preserves startup cancellation when stopping the started client fails', async () => { @@ -58682,66 +60887,62 @@ index f24a97a6296..00000000000 - client.startGate = startGate.p; - const telemetryService = new RecordingTelemetryService(); - const agent = createTestAgent(disposables, { copilotClient: client, telemetryService }); -- try { -- const listPromise = agent.listChatsToMigrate(); -- await client.startCalled.p; -- const shutdownPromise = agent.shutdown(); -- startGate.complete(); +- afterTest(() => disposeAgent(agent)); +- const listPromise = agent.listChatsToMigrate(); +- await client.startCalled.p; +- const shutdownPromise = agent.shutdown(); +- startGate.complete(); +- +- // Shutting down mid-start is "client transiently unavailable", not +- // an authoritative "no chats to migrate" answer, so `listChatsToMigrate` +- // reports unavailable rather than rejecting with the +- // `CancellationError` that `_ensureClient` itself throws. +- assert.strictEqual(await listPromise, undefined); +- await shutdownPromise; - -- // Shutting down mid-start is "client transiently unavailable", not -- // an authoritative "no chats to migrate" answer, so `listChatsToMigrate` -- // reports unavailable rather than rejecting with the -- // `CancellationError` that `_ensureClient` itself throws. -- assert.strictEqual(await listPromise, undefined); -- await shutdownPromise; -- -- assert.deepStrictEqual({ -- starts: client.startCallCount, -- stops: client.stopCallCount, -- startup: telemetryService.events -- .filter(event => event.eventName === 'agentHost.copilotClientStartup') -- .map(event => { -- const data = event.data as Record; -- return { ...data, durationMs: typeof data.durationMs }; -- }), -- }, { -- starts: 1, -- stops: 1, -- startup: [{ -- outcome: 'cancelled', -- durationMs: 'number', -- attemptNumber: 1, -- }], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- starts: client.startCallCount, +- stops: client.stopCallCount, +- startup: telemetryService.events +- .filter(event => event.eventName === 'agentHost.copilotClientStartup') +- .map(event => { +- const data = event.data as Record; +- return { ...data, durationMs: typeof data.durationMs }; +- }), +- }, { +- starts: 1, +- stops: 1, +- startup: [{ +- outcome: 'cancelled', +- durationMs: 'number', +- attemptNumber: 1, +- }], +- }); - }); - - test('createChat infers workspace-less from an omitted workingDirectory and uses a stable scratch dir', async () => { - const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/qc-home-`)); - const agent = createTestAgent(disposables, { userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- -- const result = await provisionSession(agent, { -- session: AgentSession.uri('copilotcli', 'temp-fallback'), -- }); -- -- assert.strictEqual(result.provisional, true); -- const resultWorkingDirectory = result.resolvedWorkingDirectory; -- assert.ok(resultWorkingDirectory); -- const expected = URI.joinPath(userHome, '.copilot', 'chats', 'temp-fallback'); -- assert.strictEqual(resultWorkingDirectory.scheme, Schemas.file); -- assert.strictEqual(resultWorkingDirectory.fsPath, expected.fsPath); -- assert.deepStrictEqual(await fs.readdir(resultWorkingDirectory.fsPath), []); -- // Tagged workspace-less purely from inference (no input flag). -- const provisional = (agent as unknown as { _provisionalSessions: Map })._provisionalSessions.get('temp-fallback'); -- assert.strictEqual(provisional?.workspaceless, true); -- } finally { +- afterTest(async () => { - await disposeAgent(agent); - await fs.rm(userHome.fsPath, { recursive: true, force: true, maxRetries: 5 }); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- +- const result = await provisionSession(agent, { +- session: AgentSession.uri('copilotcli', 'temp-fallback'), +- }); +- +- assert.strictEqual(result.provisional, true); +- const resultWorkingDirectory = result.resolvedWorkingDirectory; +- assert.ok(resultWorkingDirectory); +- const expected = URI.joinPath(userHome, '.copilot', 'chats', 'temp-fallback'); +- assert.strictEqual(resultWorkingDirectory.scheme, Schemas.file); +- assert.strictEqual(resultWorkingDirectory.fsPath, expected.fsPath); +- assert.deepStrictEqual(await fs.readdir(resultWorkingDirectory.fsPath), []); +- // Tagged workspace-less purely from inference (no input flag). +- const provisional = (agent as unknown as { _provisionalSessions: Map })._provisionalSessions.get('temp-fallback'); +- assert.strictEqual(provisional?.workspaceless, true); - }).timeout(30_000); - - suite('quick chat scratch directory', () => { @@ -58800,23 +61001,20 @@ index f24a97a6296..00000000000 - test('a host decision of not workspaceless overrides the stored session marker', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]), sessionDataService }); +- afterTest(() => disposeAgent(agent)); +- const session = URI.parse('ahp-session:/workspaceless-nested-own-folder'); +- const db = sessionDataService.openDatabase(session); - try { -- const session = URI.parse('ahp-session:/workspaceless-nested-own-folder'); -- const db = sessionDataService.openDatabase(session); -- try { -- await db.object.setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'true'); -- } finally { -- db.dispose(); -- } -- const read = (hostWorkspaceless: boolean | undefined) => (agent as unknown as { _readSessionMetadata(scope: { storage: URI; owner: URI }, host: boolean | undefined): Promise<{ workspaceless?: boolean }> })._readSessionMetadata({ storage: session, owner: session }, hostWorkspaceless); -- assert.deepStrictEqual({ -- hostSaysNo: (await read(false)).workspaceless, -- hostSaysYes: (await read(true)).workspaceless, -- legacyFallsBackToMarker: (await read(undefined)).workspaceless, -- }, { hostSaysNo: false, hostSaysYes: true, legacyFallsBackToMarker: true }); +- await db.object.setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'true'); - } finally { -- await disposeAgent(agent); +- db.dispose(); - } +- const read = (hostWorkspaceless: boolean | undefined) => (agent as unknown as { _readSessionMetadata(scope: { storage: URI; owner: URI }, host: boolean | undefined): Promise<{ workspaceless?: boolean }> })._readSessionMetadata({ storage: session, owner: session }, hostWorkspaceless); +- assert.deepStrictEqual({ +- hostSaysNo: (await read(false)).workspaceless, +- hostSaysYes: (await read(true)).workspaceless, +- legacyFallsBackToMarker: (await read(undefined)).workspaceless, +- }, { hostSaysNo: false, hostSaysYes: true, legacyFallsBackToMarker: true }); - }); - - test('minted peer and fork in a workspaceless session keep the parent system message across resume', async () => { @@ -58834,71 +61032,69 @@ index f24a97a6296..00000000000 - }; - const agent = createTestAgent(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService, userHome }); - (agent as unknown as { _forkSdkChat: () => Promise<{ sessionId: string; inheritedTurnId: string | undefined }> })._forkSdkChat = async () => ({ sessionId: 'forked-sdk', inheritedTurnId: undefined }); -- try { -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- const session = URI.parse('ahp-session:/workspaceless-mint'); -- const parent = defaultChatUri(session); -- const created = await provisionSession(agent, { session }); -- const scratchDirectory = created.resolvedWorkingDirectory; -- assert.ok(scratchDirectory); -- const db = sessionDataService.openDatabase(session); -- try { -- await db.object.setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'true'); -- } finally { -- db.dispose(); -- } -- await agent.chats.sendMessage(parent, 'hello', undefined, undefined, 'turn-1', undefined, exactChatContext(session, parent, session)); -- -- const workspacelessContext = (chat: URI): IAgentChatContext => ({ ...exactChatContext(session, chat), workspaceless: true }); -- const peer = URI.parse(buildChatUri(session, 'peer')); -- await agent.chats.createChat(peer, workspacelessContext(peer), { workingDirectories: [scratchDirectory] }); -- const fork = URI.parse(buildChatUri(session, 'fork')); -- await agent.chats.createChat(fork, workspacelessContext(fork), { fork: { source: parent, turnId: 'turn-1' }, workingDirectories: [scratchDirectory] }); -- -- for (const chat of [peer, fork]) { -- await agent.chats.releaseChat(chat, workspacelessContext(chat)); -- await agent.chats.sendMessage(chat, 'hello again', [scratchDirectory], undefined, `turn-${chat.toString()}`, undefined, workspacelessContext(chat)); -- } -- -- const parentMessage = launches[0]?.systemMessage; -- assert.ok(parentMessage?.content?.includes(AGENT_HOST_WORKSPACELESS_INSTRUCTIONS)); -- assert.deepStrictEqual(launches, [ -- { kind: 'create', systemMessage: parentMessage }, -- { kind: 'create', systemMessage: parentMessage }, -- { kind: 'resume', systemMessage: parentMessage }, -- { kind: 'resume', systemMessage: parentMessage }, -- { kind: 'resume', systemMessage: parentMessage }, -- ]); -- } finally { +- afterTest(async () => { - await disposeAgent(agent); - await fs.rm(userHome.fsPath, { recursive: true, force: true }); +- }); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); +- const session = URI.parse('ahp-session:/workspaceless-mint'); +- const parent = defaultChatUri(session); +- const created = await provisionSession(agent, { session }); +- const scratchDirectory = created.resolvedWorkingDirectory; +- assert.ok(scratchDirectory); +- const db = sessionDataService.openDatabase(session); +- try { +- await db.object.setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'true'); +- } finally { +- db.dispose(); - } +- await agent.chats.sendMessage(parent, 'hello', undefined, undefined, 'turn-1', undefined, exactChatContext(session, parent, session)); +- +- const workspacelessContext = (chat: URI): IAgentChatContext => ({ ...exactChatContext(session, chat), workspaceless: true }); +- const peer = URI.parse(buildChatUri(session, 'peer')); +- await agent.chats.createChat(peer, workspacelessContext(peer), { workingDirectories: [scratchDirectory] }); +- const fork = URI.parse(buildChatUri(session, 'fork')); +- await agent.chats.createChat(fork, workspacelessContext(fork), { fork: { source: parent, turnId: 'turn-1' }, workingDirectories: [scratchDirectory] }); +- +- for (const chat of [peer, fork]) { +- await agent.chats.releaseChat(chat, workspacelessContext(chat)); +- await agent.chats.sendMessage(chat, 'hello again', [scratchDirectory], undefined, `turn-${chat.toString()}`, undefined, workspacelessContext(chat)); +- } +- +- const parentMessage = launches[0]?.systemMessage; +- assert.ok(parentMessage?.content?.includes(AGENT_HOST_WORKSPACELESS_INSTRUCTIONS)); +- assert.deepStrictEqual(launches, [ +- { kind: 'create', systemMessage: parentMessage }, +- { kind: 'create', systemMessage: parentMessage }, +- { kind: 'resume', systemMessage: parentMessage }, +- { kind: 'resume', systemMessage: parentMessage }, +- { kind: 'resume', systemMessage: parentMessage }, +- ]); - }).timeout(30_000); - - test('does not discover workspace customizations from scratch', async () => { - const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/qc-home-`)); - const agent = createTestAgent(disposables, { userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'qc-customizations'); -- const result = await provisionSession(agent, { session }); -- const scratchDir = result.resolvedWorkingDirectory; -- assert.ok(scratchDir); -- const scratchInstructions = URI.joinPath(scratchDir, '.github', 'copilot-instructions.md'); -- await fs.mkdir(dirname(scratchInstructions.fsPath), { recursive: true }); -- await fs.writeFile(scratchInstructions.fsPath, 'scratch instructions'); -- -- const customizations = await getDefaultChatCustomizations(agent, session); -- const uris = customizations.flatMap(customization => [ -- customization.uri, -- ...(customization.type === CustomizationType.Directory ? customization.children?.map(child => child.uri) ?? [] : []), -- ]); -- -- assert.deepStrictEqual(uris.includes(scratchInstructions.toString()), false); -- } finally { +- afterTest(async () => { - await disposeAgent(agent); - await fs.rm(userHome.fsPath, { recursive: true, force: true, maxRetries: 5 }); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'qc-customizations'); +- const result = await provisionSession(agent, { session }); +- const scratchDir = result.resolvedWorkingDirectory; +- assert.ok(scratchDir); +- const scratchInstructions = URI.joinPath(scratchDir, '.github', 'copilot-instructions.md'); +- await fs.mkdir(dirname(scratchInstructions.fsPath), { recursive: true }); +- await fs.writeFile(scratchInstructions.fsPath, 'scratch instructions'); +- +- const customizations = await getDefaultChatCustomizations(agent, session); +- const uris = customizations.flatMap(customization => [ +- customization.uri, +- ...(customization.type === CustomizationType.Directory ? customization.children?.map(child => child.uri) ?? [] : []), +- ]); +- +- assert.deepStrictEqual(uris.includes(scratchInstructions.toString()), false); - }).timeout(30_000); - - test('resume recreates a reaped quick chat scratch dir (ensure-exists on restore)', async () => { @@ -58914,17 +61110,16 @@ index f24a97a6296..00000000000 - const client = new TestCopilotClient([sdkSession(sessionId, scratchDir.fsPath)]); - const agent = createTestAgent(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService, userHome }); - const internals = agent as unknown as { _resumeSession: (id: string, chat: URI, workspaceless: boolean) => Promise }; -- try { -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- await assert.rejects(() => fs.access(scratchDir.fsPath)); -- // The stubbed SDK can't finish initializing the resumed session, but -- // the scratch dir is ensured before that point. -- await internals._resumeSession(sessionId, sessionBackedChat(sessionId), true).catch(() => undefined); -- await fs.access(scratchDir.fsPath); -- } finally { +- afterTest(async () => { - await disposeAgent(agent); - await fs.rm(userHome.fsPath, { recursive: true, force: true, maxRetries: 5 }); -- } +- }); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); +- await assert.rejects(() => fs.access(scratchDir.fsPath)); +- // The stubbed SDK can't finish initializing the resumed session, but +- // the scratch dir is ensured before that point. +- await internals._resumeSession(sessionId, sessionBackedChat(sessionId), true).catch(() => undefined); +- await fs.access(scratchDir.fsPath); - }).timeout(30_000); - - test('resume recreates a reaped recorded scratch folder from the per-chat decision alone', async () => { @@ -58939,16 +61134,15 @@ index f24a97a6296..00000000000 - const client = new TestCopilotClient([sdkSession(sessionId, scratchDir.fsPath)]); - const agent = createTestAgent(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService, userHome }); - const internals = agent as unknown as { _resumeSession: (id: string, chat: URI, workspaceless: boolean) => Promise }; -- try { -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- await internals._resumeSession(sessionId, sessionBackedChat(sessionId), false).catch(() => undefined); -- await assert.rejects(() => fs.access(scratchDir.fsPath)); -- await internals._resumeSession(sessionId, sessionBackedChat(sessionId), true).catch(() => undefined); -- await fs.access(scratchDir.fsPath); -- } finally { +- afterTest(async () => { - await disposeAgent(agent); - await fs.rm(userHome.fsPath, { recursive: true, force: true, maxRetries: 5 }); -- } +- }); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); +- await internals._resumeSession(sessionId, sessionBackedChat(sessionId), false).catch(() => undefined); +- await assert.rejects(() => fs.access(scratchDir.fsPath)); +- await internals._resumeSession(sessionId, sessionBackedChat(sessionId), true).catch(() => undefined); +- await fs.access(scratchDir.fsPath); - }).timeout(30_000); - - test('a live chat whose recorded scratch folder was deleted gets it recreated, a user folder never', async () => { @@ -58957,38 +61151,36 @@ index f24a97a6296..00000000000 - const scratchDir = URI.joinPath(userHome, '.copilot', 'chats', 'live-nested-folder'); - const agent = createTestAgent(disposables, { userHome }); - const internals = agent as unknown as { _withRecordedScratchFolder: (session: { workingDirectory: URI }, owner: string) => Promise }; -- try { -- await internals._withRecordedScratchFolder({ workingDirectory: scratchDir }, 'owner'); -- await fs.rm(userFolder.fsPath, { recursive: true, force: true }); -- await internals._withRecordedScratchFolder({ workingDirectory: userFolder }, 'owner'); -- assert.deepStrictEqual({ -- scratch: await fs.access(scratchDir.fsPath).then(() => true, () => false), -- user: await fs.access(userFolder.fsPath).then(() => true, () => false), -- }, { scratch: true, user: false }); -- } finally { +- afterTest(async () => { - await disposeAgent(agent); - await fs.rm(userHome.fsPath, { recursive: true, force: true, maxRetries: 5 }); -- } +- }); +- await internals._withRecordedScratchFolder({ workingDirectory: scratchDir }, 'owner'); +- await fs.rm(userFolder.fsPath, { recursive: true, force: true }); +- await internals._withRecordedScratchFolder({ workingDirectory: userFolder }, 'owner'); +- assert.deepStrictEqual({ +- scratch: await fs.access(scratchDir.fsPath).then(() => true, () => false), +- user: await fs.access(userFolder.fsPath).then(() => true, () => false), +- }, { scratch: true, user: false }); - }).timeout(30_000); - - test('session teardown cleans up the quick chat scratch dir', async () => { - const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/qc-home-`)); - const agent = createTestAgent(disposables, { userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'qc-dispose'); -- const result = await provisionSession(agent, { session }); -- const scratchDir = URI.joinPath(userHome, '.copilot', 'chats', 'qc-dispose'); -- await fs.access(scratchDir.fsPath); -- // No `workingDirectories` was supplied at creation, so the provider's -- // own provisional record already carries `workspaceless: true`; chat -- // disposal reads that (never the host) to decide the scratch dir goes. -- await disposeProvisionedSession(agent, result.session); -- await assert.rejects(() => fs.access(scratchDir.fsPath)); -- } finally { +- afterTest(async () => { - await disposeAgent(agent); - await fs.rm(userHome.fsPath, { recursive: true, force: true, maxRetries: 5 }); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'qc-dispose'); +- const result = await provisionSession(agent, { session }); +- const scratchDir = URI.joinPath(userHome, '.copilot', 'chats', 'qc-dispose'); +- await fs.access(scratchDir.fsPath); +- // No `workingDirectories` was supplied at creation, so the provider's +- // own provisional record already carries `workspaceless: true`; chat +- // disposal reads that (never the host) to decide the scratch dir goes. +- await disposeProvisionedSession(agent, result.session); +- await assert.rejects(() => fs.access(scratchDir.fsPath)); - }).timeout(30_000); - - // Reclaim is counted per owning session (Block 4 refuses moving a chat in a scratch folder), so these pin today's behaviour. @@ -59033,19 +61225,16 @@ index f24a97a6296..00000000000 - const owner = disposables.add(new TestChatOwnerRef(session)); - const peerContext = { ...exactChatContext(session, peer), owner }; - const reservations = () => [...(agent as unknown as { _provisionalSessions: Map })._provisionalSessions.keys()]; -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); -- await agent.chats.createChat(peer, peerContext, { workingDirectories: [URI.file('/workspace')], deferBacking: true }); -- owner.set(later); -- await disposeProvisionedSession(agent, session); -- const afterSessionFinalized = reservations(); -- await agent.chats.disposeChat(peer, peerContext); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); +- await agent.chats.createChat(peer, peerContext, { workingDirectories: [URI.file('/workspace')], deferBacking: true }); +- owner.set(later); +- await disposeProvisionedSession(agent, session); +- const afterSessionFinalized = reservations(); +- await agent.chats.disposeChat(peer, peerContext); - -- assert.deepStrictEqual({ afterSessionFinalized, afterPeer: reservations() }, { afterSessionFinalized: [peer.toString()], afterPeer: [] }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ afterSessionFinalized, afterPeer: reservations() }, { afterSessionFinalized: [peer.toString()], afterPeer: [] }); - }); - }); - @@ -59140,75 +61329,71 @@ index f24a97a6296..00000000000 - readonly additionalDirectories: readonly URI[]; - }; - }; -- try { -- await created.session.initializeSession(); -- (agent as unknown as { -- _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; -- })._registerLiveChat(chat, created.session, created.activeClient); -- const originalSession = getPeerChatStub(agent, chat); -- const originalSdkSessionId = chatBackings(agent).get(chat.toString())?.sdkSessionId; -- -- await agent.setChatWorkingDirectory(chat, exactChatContext(session, chat, session), next, { replaceSessionWorkspace: true }); -- -- const stored = sessionDataService.openDatabase(session); -- const metadata = await stored.object.getMetadataObject({ -- 'copilot.workingDirectory': true, -- 'copilot.workingDirectories': true, -- 'copilot.customizationDirectory': true, -- 'copilot.project.uri': true, -- 'copilot.project.displayName': true, -- 'copilot.project.resolved': true, -- 'agentHost.workspaceless': true, -- }); -- stored.dispose(); -- assert.deepStrictEqual({ -- sameSession: getPeerChatStub(agent, chat) === originalSession, -- sdkSessionId: chatBackings(agent).get(chat.toString())?.sdkSessionId, -- originalSdkSessionId, -- liveWorkingDirectory: created.session.workingDirectory?.toString(), -- sdkCalls: mockSession.workingDirectoryCalls, -- pluginDirectory: activeClient.pluginController.directory?.toString(), -- pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), -- metadata, -- centralCustomizationDirectory: await centralCustomizationDirectory(agent, session), -- }, { -- sameSession: true, -- sdkSessionId: 'test-session-1', -- originalSdkSessionId: 'test-session-1', -- liveWorkingDirectory: next.toString(), -- sdkCalls: [next.fsPath], -- pluginDirectory: next.toString(), -- pluginAdditionalDirectories: [], -- metadata: { -- 'copilot.workingDirectory': next.toString(), -- 'copilot.workingDirectories': JSON.stringify([next.toString()]), -- 'copilot.customizationDirectory': next.toString(), -- 'copilot.project.uri': next.toString(), -- 'copilot.project.displayName': 'next', -- 'copilot.project.resolved': 'true', -- 'agentHost.workspaceless': 'true', -- }, -- centralCustomizationDirectory: next.toString(), -- }); -- -- const restored = createTestAgent(disposables, { -- sessionDataService, -- copilotClient: new TestCopilotClient([sdkSession('test-session-1', previous.fsPath)]), -- }); -- try { -- await restored.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- const restoredMetadata = await restored.getChatMetadata(chat, exactChatContext(session, chat, session)); -- assert.deepStrictEqual({ -- project: restoredMetadata?.project?.uri.toString(), -- directories: restoredMetadata?.workingDirectories?.map(directory => directory.toString()), -- }, { project: next.toString(), directories: [next.toString()] }); -- } finally { -- await disposeAgent(restored); -- } -- } finally { +- afterTest(async () => { - await fs.rm(root, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await created.session.initializeSession(); +- (agent as unknown as { +- _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; +- })._registerLiveChat(chat, created.session, created.activeClient); +- const originalSession = getPeerChatStub(agent, chat); +- const originalSdkSessionId = chatBackings(agent).get(chat.toString())?.sdkSessionId; +- +- await agent.setChatWorkingDirectory(chat, exactChatContext(session, chat, session), next, { replaceSessionWorkspace: true }); +- +- const stored = sessionDataService.openDatabase(session); +- const metadata = await stored.object.getMetadataObject({ +- 'copilot.workingDirectory': true, +- 'copilot.workingDirectories': true, +- 'copilot.customizationDirectory': true, +- 'copilot.project.uri': true, +- 'copilot.project.displayName': true, +- 'copilot.project.resolved': true, +- 'agentHost.workspaceless': true, +- }); +- stored.dispose(); +- assert.deepStrictEqual({ +- sameSession: getPeerChatStub(agent, chat) === originalSession, +- sdkSessionId: chatBackings(agent).get(chat.toString())?.sdkSessionId, +- originalSdkSessionId, +- liveWorkingDirectory: created.session.workingDirectory?.toString(), +- sdkCalls: mockSession.workingDirectoryCalls, +- pluginDirectory: activeClient.pluginController.directory?.toString(), +- pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), +- metadata, +- centralCustomizationDirectory: await centralCustomizationDirectory(agent, session), +- }, { +- sameSession: true, +- sdkSessionId: 'test-session-1', +- originalSdkSessionId: 'test-session-1', +- liveWorkingDirectory: next.toString(), +- sdkCalls: [next.fsPath], +- pluginDirectory: next.toString(), +- pluginAdditionalDirectories: [], +- metadata: { +- 'copilot.workingDirectory': next.toString(), +- 'copilot.workingDirectories': JSON.stringify([next.toString()]), +- 'copilot.customizationDirectory': next.toString(), +- 'copilot.project.uri': next.toString(), +- 'copilot.project.displayName': 'next', +- 'copilot.project.resolved': 'true', +- 'agentHost.workspaceless': 'true', +- }, +- centralCustomizationDirectory: next.toString(), +- }); +- +- const restored = createTestAgent(disposables, { +- sessionDataService, +- copilotClient: new TestCopilotClient([sdkSession('test-session-1', previous.fsPath)]), +- }); +- afterTest(() => disposeAgent(restored)); +- await restored.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); +- const restoredMetadata = await restored.getChatMetadata(chat, exactChatContext(session, chat, session)); +- assert.deepStrictEqual({ +- project: restoredMetadata?.project?.uri.toString(), +- directories: restoredMetadata?.workingDirectories?.map(directory => directory.toString()), +- }, { project: next.toString(), directories: [next.toString()] }); - }); - - test('resumes the SDK session with the new workspace customizations and existing client tools before the next turn', async () => { @@ -59279,42 +61464,41 @@ index f24a97a6296..00000000000 - mockSession: new MockCopilotSession(), - workingDirectory: previous, - }); -- try { -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- await created.session.initializeSession(); -- (agent as unknown as { -- _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; -- })._registerLiveChat(chat, created.session, created.activeClient); -- agent.getOrCreateActiveClient(chat, exactChatContext(session, chat, session), { clientId: 'client' }).tools = [{ -- name: 'workspace_client_tool', -- description: 'Existing client tool', -- inputSchema: { type: 'object', properties: {} }, -- }]; -- -- await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); -- await agent.chats.sendMessage(chat, 'Continue', next, undefined, 'turn-2', undefined, undefined, exactChatContext(session, chat, session)); -- -- assert.deepStrictEqual({ -- resumedConfigs, -- agentDiscoveryProjectPaths: client.agentDiscoveryRequests.at(-1)?.projectPaths, -- instructionDiscoveryProjectPaths: client.instructionDiscoveryRequests.at(-1)?.projectPaths, -- skillDiscoveryProjectPaths: client.skillDiscoveryRequests.at(-1)?.projectPaths, -- }, { -- resumedConfigs: [{ -- workingDirectory: next.fsPath, -- skillDirectories: [], -- instructionDirectories: [], -- mcpServerNames: ['workspace-server'], -- hasClientTool: true, -- }], -- agentDiscoveryProjectPaths: [next.fsPath], -- instructionDiscoveryProjectPaths: [next.fsPath], -- skillDiscoveryProjectPaths: [next.fsPath], -- }); -- } finally { +- afterTest(async () => { - await fs.rm(root, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); +- await created.session.initializeSession(); +- (agent as unknown as { +- _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; +- })._registerLiveChat(chat, created.session, created.activeClient); +- agent.getOrCreateActiveClient(chat, exactChatContext(session, chat, session), { clientId: 'client' }).tools = [{ +- name: 'workspace_client_tool', +- description: 'Existing client tool', +- inputSchema: { type: 'object', properties: {} }, +- }]; +- +- await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); +- await agent.chats.sendMessage(chat, 'Continue', next, undefined, 'turn-2', undefined, undefined, exactChatContext(session, chat, session)); +- +- assert.deepStrictEqual({ +- resumedConfigs, +- agentDiscoveryProjectPaths: client.agentDiscoveryRequests.at(-1)?.projectPaths, +- instructionDiscoveryProjectPaths: client.instructionDiscoveryRequests.at(-1)?.projectPaths, +- skillDiscoveryProjectPaths: client.skillDiscoveryRequests.at(-1)?.projectPaths, +- }, { +- resumedConfigs: [{ +- workingDirectory: next.fsPath, +- skillDirectories: [], +- instructionDirectories: [], +- mcpServerNames: ['workspace-server'], +- hasClientTool: true, +- }], +- agentDiscoveryProjectPaths: [next.fsPath], +- instructionDiscoveryProjectPaths: [next.fsPath], +- skillDiscoveryProjectPaths: [next.fsPath], +- }); - }); - - test('holds the session turn reservation until provider metadata persistence completes', async () => { @@ -59388,42 +61572,41 @@ index f24a97a6296..00000000000 - const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); - const mockSession = new MockCopilotSession(); - const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous, additionalDirectories: [secondary] }); -- try { -- await created.session.initializeSession(); -- (agent as unknown as { -- _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; -- })._registerLiveChat(chat, created.session, created.activeClient); -- -- const failures: string[] = []; -- for (const target of [ -- URI.from({ scheme: Schemas.inMemory, path: '/not-local' }), -- notDirectory, -- secondary, -- ]) { -- try { -- await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), target); -- } catch (error) { -- failures.push(error instanceof Error ? error.message : String(error)); -- } -- } -- -- assert.deepStrictEqual({ -- failures, -- sdkCalls: mockSession.workingDirectoryCalls, -- liveWorkingDirectory: created.session.workingDirectory?.toString(), -- }, { -- failures: [ -- 'Cannot change the working directory to non-local or relative resource \'inmemory:/not-local\'', -- `Cannot change the working directory because '${notDirectory.fsPath}' is not an existing directory`, -- `Cannot change the working directory for multi-root chat '${chat.toString()}'`, -- ], -- sdkCalls: [], -- liveWorkingDirectory: previous.toString(), -- }); -- } finally { +- afterTest(async () => { - await fs.rm(root, { recursive: true, force: true }); - await disposeAgent(agent); +- }); +- await created.session.initializeSession(); +- (agent as unknown as { +- _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; +- })._registerLiveChat(chat, created.session, created.activeClient); +- +- const failures: string[] = []; +- for (const target of [ +- URI.from({ scheme: Schemas.inMemory, path: '/not-local' }), +- notDirectory, +- secondary, +- ]) { +- try { +- await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), target); +- } catch (error) { +- failures.push(error instanceof Error ? error.message : String(error)); +- } - } +- +- assert.deepStrictEqual({ +- failures, +- sdkCalls: mockSession.workingDirectoryCalls, +- liveWorkingDirectory: created.session.workingDirectory?.toString(), +- }, { +- failures: [ +- 'Cannot change the working directory to non-local or relative resource \'inmemory:/not-local\'', +- `Cannot change the working directory because '${notDirectory.fsPath}' is not an existing directory`, +- `Cannot change the working directory for multi-root chat '${chat.toString()}'`, +- ], +- sdkCalls: [], +- liveWorkingDirectory: previous.toString(), +- }); - }); - - suite('exact-chat working-directory conversion', () => { @@ -59983,34 +62166,31 @@ index f24a97a6296..00000000000 - - test('does not overwrite a peer working directory that is persisted while the main chat pins plain peers', async () => { - const fixture = await createFixture(); -- try { -- const { agent, session, chats, resources, next, secondary, sessionDataService } = fixture; -- const db = sessionDataService.openDatabase(resources[1]); -- await db.object.deleteMetadata(['copilot.workingDirectory', 'copilot.workingDirectories', 'copilot.customizationDirectory']); -- const mainContext = exactChatContext(session, chats[0], resources[0]); -- await agent.chats.getMessages(chats[0], mainContext); -- await agent.chats.getMessages(chats[1], exactChatContext(session, chats[1], resources[1])); -- const readEntered = new DeferredPromise(); -- const releaseRead = new DeferredPromise(); -- sessionDataService.gateNextMetadataRead(resources[1], releaseRead.p, readEntered); -- const moving = agent.setChatWorkingDirectory(chats[0], mainContext, next); -- await readEntered.p; -- // The pin has already observed the peer as unpinned; the peer now persists its own directory. -- await db.object.setMetadataValues({ -- 'copilot.workingDirectory': secondary.toString(), -- 'copilot.workingDirectories': JSON.stringify([secondary.toString()]), -- }); -- releaseRead.complete(); -- await moving; -- db.dispose(); -- assert.deepStrictEqual((await fixture.metadata())[1], { -- 'copilot.workingDirectory': secondary.toString(), -- 'copilot.workingDirectories': JSON.stringify([secondary.toString()]), -- 'copilot.customizationDirectory': undefined, -- }); -- } finally { -- await fixture.dispose(); -- } +- afterTest(() => fixture.dispose()); +- const { agent, session, chats, resources, next, secondary, sessionDataService } = fixture; +- const db = sessionDataService.openDatabase(resources[1]); +- await db.object.deleteMetadata(['copilot.workingDirectory', 'copilot.workingDirectories', 'copilot.customizationDirectory']); +- const mainContext = exactChatContext(session, chats[0], resources[0]); +- await agent.chats.getMessages(chats[0], mainContext); +- await agent.chats.getMessages(chats[1], exactChatContext(session, chats[1], resources[1])); +- const readEntered = new DeferredPromise(); +- const releaseRead = new DeferredPromise(); +- sessionDataService.gateNextMetadataRead(resources[1], releaseRead.p, readEntered); +- const moving = agent.setChatWorkingDirectory(chats[0], mainContext, next); +- await readEntered.p; +- // The pin has already observed the peer as unpinned; the peer now persists its own directory. +- await db.object.setMetadataValues({ +- 'copilot.workingDirectory': secondary.toString(), +- 'copilot.workingDirectories': JSON.stringify([secondary.toString()]), +- }); +- releaseRead.complete(); +- await moving; +- db.dispose(); +- assert.deepStrictEqual((await fixture.metadata())[1], { +- 'copilot.workingDirectory': secondary.toString(), +- 'copilot.workingDirectories': JSON.stringify([secondary.toString()]), +- 'copilot.customizationDirectory': undefined, +- }); - }); - - test('keeps plain peers without their own metadata in their directory when the main chat moves', async () => { @@ -60059,21 +62239,18 @@ index f24a97a6296..00000000000 - - test('a cold peer without its own pin resumes in the live default session\'s folder before the host-supplied launch folder, as on main', async () => { - const fixture = await createFixture(); -- try { -- const { session, chats, resources, previous, next, sessionDataService } = fixture; -- const peerDb = sessionDataService.openDatabase(resources[2]); -- await peerDb.object.deleteMetadata(['copilot.workingDirectory', 'copilot.workingDirectories', 'copilot.customizationDirectory']); -- peerDb.dispose(); -- await fixture.agent.chats.getMessages(chats[0], exactChatContext(session, chats[0], resources[0])); -- await fixture.agent.chats.getMessages(chats[2], { -- ...exactChatContext(session, chats[2], resources[2]), -- launchWorkingDirectories: [next], -- sessionSettings: { customizationDirectory: undefined, additionalCustomizationDirectories: [], project: undefined, workspaceless: false, defaultChatWorkingDirectory: undefined, additionalWorktrees: [] }, -- }); -- assert.deepStrictEqual(fixture.resumed.filter(resume => resume.id === 'cold-sdk'), [{ id: 'cold-sdk', directory: previous.fsPath }]); -- } finally { -- await fixture.dispose(); -- } +- afterTest(() => fixture.dispose()); +- const { session, chats, resources, previous, next, sessionDataService } = fixture; +- const peerDb = sessionDataService.openDatabase(resources[2]); +- await peerDb.object.deleteMetadata(['copilot.workingDirectory', 'copilot.workingDirectories', 'copilot.customizationDirectory']); +- peerDb.dispose(); +- await fixture.agent.chats.getMessages(chats[0], exactChatContext(session, chats[0], resources[0])); +- await fixture.agent.chats.getMessages(chats[2], { +- ...exactChatContext(session, chats[2], resources[2]), +- launchWorkingDirectories: [next], +- sessionSettings: { customizationDirectory: undefined, additionalCustomizationDirectories: [], project: undefined, workspaceless: false, defaultChatWorkingDirectory: undefined, additionalWorktrees: [] }, +- }); +- assert.deepStrictEqual(fixture.resumed.filter(resume => resume.id === 'cold-sdk'), [{ id: 'cold-sdk', directory: previous.fsPath }]); - }); - - for (const pinned of [false, true]) { @@ -60252,75 +62429,69 @@ index f24a97a6296..00000000000 - - test('isolates a live peer without changing the main or cold peer metadata or shared discovery anchor', async () => { - const fixture = await createFixture(); -- try { -- const before = await fixture.metadata(); -- await fixture.agent.setChatWorkingDirectory(fixture.chats[1], exactChatContext(fixture.session, fixture.chats[1], fixture.resources[1]), fixture.next); -- assert.deepStrictEqual({ -- calls: fixture.sdkSessions.map(sdk => sdk.workingDirectoryCalls), -- metadata: await fixture.metadata(), -- anchor: fixture.pluginDirectory(), -- reanchors: fixture.reanchors(), -- sends: fixture.sends(), -- }, { -- calls: [[], [fixture.next.fsPath], []], -- metadata: [before[0], { -- 'copilot.workingDirectory': fixture.next.toString(), -- 'copilot.workingDirectories': JSON.stringify([fixture.next.toString()]), -- 'copilot.customizationDirectory': fixture.next.toString(), -- }, before[2]], -- anchor: fixture.previous.toString(), reanchors: [], sends: 0, -- }); -- } finally { -- await fixture.dispose(); -- } +- afterTest(() => fixture.dispose()); +- const before = await fixture.metadata(); +- await fixture.agent.setChatWorkingDirectory(fixture.chats[1], exactChatContext(fixture.session, fixture.chats[1], fixture.resources[1]), fixture.next); +- assert.deepStrictEqual({ +- calls: fixture.sdkSessions.map(sdk => sdk.workingDirectoryCalls), +- metadata: await fixture.metadata(), +- anchor: fixture.pluginDirectory(), +- reanchors: fixture.reanchors(), +- sends: fixture.sends(), +- }, { +- calls: [[], [fixture.next.fsPath], []], +- metadata: [before[0], { +- 'copilot.workingDirectory': fixture.next.toString(), +- 'copilot.workingDirectories': JSON.stringify([fixture.next.toString()]), +- 'copilot.customizationDirectory': fixture.next.toString(), +- }, before[2]], +- anchor: fixture.previous.toString(), reanchors: [], sends: 0, +- }); - }); - - test('replaces the inherited roots of a live multi-root peer', async () => { - const fixture = await createFixture(true); +- afterTest(() => fixture.dispose()); +- const { agent, session, chats, resources, previous, next, secondary, sessionDirectories, sessionDataService } = fixture; +- const context = exactChatContext(session, chats[1], resources[1]); +- await agent.chats.sendMessage(chats[1], 'Prepare inherited roots', sessionDirectories, undefined, undefined, undefined, context); +- const db = sessionDataService.openDatabase(resources[1]); - try { -- const { agent, session, chats, resources, previous, next, secondary, sessionDirectories, sessionDataService } = fixture; -- const context = exactChatContext(session, chats[1], resources[1]); -- await agent.chats.sendMessage(chats[1], 'Prepare inherited roots', sessionDirectories, undefined, undefined, undefined, context); -- const db = sessionDataService.openDatabase(resources[1]); -- try { -- await db.object.setMetadataValues({ -- 'copilot.workingDirectory': previous.toString(), -- 'copilot.workingDirectories': JSON.stringify(sessionDirectories.map(directory => directory.toString())), -- 'copilot.customizationDirectory': previous.toString(), -- }); -- } finally { -- db.dispose(); -- } -- const before = await fixture.metadata(); -- const appliedBefore = getPeerChatStub(agent, chats[1])?.appliedAdditionalDirectories.map(directory => directory.toString()); -- -- await agent.setChatWorkingDirectory(chats[1], context, next); -- await agent.chats.sendMessage(chats[1], 'Continue in the selected workspace', [next], undefined, undefined, undefined, context); -- -- assert.deepStrictEqual({ -- appliedBefore, -- calls: fixture.sdkSessions.map(sdk => sdk.workingDirectoryCalls), -- directories: chats.map(chat => getPeerChatStub(agent, chat)?.workingDirectory?.toString()), -- additionalDirectories: chats.map(chat => getPeerChatStub(agent, chat)?.appliedAdditionalDirectories.map(directory => directory.toString())), -- metadata: await fixture.metadata(), -- anchor: fixture.pluginDirectory(), -- reanchors: fixture.reanchors(), -- }, { -- appliedBefore: [secondary.toString()], -- calls: [[], [next.fsPath], []], -- directories: [undefined, next.toString(), undefined], -- additionalDirectories: [undefined, [], undefined], -- metadata: [before[0], { -- 'copilot.workingDirectory': next.toString(), -- 'copilot.workingDirectories': JSON.stringify([next.toString()]), -- 'copilot.customizationDirectory': next.toString(), -- }, before[2]], -- anchor: previous.toString(), -- reanchors: [], +- await db.object.setMetadataValues({ +- 'copilot.workingDirectory': previous.toString(), +- 'copilot.workingDirectories': JSON.stringify(sessionDirectories.map(directory => directory.toString())), +- 'copilot.customizationDirectory': previous.toString(), - }); - } finally { -- await fixture.dispose(); +- db.dispose(); - } +- const before = await fixture.metadata(); +- const appliedBefore = getPeerChatStub(agent, chats[1])?.appliedAdditionalDirectories.map(directory => directory.toString()); +- +- await agent.setChatWorkingDirectory(chats[1], context, next); +- await agent.chats.sendMessage(chats[1], 'Continue in the selected workspace', [next], undefined, undefined, undefined, context); +- +- assert.deepStrictEqual({ +- appliedBefore, +- calls: fixture.sdkSessions.map(sdk => sdk.workingDirectoryCalls), +- directories: chats.map(chat => getPeerChatStub(agent, chat)?.workingDirectory?.toString()), +- additionalDirectories: chats.map(chat => getPeerChatStub(agent, chat)?.appliedAdditionalDirectories.map(directory => directory.toString())), +- metadata: await fixture.metadata(), +- anchor: fixture.pluginDirectory(), +- reanchors: fixture.reanchors(), +- }, { +- appliedBefore: [secondary.toString()], +- calls: [[], [next.fsPath], []], +- directories: [undefined, next.toString(), undefined], +- additionalDirectories: [undefined, [], undefined], +- metadata: [before[0], { +- 'copilot.workingDirectory': next.toString(), +- 'copilot.workingDirectories': JSON.stringify([next.toString()]), +- 'copilot.customizationDirectory': next.toString(), +- }, before[2]], +- anchor: previous.toString(), +- reanchors: [], +- }); - }); - - for (const [resume, multiRoot] of [ @@ -60330,59 +62501,58 @@ index f24a97a6296..00000000000 - test(`anchors an isolated peer's discovery to its own workspace through ${resume} and sibling sends (multi-root: ${multiRoot})`, async () => { - const fixture = await createFixture(multiRoot); - let agent = fixture.agent; -- try { -- const { session, chats, resources, previous, next, sessionDirectories } = fixture; -- const context = exactChatContext(session, chats[1], resources[1]); -- const before = await fixture.metadata(); -- await agent.setChatWorkingDirectory(chats[1], context, next); -- const peerMark = fixture.discoveryMark(); -- if (resume === 'cold restart') { -- agent = await fixture.restore(); -- } else if (resume === 'release and reopen') { -- await agent.chats.releaseChat(chats[1], context); -- } -- await agent.chats.getMessages(chats[1], context); -- const reopenedDirectory = getPeerChatStub(agent, chats[1])?.workingDirectory?.toString(); -- await agent.chats.sendMessage(chats[1], 'Continue in the isolated workspace', [next], undefined, undefined, undefined, context); -- const discoveriesForPeer = fixture.discoveriesSince(peerMark); -- const siblingMark = fixture.discoveryMark(); -- for (const index of [0, 2]) { -- await agent.chats.sendMessage(chats[index], 'Continue in the original workspace', index === 0 ? sessionDirectories : [previous], undefined, undefined, undefined, exactChatContext(session, chats[index], resources[index])); -- } -- const discoveriesForSiblings = fixture.discoveriesSince(siblingMark); -- const finalMark = fixture.discoveryMark(); -- await agent.chats.sendMessage(chats[1], 'Continue after the siblings', [next], undefined, undefined, undefined, context); -- assert.deepStrictEqual({ -- reopenedDirectory, -- directories: chats.map(chat => getPeerChatStub(agent, chat)?.workingDirectory?.toString()), -- additionalDirectories: chats.map(chat => getPeerChatStub(agent, chat)?.appliedAdditionalDirectories.map(directory => directory.toString())), -- discoveriesForPeer, -- discoveriesForSiblings, -- discoveriesAfterFinalPeerSend: fixture.discoveriesSince(finalMark), -- metadata: await fixture.metadata(), -- sends: fixture.sends(), -- }, { -- reopenedDirectory: next.toString(), -- directories: [previous.toString(), next.toString(), previous.toString()], -- additionalDirectories: [sessionDirectories.slice(1).map(directory => directory.toString()), [], []], -- // The peer discovers only its own workspace; its previous anchor is released with its previous runtime. -- discoveriesForPeer: [[next.fsPath]], -- // The main chat discovers its own folders; the cold peer shares that discovery only when they are the same set. -- discoveriesForSiblings: multiRoot ? [sessionDirectories.map(directory => directory.fsPath), [previous.fsPath]] : [[previous.fsPath]], -- discoveriesAfterFinalPeerSend: [], -- metadata: [before[0], { -- 'copilot.workingDirectory': next.toString(), -- 'copilot.workingDirectories': JSON.stringify([next.toString()]), -- 'copilot.customizationDirectory': next.toString(), -- }, before[2]], -- sends: 4, -- }); -- } finally { +- afterTest(async () => { - if (agent !== fixture.agent) { - await disposeAgent(agent); - } - await fixture.dispose(); +- }); +- const { session, chats, resources, previous, next, sessionDirectories } = fixture; +- const context = exactChatContext(session, chats[1], resources[1]); +- const before = await fixture.metadata(); +- await agent.setChatWorkingDirectory(chats[1], context, next); +- const peerMark = fixture.discoveryMark(); +- if (resume === 'cold restart') { +- agent = await fixture.restore(); +- } else if (resume === 'release and reopen') { +- await agent.chats.releaseChat(chats[1], context); +- } +- await agent.chats.getMessages(chats[1], context); +- const reopenedDirectory = getPeerChatStub(agent, chats[1])?.workingDirectory?.toString(); +- await agent.chats.sendMessage(chats[1], 'Continue in the isolated workspace', [next], undefined, undefined, undefined, context); +- const discoveriesForPeer = fixture.discoveriesSince(peerMark); +- const siblingMark = fixture.discoveryMark(); +- for (const index of [0, 2]) { +- await agent.chats.sendMessage(chats[index], 'Continue in the original workspace', index === 0 ? sessionDirectories : [previous], undefined, undefined, undefined, exactChatContext(session, chats[index], resources[index])); - } +- const discoveriesForSiblings = fixture.discoveriesSince(siblingMark); +- const finalMark = fixture.discoveryMark(); +- await agent.chats.sendMessage(chats[1], 'Continue after the siblings', [next], undefined, undefined, undefined, context); +- assert.deepStrictEqual({ +- reopenedDirectory, +- directories: chats.map(chat => getPeerChatStub(agent, chat)?.workingDirectory?.toString()), +- additionalDirectories: chats.map(chat => getPeerChatStub(agent, chat)?.appliedAdditionalDirectories.map(directory => directory.toString())), +- discoveriesForPeer, +- discoveriesForSiblings, +- discoveriesAfterFinalPeerSend: fixture.discoveriesSince(finalMark), +- metadata: await fixture.metadata(), +- sends: fixture.sends(), +- }, { +- reopenedDirectory: next.toString(), +- directories: [previous.toString(), next.toString(), previous.toString()], +- additionalDirectories: [sessionDirectories.slice(1).map(directory => directory.toString()), [], []], +- // The peer discovers only its own workspace; its previous anchor is released with its previous runtime. +- discoveriesForPeer: [[next.fsPath]], +- // The main chat discovers its own folders; the cold peer shares that discovery only when they are the same set. +- discoveriesForSiblings: multiRoot ? [sessionDirectories.map(directory => directory.fsPath), [previous.fsPath]] : [[previous.fsPath]], +- discoveriesAfterFinalPeerSend: [], +- metadata: [before[0], { +- 'copilot.workingDirectory': next.toString(), +- 'copilot.workingDirectories': JSON.stringify([next.toString()]), +- 'copilot.customizationDirectory': next.toString(), +- }, before[2]], +- sends: 4, +- }); - }); - } - @@ -60390,76 +62560,75 @@ index f24a97a6296..00000000000 - test(`the default chat's discovery follows its chat-only workspace change through ${resume}`, async () => { - const fixture = await createFixture(); - let agent = fixture.agent; -- try { -- const { session, chats, resources, previous, next } = fixture; -- const context = exactChatContext(session, chats[0], resources[0]); -- await agent.chats.getMessages(chats[0], context); -- const mainMark = fixture.discoveryMark(); -- const customizationsPublished = Event.toPromise(Event.filter(agent.onDidChatProgress, signal => -- signal.kind === 'action' && isEqual(signal.resource, session) && signal.action.type === ActionType.SessionCustomizationsChanged -- ), disposables.add(new DisposableStore())); -- await agent.setChatWorkingDirectory(chats[0], context, next); -- // Wait for automatic publication before a restart can cancel the move's discovery. -- await customizationsPublished; -- const resumeStart = fixture.resumed.length; -- if (resume === 'cold restart' || resume === 'peer-first cold restart') { -- agent = await fixture.restore(); -- if (resume === 'peer-first cold restart') { -- await agent.chats.getMessages(chats[1], exactChatContext(session, chats[1], resources[1])); -- } -- } else if (resume === 'release and reopen') { -- await agent.chats.releaseChat(chats[0], context); -- } -- if (resume !== 'next send') { -- await agent.chats.getMessages(chats[0], context); -- } -- await agent.chats.sendMessage(chats[0], 'Continue in the new main workspace', [next], undefined, undefined, undefined, context); -- const discoveriesThroughMainSend = fixture.discoveriesSince(mainMark); -- const siblingMark = fixture.discoveryMark(); -- for (const index of [1, 2]) { -- await agent.chats.sendMessage(chats[index], 'Continue in the original workspace', [previous], undefined, undefined, undefined, exactChatContext(session, chats[index], resources[index])); -- } -- const discoveriesForSiblings = fixture.discoveriesSince(siblingMark); -- const finalMark = fixture.discoveryMark(); -- await agent.chats.sendMessage(chats[0], 'Continue after the siblings', [next], undefined, undefined, undefined, context); -- const db = fixture.sessionDataService.openDatabase(session); -- let sharedRoots: string | undefined; -- try { -- sharedRoots = await db.object.getMetadata('copilot.sharedCustomizationDirectories'); -- } finally { -- db.dispose(); -- } -- assert.deepStrictEqual({ -- directories: chats.map(chat => getPeerChatStub(agent, chat)?.workingDirectory?.toString()), -- mainResumes: fixture.resumed.slice(resumeStart).filter(call => call.id === 'session-wide'), -- discoveriesThroughMainSend, -- discoveriesForSiblings, -- discoveriesAfterFinalMainSend: fixture.discoveriesSince(finalMark), -- publishedAnchor: fixture.pluginDirectory(), -- sharedRoots, -- sends: fixture.sends(), -- }, { -- directories: [next.toString(), previous.toString(), previous.toString()], -- mainResumes: [{ id: 'session-wide', directory: next.fsPath }], -- // The move publishes the main chat's new folder, which its relaunch then shares; -- // a restarted host discovers it again, after any peer reopened first. -- discoveriesThroughMainSend: resume === 'cold restart' ? [[next.fsPath], [next.fsPath]] -- : resume === 'peer-first cold restart' ? [[next.fsPath], [previous.fsPath], [next.fsPath]] -- : [[next.fsPath]], -- // The peers share one discovery of their folder, which a live peer already holds unless the host restarted. -- discoveriesForSiblings: resume === 'cold restart' ? [[previous.fsPath]] : [], -- discoveriesAfterFinalMainSend: [], -- publishedAnchor: next.toString(), -- // Still written for older builds, which keep discovery at the previous folders. -- sharedRoots: JSON.stringify([previous.toString()]), -- sends: 4, -- }); -- } finally { +- afterTest(async () => { - if (agent !== fixture.agent) { - await disposeAgent(agent); - } - await fixture.dispose(); +- }); +- const { session, chats, resources, previous, next } = fixture; +- const context = exactChatContext(session, chats[0], resources[0]); +- await agent.chats.getMessages(chats[0], context); +- const mainMark = fixture.discoveryMark(); +- const customizationsPublished = Event.toPromise(Event.filter(agent.onDidChatProgress, signal => +- signal.kind === 'action' && isEqual(signal.resource, session) && signal.action.type === ActionType.SessionCustomizationsChanged +- ), disposables.add(new DisposableStore())); +- await agent.setChatWorkingDirectory(chats[0], context, next); +- // Wait for automatic publication before a restart can cancel the move's discovery. +- await customizationsPublished; +- const resumeStart = fixture.resumed.length; +- if (resume === 'cold restart' || resume === 'peer-first cold restart') { +- agent = await fixture.restore(); +- if (resume === 'peer-first cold restart') { +- await agent.chats.getMessages(chats[1], exactChatContext(session, chats[1], resources[1])); +- } +- } else if (resume === 'release and reopen') { +- await agent.chats.releaseChat(chats[0], context); - } +- if (resume !== 'next send') { +- await agent.chats.getMessages(chats[0], context); +- } +- await agent.chats.sendMessage(chats[0], 'Continue in the new main workspace', [next], undefined, undefined, undefined, context); +- const discoveriesThroughMainSend = fixture.discoveriesSince(mainMark); +- const siblingMark = fixture.discoveryMark(); +- for (const index of [1, 2]) { +- await agent.chats.sendMessage(chats[index], 'Continue in the original workspace', [previous], undefined, undefined, undefined, exactChatContext(session, chats[index], resources[index])); +- } +- const discoveriesForSiblings = fixture.discoveriesSince(siblingMark); +- const finalMark = fixture.discoveryMark(); +- await agent.chats.sendMessage(chats[0], 'Continue after the siblings', [next], undefined, undefined, undefined, context); +- const db = fixture.sessionDataService.openDatabase(session); +- let sharedRoots: string | undefined; +- try { +- sharedRoots = await db.object.getMetadata('copilot.sharedCustomizationDirectories'); +- } finally { +- db.dispose(); +- } +- assert.deepStrictEqual({ +- directories: chats.map(chat => getPeerChatStub(agent, chat)?.workingDirectory?.toString()), +- mainResumes: fixture.resumed.slice(resumeStart).filter(call => call.id === 'session-wide'), +- discoveriesThroughMainSend, +- discoveriesForSiblings, +- discoveriesAfterFinalMainSend: fixture.discoveriesSince(finalMark), +- publishedAnchor: fixture.pluginDirectory(), +- sharedRoots, +- sends: fixture.sends(), +- }, { +- directories: [next.toString(), previous.toString(), previous.toString()], +- mainResumes: [{ id: 'session-wide', directory: next.fsPath }], +- // The move publishes the main chat's new folder, which its relaunch then shares; +- // a restarted host discovers it again, after any peer reopened first. +- discoveriesThroughMainSend: resume === 'cold restart' ? [[next.fsPath], [next.fsPath]] +- : resume === 'peer-first cold restart' ? [[next.fsPath], [previous.fsPath], [next.fsPath]] +- : [[next.fsPath]], +- // The peers share one discovery of their folder, which a live peer already holds unless the host restarted. +- discoveriesForSiblings: resume === 'cold restart' ? [[previous.fsPath]] : [], +- discoveriesAfterFinalMainSend: [], +- publishedAnchor: next.toString(), +- // Still written for older builds, which keep discovery at the previous folders. +- sharedRoots: JSON.stringify([previous.toString()]), +- sends: 4, +- }); - }); - } - @@ -60534,32 +62703,29 @@ index f24a97a6296..00000000000 - for (const roots of ['live', 'persisted'] as const) { - test(`rejects a workspace replacement with actual ${roots} multi-root chat state`, async () => { - const fixture = await createFixture(roots === 'live'); +- afterTest(() => fixture.dispose()); +- const { agent, session, chats, resources, previous, next, secondary } = fixture; +- const context = exactChatContext(session, chats[0], resources[0]); +- await agent.chats.getMessages(chats[0], context); +- for (const index of [1, 2]) { +- await agent.chats.disposeChat(chats[index], exactChatContext(session, chats[index], resources[index])); +- } +- const db = fixture.sessionDataService.openDatabase(session); - try { -- const { agent, session, chats, resources, previous, next, secondary } = fixture; -- const context = exactChatContext(session, chats[0], resources[0]); -- await agent.chats.getMessages(chats[0], context); -- for (const index of [1, 2]) { -- await agent.chats.disposeChat(chats[index], exactChatContext(session, chats[index], resources[index])); -- } -- const db = fixture.sessionDataService.openDatabase(session); -- try { -- await db.object.setMetadata('copilot.workingDirectories', JSON.stringify( -- (roots === 'live' ? [previous] : [previous, secondary]).map(directory => directory.toString()), -- )); -- } finally { -- db.dispose(); -- } -- await assert.rejects( -- agent.setChatWorkingDirectory(chats[0], context, next, { replaceSessionWorkspace: true }), -- /multi-root chat/, -- ); -- assert.deepStrictEqual({ -- directory: getPeerChatStub(agent, chats[0])?.workingDirectory?.toString(), -- sdkCalls: fixture.sdkSessions[0].workingDirectoryCalls, -- }, { directory: previous.toString(), sdkCalls: [] }); +- await db.object.setMetadata('copilot.workingDirectories', JSON.stringify( +- (roots === 'live' ? [previous] : [previous, secondary]).map(directory => directory.toString()), +- )); - } finally { -- await fixture.dispose(); +- db.dispose(); - } +- await assert.rejects( +- agent.setChatWorkingDirectory(chats[0], context, next, { replaceSessionWorkspace: true }), +- /multi-root chat/, +- ); +- assert.deepStrictEqual({ +- directory: getPeerChatStub(agent, chats[0])?.workingDirectory?.toString(), +- sdkCalls: fixture.sdkSessions[0].workingDirectoryCalls, +- }, { directory: previous.toString(), sdkCalls: [] }); - }); - } - @@ -60570,15 +62736,14 @@ index f24a97a6296..00000000000 - const session = AgentSession.uri('copilotcli', 'not-live'); - const chat = defaultChatUri(session); - const agent = createTestAgent(disposables); -- try { -- await assert.rejects( -- () => agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), root), -- error => error instanceof Error && error.message === `Cannot change the working directory: chat '${chat.toString()}' is unknown, not live, or does not match the supplied context`, -- ); -- } finally { +- afterTest(async () => { - await fs.rm(root.fsPath, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await assert.rejects( +- () => agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), root), +- error => error instanceof Error && error.message === `Cannot change the working directory: chat '${chat.toString()}' is unknown, not live, or does not match the supplied context`, +- ); - }); - - test('rejects mismatched live configuration and resource contexts without mutation', async () => { @@ -60604,47 +62769,46 @@ index f24a97a6296..00000000000 - readonly additionalDirectories: readonly URI[]; - }; - }; -- try { -- await created.session.initializeSession(); -- (agent as unknown as { -- _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; -- })._registerLiveChat(chat, created.session, created.activeClient); -- const writeStart = sessionDataService.metadataWrites(session).length; -- const failures: string[] = []; -- -- for (const mismatchedContext of [ -- exactChatContext(otherSession, chat, otherSession), -- exactChatContext(session, chat, chat), -- ]) { -- try { -- await agent.setWorkingDirectory(chat, mismatchedContext, next); -- } catch (error) { -- failures.push(error instanceof Error ? error.message : String(error)); -- } -- } -- -- assert.deepStrictEqual({ -- failures, -- liveWorkingDirectory: created.session.workingDirectory?.toString(), -- sdkCalls: mockSession.workingDirectoryCalls, -- pluginDirectory: activeClient.pluginController.directory?.toString(), -- pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), -- metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), -- }, { -- failures: [ -- `Cannot change the working directory: chat '${chat.toString()}' is unknown, not live, or does not match the supplied context`, -- `Cannot change the working directory: chat '${chat.toString()}' is unknown, not live, or does not match the supplied context`, -- ], -- liveWorkingDirectory: previous.toString(), -- sdkCalls: [], -- pluginDirectory: previous.toString(), -- pluginAdditionalDirectories: [], -- metadataWrites: [], -- }); -- } finally { +- afterTest(async () => { - await fs.rm(root, { recursive: true, force: true }); - await disposeAgent(agent); +- }); +- await created.session.initializeSession(); +- (agent as unknown as { +- _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; +- })._registerLiveChat(chat, created.session, created.activeClient); +- const writeStart = sessionDataService.metadataWrites(session).length; +- const failures: string[] = []; +- +- for (const mismatchedContext of [ +- exactChatContext(otherSession, chat, otherSession), +- exactChatContext(session, chat, chat), +- ]) { +- try { +- await agent.setWorkingDirectory(chat, mismatchedContext, next); +- } catch (error) { +- failures.push(error instanceof Error ? error.message : String(error)); +- } - } +- +- assert.deepStrictEqual({ +- failures, +- liveWorkingDirectory: created.session.workingDirectory?.toString(), +- sdkCalls: mockSession.workingDirectoryCalls, +- pluginDirectory: activeClient.pluginController.directory?.toString(), +- pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), +- metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), +- }, { +- failures: [ +- `Cannot change the working directory: chat '${chat.toString()}' is unknown, not live, or does not match the supplied context`, +- `Cannot change the working directory: chat '${chat.toString()}' is unknown, not live, or does not match the supplied context`, +- ], +- liveWorkingDirectory: previous.toString(), +- sdkCalls: [], +- pluginDirectory: previous.toString(), +- pluginAdditionalDirectories: [], +- metadataWrites: [], +- }); - }); - - test('rejects an exact live peer chat without mutation', async () => { @@ -60670,37 +62834,36 @@ index f24a97a6296..00000000000 - }; - }; - const peerSdkCalls: string[] = []; -- try { -- setPeerChatStub(agent, peerChat, { -- workingDirectory: previous, -- appliedAdditionalDirectories: [], -- setWorkingDirectory: async (directory: URI) => { peerSdkCalls.push(directory.toString()); }, -- }, 'peer-sdk-session'); -- -- let failure: string | undefined; -- try { -- await agent.setWorkingDirectory(peerChat, exactChatContext(session, peerChat, peerChat), next); -- } catch (error) { -- failure = error instanceof Error ? error.message : String(error); -- } -- -- assert.deepStrictEqual({ -- failure, -- peerSdkCalls, -- pluginDirectory: activeClient.pluginController.directory?.toString(), -- pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), -- openedSessions: sessionDataService.openedSessions, -- }, { -- failure: `Cannot change the working directory for peer chat '${peerChat.toString()}': live working-directory changes are only supported for the owning default chat`, -- peerSdkCalls: [], -- pluginDirectory: previous.toString(), -- pluginAdditionalDirectories: [], -- openedSessions: [], -- }); -- } finally { +- afterTest(async () => { - await fs.rm(root, { recursive: true, force: true }); - await disposeAgent(agent); +- }); +- setPeerChatStub(agent, peerChat, { +- workingDirectory: previous, +- appliedAdditionalDirectories: [], +- setWorkingDirectory: async (directory: URI) => { peerSdkCalls.push(directory.toString()); }, +- }, 'peer-sdk-session'); +- +- let failure: string | undefined; +- try { +- await agent.setWorkingDirectory(peerChat, exactChatContext(session, peerChat, peerChat), next); +- } catch (error) { +- failure = error instanceof Error ? error.message : String(error); - } +- +- assert.deepStrictEqual({ +- failure, +- peerSdkCalls, +- pluginDirectory: activeClient.pluginController.directory?.toString(), +- pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), +- openedSessions: sessionDataService.openedSessions, +- }, { +- failure: `Cannot change the working directory for peer chat '${peerChat.toString()}': live working-directory changes are only supported for the owning default chat`, +- peerSdkCalls: [], +- pluginDirectory: previous.toString(), +- pluginAdditionalDirectories: [], +- openedSessions: [], +- }); - }); - - test('rejects a default chat while another live chat shares its configuration', async () => { @@ -60726,47 +62889,46 @@ index f24a97a6296..00000000000 - readonly additionalDirectories: readonly URI[]; - }; - }; -- try { -- await created.session.initializeSession(); -- (agent as unknown as { -- _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; -- })._registerLiveChat(chat, created.session, created.activeClient); -- setLiveChatStub(agent, 'peer-sdk-session', { -- sessionId: 'peer-sdk-session', -- ownerSessionUri: session, -- sessionUri: peerChat, -- resourceUri: peerChat, -- chatChannelUri: peerChat, -- dispose: () => { }, -- }, peerChat); -- const writeStart = sessionDataService.metadataWrites(session).length; -- -- let failure: string | undefined; -- try { -- await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); -- } catch (error) { -- failure = error instanceof Error ? error.message : String(error); -- } -- -- assert.deepStrictEqual({ -- failure, -- sdkCalls: mockSession.workingDirectoryCalls, -- liveWorkingDirectory: created.session.workingDirectory?.toString(), -- pluginDirectory: activeClient.pluginController.directory?.toString(), -- pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), -- metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), -- }, { -- failure: `Cannot change the working directory for chat '${chat.toString()}' while another live chat shares its configuration`, -- sdkCalls: [], -- liveWorkingDirectory: previous.toString(), -- pluginDirectory: previous.toString(), -- pluginAdditionalDirectories: [], -- metadataWrites: [], -- }); -- } finally { +- afterTest(async () => { - await fs.rm(root, { recursive: true, force: true }); - await disposeAgent(agent); +- }); +- await created.session.initializeSession(); +- (agent as unknown as { +- _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; +- })._registerLiveChat(chat, created.session, created.activeClient); +- setLiveChatStub(agent, 'peer-sdk-session', { +- sessionId: 'peer-sdk-session', +- ownerSessionUri: session, +- sessionUri: peerChat, +- resourceUri: peerChat, +- chatChannelUri: peerChat, +- dispose: () => { }, +- }, peerChat); +- const writeStart = sessionDataService.metadataWrites(session).length; +- +- let failure: string | undefined; +- try { +- await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); +- } catch (error) { +- failure = error instanceof Error ? error.message : String(error); - } +- +- assert.deepStrictEqual({ +- failure, +- sdkCalls: mockSession.workingDirectoryCalls, +- liveWorkingDirectory: created.session.workingDirectory?.toString(), +- pluginDirectory: activeClient.pluginController.directory?.toString(), +- pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), +- metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), +- }, { +- failure: `Cannot change the working directory for chat '${chat.toString()}' while another live chat shares its configuration`, +- sdkCalls: [], +- liveWorkingDirectory: previous.toString(), +- pluginDirectory: previous.toString(), +- pluginAdditionalDirectories: [], +- metadataWrites: [], +- }); - }); - - test('rejects shared customization additional roots absent from session and metadata state', async () => { @@ -60793,40 +62955,39 @@ index f24a97a6296..00000000000 - setAdditionalDirectories(directories: readonly URI[]): void; - }; - }; -- try { -- await created.session.initializeSession(); -- (agent as unknown as { -- _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; -- })._registerLiveChat(chat, created.session, created.activeClient); -- activeClient.pluginController.setAdditionalDirectories([secondary]); -- const writeStart = sessionDataService.metadataWrites(session).length; -- -- let failure: string | undefined; -- try { -- await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); -- } catch (error) { -- failure = error instanceof Error ? error.message : String(error); -- } -- -- assert.deepStrictEqual({ -- failure, -- sdkCalls: mockSession.workingDirectoryCalls, -- liveWorkingDirectory: created.session.workingDirectory?.toString(), -- pluginDirectory: activeClient.pluginController.directory?.toString(), -- pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), -- metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), -- }, { -- failure: `Cannot change the working directory for multi-root chat '${chat.toString()}'`, -- sdkCalls: [], -- liveWorkingDirectory: previous.toString(), -- pluginDirectory: previous.toString(), -- pluginAdditionalDirectories: [secondary.toString()], -- metadataWrites: [], -- }); -- } finally { +- afterTest(async () => { - await fs.rm(root, { recursive: true, force: true }); - await disposeAgent(agent); +- }); +- await created.session.initializeSession(); +- (agent as unknown as { +- _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; +- })._registerLiveChat(chat, created.session, created.activeClient); +- activeClient.pluginController.setAdditionalDirectories([secondary]); +- const writeStart = sessionDataService.metadataWrites(session).length; +- +- let failure: string | undefined; +- try { +- await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); +- } catch (error) { +- failure = error instanceof Error ? error.message : String(error); - } +- +- assert.deepStrictEqual({ +- failure, +- sdkCalls: mockSession.workingDirectoryCalls, +- liveWorkingDirectory: created.session.workingDirectory?.toString(), +- pluginDirectory: activeClient.pluginController.directory?.toString(), +- pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), +- metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), +- }, { +- failure: `Cannot change the working directory for multi-root chat '${chat.toString()}'`, +- sdkCalls: [], +- liveWorkingDirectory: previous.toString(), +- pluginDirectory: previous.toString(), +- pluginAdditionalDirectories: [secondary.toString()], +- metadataWrites: [], +- }); - }); - - test('rejects peer creation before recording while a configuration working-directory change is prepared', async () => { @@ -61130,72 +63291,71 @@ index f24a97a6296..00000000000 - readonly additionalDirectories: readonly URI[]; - }; - }; -- try { -- await created.session.initializeSession(); -- (agent as unknown as { -- _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; -- })._registerLiveChat(chat, created.session, created.activeClient); -- const writeStart = sessionDataService.metadataWrites(session).length; -- sessionDataService.failNextMetadataWrite(session, 'copilot.workingDirectories', new Error('metadata write failed')); -- -- let failure: string | undefined; -- try { -- await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); -- } catch (error) { -- failure = error instanceof Error ? error.message : String(error); -- } -- -- const stored = sessionDataService.openDatabase(session); -- const metadata = await stored.object.getMetadataObject({ -- 'copilot.workingDirectory': true, -- 'copilot.workingDirectories': true, -- 'copilot.customizationDirectory': true, -- 'agentHost.workspaceless': true, -- }); -- stored.dispose(); -- assert.deepStrictEqual({ -- failure, -- liveWorkingDirectory: created.session.workingDirectory?.toString(), -- sdkCalls: mockSession.workingDirectoryCalls, -- pluginDirectory: activeClient.pluginController.directory?.toString(), -- pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), -- metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), -- metadata, -- centralCustomizationDirectory: await centralCustomizationDirectory(agent, session), -- }, { -- failure: 'provider metadata: metadata write failed', -- liveWorkingDirectory: previous.toString(), -- sdkCalls: [], -- pluginDirectory: previous.toString(), -- pluginAdditionalDirectories: [], -- metadataWrites: [ -- { key: 'copilot.customizationDirectory', value: next.toString() }, -- { key: 'copilot.sharedCustomizationDirectories', value: '' }, -- { key: 'copilot.project.uri', value: '' }, -- { key: 'copilot.project.displayName', value: '' }, -- { key: 'copilot.project.resolved', value: 'true' }, -- { key: 'copilot.workingDirectory', value: next.toString() }, -- { key: 'copilot.workingDirectories', value: JSON.stringify([next.toString()]) }, -- { key: 'copilot.customizationDirectory', value: previous.toString() }, -- { key: 'copilot.sharedCustomizationDirectories', value: '' }, -- { key: 'copilot.project.uri', value: '' }, -- { key: 'copilot.project.displayName', value: '' }, -- { key: 'copilot.project.resolved', value: '' }, -- { key: 'copilot.workingDirectory', value: previous.toString() }, -- { key: 'copilot.workingDirectories', value: JSON.stringify([previous.toString()]) }, -- ], -- metadata: { -- 'copilot.workingDirectory': previous.toString(), -- 'copilot.workingDirectories': JSON.stringify([previous.toString()]), -- 'copilot.customizationDirectory': previous.toString(), -- 'agentHost.workspaceless': 'true', -- }, -- centralCustomizationDirectory: previous.toString(), -- }); -- } finally { +- afterTest(async () => { - await fs.rm(root, { recursive: true, force: true }); - await disposeAgent(agent); +- }); +- await created.session.initializeSession(); +- (agent as unknown as { +- _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; +- })._registerLiveChat(chat, created.session, created.activeClient); +- const writeStart = sessionDataService.metadataWrites(session).length; +- sessionDataService.failNextMetadataWrite(session, 'copilot.workingDirectories', new Error('metadata write failed')); +- +- let failure: string | undefined; +- try { +- await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); +- } catch (error) { +- failure = error instanceof Error ? error.message : String(error); - } +- +- const stored = sessionDataService.openDatabase(session); +- const metadata = await stored.object.getMetadataObject({ +- 'copilot.workingDirectory': true, +- 'copilot.workingDirectories': true, +- 'copilot.customizationDirectory': true, +- 'agentHost.workspaceless': true, +- }); +- stored.dispose(); +- assert.deepStrictEqual({ +- failure, +- liveWorkingDirectory: created.session.workingDirectory?.toString(), +- sdkCalls: mockSession.workingDirectoryCalls, +- pluginDirectory: activeClient.pluginController.directory?.toString(), +- pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), +- metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), +- metadata, +- centralCustomizationDirectory: await centralCustomizationDirectory(agent, session), +- }, { +- failure: 'provider metadata: metadata write failed', +- liveWorkingDirectory: previous.toString(), +- sdkCalls: [], +- pluginDirectory: previous.toString(), +- pluginAdditionalDirectories: [], +- metadataWrites: [ +- { key: 'copilot.customizationDirectory', value: next.toString() }, +- { key: 'copilot.sharedCustomizationDirectories', value: '' }, +- { key: 'copilot.project.uri', value: '' }, +- { key: 'copilot.project.displayName', value: '' }, +- { key: 'copilot.project.resolved', value: 'true' }, +- { key: 'copilot.workingDirectory', value: next.toString() }, +- { key: 'copilot.workingDirectories', value: JSON.stringify([next.toString()]) }, +- { key: 'copilot.customizationDirectory', value: previous.toString() }, +- { key: 'copilot.sharedCustomizationDirectories', value: '' }, +- { key: 'copilot.project.uri', value: '' }, +- { key: 'copilot.project.displayName', value: '' }, +- { key: 'copilot.project.resolved', value: '' }, +- { key: 'copilot.workingDirectory', value: previous.toString() }, +- { key: 'copilot.workingDirectories', value: JSON.stringify([previous.toString()]) }, +- ], +- metadata: { +- 'copilot.workingDirectory': previous.toString(), +- 'copilot.workingDirectories': JSON.stringify([previous.toString()]), +- 'copilot.customizationDirectory': previous.toString(), +- 'agentHost.workspaceless': 'true', +- }, +- centralCustomizationDirectory: previous.toString(), +- }); - }); - - test('restores customization and provider metadata state when the SDK rejects the target', async () => { @@ -61224,73 +63384,72 @@ index f24a97a6296..00000000000 - readonly additionalDirectories: readonly URI[]; - }; - }; -- try { -- await created.session.initializeSession(); -- (agent as unknown as { -- _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; -- })._registerLiveChat(chat, created.session, created.activeClient); -- const writeStart = sessionDataService.metadataWrites(session).length; -- -- let failure: string | undefined; -- try { -- await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); -- } catch (error) { -- failure = error instanceof Error ? error.message : String(error); -- } -- -- const stored = sessionDataService.openDatabase(session); -- const metadata = await stored.object.getMetadataObject({ -- 'copilot.workingDirectory': true, -- 'copilot.workingDirectories': true, -- 'copilot.customizationDirectory': true, -- 'copilot.project.uri': true, -- 'copilot.project.displayName': true, -- 'copilot.project.resolved': true, -- }); -- stored.dispose(); -- assert.deepStrictEqual({ -- failure, -- liveWorkingDirectory: created.session.workingDirectory?.toString(), -- sdkCalls: mockSession.workingDirectoryCalls, -- pluginDirectory: activeClient.pluginController.directory?.toString(), -- pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), -- metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), -- metadata, -- }, { -- failure: 'SDK rejected target', -- liveWorkingDirectory: previous.toString(), -- sdkCalls: [next.fsPath], -- pluginDirectory: previous.toString(), -- pluginAdditionalDirectories: [], -- metadataWrites: [ -- { key: 'copilot.customizationDirectory', value: next.toString() }, -- { key: 'copilot.sharedCustomizationDirectories', value: '' }, -- { key: 'copilot.project.uri', value: '' }, -- { key: 'copilot.project.displayName', value: '' }, -- { key: 'copilot.project.resolved', value: 'true' }, -- { key: 'copilot.workingDirectory', value: next.toString() }, -- { key: 'copilot.workingDirectories', value: JSON.stringify([next.toString()]) }, -- { key: 'copilot.customizationDirectory', value: previous.toString() }, -- { key: 'copilot.sharedCustomizationDirectories', value: '' }, -- { key: 'copilot.project.uri', value: previous.toString() }, -- { key: 'copilot.project.displayName', value: 'previous' }, -- { key: 'copilot.project.resolved', value: 'true' }, -- { key: 'copilot.workingDirectory', value: previous.toString() }, -- { key: 'copilot.workingDirectories', value: JSON.stringify([previous.toString()]) }, -- ], -- metadata: { -- 'copilot.workingDirectory': previous.toString(), -- 'copilot.workingDirectories': JSON.stringify([previous.toString()]), -- 'copilot.customizationDirectory': previous.toString(), -- 'copilot.project.uri': previous.toString(), -- 'copilot.project.displayName': 'previous', -- 'copilot.project.resolved': 'true', -- }, -- }); -- } finally { +- afterTest(async () => { - await fs.rm(root, { recursive: true, force: true }); - await disposeAgent(agent); +- }); +- await created.session.initializeSession(); +- (agent as unknown as { +- _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; +- })._registerLiveChat(chat, created.session, created.activeClient); +- const writeStart = sessionDataService.metadataWrites(session).length; +- +- let failure: string | undefined; +- try { +- await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); +- } catch (error) { +- failure = error instanceof Error ? error.message : String(error); - } +- +- const stored = sessionDataService.openDatabase(session); +- const metadata = await stored.object.getMetadataObject({ +- 'copilot.workingDirectory': true, +- 'copilot.workingDirectories': true, +- 'copilot.customizationDirectory': true, +- 'copilot.project.uri': true, +- 'copilot.project.displayName': true, +- 'copilot.project.resolved': true, +- }); +- stored.dispose(); +- assert.deepStrictEqual({ +- failure, +- liveWorkingDirectory: created.session.workingDirectory?.toString(), +- sdkCalls: mockSession.workingDirectoryCalls, +- pluginDirectory: activeClient.pluginController.directory?.toString(), +- pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), +- metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), +- metadata, +- }, { +- failure: 'SDK rejected target', +- liveWorkingDirectory: previous.toString(), +- sdkCalls: [next.fsPath], +- pluginDirectory: previous.toString(), +- pluginAdditionalDirectories: [], +- metadataWrites: [ +- { key: 'copilot.customizationDirectory', value: next.toString() }, +- { key: 'copilot.sharedCustomizationDirectories', value: '' }, +- { key: 'copilot.project.uri', value: '' }, +- { key: 'copilot.project.displayName', value: '' }, +- { key: 'copilot.project.resolved', value: 'true' }, +- { key: 'copilot.workingDirectory', value: next.toString() }, +- { key: 'copilot.workingDirectories', value: JSON.stringify([next.toString()]) }, +- { key: 'copilot.customizationDirectory', value: previous.toString() }, +- { key: 'copilot.sharedCustomizationDirectories', value: '' }, +- { key: 'copilot.project.uri', value: previous.toString() }, +- { key: 'copilot.project.displayName', value: 'previous' }, +- { key: 'copilot.project.resolved', value: 'true' }, +- { key: 'copilot.workingDirectory', value: previous.toString() }, +- { key: 'copilot.workingDirectories', value: JSON.stringify([previous.toString()]) }, +- ], +- metadata: { +- 'copilot.workingDirectory': previous.toString(), +- 'copilot.workingDirectories': JSON.stringify([previous.toString()]), +- 'copilot.customizationDirectory': previous.toString(), +- 'copilot.project.uri': previous.toString(), +- 'copilot.project.displayName': 'previous', +- 'copilot.project.resolved': 'true', +- }, +- }); - }); - - test('adopts an SDK-returned working directory mismatch and reconciles provider state', async () => { @@ -61320,73 +63479,72 @@ index f24a97a6296..00000000000 - readonly additionalDirectories: readonly URI[]; - }; - }; -- try { -- await created.session.initializeSession(); -- (agent as unknown as { -- _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; -- })._registerLiveChat(chat, created.session, created.activeClient); -- const writeStart = sessionDataService.metadataWrites(session).length; -- -- let failure: string | undefined; -- try { -- await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); -- } catch (error) { -- failure = error instanceof Error ? error.message : String(error); -- } -- -- const stored = sessionDataService.openDatabase(session); -- const metadata = await stored.object.getMetadataObject({ -- 'copilot.workingDirectory': true, -- 'copilot.workingDirectories': true, -- 'copilot.customizationDirectory': true, -- 'copilot.project.uri': true, -- 'copilot.project.displayName': true, -- 'copilot.project.resolved': true, -- }); -- stored.dispose(); -- assert.deepStrictEqual({ -- failure, -- liveWorkingDirectory: created.session.workingDirectory?.toString(), -- sdkCalls: mockSession.workingDirectoryCalls, -- pluginDirectory: activeClient.pluginController.directory?.toString(), -- pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), -- metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), -- metadata, -- }, { -- failure: `The SDK returned working directory '${actual.fsPath}' instead of '${next.fsPath}'`, -- liveWorkingDirectory: actual.toString(), -- sdkCalls: [next.fsPath], -- pluginDirectory: actual.toString(), -- pluginAdditionalDirectories: [], -- metadataWrites: [ -- { key: 'copilot.customizationDirectory', value: next.toString() }, -- { key: 'copilot.sharedCustomizationDirectories', value: '' }, -- { key: 'copilot.project.uri', value: '' }, -- { key: 'copilot.project.displayName', value: '' }, -- { key: 'copilot.project.resolved', value: 'true' }, -- { key: 'copilot.workingDirectory', value: next.toString() }, -- { key: 'copilot.workingDirectories', value: JSON.stringify([next.toString()]) }, -- { key: 'copilot.customizationDirectory', value: actual.toString() }, -- { key: 'copilot.sharedCustomizationDirectories', value: '' }, -- { key: 'copilot.project.uri', value: '' }, -- { key: 'copilot.project.displayName', value: '' }, -- { key: 'copilot.project.resolved', value: 'true' }, -- { key: 'copilot.workingDirectory', value: actual.toString() }, -- { key: 'copilot.workingDirectories', value: JSON.stringify([actual.toString()]) }, -- ], -- metadata: { -- 'copilot.workingDirectory': actual.toString(), -- 'copilot.workingDirectories': JSON.stringify([actual.toString()]), -- 'copilot.customizationDirectory': actual.toString(), -- 'copilot.project.uri': '', -- 'copilot.project.displayName': '', -- 'copilot.project.resolved': 'true', -- }, -- }); -- } finally { +- afterTest(async () => { - await fs.rm(root, { recursive: true, force: true }); - await disposeAgent(agent); +- }); +- await created.session.initializeSession(); +- (agent as unknown as { +- _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; +- })._registerLiveChat(chat, created.session, created.activeClient); +- const writeStart = sessionDataService.metadataWrites(session).length; +- +- let failure: string | undefined; +- try { +- await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); +- } catch (error) { +- failure = error instanceof Error ? error.message : String(error); - } +- +- const stored = sessionDataService.openDatabase(session); +- const metadata = await stored.object.getMetadataObject({ +- 'copilot.workingDirectory': true, +- 'copilot.workingDirectories': true, +- 'copilot.customizationDirectory': true, +- 'copilot.project.uri': true, +- 'copilot.project.displayName': true, +- 'copilot.project.resolved': true, +- }); +- stored.dispose(); +- assert.deepStrictEqual({ +- failure, +- liveWorkingDirectory: created.session.workingDirectory?.toString(), +- sdkCalls: mockSession.workingDirectoryCalls, +- pluginDirectory: activeClient.pluginController.directory?.toString(), +- pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), +- metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), +- metadata, +- }, { +- failure: `The SDK returned working directory '${actual.fsPath}' instead of '${next.fsPath}'`, +- liveWorkingDirectory: actual.toString(), +- sdkCalls: [next.fsPath], +- pluginDirectory: actual.toString(), +- pluginAdditionalDirectories: [], +- metadataWrites: [ +- { key: 'copilot.customizationDirectory', value: next.toString() }, +- { key: 'copilot.sharedCustomizationDirectories', value: '' }, +- { key: 'copilot.project.uri', value: '' }, +- { key: 'copilot.project.displayName', value: '' }, +- { key: 'copilot.project.resolved', value: 'true' }, +- { key: 'copilot.workingDirectory', value: next.toString() }, +- { key: 'copilot.workingDirectories', value: JSON.stringify([next.toString()]) }, +- { key: 'copilot.customizationDirectory', value: actual.toString() }, +- { key: 'copilot.sharedCustomizationDirectories', value: '' }, +- { key: 'copilot.project.uri', value: '' }, +- { key: 'copilot.project.displayName', value: '' }, +- { key: 'copilot.project.resolved', value: 'true' }, +- { key: 'copilot.workingDirectory', value: actual.toString() }, +- { key: 'copilot.workingDirectories', value: JSON.stringify([actual.toString()]) }, +- ], +- metadata: { +- 'copilot.workingDirectory': actual.toString(), +- 'copilot.workingDirectories': JSON.stringify([actual.toString()]), +- 'copilot.customizationDirectory': actual.toString(), +- 'copilot.project.uri': '', +- 'copilot.project.displayName': '', +- 'copilot.project.resolved': 'true', +- }, +- }); - }); - }); - @@ -61394,12 +63552,9 @@ index f24a97a6296..00000000000 - test('lists SDK session metadata before Copilot authentication', async () => { - const client = new TestCopilotClient([sdkSession('signed-out-listing')]); - const { agent } = createTestAgentContext(disposables, { copilotClient: client }); -- try { -- disposables.add(await agent.prewarmSessionMetadata(100)); -- assert.strictEqual(client.listSessionCallCount, 1); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- disposables.add(await agent.prewarmSessionMetadata(100)); +- assert.strictEqual(client.listSessionCallCount, 1); - }); - - test('reports actual startup scans without bypassing the bulk threshold or metadata cache', async () => { @@ -61407,26 +63562,23 @@ index f24a97a6296..00000000000 - const startupPerformance = disposables.add(new AgentHostStartupPerformance(AgentHostLaunchKind.Unknown, undefined, telemetry, new NullLogService())); - const client = new TestCopilotClient(Array.from({ length: 101 }, (_, i) => sdkSession(`startup-${i}`))); - const { agent } = createTestAgentContext(disposables, { copilotClient: client, startupPerformance }); -- try { -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- disposables.add(await agent.prewarmSessionMetadata(99)).dispose(); -- const beforeBulk = { sdkCalls: client.listSessionCallCount, markers: telemetry.events.length }; -- disposables.add(await agent.prewarmSessionMetadata(100)).dispose(); -- disposables.add(await agent.prewarmSessionMetadata(100)).dispose(); -- assert.deepStrictEqual({ -- beforeBulk, -- sdkCalls: client.listSessionCallCount, -- contexts: telemetry.events.filter(event => event.data?.name === 'providerContext').map(({ data }) => [data?.provider, data?.activationState, data?.sdkAvailability]), -- timings: telemetry.events.filter(event => event.data?.outcome).map(({ data }) => [data?.name, data?.provider, data?.outcome, data?.scannedSessionCount]), -- }, { -- beforeBulk: { sdkCalls: 0, markers: 0 }, -- sdkCalls: 1, -- contexts: [['copilotcli', 'notRequired', 'available']], -- timings: [['sessionMetadataScan', 'copilotcli', 'success', 101]], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); +- disposables.add(await agent.prewarmSessionMetadata(99)).dispose(); +- const beforeBulk = { sdkCalls: client.listSessionCallCount, markers: telemetry.events.length }; +- disposables.add(await agent.prewarmSessionMetadata(100)).dispose(); +- disposables.add(await agent.prewarmSessionMetadata(100)).dispose(); +- assert.deepStrictEqual({ +- beforeBulk, +- sdkCalls: client.listSessionCallCount, +- contexts: telemetry.events.filter(event => event.data?.name === 'providerContext').map(({ data }) => [data?.provider, data?.activationState, data?.sdkAvailability]), +- timings: telemetry.events.filter(event => event.data?.outcome).map(({ data }) => [data?.name, data?.provider, data?.outcome, data?.scannedSessionCount]), +- }, { +- beforeBulk: { sdkCalls: 0, markers: 0 }, +- sdkCalls: 1, +- contexts: [['copilotcli', 'notRequired', 'available']], +- timings: [['sessionMetadataScan', 'copilotcli', 'success', 101]], +- }); - }); - - class MetadataClient extends TestCopilotClient { @@ -61455,28 +63607,25 @@ index f24a97a6296..00000000000 - test('repeated small fallback sets never scan the unrelated SDK catalog', async () => { - const client = new TestCopilotClient(Array.from({ length: 4858 }, (_, i) => sdkSession(`session-${i}`))); - const agent = createTestAgent(disposables, { copilotClient: client }); -- try { -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- const returned: (string | undefined)[] = []; -- for (let pass = 0; pass < 2; pass++) { -- const warm = disposables.add(await agent.prewarmSessionMetadata(47)); -- for (let i = 0; i < 47; i++) { -- returned.push((await readMetadata(agent, `session-${i}`))?.chat.toString()); -- } -- warm.dispose(); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); +- const returned: (string | undefined)[] = []; +- for (let pass = 0; pass < 2; pass++) { +- const warm = disposables.add(await agent.prewarmSessionMetadata(47)); +- for (let i = 0; i < 47; i++) { +- returned.push((await readMetadata(agent, `session-${i}`))?.chat.toString()); - } -- assert.deepStrictEqual({ -- listCalls: client.listSessionCallCount, -- metadataCalls: client.getSessionMetadataCalls, -- returned, -- }, { -- listCalls: 0, -- metadataCalls: Array.from({ length: 94 }, (_, i) => `session-${i % 47}`), -- returned: Array.from({ length: 94 }, (_, i) => defaultChatUri(AgentSession.uri('copilotcli', `session-${i % 47}`)).toString()), -- }); -- } finally { -- await disposeAgent(agent); +- warm.dispose(); - } +- assert.deepStrictEqual({ +- listCalls: client.listSessionCallCount, +- metadataCalls: client.getSessionMetadataCalls, +- returned, +- }, { +- listCalls: 0, +- metadataCalls: Array.from({ length: 94 }, (_, i) => `session-${i % 47}`), +- returned: Array.from({ length: 94 }, (_, i) => defaultChatUri(AgentSession.uri('copilotcli', `session-${i % 47}`)).toString()), +- }); - }); - - test('serves getChatMetadata from one bulk list, falls back on miss, and reverts after disposal', async () => { @@ -61485,32 +63634,29 @@ index f24a97a6296..00000000000 - const sessionMissing = AgentSession.uri('copilotcli', 'prewarm-missing'); - const client = new TestCopilotClient([sdkSession('prewarm-a'), sdkSession('prewarm-b')]); - const agent = createTestAgent(disposables, { copilotClient: client }); -- try { -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- const read = (session: URI) => agent.getChatMetadata(defaultChatUri(session), exactChatContext(session, defaultChatUri(session), session)); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); +- const read = (session: URI) => agent.getChatMetadata(defaultChatUri(session), exactChatContext(session, defaultChatUri(session), session)); - -- const warm = disposables.add(await agent.prewarmSessionMetadata(100)); -- // One bulk list warmed the cache; a hit is served without a per-session RPC. -- await read(sessionA); -- const afterHit = { listCalls: client.listSessionCallCount, rpcCalls: [...client.getSessionMetadataCalls] }; +- const warm = disposables.add(await agent.prewarmSessionMetadata(100)); +- // One bulk list warmed the cache; a hit is served without a per-session RPC. +- await read(sessionA); +- const afterHit = { listCalls: client.listSessionCallCount, rpcCalls: [...client.getSessionMetadataCalls] }; - -- // A session absent from the bulk list falls back to a per-session RPC. -- await read(sessionMissing); -- const afterMiss = [...client.getSessionMetadataCalls]; +- // A session absent from the bulk list falls back to a per-session RPC. +- await read(sessionMissing); +- const afterMiss = [...client.getSessionMetadataCalls]; - -- // Outside a listing lease, normal per-session reads remain fresh. -- warm.dispose(); -- await read(sessionB); -- const afterDisposal = [...client.getSessionMetadataCalls]; +- // Outside a listing lease, normal per-session reads remain fresh. +- warm.dispose(); +- await read(sessionB); +- const afterDisposal = [...client.getSessionMetadataCalls]; - -- assert.deepStrictEqual({ afterHit, afterMiss, afterDisposal }, { -- afterHit: { listCalls: 1, rpcCalls: [] }, -- afterMiss: ['prewarm-missing'], -- afterDisposal: ['prewarm-missing', 'prewarm-b'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ afterHit, afterMiss, afterDisposal }, { +- afterHit: { listCalls: 1, rpcCalls: [] }, +- afterMiss: ['prewarm-missing'], +- afterDisposal: ['prewarm-missing', 'prewarm-b'], +- }); - }); - - test('coalesces concurrent scans and reuses the snapshot for trailing small listings', async () => { @@ -61616,83 +63762,71 @@ index f24a97a6296..00000000000 - const client = new MetadataClient([sdkSession('retry')]); - client.listError = new Error('metadata scan failed'); - const agent = createTestAgent(disposables, { copilotClient: client }); -- try { -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- await Promise.all([ -- assert.rejects(agent.prewarmSessionMetadata(100), /metadata scan failed/), -- assert.rejects(agent.prewarmSessionMetadata(100), /metadata scan failed/), -- ]); -- client.listError = undefined; -- const lease = disposables.add(await agent.prewarmSessionMetadata(100)); -- await readMetadata(agent, 'retry'); -- lease.dispose(); -- assert.deepStrictEqual({ -- listCalls: client.listSessionCallCount, -- metadataCalls: client.getSessionMetadataCalls, -- }, { listCalls: 2, metadataCalls: [] }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); +- await Promise.all([ +- assert.rejects(agent.prewarmSessionMetadata(100), /metadata scan failed/), +- assert.rejects(agent.prewarmSessionMetadata(100), /metadata scan failed/), +- ]); +- client.listError = undefined; +- const lease = disposables.add(await agent.prewarmSessionMetadata(100)); +- await readMetadata(agent, 'retry'); +- lease.dispose(); +- assert.deepStrictEqual({ +- listCalls: client.listSessionCallCount, +- metadataCalls: client.getSessionMetadataCalls, +- }, { listCalls: 2, metadataCalls: [] }); - }); - - test('publishes a successful bulk retry after closed-connection recovery', async () => { - const client = new MetadataClient([sdkSession('recovered')]); - client.listErrors.push(new Error('Connection is closed.')); - const agent = createTestAgent(disposables, { copilotClient: client }); -- try { -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- const first = disposables.add(await agent.prewarmSessionMetadata(100)); -- await readMetadata(agent, 'recovered'); -- first.dispose(); -- const trailing = disposables.add(await agent.prewarmSessionMetadata(100)); -- await readMetadata(agent, 'recovered'); -- trailing.dispose(); -- assert.deepStrictEqual({ -- listCalls: client.listSessionCallCount, -- metadataCalls: client.getSessionMetadataCalls, -- startCalls: client.startCallCount, -- stopCalls: client.stopCallCount, -- }, { listCalls: 2, metadataCalls: [], startCalls: 2, stopCalls: 1 }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); +- const first = disposables.add(await agent.prewarmSessionMetadata(100)); +- await readMetadata(agent, 'recovered'); +- first.dispose(); +- const trailing = disposables.add(await agent.prewarmSessionMetadata(100)); +- await readMetadata(agent, 'recovered'); +- trailing.dispose(); +- assert.deepStrictEqual({ +- listCalls: client.listSessionCallCount, +- metadataCalls: client.getSessionMetadataCalls, +- startCalls: client.startCallCount, +- stopCalls: client.stopCallCount, +- }, { listCalls: 2, metadataCalls: [], startCalls: 2, stopCalls: 1 }); - }); - - test('client shutdown invalidates retained metadata even while an old lease is held', async () => { - const client = new TestCopilotClient([sdkSession('shutdown')]); - const agent = createTestAgent(disposables, { copilotClient: client }); -- try { -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- const lease = disposables.add(await agent.prewarmSessionMetadata(100)); -- await agent.shutdown(); -- const afterShutdown = disposables.add(await agent.prewarmSessionMetadata(100)); -- assert.strictEqual(afterShutdown, Disposable.None); -- lease.dispose(); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); +- const lease = disposables.add(await agent.prewarmSessionMetadata(100)); +- await agent.shutdown(); +- const afterShutdown = disposables.add(await agent.prewarmSessionMetadata(100)); +- assert.strictEqual(afterShutdown, Disposable.None); +- lease.dispose(); - }); - - test('an unavailable client does not cache an empty snapshot or prevent retry', async () => { - const client = new MetadataClient([sdkSession('available')]); - client.listError = new CancellationError(); - const agent = createTestAgent(disposables, { copilotClient: client }); -- try { -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- const unavailable = disposables.add(await agent.prewarmSessionMetadata(100)); -- client.listError = undefined; -- const available = disposables.add(await agent.prewarmSessionMetadata(100)); -- const metadata = await readMetadata(agent, 'available'); -- available.dispose(); -- assert.deepStrictEqual({ -- unavailable: unavailable === Disposable.None, -- listCalls: client.listSessionCallCount, -- metadataCalls: client.getSessionMetadataCalls, -- summary: metadata?.summary, -- }, { unavailable: true, listCalls: 2, metadataCalls: [], summary: 'SDK available' }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); +- const unavailable = disposables.add(await agent.prewarmSessionMetadata(100)); +- client.listError = undefined; +- const available = disposables.add(await agent.prewarmSessionMetadata(100)); +- const metadata = await readMetadata(agent, 'available'); +- available.dispose(); +- assert.deepStrictEqual({ +- unavailable: unavailable === Disposable.None, +- listCalls: client.listSessionCallCount, +- metadataCalls: client.getSessionMetadataCalls, +- summary: metadata?.summary, +- }, { unavailable: true, listCalls: 2, metadataCalls: [], summary: 'SDK available' }); - }); - }); - @@ -61811,25 +63945,22 @@ index f24a97a6296..00000000000 - configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: false }); - } - }; -- try { -- const catalog = await agent.listChatsToMigrate(); -- const startupOutcomes = telemetryService.events -- .filter(event => event.eventName === 'agentHost.copilotClientStartup') -- .map(event => (event.data as Record).outcome); -- assert.deepStrictEqual({ -- sessions: catalog?.map(session => sessionIdOfChat(session.chat)), -- startCallCount: client.startCallCount, -- stopCallCount: client.stopCallCount, -- startupOutcomes, -- }, { -- sessions: ['owned-selfheal'], -- startCallCount: 2, -- stopCallCount: 1, -- startupOutcomes: ['failure', 'success'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const catalog = await agent.listChatsToMigrate(); +- const startupOutcomes = telemetryService.events +- .filter(event => event.eventName === 'agentHost.copilotClientStartup') +- .map(event => (event.data as Record).outcome); +- assert.deepStrictEqual({ +- sessions: catalog?.map(session => sessionIdOfChat(session.chat)), +- startCallCount: client.startCallCount, +- stopCallCount: client.stopCallCount, +- startupOutcomes, +- }, { +- sessions: ['owned-selfheal'], +- startCallCount: 2, +- stopCallCount: 1, +- startupOutcomes: ['failure', 'success'], +- }); - }); - - test('self-heals a clean configuration-changed cold-start abort on the restore describe path', async () => { @@ -61845,29 +63976,26 @@ index f24a97a6296..00000000000 - configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: false }); - } - }; -- try { -- const chat = defaultChatUri(session); -- const metadata = await agent.getChatMetadata(chat, exactChatContext(session, chat, session)); -- assert.deepStrictEqual({ -- metadata: metadata && withoutUndefinedProperties(metadata), -- startCallCount: client.startCallCount, -- stopCallCount: client.stopCallCount, -- getSessionMetadataCalls: client.getSessionMetadataCalls, -- }, { -- metadata: { -- chat, -- startTime: 1000, -- modifiedTime: 2000, -- summary: 'SDK restore-target', -- workingDirectories: [URI.file('/workspace')], -- }, -- startCallCount: 2, -- stopCallCount: 1, -- getSessionMetadataCalls: ['restore-target'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const chat = defaultChatUri(session); +- const metadata = await agent.getChatMetadata(chat, exactChatContext(session, chat, session)); +- assert.deepStrictEqual({ +- metadata: metadata && withoutUndefinedProperties(metadata), +- startCallCount: client.startCallCount, +- stopCallCount: client.stopCallCount, +- getSessionMetadataCalls: client.getSessionMetadataCalls, +- }, { +- metadata: { +- chat, +- startTime: 1000, +- modifiedTime: 2000, +- summary: 'SDK restore-target', +- workingDirectories: [URI.file('/workspace')], +- }, +- startCallCount: 2, +- stopCallCount: 1, +- getSessionMetadataCalls: ['restore-target'], +- }); - }); - - test('gives up after the bounded number of re-acquires when the startup config keeps changing', async () => { @@ -61877,18 +64005,15 @@ index f24a97a6296..00000000000 - client.onAfterStart = () => { - configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: client.startCallCount % 2 === 0 }); - }; -- try { -- const result = await agent.listChatsToMigrate(); -- assert.deepStrictEqual({ -- result, -- startCallCount: client.startCallCount, -- }, { -- result: undefined, -- startCallCount: 2, -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const result = await agent.listChatsToMigrate(); +- assert.deepStrictEqual({ +- result, +- startCallCount: client.startCallCount, +- }, { +- result: undefined, +- startCallCount: 2, +- }); - }); - - test('shares one global re-acquire budget: a late joiner cannot drive an extra start', async () => { @@ -61947,21 +64072,127 @@ index f24a97a6296..00000000000 - } - }; - const ensureClient = () => (agent as unknown as { _ensureClient(): Promise })._ensureClient(); +- afterTest(() => disposeAgent(agent)); +- const first = ensureClient(); +- const second = ensureClient(); +- const [firstClient, secondClient] = await Promise.all([first, second]); +- assert.deepStrictEqual({ +- sameHealthyClient: firstClient === client && secondClient === client, +- startCallCount: client.startCallCount, +- stopCallCount: client.stopCallCount, +- }, { +- sameHealthyClient: true, +- startCallCount: 2, +- stopCallCount: 1, +- }); +- }); +- +- test('waits for the client certificate pull before starting the Copilot runtime', async () => { +- const directory = await fs.mkdtemp(join(os.tmpdir(), 'copilot-client-certificate-pull-')); +- const pending = new DeferredPromise(); +- const requested = new DeferredPromise(); +- const client = new TestCopilotClient([]); +- const proxyResolver = new class extends TestProxyResolver { +- override async loadClientCertificates(): Promise { +- requested.complete(); +- this.clientCertificates = await pending.p; +- return this.clientCertificates; +- } +- }(); +- const { agent, instantiationService } = createTestAgentContext(disposables, { copilotClient: client, proxyResolver }); +- const environment = instantiationService.invokeFunction(accessor => accessor.get(INativeEnvironmentService)); +- const environmentStub = stub(environment, 'userDataPath').value(directory); - try { -- const first = ensureClient(); -- const second = ensureClient(); -- const [firstClient, secondClient] = await Promise.all([first, second]); +- const startup = agent.listChatsToMigrate(); +- await requested.p; +- assert.strictEqual(client.startCallCount, 0); +- pending.complete([rootCertificates[0]]); +- await startup; +- const path = getCreatedClientOptions(agent).at(-1)?.env?.NODE_EXTRA_CA_CERTS; +- assert.ok(path); +- assert.deepStrictEqual({ starts: client.startCallCount, bundle: await fs.readFile(path, 'utf8') }, { +- starts: 1, bundle: new X509Certificate(rootCertificates[0]).toString(), +- }); +- } finally { +- if (!pending.isSettled) { +- pending.complete([]); +- } +- await disposeAgent(agent); +- environmentStub.restore(); +- await fs.rm(directory, { recursive: true, force: true }); +- } +- }); +- +- test('starts the Copilot runtime when the client certificate bundle cannot be written', async () => { +- const directory = await fs.mkdtemp(join(os.tmpdir(), 'copilot-client-certificate-write-')); +- const userDataPath = join(directory, 'user-data'); +- await fs.writeFile(userDataPath, ''); +- const client = new TestCopilotClient([]); +- const proxyResolver = new TestProxyResolver(); +- proxyResolver.clientCertificates = [rootCertificates[0]]; +- const { agent, instantiationService } = createTestAgentContext(disposables, { copilotClient: client, proxyResolver }); +- const environment = instantiationService.invokeFunction(accessor => accessor.get(INativeEnvironmentService)); +- const environmentStub = stub(environment, 'userDataPath').value(userDataPath); +- try { +- await agent.listChatsToMigrate(); - assert.deepStrictEqual({ -- sameHealthyClient: firstClient === client && secondClient === client, -- startCallCount: client.startCallCount, -- stopCallCount: client.stopCallCount, +- starts: client.startCallCount, +- bundle: getCreatedClientOptions(agent).at(-1)?.env?.NODE_EXTRA_CA_CERTS, +- }, { starts: 1, bundle: process.env.NODE_EXTRA_CA_CERTS }); +- } finally { +- await disposeAgent(agent); +- environmentStub.restore(); +- await fs.rm(directory, { recursive: true, force: true }); +- } +- }); +- +- test('refreshes a running Copilot runtime after reconnect pulls changed client certificates', async () => { +- const directory = await fs.mkdtemp(join(os.tmpdir(), 'copilot-client-certificates-')); +- const environmentBefore = { ...process.env }; +- const client = new TestCopilotClient([]); +- const logService = new NullLogService(); +- const proxyConfiguration = disposables.add(new AgentConfigurationService(disposables.add(new AgentHostStateManager(logService)), logService)); +- const certificates = rootCertificates.slice(0, 3).map(certificate => new X509Certificate(certificate).toString()); +- const proxyResolver = disposables.add(new class extends AgentHostProxyResolver { +- protected override async _loadNativeCertificates(): Promise { +- return [certificates[2]]; +- } +- }(proxyConfiguration, logService)); +- disposables.add(proxyResolver.registerCertificateProvider('client', async () => [certificates[0]])); +- const { agent, instantiationService } = createTestAgentContext(disposables, { copilotClient: client, proxyResolver }); +- const environmentService = instantiationService.invokeFunction(accessor => accessor.get(INativeEnvironmentService)); +- const environmentStub = stub(environmentService, 'userDataPath').value(directory); +- try { +- await agent.listChatsToMigrate(); +- const firstPath = getCreatedClientOptions(agent).at(-1)?.env?.NODE_EXTRA_CA_CERTS; +- assert.ok(firstPath); +- const firstContent = await fs.readFile(firstPath, 'utf8'); +- disposables.add(proxyResolver.registerCertificateProvider('client', async () => [certificates[0], certificates[0]])); +- await proxyRefresh(agent); +- assert.strictEqual(client.stopCallCount, 0); +- disposables.add(proxyResolver.registerCertificateProvider('client', async () => [certificates[1]])); +- await client.waitForCalls(() => client.stopCallCount >= 1); +- await proxyRefresh(agent); +- await agent.listChatsToMigrate(); +- const secondPath = getCreatedClientOptions(agent).at(-1)?.env?.NODE_EXTRA_CA_CERTS; +- assert.ok(secondPath); +- assert.deepStrictEqual({ +- firstContent, +- secondContent: await fs.readFile(secondPath, 'utf8'), +- sharedDirectory: [firstPath, secondPath].every(path => path.startsWith(join(directory, 'certificates'))), +- distinctBundles: firstPath !== secondPath, +- environmentUnchanged: JSON.stringify(process.env) === JSON.stringify(environmentBefore), - }, { -- sameHealthyClient: true, -- startCallCount: 2, -- stopCallCount: 1, +- firstContent: [certificates[0], certificates[2]].sort().join('\n'), +- secondContent: [certificates[1], certificates[2]].sort().join('\n'), +- sharedDirectory: true, +- distinctBundles: true, +- environmentUnchanged: true, - }); - } finally { - await disposeAgent(agent); +- environmentStub.restore(); +- await fs.rm(directory, { recursive: true, force: true }); - } - }); - @@ -61972,20 +64203,17 @@ index f24a97a6296..00000000000 - const resolveProxyForSdk = (env: Record) => (agent as unknown as { - _resolveProxyForSdk(env: Record): Promise; - })._resolveProxyForSdk(env); -- try { -- assert.strictEqual(await resolveProxyForSdk({}), proxyResolver.resolvedProxy); +- afterTest(() => disposeAgent(agent)); +- assert.strictEqual(await resolveProxyForSdk({}), proxyResolver.resolvedProxy); - -- configurationService.updateRootConfig({ [AgentHostSystemProxyEnabledConfigKey]: false }); -- assert.deepStrictEqual({ -- proxy: await resolveProxyForSdk({}), -- resolveProxyCalls: proxyResolver.resolveProxyCalls, -- }, { -- proxy: undefined, -- resolveProxyCalls: 1, -- }); -- } finally { -- await disposeAgent(agent); -- } +- configurationService.updateRootConfig({ [AgentHostSystemProxyEnabledConfigKey]: false }); +- assert.deepStrictEqual({ +- proxy: await resolveProxyForSdk({}), +- resolveProxyCalls: proxyResolver.resolveProxyCalls, +- }, { +- proxy: undefined, +- resolveProxyCalls: 1, +- }); - }); - - test('preserves proxy environment variables without a configured proxy', async () => { @@ -62004,21 +64232,18 @@ index f24a97a6296..00000000000 - all_proxy: 'http://lowercase-all.example:8080', - }; - const expectedEnv = { ...env }; -- try { -- proxyState._resolvedProxy = await proxyState._resolveProxyForSdk(env); +- afterTest(() => disposeAgent(agent)); +- proxyState._resolvedProxy = await proxyState._resolveProxyForSdk(env); - -- assert.deepStrictEqual({ -- env, -- resolvedProxy: proxyState._resolvedProxy, -- resolveProxyCalls: proxyResolver.resolveProxyCalls, -- }, { -- env: expectedEnv, -- resolvedProxy: undefined, -- resolveProxyCalls: 0, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- env, +- resolvedProxy: proxyState._resolvedProxy, +- resolveProxyCalls: proxyResolver.resolveProxyCalls, +- }, { +- env: expectedEnv, +- resolvedProxy: undefined, +- resolveProxyCalls: 0, +- }); - }); - - test('prefers the configured proxy over proxy environment variables', async () => { @@ -62045,43 +64270,40 @@ index f24a97a6296..00000000000 - all_proxy: 'http://lowercase-all.example:8080', - }; - const expectedEnv = { ...env }; -- try { -- const resolvedProxy = await proxyState._resolveProxyForSdk(env); -- await agent.listChatsToMigrate(); -- const createdEnv = getCreatedClientOptions(agent).at(-1)?.env; +- afterTest(() => disposeAgent(agent)); +- const resolvedProxy = await proxyState._resolveProxyForSdk(env); +- await agent.listChatsToMigrate(); +- const createdEnv = getCreatedClientOptions(agent).at(-1)?.env; - -- assert.deepStrictEqual({ -- resolvedProxy, -- env, -- createdProxyEnv: { -- HTTP_PROXY: createdEnv?.['HTTP_PROXY'], -- HTTPS_PROXY: createdEnv?.['HTTPS_PROXY'], -- http_proxy: createdEnv?.['http_proxy'], -- https_proxy: createdEnv?.['https_proxy'], -- ALL_PROXY: createdEnv?.['ALL_PROXY'], -- all_proxy: createdEnv?.['all_proxy'], -- NO_PROXY: createdEnv?.['NO_PROXY'], -- no_proxy: createdEnv?.['no_proxy'], -- }, -- resolveProxyCalls: proxyResolver.resolveProxyCalls, -- }, { -- resolvedProxy: configuredProxy, -- env: expectedEnv, -- createdProxyEnv: { -- HTTP_PROXY: configuredProxy, -- HTTPS_PROXY: configuredProxy, -- http_proxy: undefined, -- https_proxy: undefined, -- ALL_PROXY: undefined, -- all_proxy: undefined, -- NO_PROXY: 'localhost,127.0.0.1,::1,::ffff:127.0.0.1', -- no_proxy: undefined, -- }, -- resolveProxyCalls: 0, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- resolvedProxy, +- env, +- createdProxyEnv: { +- HTTP_PROXY: createdEnv?.['HTTP_PROXY'], +- HTTPS_PROXY: createdEnv?.['HTTPS_PROXY'], +- http_proxy: createdEnv?.['http_proxy'], +- https_proxy: createdEnv?.['https_proxy'], +- ALL_PROXY: createdEnv?.['ALL_PROXY'], +- all_proxy: createdEnv?.['all_proxy'], +- NO_PROXY: createdEnv?.['NO_PROXY'], +- no_proxy: createdEnv?.['no_proxy'], +- }, +- resolveProxyCalls: proxyResolver.resolveProxyCalls, +- }, { +- resolvedProxy: configuredProxy, +- env: expectedEnv, +- createdProxyEnv: { +- HTTP_PROXY: configuredProxy, +- HTTPS_PROXY: configuredProxy, +- http_proxy: undefined, +- https_proxy: undefined, +- ALL_PROXY: undefined, +- all_proxy: undefined, +- NO_PROXY: 'localhost,127.0.0.1,::1,::ffff:127.0.0.1', +- no_proxy: undefined, +- }, +- resolveProxyCalls: 0, +- }); - }); - - for (const noProxyKey of ['NO_PROXY', 'no_proxy']) { @@ -62092,26 +64314,23 @@ index f24a97a6296..00000000000 - copilotClient: new TestCopilotClient([]), - rootConfig: { [AgentHostProxyConfigKey.Proxy]: proxy }, - }); -- try { -- await agent.listChatsToMigrate(); -- const env = getCreatedClientOptions(agent).at(-1)?.env; +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- const env = getCreatedClientOptions(agent).at(-1)?.env; - -- assert.deepStrictEqual({ -- httpProxy: env?.['HTTP_PROXY'], -- httpsProxy: env?.['HTTPS_PROXY'], -- noProxy: env?.['NO_PROXY'], -- lowercaseNoProxy: env?.['no_proxy'], -- inheritedNoProxy: process.env[noProxyKey], -- }, { -- httpProxy: proxy, -- httpsProxy: proxy, -- noProxy: 'example.com, localhost', -- lowercaseNoProxy: undefined, -- inheritedNoProxy: 'example.com, localhost', -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- httpProxy: env?.['HTTP_PROXY'], +- httpsProxy: env?.['HTTPS_PROXY'], +- noProxy: env?.['NO_PROXY'], +- lowercaseNoProxy: env?.['no_proxy'], +- inheritedNoProxy: process.env[noProxyKey], +- }, { +- httpProxy: proxy, +- httpsProxy: proxy, +- noProxy: 'example.com, localhost', +- lowercaseNoProxy: undefined, +- inheritedNoProxy: 'example.com, localhost', +- }); - }); - } - } @@ -62209,23 +64428,20 @@ index f24a97a6296..00000000000 - test('passes sandbox tool PATH to the runtime without changing the host PATH', async () => { - const inheritedPath = process.env.PATH; - const { agent } = createTestAgentContext(disposables, { copilotClient: new TestCopilotClient([]) }); -- try { -- await agent.listChatsToMigrate(); -- const env = getCreatedClientOptions(agent).at(-1)?.env; -- const pathKey = Object.keys(env ?? {}).find(key => key.toUpperCase() === 'PATH') ?? 'PATH'; -- const path = env?.[pathKey]; -- assert.ok(path); -- const separator = process.platform === 'win32' ? ';' : ':'; -- const directories = path.split(separator); -- const fallbackDirectories = process.platform === 'linux' ? ['/usr/local/sbin', '/usr/local/bin', '/usr/sbin', '/usr/bin', '/sbin', '/bin'] : []; -- assert.deepStrictEqual({ -- inheritedPrefix: inheritedPath ? path.startsWith(`${inheritedPath}${separator}`) : true, -- missingDirectories: fallbackDirectories.filter(directory => !directories.includes(directory)), -- hostPath: process.env.PATH, -- }, { inheritedPrefix: true, missingDirectories: [], hostPath: inheritedPath }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- const env = getCreatedClientOptions(agent).at(-1)?.env; +- const pathKey = Object.keys(env ?? {}).find(key => key.toUpperCase() === 'PATH') ?? 'PATH'; +- const path = env?.[pathKey]; +- assert.ok(path); +- const separator = process.platform === 'win32' ? ';' : ':'; +- const directories = path.split(separator); +- const fallbackDirectories = process.platform === 'linux' ? ['/usr/local/sbin', '/usr/local/bin', '/usr/sbin', '/usr/bin', '/sbin', '/bin'] : []; +- assert.deepStrictEqual({ +- inheritedPrefix: inheritedPath ? path.startsWith(`${inheritedPath}${separator}`) : true, +- missingDirectories: fallbackDirectories.filter(directory => !directories.includes(directory)), +- hostPath: process.env.PATH, +- }, { inheritedPrefix: true, missingDirectories: [], hostPath: inheritedPath }); - }); - - test('sets the search subagent model without inheriting an environment override', () => { @@ -62504,33 +64720,30 @@ index f24a97a6296..00000000000 - proxyResolver, - rootConfig: { [AgentHostProxyConfigKey.NoProxy]: [' 127.0.0.1 ', '', 'localhost', 'example.com'] }, - }); -- try { -- disposables.add(proxyResolver.register('test', { -- resolveProxy: async () => undefined, -- lookupAuthorization: async () => undefined, -- lookupKerberosAuthorization: async () => undefined, -- })); -- await timeout(0); -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- disposables.add(proxyResolver.register('test', { +- resolveProxy: async () => undefined, +- lookupAuthorization: async () => undefined, +- lookupKerberosAuthorization: async () => undefined, +- })); +- await timeout(0); +- await agent.listChatsToMigrate(); - -- assert.deepStrictEqual({ -- startCallCount: client.startCallCount, -- resolveProxyCalls: proxyResolver.resolveProxyCalls, -- httpProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTP_PROXY'], -- httpsProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTPS_PROXY'], -- noProxy: getCreatedClientOptions(agent).at(-1)?.env?.['NO_PROXY'], -- lowercaseNoProxy: getCreatedClientOptions(agent).at(-1)?.env?.['no_proxy'], -- }, { -- startCallCount: 1, -- resolveProxyCalls: 2, -- httpProxy: proxyResolver.resolvedProxy, -- httpsProxy: proxyResolver.resolvedProxy, -- noProxy: '127.0.0.1,localhost,example.com', -- lowercaseNoProxy: undefined, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- startCallCount: client.startCallCount, +- resolveProxyCalls: proxyResolver.resolveProxyCalls, +- httpProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTP_PROXY'], +- httpsProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTPS_PROXY'], +- noProxy: getCreatedClientOptions(agent).at(-1)?.env?.['NO_PROXY'], +- lowercaseNoProxy: getCreatedClientOptions(agent).at(-1)?.env?.['no_proxy'], +- }, { +- startCallCount: 1, +- resolveProxyCalls: 2, +- httpProxy: proxyResolver.resolvedProxy, +- httpsProxy: proxyResolver.resolvedProxy, +- noProxy: '127.0.0.1,localhost,example.com', +- lowercaseNoProxy: undefined, +- }); - }); - - test('resolves the proxy when system proxy support is enabled after construction', async () => { @@ -62542,29 +64755,26 @@ index f24a97a6296..00000000000 - proxyResolver, - rootConfig: { [AgentHostSystemProxyEnabledConfigKey]: false }, - }); -- try { -- await agent.listChatsToMigrate(); -- proxyResolver.resolvedProxy = proxy; -- configurationService.updateRootConfig({ [AgentHostSystemProxyEnabledConfigKey]: true }); -- await client.waitForCalls(() => client.stopCallCount >= 1); -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- proxyResolver.resolvedProxy = proxy; +- configurationService.updateRootConfig({ [AgentHostSystemProxyEnabledConfigKey]: true }); +- await client.waitForCalls(() => client.stopCallCount >= 1); +- await agent.listChatsToMigrate(); - -- assert.deepStrictEqual({ -- startCallCount: client.startCallCount, -- stopCallCount: client.stopCallCount, -- resolveProxyCalls: proxyResolver.resolveProxyCalls, -- httpProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTP_PROXY'], -- httpsProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTPS_PROXY'], -- }, { -- startCallCount: 2, -- stopCallCount: 1, -- resolveProxyCalls: 1, -- httpProxy: proxy, -- httpsProxy: proxy, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- startCallCount: client.startCallCount, +- stopCallCount: client.stopCallCount, +- resolveProxyCalls: proxyResolver.resolveProxyCalls, +- httpProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTP_PROXY'], +- httpsProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTPS_PROXY'], +- }, { +- startCallCount: 2, +- stopCallCount: 1, +- resolveProxyCalls: 1, +- httpProxy: proxy, +- httpsProxy: proxy, +- }); - }); - - test('refreshes the proxy when the enterprise host changes', async () => { @@ -62624,29 +64834,26 @@ index f24a97a6296..00000000000 - const proxyResolver = new TestProxyResolver(); - const proxy = 'http://configured-proxy.example:8080'; - const { agent } = createTestAgentContext(disposables, { copilotClient: client, proxyResolver }); -- try { -- await agent.listChatsToMigrate(); -- proxyResolver.resolvedProxy = proxy; -- proxyResolver.fireConfigurationChange(); -- await proxyRefresh(agent); -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- proxyResolver.resolvedProxy = proxy; +- proxyResolver.fireConfigurationChange(); +- await proxyRefresh(agent); +- await agent.listChatsToMigrate(); - -- assert.deepStrictEqual({ -- startCallCount: client.startCallCount, -- stopCallCount: client.stopCallCount, -- resolveProxyCalls: proxyResolver.resolveProxyCalls, -- httpProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTP_PROXY'], -- httpsProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTPS_PROXY'], -- }, { -- startCallCount: 2, -- stopCallCount: 1, -- resolveProxyCalls: 3, -- httpProxy: proxy, -- httpsProxy: proxy, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- startCallCount: client.startCallCount, +- stopCallCount: client.stopCallCount, +- resolveProxyCalls: proxyResolver.resolveProxyCalls, +- httpProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTP_PROXY'], +- httpsProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTPS_PROXY'], +- }, { +- startCallCount: 2, +- stopCallCount: 1, +- resolveProxyCalls: 3, +- httpProxy: proxy, +- httpsProxy: proxy, +- }); - }); - - test('restarts the Copilot runtime when the no-proxy configuration changes', async () => { @@ -62657,23 +64864,46 @@ index f24a97a6296..00000000000 - proxyResolver, - rootConfig: { [AgentHostProxyConfigKey.NoProxy]: ['localhost'] }, - }); +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- configurationService.updateRootConfig({ [AgentHostProxyConfigKey.NoProxy]: ['127.0.0.1', 'localhost'] }); +- proxyResolver.fireConfigurationChange(); +- await proxyRefresh(agent); +- await agent.listChatsToMigrate(); +- +- assert.deepStrictEqual({ +- startCallCount: client.startCallCount, +- stopCallCount: client.stopCallCount, +- noProxy: getCreatedClientOptions(agent).at(-1)?.env?.['NO_PROXY'], +- lowercaseNoProxy: getCreatedClientOptions(agent).at(-1)?.env?.['no_proxy'], +- }, { +- startCallCount: 2, +- stopCallCount: 1, +- noProxy: '127.0.0.1,localhost', +- lowercaseNoProxy: undefined, +- }); +- }); +- +- test('registers a runtime-scoped fallback Kerberos callback backed by clients sharing the network', async () => { +- const proxyResolver = new TestProxyResolver(); +- const { agent } = createTestAgentContext(disposables, { copilotClient: new TestCopilotClient([]), proxyResolver }); - try { - await agent.listChatsToMigrate(); -- configurationService.updateRootConfig({ [AgentHostProxyConfigKey.NoProxy]: ['127.0.0.1', 'localhost'] }); -- proxyResolver.fireConfigurationChange(); -- await proxyRefresh(agent); -- await agent.listChatsToMigrate(); -- +- const callback = getCreatedClientOptions(agent).at(-1)?.kerberosAuth; +- const signal = new AbortController().signal; - assert.deepStrictEqual({ -- startCallCount: client.startCallCount, -- stopCallCount: client.stopCallCount, -- noProxy: getCreatedClientOptions(agent).at(-1)?.env?.['NO_PROXY'], -- lowercaseNoProxy: getCreatedClientOptions(agent).at(-1)?.env?.['no_proxy'], +- mode: callback?.mode, +- scope: callback?.scope, +- tokens: [ +- await callback?.callback({ spn: 'HTTP/proxy.example@EXAMPLE.COM' }, signal), +- await callback?.callback({ spn: 'ldap/directory.example' }, signal), +- ], +- lookups: proxyResolver.clientKerberosLookups, - }, { -- startCallCount: 2, -- stopCallCount: 1, -- noProxy: '127.0.0.1,localhost', -- lowercaseNoProxy: undefined, +- mode: 'fallback', +- scope: 'runtime', +- tokens: ['client-ticket', undefined], +- lookups: ['http://proxy.example'], - }); - } finally { - await disposeAgent(agent); @@ -62687,13 +64917,10 @@ index f24a97a6296..00000000000 - copilotClient: client, - rootConfig: { [AgentHostProxyConfigKey.ProxyKerberosServicePrincipal]: kerberosSpn }, - }); -- try { -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); - -- assert.strictEqual(getCreatedClientOptions(agent).at(-1)?.env?.['COPILOT_PROXY_KERBEROS_SPN'], kerberosSpn); -- } finally { -- await disposeAgent(agent); -- } +- assert.strictEqual(getCreatedClientOptions(agent).at(-1)?.env?.['COPILOT_PROXY_KERBEROS_SPN'], kerberosSpn); - }); - - test('preserves an explicit Kerberos proxy SPN environment override', async () => { @@ -62730,25 +64957,22 @@ index f24a97a6296..00000000000 - proxyResolver, - rootConfig: { [AgentHostProxyConfigKey.ProxyKerberosServicePrincipal]: initialSpn }, - }); -- try { -- await agent.listChatsToMigrate(); -- configurationService.updateRootConfig({ [AgentHostProxyConfigKey.ProxyKerberosServicePrincipal]: changedSpn }); -- proxyResolver.fireConfigurationChange(); -- await proxyRefresh(agent); -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- configurationService.updateRootConfig({ [AgentHostProxyConfigKey.ProxyKerberosServicePrincipal]: changedSpn }); +- proxyResolver.fireConfigurationChange(); +- await proxyRefresh(agent); +- await agent.listChatsToMigrate(); - -- assert.deepStrictEqual({ -- startCallCount: client.startCallCount, -- stopCallCount: client.stopCallCount, -- kerberosSpn: getCreatedClientOptions(agent).at(-1)?.env?.['COPILOT_PROXY_KERBEROS_SPN'], -- }, { -- startCallCount: 2, -- stopCallCount: 1, -- kerberosSpn: changedSpn, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- startCallCount: client.startCallCount, +- stopCallCount: client.stopCallCount, +- kerberosSpn: getCreatedClientOptions(agent).at(-1)?.env?.['COPILOT_PROXY_KERBEROS_SPN'], +- }, { +- startCallCount: 2, +- stopCallCount: 1, +- kerberosSpn: changedSpn, +- }); - }); - - test('does not restart the Copilot runtime when an unset Kerberos proxy SPN is mirrored as empty', async () => { @@ -62833,29 +65057,26 @@ index f24a97a6296..00000000000 - const proxyResolver = new TestProxyResolver(); - const proxy = 'http://late-system-proxy.example:8080'; - const { agent } = createTestAgentContext(disposables, { copilotClient: client, proxyResolver }); -- try { -- await timeout(0); -- proxyResolver.resolvedProxy = proxy; -- await agent.listChatsToMigrate(); -- await client.waitForCalls(() => client.stopCallCount >= 1); -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await timeout(0); +- proxyResolver.resolvedProxy = proxy; +- await agent.listChatsToMigrate(); +- await client.waitForCalls(() => client.stopCallCount >= 1); +- await agent.listChatsToMigrate(); - -- assert.deepStrictEqual({ -- startCallCount: client.startCallCount, -- stopCallCount: client.stopCallCount, -- resolveProxyCalls: proxyResolver.resolveProxyCalls, -- httpProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTP_PROXY'], -- httpsProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTPS_PROXY'], -- }, { -- startCallCount: 2, -- stopCallCount: 1, -- resolveProxyCalls: 1, -- httpProxy: proxy, -- httpsProxy: proxy, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- startCallCount: client.startCallCount, +- stopCallCount: client.stopCallCount, +- resolveProxyCalls: proxyResolver.resolveProxyCalls, +- httpProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTP_PROXY'], +- httpsProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTPS_PROXY'], +- }, { +- startCallCount: 2, +- stopCallCount: 1, +- resolveProxyCalls: 1, +- httpProxy: proxy, +- httpsProxy: proxy, +- }); - }); - - test('refreshes the cached proxy without blocking every fresh client start', async () => { @@ -62928,28 +65149,22 @@ index f24a97a6296..00000000000 - test('passes the configured log level to the Copilot SDK client', async () => { - const client = new TestCopilotClient([]); - const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client }); -- try { -- configurationService.updateRootConfig({ [CopilotCliConfigKey.CopilotSdkLogLevel]: 'trace' }); -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.CopilotSdkLogLevel]: 'trace' }); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.listChatsToMigrate(); - -- assert.deepStrictEqual(getCreatedClientOptions(agent).map(options => options.logLevel), ['all']); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(getCreatedClientOptions(agent).map(options => options.logLevel), ['all']); - }); - - test('uses info when neither the setting nor agent host enables trace', async () => { - const client = new TestCopilotClient([]); - const { agent } = createTestAgentContext(disposables, { copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.listChatsToMigrate(); - -- assert.deepStrictEqual(getCreatedClientOptions(agent).map(options => options.logLevel), ['info']); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(getCreatedClientOptions(agent).map(options => options.logLevel), ['info']); - }); - - test('uses trace when the agent host log level is trace', async () => { @@ -62957,36 +65172,30 @@ index f24a97a6296..00000000000 - const logService = new MutableLogService(); - logService.setLevel(LogLevel.Trace); - const { agent } = createTestAgentContext(disposables, { copilotClient: client, logService }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.listChatsToMigrate(); - -- assert.deepStrictEqual(getCreatedClientOptions(agent).map(options => options.logLevel), ['all']); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(getCreatedClientOptions(agent).map(options => options.logLevel), ['all']); - }); - - test('enables Rubber Duck and disables Claude Advisor by default', async () => { - const client = new TestCopilotClient([]); - const { agent } = createTestAgentContext(disposables, { copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.listChatsToMigrate(); - -- const env = getCreatedClientOptions(agent).at(-1)?.env; -- assert.deepStrictEqual({ -- rubberDuck: env?.['RUBBER_DUCK_AGENT'], -- advisor: env?.['ANTHROPIC_ADVISOR'], -- skillCharBudget: env?.['SKILL_CHAR_BUDGET'], -- }, { -- rubberDuck: 'true', -- advisor: 'false', -- skillCharBudget: '30000', -- }); -- } finally { -- await disposeAgent(agent); -- } +- const env = getCreatedClientOptions(agent).at(-1)?.env; +- assert.deepStrictEqual({ +- rubberDuck: env?.['RUBBER_DUCK_AGENT'], +- advisor: env?.['ANTHROPIC_ADVISOR'], +- skillCharBudget: env?.['SKILL_CHAR_BUDGET'], +- }, { +- rubberDuck: 'true', +- advisor: 'false', +- skillCharBudget: '30000', +- }); - }); - - test('applies skill character budget changes by restarting the runtime', async () => { @@ -62995,25 +65204,22 @@ index f24a97a6296..00000000000 - copilotClient: client, - rootConfig: { [CopilotCliConfigKey.SkillCharBudget]: 15_000 }, - }); -- try { -- await agent.listChatsToMigrate(); -- const initialBudget = getCreatedClientOptions(agent).at(-1)?.env?.['SKILL_CHAR_BUDGET']; +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- const initialBudget = getCreatedClientOptions(agent).at(-1)?.env?.['SKILL_CHAR_BUDGET']; - -- configurationService.updateRootConfig({ [CopilotCliConfigKey.SkillCharBudget]: 35_000 }); -- await agent.listChatsToMigrate(); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.SkillCharBudget]: 35_000 }); +- await agent.listChatsToMigrate(); - -- assert.deepStrictEqual({ -- initialBudget, -- updatedBudget: getCreatedClientOptions(agent).at(-1)?.env?.['SKILL_CHAR_BUDGET'], -- stopCallCount: client.stopCallCount, -- }, { -- initialBudget: '15000', -- updatedBudget: '35000', -- stopCallCount: 1, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- initialBudget, +- updatedBudget: getCreatedClientOptions(agent).at(-1)?.env?.['SKILL_CHAR_BUDGET'], +- stopCallCount: client.stopCallCount, +- }, { +- initialBudget: '15000', +- updatedBudget: '35000', +- stopCallCount: 1, +- }); - }); - - test('applies and clears the search subagent model by restarting the runtime', async () => { @@ -63022,22 +65228,19 @@ index f24a97a6296..00000000000 - copilotClient: client, - rootConfig: { [CopilotCliConfigKey.SearchSubagentModel]: 'gpt-5' }, - }); -- try { +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- const models = [getCreatedClientOptions(agent).at(-1)?.env?.['SEARCH_SUBAGENT_MODEL']]; +- for (const model of ['gpt-5-mini', '']) { +- configurationService.updateRootConfig({ [CopilotCliConfigKey.SearchSubagentModel]: model }); - await agent.listChatsToMigrate(); -- const models = [getCreatedClientOptions(agent).at(-1)?.env?.['SEARCH_SUBAGENT_MODEL']]; -- for (const model of ['gpt-5-mini', '']) { -- configurationService.updateRootConfig({ [CopilotCliConfigKey.SearchSubagentModel]: model }); -- await agent.listChatsToMigrate(); -- models.push(getCreatedClientOptions(agent).at(-1)?.env?.['SEARCH_SUBAGENT_MODEL']); -- } -- -- assert.deepStrictEqual({ models, stopCallCount: client.stopCallCount }, { -- models: ['gpt-5', 'gpt-5-mini', undefined], -- stopCallCount: 2, -- }); -- } finally { -- await disposeAgent(agent); +- models.push(getCreatedClientOptions(agent).at(-1)?.env?.['SEARCH_SUBAGENT_MODEL']); - } +- +- assert.deepStrictEqual({ models, stopCallCount: client.stopCallCount }, { +- models: ['gpt-5', 'gpt-5-mini', undefined], +- stopCallCount: 2, +- }); - }); - - test('launches the runtime with DYNAMIC_AUTO_TIERS only while opted in, dropping advertised tiers when a restart turns it off', async () => { @@ -63136,17 +65339,14 @@ index f24a97a6296..00000000000 - copilotClient: client, - rootConfig: { [CopilotCliConfigKey.ClaudeAdvisor]: true }, - }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.listChatsToMigrate(); - -- assert.strictEqual(getCreatedClientOptions(agent).at(-1)?.env?.['ANTHROPIC_ADVISOR'], 'true'); -- } finally { -- await disposeAgent(agent); -- } +- assert.strictEqual(getCreatedClientOptions(agent).at(-1)?.env?.['ANTHROPIC_ADVISOR'], 'true'); - }); - -- test('configures repository-size-gated tgrep only when enabled', async () => { +- test('allows repository-size-gated tgrep by default and ignores the removed opt-out', async () => { - const readTgrepEnv = async (rootConfig: Record) => { - const { agent } = createTestAgentContext(disposables, { copilotClient: new TestCopilotClient([]), rootConfig }); - try { @@ -63160,11 +65360,11 @@ index f24a97a6296..00000000000 - }; - - assert.deepStrictEqual({ -- off: await readTgrepEnv({}), -- on: await readTgrepEnv({ [CopilotCliConfigKey.Tgrep]: true }), +- default: await readTgrepEnv({}), +- legacyOptOut: await readTgrepEnv({ tgrep: false }), - }, { -- off: { useTgrep: 'false', useBuiltinRipgrep: 'false' }, -- on: { useTgrep: undefined, useBuiltinRipgrep: undefined }, +- default: { useTgrep: undefined, useBuiltinRipgrep: undefined }, +- legacyOptOut: { useTgrep: undefined, useBuiltinRipgrep: undefined }, - }); - }); - @@ -63177,35 +65377,32 @@ index f24a97a6296..00000000000 - }, - }); - const publishedModels = () => agent.models.get().map(model => `${model.id}:${model.name}`); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.listChatsToMigrate(); -- await agent.refreshModels(); -- const enabled = publishedModels(); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.listChatsToMigrate(); +- await agent.refreshModels(); +- const enabled = publishedModels(); - -- configurationService.updateRootConfig({ [CopilotCliConfigKey.HydraFusionV2]: true }); -- await agent.listChatsToMigrate(); -- await agent.refreshModels(); -- const v2 = publishedModels(); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.HydraFusionV2]: true }); +- await agent.listChatsToMigrate(); +- await agent.refreshModels(); +- const v2 = publishedModels(); - -- // v2 left on does not keep Max once HydraFusion itself is off. -- configurationService.updateRootConfig({ [CopilotCliConfigKey.HydraFusion]: false }); -- await agent.listChatsToMigrate(); -- await agent.refreshModels(); -- assert.deepStrictEqual({ -- enabled, -- v2, -- disabledWithV2: publishedModels(), -- stopCallCount: client.stopCallCount, -- }, { -- enabled: ['gpt-5:GPT-5', 'hydrafusion:HydraFusion'], -- v2: ['gpt-5:GPT-5', 'hydrafusion:HydraFusion', 'hydrafusion-max:HydraFusion Max'], -- disabledWithV2: ['gpt-5:GPT-5'], -- stopCallCount: 2, -- }); -- } finally { -- await disposeAgent(agent); -- } +- // v2 left on does not keep Max once HydraFusion itself is off. +- configurationService.updateRootConfig({ [CopilotCliConfigKey.HydraFusion]: false }); +- await agent.listChatsToMigrate(); +- await agent.refreshModels(); +- assert.deepStrictEqual({ +- enabled, +- v2, +- disabledWithV2: publishedModels(), +- stopCallCount: client.stopCallCount, +- }, { +- enabled: ['gpt-5:GPT-5', 'hydrafusion:HydraFusion'], +- v2: ['gpt-5:GPT-5', 'hydrafusion:HydraFusion', 'hydrafusion-max:HydraFusion Max'], +- disabledWithV2: ['gpt-5:GPT-5'], +- stopCallCount: 2, +- }); - }); - - test('with DYNAMIC_AUTO_TIERS, publishes only the HydraFusion the runtime lists, and adds it back once a restart turns the flag off', async () => { @@ -63256,53 +65453,47 @@ index f24a97a6296..00000000000 - await agent.listChatsToMigrate(); - return client.stopCallCount; - }; -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.listChatsToMigrate(); - -- assert.deepStrictEqual({ -- initial: client.stopCallCount, -- multiRoot: await stopsAfter({ [CopilotCliConfigKey.HydraFusionMultiRoot]: true }), -- backToV2: await stopsAfter({ [CopilotCliConfigKey.HydraFusionMultiRoot]: false }), -- v1: await stopsAfter({ [CopilotCliConfigKey.HydraFusionV2]: false }), -- // Without v2 the hint is inert, so toggling it must not restart. -- v1MultiRoot: await stopsAfter({ [CopilotCliConfigKey.HydraFusionMultiRoot]: true }), -- }, { -- initial: 0, -- multiRoot: 1, -- backToV2: 2, -- v1: 3, -- v1MultiRoot: 3, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- initial: client.stopCallCount, +- multiRoot: await stopsAfter({ [CopilotCliConfigKey.HydraFusionMultiRoot]: true }), +- backToV2: await stopsAfter({ [CopilotCliConfigKey.HydraFusionMultiRoot]: false }), +- v1: await stopsAfter({ [CopilotCliConfigKey.HydraFusionV2]: false }), +- // Without v2 the hint is inert, so toggling it must not restart. +- v1MultiRoot: await stopsAfter({ [CopilotCliConfigKey.HydraFusionMultiRoot]: true }), +- }, { +- initial: 0, +- multiRoot: 1, +- backToV2: 2, +- v1: 3, +- v1MultiRoot: 3, +- }); - }); - - test('restarts the runtime only when the effective local memory store changes', async () => { - const client = new TestCopilotClient([]); - const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.listChatsToMigrate(); - -- configurationService.updateRootConfig({ [CopilotCliConfigKey.LocalMemory]: true }); -- await agent.listChatsToMigrate(); -- const localWithoutMemory = client.stopCallCount; +- configurationService.updateRootConfig({ [CopilotCliConfigKey.LocalMemory]: true }); +- await agent.listChatsToMigrate(); +- const localWithoutMemory = client.stopCallCount; - -- configurationService.updateRootConfig({ [CopilotCliConfigKey.Memory]: true }); -- await agent.listChatsToMigrate(); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.Memory]: true }); +- await agent.listChatsToMigrate(); - -- assert.deepStrictEqual({ -- localWithoutMemory, -- localWithMemory: client.stopCallCount, -- }, { -- localWithoutMemory: 0, -- localWithMemory: 1, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- localWithoutMemory, +- localWithMemory: client.stopCallCount, +- }, { +- localWithoutMemory: 0, +- localWithMemory: 1, +- }); - }); - - test('restarts the runtime when execution enablement or model changes and restores session-model inheritance', async () => { @@ -63318,31 +65509,28 @@ index f24a97a6296..00000000000 - stops: client.stopCallCount, - }); - }; -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.listChatsToMigrate(); +- capture(); +- for (const config of [ +- { [CopilotCliConfigKey.ExecutionSubagent]: true }, +- { [CopilotCliConfigKey.ExecutionSubagentModel]: ' exec-agent-b ' }, +- { [CopilotCliConfigKey.ExecutionSubagentModel]: '' }, +- { [CopilotCliConfigKey.ExecutionSubagent]: false }, +- ]) { +- configurationService.updateRootConfig(config); - await agent.listChatsToMigrate(); - capture(); -- for (const config of [ -- { [CopilotCliConfigKey.ExecutionSubagent]: true }, -- { [CopilotCliConfigKey.ExecutionSubagentModel]: ' exec-agent-b ' }, -- { [CopilotCliConfigKey.ExecutionSubagentModel]: '' }, -- { [CopilotCliConfigKey.ExecutionSubagent]: false }, -- ]) { -- configurationService.updateRootConfig(config); -- await agent.listChatsToMigrate(); -- capture(); -- } -- -- assert.deepStrictEqual(observed, [ -- { enabled: 'false', model: undefined, experimentModel: 'gpt-5.6-luna', stops: 0 }, -- { enabled: 'true', model: undefined, experimentModel: 'gpt-5.6-luna', stops: 1 }, -- { enabled: 'true', model: undefined, experimentModel: 'exec-agent-b', stops: 2 }, -- { enabled: 'true', model: undefined, experimentModel: 'null', stops: 3 }, -- { enabled: 'false', model: undefined, experimentModel: 'null', stops: 4 }, -- ]); -- } finally { -- await disposeAgent(agent); - } +- +- assert.deepStrictEqual(observed, [ +- { enabled: 'false', model: undefined, experimentModel: 'gpt-5.6-luna', stops: 0 }, +- { enabled: 'true', model: undefined, experimentModel: 'gpt-5.6-luna', stops: 1 }, +- { enabled: 'true', model: undefined, experimentModel: 'exec-agent-b', stops: 2 }, +- { enabled: 'true', model: undefined, experimentModel: 'null', stops: 3 }, +- { enabled: 'false', model: undefined, experimentModel: 'null', stops: 4 }, +- ]); - }); - - const fusionTierMetadata = (fusionEnabled = true): CopilotAutoTierMetadata => ({ @@ -63357,29 +65545,74 @@ index f24a97a6296..00000000000 - ], - }); - -- test('presents Auto, not Fusion, with CAPI\'s Fusion tiers unavailable and no HydraFusion when CAPI offers no Fusion tier the account can run', async () => { +- test('presents Fusion with CAPI\'s Fusion tiers unavailable and no HydraFusion when the account cannot run them, and Auto when CAPI offers no Fusion tier', async () => { +- const presented = async (models: { id: string; name: string }[], autoTierMetadata: CopilotAutoTierMetadata) => { +- const client = new TestCopilotClient([], models); +- client.autoTierMetadata = autoTierMetadata; +- const { agent } = createTestAgentContext(disposables, { +- copilotClient: client, +- rootConfig: { [CopilotCliConfigKey.DynamicAutoTiers]: true, [CopilotCliConfigKey.HydraFusion]: true, [CopilotCliConfigKey.HydraFusionV2]: true }, +- }); +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.listChatsToMigrate(); +- await agent.refreshModels(); +- const auto = agent.models.get().find(model => model.id === 'auto'); +- const tier = auto?.configSchema?.properties.tier; +- return { +- models: agent.models.get().map(model => `${model.id}:${model.name}${isAgentModelFusion(model) ? ':fusion' : ''}`), +- tier: `${tier?.enum?.join('/')}=${tier?.default}:${tier?.enumLabels?.join('/')}:${tier?.title}`, +- autoTiers: readAgentModelAutoTiersMeta(auto!)?.tiers.map(({ id, enabled }) => `${id}:${enabled}`), +- }; +- } finally { +- await disposeAgent(agent); +- setAdvertisedAutoModeTiers(undefined); +- } +- }; +- const withoutFusionTiers = fusionTierMetadata(); +- assert.deepStrictEqual({ +- unavailableFusion: await presented([{ id: 'auto', name: 'Auto' }, { id: 'gpt-5', name: 'GPT-5' }], fusionTierMetadata(false)), +- unavailableFusionWithoutModels: await presented([], fusionTierMetadata(false)), +- noFusionTier: await presented([{ id: 'auto', name: 'Auto' }, { id: 'gpt-5', name: 'GPT-5' }], { ...withoutFusionTiers, tiers: withoutFusionTiers.tiers.filter(tier => tier.type !== 'fusion') }), +- }, { +- unavailableFusion: { +- models: ['auto:Copilot Fusion:fusion', 'gpt-5:GPT-5'], +- tier: 'efficiency/balance/intelligence=balance:Efficiency/Balance/Intelligence:Effort Level', +- autoTiers: ['efficiency:true', 'balance:true', 'intelligence:true', 'hydrafusion:false', 'hydrafusion-max:false'], +- }, +- unavailableFusionWithoutModels: { +- models: ['auto:Copilot Fusion:fusion'], +- tier: 'efficiency/balance/intelligence=balance:Efficiency/Balance/Intelligence:Effort Level', +- autoTiers: ['efficiency:true', 'balance:true', 'intelligence:true', 'hydrafusion:false', 'hydrafusion-max:false'], +- }, +- noFusionTier: { +- models: ['auto:Auto', 'gpt-5:GPT-5'], +- tier: 'efficiency/balance/intelligence=balance:Efficiency/Balance/Intelligence:Optimize for', +- autoTiers: ['efficiency:true', 'balance:true', 'intelligence:true'], +- }, +- }); +- }); +- +- test('advertises Fusion\'s inline chat tier only while the provider accepts it', async () => { - const client = new TestCopilotClient([], [{ id: 'auto', name: 'Auto' }, { id: 'gpt-5', name: 'GPT-5' }]); -- client.autoTierMetadata = fusionTierMetadata(false); +- client.autoTierMetadata = fusionTierMetadata(); - const { agent } = createTestAgentContext(disposables, { - copilotClient: client, - rootConfig: { [CopilotCliConfigKey.DynamicAutoTiers]: true, [CopilotCliConfigKey.HydraFusion]: true, [CopilotCliConfigKey.HydraFusionV2]: true }, - }); +- const inlineChatTiers = () => agent.models.get().map(model => `${model.id}:${readAgentModelFusionInlineChatTier(model)}`); - try { - await agent.authenticate('https://api.github.com', 'token'); - await agent.listChatsToMigrate(); - await agent.refreshModels(); -- const auto = agent.models.get().find(model => model.id === 'auto'); -- const tier = auto?.configSchema?.properties.tier; -- assert.deepStrictEqual({ -- models: agent.models.get().map(model => `${model.id}:${model.name}${isAgentModelFusion(model) ? ':fusion' : ''}`), -- tier: `${tier?.enum?.join('/')}=${tier?.default}:${tier?.enumLabels?.join('/')}:${tier?.title}`, -- autoTiers: readAgentModelAutoTiersMeta(auto!)?.tiers.map(({ id, enabled }) => `${id}:${enabled}`), -- acceptsEfficiencyAsIs: getCopilotAutoTier({ id: 'auto', config: { tier: 'efficiency' } }), -- }, { -- models: ['auto:Auto', 'gpt-5:GPT-5'], -- tier: 'efficiency/balance/intelligence=balance:Efficiency/Balance/Intelligence:Optimize for', -- autoTiers: ['efficiency:true', 'balance:true', 'intelligence:true', 'hydrafusion:false', 'hydrafusion-max:false'], -- acceptsEfficiencyAsIs: 'efficiency', +- const accepted = inlineChatTiers(); +- +- const metadata = fusionTierMetadata(); +- client.autoTierMetadata = { ...metadata, tiers: metadata.tiers.filter(tier => tier.id !== 'fast') }; +- await agent.refreshModels(); +- assert.deepStrictEqual({ accepted, notAccepted: inlineChatTiers() }, { +- accepted: ['auto:fast', 'gpt-5:undefined', 'hydrafusion:undefined', 'hydrafusion-max:undefined'], +- notAccepted: ['auto:undefined', 'gpt-5:undefined', 'hydrafusion:undefined', 'hydrafusion-max:undefined'], - }); - } finally { - await disposeAgent(agent); @@ -63518,53 +65751,44 @@ index f24a97a6296..00000000000 - copilotClient: client, - rootConfig: { [CopilotCliConfigKey.HydraFusion]: true }, - }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.refreshModels(); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.refreshModels(); - -- assert.deepStrictEqual(agent.models.get().map(model => model.id), ['hydrafusion']); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(agent.models.get().map(model => model.id), ['hydrafusion']); - }); - - test('does not enable the rubber duck CLI feature when explicitly disabled', async () => { - const client = new TestCopilotClient([]); - const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client }); -- try { -- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: false }); -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: false }); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.listChatsToMigrate(); - -- assert.strictEqual(getCreatedClientOptions(agent).at(-1)?.env?.['RUBBER_DUCK_AGENT'], undefined); -- } finally { -- await disposeAgent(agent); -- } +- assert.strictEqual(getCreatedClientOptions(agent).at(-1)?.env?.['RUBBER_DUCK_AGENT'], undefined); - }); - - test('uses SDK defaults for Auto routing', async () => { - const client = new TestCopilotClient([]); - const { agent } = createTestAgentContext(disposables, { copilotClient: client }); -- try { -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); - -- const defaultEnv = createCopilotCliEnvironment({}); -- const clientEnv = getCreatedClientOptions(agent).at(-1)?.env; -- assert.ok(clientEnv); -- assert.deepStrictEqual({ -- defaultAutoV2: defaultEnv['AUTO_V2_ENDPOINT'], -- defaultMultiTurn: defaultEnv['MULTI_TURN_CONTEXT_ROUTING'], -- clientAutoV2: clientEnv['AUTO_V2_ENDPOINT'], -- clientMultiTurn: clientEnv['MULTI_TURN_CONTEXT_ROUTING'], -- }, { -- defaultAutoV2: undefined, -- defaultMultiTurn: undefined, -- clientAutoV2: process.env['AUTO_V2_ENDPOINT'], -- clientMultiTurn: process.env['MULTI_TURN_CONTEXT_ROUTING'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- const defaultEnv = createCopilotCliEnvironment({}); +- const clientEnv = getCreatedClientOptions(agent).at(-1)?.env; +- assert.ok(clientEnv); +- assert.deepStrictEqual({ +- defaultAutoV2: defaultEnv['AUTO_V2_ENDPOINT'], +- defaultMultiTurn: defaultEnv['MULTI_TURN_CONTEXT_ROUTING'], +- clientAutoV2: clientEnv['AUTO_V2_ENDPOINT'], +- clientMultiTurn: clientEnv['MULTI_TURN_CONTEXT_ROUTING'], +- }, { +- defaultAutoV2: undefined, +- defaultMultiTurn: undefined, +- clientAutoV2: process.env['AUTO_V2_ENDPOINT'], +- clientMultiTurn: process.env['MULTI_TURN_CONTEXT_ROUTING'], +- }); - }); - - for (const quality of ['stable', 'insider', undefined] as const) { @@ -63643,22 +65867,19 @@ index f24a97a6296..00000000000 - copilotClient: client, - rootConfig: { [AgentHostGitHubMcpServerEnabledConfigKey]: false }, - }); -- try { -- await agent.listChatsToMigrate(); -- configurationService.updateRootConfig({ [AgentHostGitHubMcpServerEnabledConfigKey]: true }); -- await Promise.resolve(); -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- configurationService.updateRootConfig({ [AgentHostGitHubMcpServerEnabledConfigKey]: true }); +- await Promise.resolve(); +- await agent.listChatsToMigrate(); - -- assert.deepStrictEqual({ -- stopCount: client.stopCount, -- env: getCreatedClientOptions(agent).at(-1)?.env?.['COPILOT_ENABLE_BUILTIN_GITHUB_MCP'], -- }, { -- stopCount: 1, -- env: 'true', -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- stopCount: client.stopCount, +- env: getCreatedClientOptions(agent).at(-1)?.env?.['COPILOT_ENABLE_BUILTIN_GITHUB_MCP'], +- }, { +- stopCount: 1, +- env: 'true', +- }); - }); - - function createComputerUseAgent(enabled: boolean, isLocalAgentHost: boolean, client: TestCopilotClient, plugin?: Pick) { @@ -63774,27 +65995,24 @@ index f24a97a6296..00000000000 - const client = new StopCountingClient([]); - const fake = createDownloadingComputerUsePlugin(); - const { agent } = createComputerUseAgent(true, true, client, fake.plugin); -- try { -- await agent.listChatsToMigrate(); -- const whileDownloading = getCreatedClientOptions(agent).at(-1)?.builtinPluginDirectories; -- fake.install('/cache/computer-use'); -- await timeout(0); -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- const whileDownloading = getCreatedClientOptions(agent).at(-1)?.builtinPluginDirectories; +- fake.install('/cache/computer-use'); +- await timeout(0); +- await agent.listChatsToMigrate(); - -- assert.deepStrictEqual({ -- whileDownloading, -- downloads: fake.tokens.length, -- stopCount: client.stopCount, -- afterInstall: getCreatedClientOptions(agent).at(-1)?.builtinPluginDirectories, -- }, { -- whileDownloading: undefined, -- downloads: 1, -- stopCount: 1, -- afterInstall: ['/cache/computer-use'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- whileDownloading, +- downloads: fake.tokens.length, +- stopCount: client.stopCount, +- afterInstall: getCreatedClientOptions(agent).at(-1)?.builtinPluginDirectories, +- }, { +- whileDownloading: undefined, +- downloads: 1, +- stopCount: 1, +- afterInstall: ['/cache/computer-use'], +- }); - }); - - test('starts the client again when the Computer Use plugin is installed while it starts', async () => { @@ -63845,22 +66063,19 @@ index f24a97a6296..00000000000 - fake.install('/cache/computer-use'); - } - }; -- try { -- await agent.listChatsToMigrate(); -- await timeout(0); +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- await timeout(0); - -- assert.deepStrictEqual({ -- startCallCount: client.startCallCount, -- stopCount: client.stopCount, -- builtinPluginDirectories: getCreatedClientOptions(agent).map(options => options.builtinPluginDirectories), -- }, { -- startCallCount: 3, -- stopCount: 2, -- builtinPluginDirectories: [undefined, undefined, ['/cache/computer-use']], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- startCallCount: client.startCallCount, +- stopCount: client.stopCount, +- builtinPluginDirectories: getCreatedClientOptions(agent).map(options => options.builtinPluginDirectories), +- }, { +- startCallCount: 3, +- stopCount: 2, +- builtinPluginDirectories: [undefined, undefined, ['/cache/computer-use']], +- }); - }); - - test('cancels a pending Computer Use download when the setting is turned off', async () => { @@ -63882,72 +66097,63 @@ index f24a97a6296..00000000000 - test('restarts the client when the Copilot SDK log level changes', async () => { - const client = new StopCountingClient([]); - const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.listChatsToMigrate(); - -- configurationService.updateRootConfig({ [CopilotCliConfigKey.CopilotSdkLogLevel]: 'trace' }); -- await Promise.resolve(); -- await agent.listChatsToMigrate(); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.CopilotSdkLogLevel]: 'trace' }); +- await Promise.resolve(); +- await agent.listChatsToMigrate(); - -- assert.deepStrictEqual({ -- stopCount: client.stopCount, -- logLevel: getCreatedClientOptions(agent).at(-1)?.logLevel, -- }, { -- stopCount: 1, -- logLevel: 'all', -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- stopCount: client.stopCount, +- logLevel: getCreatedClientOptions(agent).at(-1)?.logLevel, +- }, { +- stopCount: 1, +- logLevel: 'all', +- }); - }); - - test('restarts sessions when managed permission contributions change or are removed', async () => { - const client = new StopCountingClient([]); - const { agent, managedSettingsService } = createTestAgentContext(disposables, { copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.listChatsToMigrate(); - -- const restricted = { disableBypassPermissionsMode: 'disable' as const }; -- managedSettingsService.setClientPermissions('client', restricted); -- await Promise.resolve(); -- await agent.listChatsToMigrate(); -- managedSettingsService.setClientPermissions('client', restricted); -- await Promise.resolve(); -- managedSettingsService.removeClient('client'); -- await Promise.resolve(); +- const restricted = { disableBypassPermissionsMode: 'disable' as const }; +- managedSettingsService.setClientPermissions('client', restricted); +- await Promise.resolve(); +- await agent.listChatsToMigrate(); +- managedSettingsService.setClientPermissions('client', restricted); +- await Promise.resolve(); +- managedSettingsService.removeClient('client'); +- await Promise.resolve(); - -- assert.strictEqual(client.stopCount, 2); -- } finally { -- await disposeAgent(agent); -- } +- assert.strictEqual(client.stopCount, 2); - }); - - test('re-enumerates models after a startup-config restart', async () => { - const client = new StopCountingClient([], [{ id: 'gpt-4o', name: 'GPT-4o' }]); - const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.listChatsToMigrate(); -- await waitForState(agent.models, m => m.length > 0); -- const requestsBefore = client.modelListRequests.length; +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.listChatsToMigrate(); +- await waitForState(agent.models, m => m.length > 0); +- const requestsBefore = client.modelListRequests.length; - -- // The catalog belonged to the subprocess being torn down, and the -- // replacement may point at a different CAPI endpoint entirely. -- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: false }); -- await client.waitForCalls(() => client.modelListRequests.length > requestsBefore); +- // The catalog belonged to the subprocess being torn down, and the +- // replacement may point at a different CAPI endpoint entirely. +- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: false }); +- await client.waitForCalls(() => client.modelListRequests.length > requestsBefore); - -- assert.deepStrictEqual({ -- stopCount: client.stopCount, -- refreshesAfterRestart: client.modelListRequests.length - requestsBefore, -- }, { -- stopCount: 1, -- refreshesAfterRestart: 1, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- stopCount: client.stopCount, +- refreshesAfterRestart: client.modelListRequests.length - requestsBefore, +- }, { +- stopCount: 1, +- refreshesAfterRestart: 1, +- }); - }); - - test('keeps the last-known catalog across a startup-config restart instead of publishing HydraFusion alone', async () => { @@ -63960,29 +66166,26 @@ index f24a97a6296..00000000000 - disposables.add(autorun(reader => { - published.push(agent.models.read(reader).map(model => model.id)); - })); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.listChatsToMigrate(); -- await waitForState(agent.models, models => models.some(model => model.id === 'gpt-6.1-sol')); -- const requestsBefore = client.modelListRequests.length; -- published.length = 0; +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.listChatsToMigrate(); +- await waitForState(agent.models, models => models.some(model => model.id === 'gpt-6.1-sol')); +- const requestsBefore = client.modelListRequests.length; +- published.length = 0; - -- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: false }); -- await client.waitForCalls(() => client.modelListRequests.length > requestsBefore); -- await agent.refreshModels(); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: false }); +- await client.waitForCalls(() => client.modelListRequests.length > requestsBefore); +- await agent.refreshModels(); - -- assert.deepStrictEqual({ -- stopCount: client.stopCount, -- publishedDuringRestart: published.filter(models => !models.includes('gpt-6.1-sol')), -- current: agent.models.get().map(model => model.id), -- }, { -- stopCount: 1, -- publishedDuringRestart: [], -- current: ['gpt-6.1-sol', 'hydrafusion'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- stopCount: client.stopCount, +- publishedDuringRestart: published.filter(models => !models.includes('gpt-6.1-sol')), +- current: agent.models.get().map(model => model.id), +- }, { +- stopCount: 1, +- publishedDuringRestart: [], +- current: ['gpt-6.1-sol', 'hydrafusion'], +- }); - }); - - test('coalesces concurrent token and startup-config refresh triggers', async () => { @@ -64052,22 +66255,19 @@ index f24a97a6296..00000000000 - test('a failed client stop does not poison later model refreshes', async () => { - const client = new StopCountingClient([], [{ id: 'gpt-4o', name: 'GPT-4o' }]); - const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await waitForState(agent.models, models => models.length > 0); -- await agent.listChatsToMigrate(); -- const requestsBefore = client.modelListRequests.length; -- client.stopError = new Error('stop failed'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await waitForState(agent.models, models => models.length > 0); +- await agent.listChatsToMigrate(); +- const requestsBefore = client.modelListRequests.length; +- client.stopError = new Error('stop failed'); - -- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: false }); -- await timeout(10); -- client.stopError = undefined; -- await agent.refreshModels(); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: false }); +- await timeout(10); +- client.stopError = undefined; +- await agent.refreshModels(); - -- assert.strictEqual(client.modelListRequests.length, requestsBefore + 1); -- } finally { -- await disposeAgent(agent); -- } +- assert.strictEqual(client.modelListRequests.length, requestsBefore + 1); - }); - - test('drops an in-flight catalog from the previous client generation', async () => { @@ -64121,43 +66321,37 @@ index f24a97a6296..00000000000 - test('restarts the idle client when the rubber duck config changes', async () => { - const client = new StopCountingClient([]); - const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- // Force the client to start so a subsequent config change has something to restart. -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- // Force the client to start so a subsequent config change has something to restart. +- await agent.listChatsToMigrate(); - -- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: false }); -- await Promise.resolve(); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: false }); +- await Promise.resolve(); - -- assert.strictEqual(client.stopCount, 1); -- } finally { -- await disposeAgent(agent); -- } +- assert.strictEqual(client.stopCount, 1); - }); - - test('restarts and disposes active sessions when the config changes', async () => { - const client = new StopCountingClient([]); - const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.listChatsToMigrate(); - -- let disposed = false; -- setDefaultSessionStub(agent, 'active', { dispose() { disposed = true; } }); +- let disposed = false; +- setDefaultSessionStub(agent, 'active', { dispose() { disposed = true; } }); - -- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: false }); -- await Promise.resolve(); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: false }); +- await Promise.resolve(); - -- assert.deepStrictEqual({ -- stopCount: client.stopCount, -- disposed, -- }, { -- stopCount: 1, -- disposed: true, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- stopCount: client.stopCount, +- disposed, +- }, { +- stopCount: 1, +- disposed: true, +- }); - }); - - function seedDisconnectedApprovalReport(agent: CopilotAgent, stateManager: AgentHostStateManager): URI { @@ -64250,38 +66444,37 @@ index f24a97a6296..00000000000 - const { agent, configurationService, stateManager } = createTestAgentContext(disposables, { copilotClient: client }); - const pending = new DeferredPromise(); - const started = new DeferredPromise(); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.listChatsToMigrate(); -- const session = seedDisconnectedApprovalReport(agent, stateManager); -- client.resolveManagedSettings = () => { -- started.complete(); -- return pending.p; -- }; -- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: false }); -- await started.p; -- if (invalidation === 'new client') { -- client.resolveManagedSettings = async () => client.managedSettingsResolution; -- configurationService.updateRootConfig({ [AgentHostAutoApprovePolicyRestrictedConfigKey]: true }); -- await timeout(0); -- await agent.refreshModels(); -- } else if (invalidation === 'resumed chat') { -- setLiveChatStub(agent, 'resumed-sdk-backing', { resourceUri: session }); -- configurationService.updateSessionConfig(session.toString(), { availableApprovalModes: ['default'] }); -- } else { -- stateManager.removeSession(session.toString()); -- } -- await pending.complete({ -- ...client.managedSettingsResolution, -- resolved: { ...client.managedSettingsResolution.resolved, settings: { permissions: { disableAssistedPermissionsMode: false } } }, -- }); -- await timeout(0); -- assert.deepStrictEqual(configurationService.getSessionConfigValues(session.toString())?.availableApprovalModes, -- invalidation === 'removed session' ? undefined : ['default']); -- } finally { +- afterTest(async () => { - await pending.complete(client.managedSettingsResolution); - await disposeAgent(agent); +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.listChatsToMigrate(); +- const session = seedDisconnectedApprovalReport(agent, stateManager); +- client.resolveManagedSettings = () => { +- started.complete(); +- return pending.p; +- }; +- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: false }); +- await started.p; +- if (invalidation === 'new client') { +- client.resolveManagedSettings = async () => client.managedSettingsResolution; +- configurationService.updateRootConfig({ [AgentHostAutoApprovePolicyRestrictedConfigKey]: true }); +- await timeout(0); +- await agent.refreshModels(); +- } else if (invalidation === 'resumed chat') { +- setLiveChatStub(agent, 'resumed-sdk-backing', { resourceUri: session }); +- configurationService.updateSessionConfig(session.toString(), { availableApprovalModes: ['default'] }); +- } else { +- stateManager.removeSession(session.toString()); - } +- await pending.complete({ +- ...client.managedSettingsResolution, +- resolved: { ...client.managedSettingsResolution.resolved, settings: { permissions: { disableAssistedPermissionsMode: false } } }, +- }); +- await timeout(0); +- assert.deepStrictEqual(configurationService.getSessionConfigValues(session.toString())?.availableApprovalModes, +- invalidation === 'removed session' ? undefined : ['default']); - }); - } - @@ -64292,18 +66485,15 @@ index f24a97a6296..00000000000 - copilotClient: client, - rootConfig: { [CopilotCliConfigKey.LocalIndexEnabled]: !localIndexEnabled }, - }); -- try { -- await agent.listChatsToMigrate(); -- let disposed = false; -- setDefaultSessionStub(agent, 'idle', { dispose() { disposed = true; } }); +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- let disposed = false; +- setDefaultSessionStub(agent, 'idle', { dispose() { disposed = true; } }); - -- configurationService.updateRootConfig({ [CopilotCliConfigKey.LocalIndexEnabled]: localIndexEnabled }); -- await timeout(0); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.LocalIndexEnabled]: localIndexEnabled }); +- await timeout(0); - -- assert.deepStrictEqual({ stopCount: client.stopCount, disposed }, { stopCount: 1, disposed: true }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ stopCount: client.stopCount, disposed }, { stopCount: 1, disposed: true }); - }); - } - @@ -64326,93 +66516,90 @@ index f24a97a6296..00000000000 - sessionDataService, - useRealResumePath: true, - }); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.listChatsToMigrate(); +- +- const idleSessionUri = AgentSession.uri('copilotcli', 'idle'); +- const idleChatUri = defaultChatUri(idleSessionUri); +- const workingDirectory = URI.file('/workspace'); +- const dbRef = sessionDataService.openDatabase(idleSessionUri); - try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.listChatsToMigrate(); +- await dbRef.object.setMetadata('copilot.workingDirectory', workingDirectory.toString()); +- await dbRef.object.setMetadata('copilot.workingDirectories', JSON.stringify([workingDirectory.toString()])); +- } finally { +- dbRef.dispose(); +- } - -- const idleSessionUri = AgentSession.uri('copilotcli', 'idle'); -- const idleChatUri = defaultChatUri(idleSessionUri); -- const workingDirectory = URI.file('/workspace'); -- const dbRef = sessionDataService.openDatabase(idleSessionUri); -- try { -- await dbRef.object.setMetadata('copilot.workingDirectory', workingDirectory.toString()); -- await dbRef.object.setMetadata('copilot.workingDirectories', JSON.stringify([workingDirectory.toString()])); -- } finally { -- dbRef.dispose(); -- } +- let idleDisposed = false; +- let idleDestroyed = false; +- const idle = { +- requiresRestartAfterTelemetryRevocation: false, +- markTelemetryRevocationRequiresRestart() { idle.requiresRestartAfterTelemetryRevocation = true; }, +- dispose() { idleDisposed = true; }, +- async destroySession() { idleDestroyed = true; }, +- }; +- setDefaultSessionStub(agent, 'idle', idle, idleChatUri); - -- let idleDisposed = false; -- let idleDestroyed = false; -- const idle = { -- requiresRestartAfterTelemetryRevocation: false, -- markTelemetryRevocationRequiresRestart() { idle.requiresRestartAfterTelemetryRevocation = true; }, -- dispose() { idleDisposed = true; }, -- async destroySession() { idleDestroyed = true; }, -- }; -- setDefaultSessionStub(agent, 'idle', idle, idleChatUri); -- -- let busyDisposed = false; -- let busyDestroyed = false; -- const busy = { -- hasActiveTurn: true, -- requiresRestartAfterTelemetryRevocation: false, -- markTelemetryRevocationRequiresRestart() { busy.requiresRestartAfterTelemetryRevocation = true; }, -- dispose() { busyDisposed = true; }, -- async destroySession() { busyDestroyed = true; }, -- }; -- setDefaultSessionStub(agent, 'busy', busy); +- let busyDisposed = false; +- let busyDestroyed = false; +- const busy = { +- hasActiveTurn: true, +- requiresRestartAfterTelemetryRevocation: false, +- markTelemetryRevocationRequiresRestart() { busy.requiresRestartAfterTelemetryRevocation = true; }, +- dispose() { busyDisposed = true; }, +- async destroySession() { busyDestroyed = true; }, +- }; +- setDefaultSessionStub(agent, 'busy', busy); - -- configurationService.updateRootConfig({ -- [AgentHostTelemetryLevelConfigKey]: telemetryLevelToAgentHostConfigValue(TelemetryLevel.NONE), -- }); -- await timeout(0); +- configurationService.updateRootConfig({ +- [AgentHostTelemetryLevelConfigKey]: telemetryLevelToAgentHostConfigValue(TelemetryLevel.NONE), +- }); +- await timeout(0); - -- const whileBusy = { -- stopCount: client.stopCount, -- idleDisposed, -- idleDestroyed, -- busyDisposed, -- busyDestroyed, -- busyMarked: busy.requiresRestartAfterTelemetryRevocation, -- }; +- const whileBusy = { +- stopCount: client.stopCount, +- idleDisposed, +- idleDestroyed, +- busyDisposed, +- busyDestroyed, +- busyMarked: busy.requiresRestartAfterTelemetryRevocation, +- }; - -- // Reusing the previously idle chat while `busy` is still running must relaunch -- // it through resumeSession with `enableSessionTelemetry: false` rather than -- // reusing the telemetry-enabled session. -- await agent.chats.sendMessage(idleChatUri, 'hello after opt-out', [workingDirectory], undefined, 'turn-1', undefined, exactChatContext(idleSessionUri, idleChatUri, idleSessionUri)); +- // Reusing the previously idle chat while `busy` is still running must relaunch +- // it through resumeSession with `enableSessionTelemetry: false` rather than +- // reusing the telemetry-enabled session. +- await agent.chats.sendMessage(idleChatUri, 'hello after opt-out', [workingDirectory], undefined, 'turn-1', undefined, exactChatContext(idleSessionUri, idleChatUri, idleSessionUri)); - -- // Once `busy` finishes its turn, its telemetry-enabled session is torn down too. -- busy.hasActiveTurn = false; -- reportChatTurnEnded(agent); -- await timeout(0); +- // Once `busy` finishes its turn, its telemetry-enabled session is torn down too. +- busy.hasActiveTurn = false; +- reportChatTurnEnded(agent); +- await timeout(0); - -- assert.deepStrictEqual({ -- whileBusy, -- resumeTelemetryFlags, -- afterBusyTurn: { -- stopCount: client.stopCount, -- busyDisposed, -- busyDestroyed, -- }, -- }, { -- whileBusy: { -- stopCount: 0, -- idleDisposed: true, -- idleDestroyed: true, -- busyDisposed: false, -- busyDestroyed: false, -- busyMarked: true, -- }, -- resumeTelemetryFlags: [false], -- afterBusyTurn: { -- stopCount: 0, -- busyDisposed: true, -- busyDestroyed: true, -- }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- whileBusy, +- resumeTelemetryFlags, +- afterBusyTurn: { +- stopCount: client.stopCount, +- busyDisposed, +- busyDestroyed, +- }, +- }, { +- whileBusy: { +- stopCount: 0, +- idleDisposed: true, +- idleDestroyed: true, +- busyDisposed: false, +- busyDestroyed: false, +- busyMarked: true, +- }, +- resumeTelemetryFlags: [false], +- afterBusyTurn: { +- stopCount: 0, +- busyDisposed: true, +- busyDestroyed: true, +- }, +- }); - }); - - test('revokes session telemetry and releases policy sessions when handshake lowers telemetry level', async () => { @@ -64446,44 +66633,41 @@ index f24a97a6296..00000000000 - copilotClient: client, - telemetryService, - }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.searchCustomizationMarketplace(AgentSession.uri('copilotcli', 's1'), { query: 'test', limit: 10 }); -- -- let idleDisposed = false; -- let idleDestroyed = false; -- const idle = { -- requiresRestartAfterTelemetryRevocation: false, -- markTelemetryRevocationRequiresRestart() { idle.requiresRestartAfterTelemetryRevocation = true; }, -- dispose() { idleDisposed = true; }, -- async destroySession() { idleDestroyed = true; }, -- }; -- setDefaultSessionStub(agent, 'idle', idle); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.searchCustomizationMarketplace(AgentSession.uri('copilotcli', 's1'), { query: 'test', limit: 10 }); +- +- let idleDisposed = false; +- let idleDestroyed = false; +- const idle = { +- requiresRestartAfterTelemetryRevocation: false, +- markTelemetryRevocationRequiresRestart() { idle.requiresRestartAfterTelemetryRevocation = true; }, +- dispose() { idleDisposed = true; }, +- async destroySession() { idleDestroyed = true; }, +- }; +- setDefaultSessionStub(agent, 'idle', idle); - -- // Simulates ProtocolServerHandler._applyClientTelemetryLevel on initialize/reconnect. -- telemetryService.updateTelemetryLevel(TelemetryLevel.NONE); -- await timeout(0); +- // Simulates ProtocolServerHandler._applyClientTelemetryLevel on initialize/reconnect. +- telemetryService.updateTelemetryLevel(TelemetryLevel.NONE); +- await timeout(0); - -- // A subsequent marketplace search after telemetry is already disabled must create -- // the policy session once with `enableSessionTelemetry: false` (no duplicate retry). -- await agent.searchCustomizationMarketplace(AgentSession.uri('copilotcli', 's1'), { query: 'after-disable', limit: 10 }); +- // A subsequent marketplace search after telemetry is already disabled must create +- // the policy session once with `enableSessionTelemetry: false` (no duplicate retry). +- await agent.searchCustomizationMarketplace(AgentSession.uri('copilotcli', 's1'), { query: 'after-disable', limit: 10 }); - -- assert.deepStrictEqual({ -- idleDisposed, -- idleDestroyed, -- policyCreates, -- policyDisconnects: policyDisconnects.length, -- policyDeletes: policyDeletes.length, -- }, { -- idleDisposed: true, -- idleDestroyed: true, -- policyCreates: [undefined, false], -- policyDisconnects: 1, -- policyDeletes: 1, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- idleDisposed, +- idleDestroyed, +- policyCreates, +- policyDisconnects: policyDisconnects.length, +- policyDeletes: policyDeletes.length, +- }, { +- idleDisposed: true, +- idleDestroyed: true, +- policyCreates: [undefined, false], +- policyDisconnects: 1, +- policyDeletes: 1, +- }); - }); - - test('revalidates in-flight session launches when telemetry is revoked during SDK launch or post-launch setup', async () => { @@ -64492,6 +66676,7 @@ index f24a97a6296..00000000000 - const client = new StopCountingClient([]); - const launchTelemetryFlags: (boolean | undefined)[] = []; - const disconnectedSessions: number[] = []; +- const sandboxUpdatesByAttempt = new Set(); - const shellInitUpdatesByAttempt = new Map(); - const pauseReached = new DeferredPromise(); - const allowContinue = new DeferredPromise(); @@ -64507,7 +66692,10 @@ index f24a97a6296..00000000000 - } - const mockSession = new MockCopilotSession(`sess-${stage}-${pathKind}`); - const origOptionsUpdate = mockSession.rpc.options.update; -- mockSession.rpc.options.update = (async (params?: { shell?: { initScripts?: unknown[] } }) => { +- mockSession.rpc.options.update = (async (params?: { sandboxConfig?: unknown; shell?: { initScripts?: unknown[] } }) => { +- if (params?.sandboxConfig !== undefined) { +- sandboxUpdatesByAttempt.add(attempt); +- } - if (params?.shell?.initScripts !== undefined) { - shellInitUpdatesByAttempt.set(attempt, params.shell.initScripts); - if (stage === 'postLaunch' && attempt === 1) { @@ -64532,15 +66720,17 @@ index f24a97a6296..00000000000 - appenders: [], - }, TEST_PRODUCT_SERVICE))); - const sessionDataService = disposables.add(new TestSessionDataService()); -- const fileService = disposables.add(new FileService(new NullLogService())); -- disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); +- const fileService = createInMemoryFileService(disposables, Schemas.file); - const { agent, stateManager } = createTestAgentContext(disposables, { - copilotClient: client, - telemetryService, - sessionDataService, - fileService, - useRealResumePath: true, -- rootConfig: { [CopilotCliConfigKey.EnableShellInitScript]: true }, +- rootConfig: { +- [CopilotCliConfigKey.EnableShellInitScript]: true, +- [CopilotCliConfigKey.EnableCustomTerminalTool]: false, +- }, - }); - const sessionUri = AgentSession.uri('copilotcli', `sess-${stage}-${pathKind}`); - const chatUri = defaultChatUri(sessionUri); @@ -64591,12 +66781,14 @@ index f24a97a6296..00000000000 - pathKind, - launchTelemetryFlags, - disconnectedSessions, +- sandboxUpdatedOnAttempts: [...sandboxUpdatesByAttempt], - shellInitRegisteredOnAttempts: [...shellInitUpdatesByAttempt.entries()].map(([attempt, scripts]) => ({ attempt, count: scripts.length })), - }, { - stage, - pathKind, - launchTelemetryFlags: [undefined, false], - disconnectedSessions: [1], +- sandboxUpdatedOnAttempts: [1, 2], - shellInitRegisteredOnAttempts: stage === 'sdkLaunch' - ? [{ attempt: 2, count: 1 }] - : [{ attempt: 1, count: 1 }, { attempt: 2, count: 1 }], @@ -64712,18 +66904,15 @@ index f24a97a6296..00000000000 - const client = new StopCountingClient([]); - const logService = new MutableLogService(); - const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client, logService }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.listChatsToMigrate(); - -- logService.setLevel(LogLevel.Trace); -- configurationService.updateRootConfig({ [CopilotCliConfigKey.EnableCustomTerminalTool]: true }); -- await Promise.resolve(); +- logService.setLevel(LogLevel.Trace); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.EnableCustomTerminalTool]: true }); +- await Promise.resolve(); - -- assert.strictEqual(client.stopCount, 0); -- } finally { -- await disposeAgent(agent); -- } +- assert.strictEqual(client.stopCount, 0); - }); - - /** @@ -64747,59 +66936,53 @@ index f24a97a6296..00000000000 - test('defers the restart until an in-flight turn ends', async () => { - const client = new StopCountingClient([]); - const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.listChatsToMigrate(); - -- const chat = busyChatStub(); -- setDefaultSessionStub(agent, 'busy', chat); +- const chat = busyChatStub(); +- setDefaultSessionStub(agent, 'busy', chat); - -- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: false }); -- await timeout(0); -- const duringTurn = { stopCount: client.stopCount, disposed: chat.disposed }; +- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: false }); +- await timeout(0); +- const duringTurn = { stopCount: client.stopCount, disposed: chat.disposed }; - -- chat.hasActiveTurn = false; -- reportChatTurnEnded(agent); -- await timeout(0); +- chat.hasActiveTurn = false; +- reportChatTurnEnded(agent); +- await timeout(0); - -- assert.deepStrictEqual({ -- duringTurn, -- afterTurn: { stopCount: client.stopCount, disposed: chat.disposed }, -- }, { -- duringTurn: { stopCount: 0, disposed: false }, -- afterTurn: { stopCount: 1, disposed: true }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- duringTurn, +- afterTurn: { stopCount: client.stopCount, disposed: chat.disposed }, +- }, { +- duringTurn: { stopCount: 0, disposed: false }, +- afterTurn: { stopCount: 1, disposed: true }, +- }); - }); - - test('applies local index changes after the active turn finishes', async () => { - const client = new StopCountingClient([]); - const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client }); -- try { -- await agent.listChatsToMigrate(); -- const chat = busyChatStub(); -- setDefaultSessionStub(agent, 'busy', chat); +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- const chat = busyChatStub(); +- setDefaultSessionStub(agent, 'busy', chat); - -- configurationService.updateRootConfig({ [CopilotCliConfigKey.LocalIndexEnabled]: false }); -- await timeout(0); -- const duringTurn = { stopCount: client.stopCount, disposed: chat.disposed }; +- configurationService.updateRootConfig({ [CopilotCliConfigKey.LocalIndexEnabled]: false }); +- await timeout(0); +- const duringTurn = { stopCount: client.stopCount, disposed: chat.disposed }; - -- chat.hasActiveTurn = false; -- reportChatTurnEnded(agent); -- await timeout(0); +- chat.hasActiveTurn = false; +- reportChatTurnEnded(agent); +- await timeout(0); - -- assert.deepStrictEqual({ -- duringTurn, -- afterTurn: { stopCount: client.stopCount, disposed: chat.disposed }, -- }, { -- duringTurn: { stopCount: 0, disposed: false }, -- afterTurn: { stopCount: 1, disposed: true }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- duringTurn, +- afterTurn: { stopCount: client.stopCount, disposed: chat.disposed }, +- }, { +- duringTurn: { stopCount: 0, disposed: false }, +- afterTurn: { stopCount: 1, disposed: true }, +- }); - }); - - test('waits for current enterprise client authentication with Connectors enabled', async () => { @@ -64810,51 +66993,48 @@ index f24a97a6296..00000000000 - gitHubEndpointService: endpointService, - rootConfig: { [AgentHostMcpConnectorsEnabledConfigKey]: true }, - }); -- try { -- await agent.authenticate(endpointService.getCopilotResource().resource, 'connector-token-a'); -- await agent.listChatsToMigrate(); -- const chat = busyChatStub(); -- setDefaultSessionStub(agent, 'busy', chat); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate(endpointService.getCopilotResource().resource, 'connector-token-a'); +- await agent.listChatsToMigrate(); +- const chat = busyChatStub(); +- setDefaultSessionStub(agent, 'busy', chat); - -- await agent.authenticate(endpointService.getCopilotResource().resource, 'connector-token-b'); -- let acquired = false; -- const acquire = (agent as unknown as { _ensureClientForSession(): Promise })._ensureClientForSession().then(value => { -- acquired = true; -- return value; -- }); -- await timeout(0); -- const duringTurn = { +- await agent.authenticate(endpointService.getCopilotResource().resource, 'connector-token-b'); +- let acquired = false; +- const acquire = (agent as unknown as { _ensureClientForSession(): Promise })._ensureClientForSession().then(value => { +- acquired = true; +- return value; +- }); +- await timeout(0); +- const duringTurn = { +- acquired, +- stopCount: client.stopCount, +- clientTokens: getCreatedClientOptions(agent).map(options => options.gitHubToken), +- }; +- +- chat.hasActiveTurn = false; +- reportChatTurnEnded(agent); +- await acquire; +- +- assert.deepStrictEqual({ +- duringTurn, +- afterTurn: { - acquired, - stopCount: client.stopCount, - clientTokens: getCreatedClientOptions(agent).map(options => options.gitHubToken), -- }; -- -- chat.hasActiveTurn = false; -- reportChatTurnEnded(agent); -- await acquire; -- -- assert.deepStrictEqual({ -- duringTurn, -- afterTurn: { -- acquired, -- stopCount: client.stopCount, -- clientTokens: getCreatedClientOptions(agent).map(options => options.gitHubToken), -- }, -- }, { -- duringTurn: { -- acquired: false, -- stopCount: 0, -- clientTokens: ['connector-token-a'], -- }, -- afterTurn: { -- acquired: true, -- stopCount: 1, -- clientTokens: ['connector-token-a', 'connector-token-b'], -- }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- }, +- }, { +- duringTurn: { +- acquired: false, +- stopCount: 0, +- clientTokens: ['connector-token-a'], +- }, +- afterTurn: { +- acquired: true, +- stopCount: 1, +- clientTokens: ['connector-token-a', 'connector-token-b'], +- }, +- }); - }); - - test('waits for Connector enablement at startup while another chat is busy', async () => { @@ -64963,99 +67143,87 @@ index f24a97a6296..00000000000 - test('keeps deferring while another chat is still running its turn', async () => { - const client = new StopCountingClient([]); - const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.listChatsToMigrate(); - -- const first = busyChatStub(); -- const second = busyChatStub(); -- setDefaultSessionStub(agent, 'busy-1', first); -- setDefaultSessionStub(agent, 'busy-2', second); +- const first = busyChatStub(); +- const second = busyChatStub(); +- setDefaultSessionStub(agent, 'busy-1', first); +- setDefaultSessionStub(agent, 'busy-2', second); - -- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: false }); -- await timeout(0); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: false }); +- await timeout(0); - -- first.hasActiveTurn = false; -- reportChatTurnEnded(agent); -- await timeout(0); -- const afterFirst = client.stopCount; +- first.hasActiveTurn = false; +- reportChatTurnEnded(agent); +- await timeout(0); +- const afterFirst = client.stopCount; - -- second.hasActiveTurn = false; -- reportChatTurnEnded(agent); -- await timeout(0); +- second.hasActiveTurn = false; +- reportChatTurnEnded(agent); +- await timeout(0); - -- assert.deepStrictEqual({ afterFirst, afterSecond: client.stopCount }, { afterFirst: 0, afterSecond: 1 }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ afterFirst, afterSecond: client.stopCount }, { afterFirst: 0, afterSecond: 1 }); - }); - - test('applies a deferred restart when the busy session is disposed instead', async () => { - const client = new StopCountingClient([]); - const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.listChatsToMigrate(); - -- setDefaultSessionStub(agent, 'busy', busyChatStub()); +- setDefaultSessionStub(agent, 'busy', busyChatStub()); - -- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: false }); -- await timeout(0); -- const duringTurn = client.stopCount; +- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: false }); +- await timeout(0); +- const duringTurn = client.stopCount; - -- // A disposed session never reports its turn ending, so disposal -- // must drain the parked restart itself. -- await disposeProvisionedSession(agent, AgentSession.uri('copilotcli', 'busy')); +- // A disposed session never reports its turn ending, so disposal +- // must drain the parked restart itself. +- await disposeProvisionedSession(agent, AgentSession.uri('copilotcli', 'busy')); - -- assert.deepStrictEqual({ duringTurn, afterDispose: client.stopCount }, { duringTurn: 0, afterDispose: 1 }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ duringTurn, afterDispose: client.stopCount }, { duringTurn: 0, afterDispose: 1 }); - }); - - test('does not restart a client that was already stopped', async () => { - const client = new StopCountingClient([]); - const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.listChatsToMigrate(); - -- const chat = busyChatStub(); -- setDefaultSessionStub(agent, 'busy', chat); +- const chat = busyChatStub(); +- setDefaultSessionStub(agent, 'busy', chat); - -- // Two startup values change while the turn runs; the first -- // restart to actually run satisfies both. -- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: false }); -- configurationService.updateRootConfig({ [CopilotCliConfigKey.CopilotSdkLogLevel]: 'trace' }); -- await timeout(0); +- // Two startup values change while the turn runs; the first +- // restart to actually run satisfies both. +- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: false }); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.CopilotSdkLogLevel]: 'trace' }); +- await timeout(0); - -- chat.hasActiveTurn = false; -- reportChatTurnEnded(agent); -- reportChatTurnEnded(agent); -- await timeout(0); +- chat.hasActiveTurn = false; +- reportChatTurnEnded(agent); +- reportChatTurnEnded(agent); +- await timeout(0); - -- assert.strictEqual(client.stopCount, 1); -- } finally { -- await disposeAgent(agent); -- } +- assert.strictEqual(client.stopCount, 1); - }); - - test('does not restart the shared client when model-family overrides change', async () => { - const client = new StopCountingClient([]); - const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.listChatsToMigrate(); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.listChatsToMigrate(); - -- configurationService.updateRootConfig({ modelCapabilityOverrides: { 'preview-model': { family: 'claude-opus-4.8' } } }); -- await timeout(0); -- configurationService.updateRootConfig({ modelCapabilityOverrides: { '*': { family: 'gpt-5' } } }); -- await timeout(0); +- configurationService.updateRootConfig({ modelCapabilityOverrides: { 'preview-model': { family: 'claude-opus-4.8' } } }); +- await timeout(0); +- configurationService.updateRootConfig({ modelCapabilityOverrides: { '*': { family: 'gpt-5' } } }); +- await timeout(0); - -- assert.strictEqual(client.stopCount, 0); -- } finally { -- await disposeAgent(agent); -- } +- assert.strictEqual(client.stopCount, 0); - }); - - test('does not pass an ambient model-family override to the shared runtime', async () => { @@ -65089,25 +67257,22 @@ index f24a97a6296..00000000000 - capabilities: { limits: { max_context_window_tokens: 128000, max_output_tokens: 16000, max_prompt_tokens: 112000 }, supports: { vision: true } }, - }]), - }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const models = await waitForState(agent.models, models => models.length > 0); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const models = await waitForState(agent.models, models => models.length > 0); - -- assert.deepStrictEqual(models, [{ -- provider: 'copilotcli', -- id: 'gpt-4o', -- name: 'GPT-4o', -- maxContextWindow: 128000, -- maxOutputTokens: 16000, -- maxPromptTokens: 112000, -- supportsVision: true, -- configSchema: undefined, -- policyState: undefined, -- _meta: { multiplierNumeric: 1.5 }, -- }]); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(models, [{ +- provider: 'copilotcli', +- id: 'gpt-4o', +- name: 'GPT-4o', +- maxContextWindow: 128000, +- maxOutputTokens: 16000, +- maxPromptTokens: 112000, +- supportsVision: true, +- configSchema: undefined, +- policyState: undefined, +- _meta: { multiplierNumeric: 1.5 }, +- }]); - }); - - test('models include picker, notice, and promo metadata when the SDK provides it', async () => { @@ -65147,40 +67312,37 @@ index f24a97a6296..00000000000 - ], - }]), - }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const models = await waitForState(agent.models, models => models.length > 0); -- -- assert.deepStrictEqual(models[0]._meta, { -- multiplierNumeric: 1, -- inputCost: 3, -- cacheCost: 1, -- outputCost: 15, -- longContextInputCost: 6, -- longContextCacheCost: 1, -- longContextOutputCost: 22.5, -- priceCategory: 'medium', -- category: 'powerful', -- warningText: { -- data_retention: 'Prompts are retained for 30 days.', -- model_pending_deprecation: 'Claude Sonnet will be retired soon.', -- model_degraded: 'Claude Sonnet is currently degraded.', -- }, -- infoText: { -- model_relocated: 'Claude Sonnet now serves from a new region.', -- }, -- rowWarning: 'Claude Sonnet is currently degraded.', -- 'vscode.modelConfiguration.defaults': ['contextSize'], -- promo: { -- id: 'summer-sale', -- discountPercent: 25, -- endsAt: '2026-08-01T00:00:00Z', -- message: 'Save on Claude Sonnet', -- }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const models = await waitForState(agent.models, models => models.length > 0); +- +- assert.deepStrictEqual(models[0]._meta, { +- multiplierNumeric: 1, +- inputCost: 3, +- cacheCost: 1, +- outputCost: 15, +- longContextInputCost: 6, +- longContextCacheCost: 1, +- longContextOutputCost: 22.5, +- priceCategory: 'medium', +- category: 'powerful', +- warningText: { +- data_retention: 'Prompts are retained for 30 days.', +- model_pending_deprecation: 'Claude Sonnet will be retired soon.', +- model_degraded: 'Claude Sonnet is currently degraded.', +- }, +- infoText: { +- model_relocated: 'Claude Sonnet now serves from a new region.', +- }, +- rowWarning: 'Claude Sonnet is currently degraded.', +- 'vscode.modelConfiguration.defaults': ['contextSize'], +- promo: { +- id: 'summer-sale', +- discountPercent: 25, +- endsAt: '2026-08-01T00:00:00Z', +- message: 'Save on Claude Sonnet', +- }, +- }); - }); - - test('models keep an open-ended, message-only promotion', async () => { @@ -65193,14 +67355,11 @@ index f24a97a6296..00000000000 - billing: { multiplier: 1, promo: { id: 'featured', discountPercent: 0, message: 'Now available' } }, - }]), - }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const models = await waitForState(agent.models, models => models.length > 0); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const models = await waitForState(agent.models, models => models.length > 0); - -- assert.deepStrictEqual(models[0]._meta?.promo, { id: 'featured', discountPercent: 0, message: 'Now available' }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(models[0]._meta?.promo, { id: 'featured', discountPercent: 0, message: 'Now available' }); - }); - - test('configSchema emits a thinkingLevel property when the model advertises reasoning efforts', async () => { @@ -65212,18 +67371,15 @@ index f24a97a6296..00000000000 - supportedReasoningEfforts: ['low', 'medium', 'high'], - }]), - }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const models = await waitForState(agent.models, models => models.length > 0); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const models = await waitForState(agent.models, models => models.length > 0); - -- const schema = models[0].configSchema; -- assert.deepStrictEqual(schema?.properties.thinkingLevel?.enum, ['low', 'medium', 'high']); -- assert.strictEqual(schema?.properties.thinkingLevel?.default, 'medium'); -- assert.strictEqual(schema?.properties.contextSize, undefined); -- assert.deepStrictEqual(schema?.properties.reasoningEffort, { ...schema?.properties.thinkingLevel, default: undefined }); -- } finally { -- await disposeAgent(agent); -- } +- const schema = models[0].configSchema; +- assert.deepStrictEqual(schema?.properties.thinkingLevel?.enum, ['low', 'medium', 'high']); +- assert.strictEqual(schema?.properties.thinkingLevel?.default, 'medium'); +- assert.strictEqual(schema?.properties.contextSize, undefined); +- assert.deepStrictEqual(schema?.properties.reasoningEffort, { ...schema?.properties.thinkingLevel, default: undefined }); - }); - - test('configSchema falls back to a default thinkingLevel when the model advertises no default', async () => { @@ -65250,19 +67406,16 @@ index f24a97a6296..00000000000 - supportedReasoningEfforts: ['minimal', 'none'], - }]), - }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const models = await waitForState(agent.models, models => models.length === 4); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const models = await waitForState(agent.models, models => models.length === 4); - -- assert.deepStrictEqual(models.map(model => [model.id, model.configSchema?.properties.thinkingLevel?.enum, model.configSchema?.properties.thinkingLevel?.default]), [ -- ['gpt-5.6-terra', ['low', 'medium', 'high', 'xhigh'], 'medium'], -- ['claude-opus-5', ['low', 'medium', 'high', 'xhigh', 'max'], 'high'], -- ['no-preferred', ['minimal', 'xhigh'], 'minimal'], -- ['non-standard-only', ['minimal', 'none'], 'minimal'], -- ]); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(models.map(model => [model.id, model.configSchema?.properties.thinkingLevel?.enum, model.configSchema?.properties.thinkingLevel?.default]), [ +- ['gpt-5.6-terra', ['low', 'medium', 'high', 'xhigh'], 'medium'], +- ['claude-opus-5', ['low', 'medium', 'high', 'xhigh', 'max'], 'high'], +- ['no-preferred', ['minimal', 'xhigh'], 'minimal'], +- ['non-standard-only', ['minimal', 'none'], 'minimal'], +- ]); - }); - - test('BYOK model configSchema exposes the advertised reasoning efforts', async () => { @@ -65275,50 +67428,47 @@ index f24a97a6296..00000000000 - }; - disposables.add(byokBridgeRegistry.register('renderer', connection)); - -- try { -- modelSnapshots.fire([ -- { -- vendor: 'acme', -- id: 'fallback-default', -- name: 'Fallback Default', -- supportedReasoningEfforts: ['minimal', 'low', 'high'], -- defaultReasoningEffort: 'minimal', -- }, -- { -- vendor: 'acme', -- id: 'valid-default', -- name: 'Valid Default', -- supportedReasoningEfforts: ['low', 'medium', 'high'], -- defaultReasoningEffort: 'medium', -- }, -- { -- vendor: 'acme', -- id: 'minimal-only', -- name: 'Minimal Only', -- supportedReasoningEfforts: ['minimal'], -- defaultReasoningEffort: 'minimal', -- }, -- ]); -- const models = await waitForState(agent.models, models => models.length === 3); +- afterTest(() => disposeAgent(agent)); +- modelSnapshots.fire([ +- { +- vendor: 'acme', +- id: 'fallback-default', +- name: 'Fallback Default', +- supportedReasoningEfforts: ['minimal', 'low', 'high'], +- defaultReasoningEffort: 'minimal', +- }, +- { +- vendor: 'acme', +- id: 'valid-default', +- name: 'Valid Default', +- supportedReasoningEfforts: ['low', 'medium', 'high'], +- defaultReasoningEffort: 'medium', +- }, +- { +- vendor: 'acme', +- id: 'minimal-only', +- name: 'Minimal Only', +- supportedReasoningEfforts: ['minimal'], +- defaultReasoningEffort: 'minimal', +- }, +- ]); +- const models = await waitForState(agent.models, models => models.length === 3); - -- assert.deepStrictEqual(models.map(model => model.configSchema?.properties.reasoningEffort), models.map(model => ({ -- ...model.configSchema?.properties.thinkingLevel, -- default: undefined, -- }))); -- assert.deepStrictEqual(models.map(model => ({ -- id: model.id, -- thinkingLevel: model.configSchema?.properties.thinkingLevel && { -- enum: model.configSchema.properties.thinkingLevel.enum, -- default: model.configSchema.properties.thinkingLevel.default, -- }, -- })), [ -- { id: 'acme/fallback-default', thinkingLevel: { enum: ['minimal', 'low', 'high'], default: 'minimal' } }, -- { id: 'acme/valid-default', thinkingLevel: { enum: ['low', 'medium', 'high'], default: 'medium' } }, -- { id: 'acme/minimal-only', thinkingLevel: { enum: ['minimal'], default: 'minimal' } }, -- ]); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(models.map(model => model.configSchema?.properties.reasoningEffort), models.map(model => ({ +- ...model.configSchema?.properties.thinkingLevel, +- default: undefined, +- }))); +- assert.deepStrictEqual(models.map(model => ({ +- id: model.id, +- thinkingLevel: model.configSchema?.properties.thinkingLevel && { +- enum: model.configSchema.properties.thinkingLevel.enum, +- default: model.configSchema.properties.thinkingLevel.default, +- }, +- })), [ +- { id: 'acme/fallback-default', thinkingLevel: { enum: ['minimal', 'low', 'high'], default: 'minimal' } }, +- { id: 'acme/valid-default', thinkingLevel: { enum: ['low', 'medium', 'high'], default: 'medium' } }, +- { id: 'acme/minimal-only', thinkingLevel: { enum: ['minimal'], default: 'minimal' } }, +- ]); - }); - - test('BYOK models follow synchronized root configuration', async () => { @@ -65333,31 +67483,28 @@ index f24a97a6296..00000000000 - onDidChangeModels: modelSnapshots.event, - })); - -- try { -- modelSnapshots.fire([{ vendor: 'acme', id: 'model', name: 'Model', maxContextWindowTokens: 128000, maxPromptTokens: 32000, maxOutputTokens: 4000 }]); -- const disabledModels = agent.models.get(); -- configurationService.updateRootConfig({ [AgentHostByokModelsEnabledConfigKey]: true }); -- const enabledModels = await waitForState(agent.models, models => models.length === 1); -- configurationService.updateRootConfig({ [AgentHostByokModelsEnabledConfigKey]: false }); -- const disabledAgainModels = await waitForState(agent.models, models => models.length === 0); +- afterTest(() => disposeAgent(agent)); +- modelSnapshots.fire([{ vendor: 'acme', id: 'model', name: 'Model', maxContextWindowTokens: 128000, maxPromptTokens: 32000, maxOutputTokens: 4000 }]); +- const disabledModels = agent.models.get(); +- configurationService.updateRootConfig({ [AgentHostByokModelsEnabledConfigKey]: true }); +- const enabledModels = await waitForState(agent.models, models => models.length === 1); +- configurationService.updateRootConfig({ [AgentHostByokModelsEnabledConfigKey]: false }); +- const disabledAgainModels = await waitForState(agent.models, models => models.length === 0); - -- assert.deepStrictEqual({ -- disabled: disabledModels.map(model => model.id), -- enabled: enabledModels.map(({ id, maxContextWindow, maxPromptTokens, maxOutputTokens }) => ({ id, maxContextWindow, maxPromptTokens, maxOutputTokens })), -- disabledAgain: disabledAgainModels.map(model => model.id), -- }, { -- disabled: [], -- enabled: [{ id: 'acme/model', maxContextWindow: 128000, maxPromptTokens: 32000, maxOutputTokens: 4000 }], -- disabledAgain: [], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- disabled: disabledModels.map(model => model.id), +- enabled: enabledModels.map(({ id, maxContextWindow, maxPromptTokens, maxOutputTokens }) => ({ id, maxContextWindow, maxPromptTokens, maxOutputTokens })), +- disabledAgain: disabledAgainModels.map(model => model.id), +- }, { +- disabled: [], +- enabled: [{ id: 'acme/model', maxContextWindow: 128000, maxPromptTokens: 32000, maxOutputTokens: 4000 }], +- disabledAgain: [], +- }); - }); - -- test('BYOK models make Copilot authentication optional only while signed-out operation is enabled', async () => { +- test('BYOK models make Copilot authentication optional without configuration', async () => { - const byokBridgeRegistry = new ByokLmBridgeRegistry(); -- const { agent, configurationService } = createTestAgentContext(disposables, { byokBridgeRegistry }); +- const { agent } = createTestAgentContext(disposables, { byokBridgeRegistry }); - const modelSnapshots = disposables.add(new Emitter()); - const connection: IByokLmBridgeConnection = { - chat: async () => ({ output: [] }), @@ -65367,47 +67514,36 @@ index f24a97a6296..00000000000 - const copilotRequired = () => agent.getProtectedResources() - .find(resource => resource.resource === GITHUB_COPILOT_PROTECTED_RESOURCE.resource)?.required !== false; - -- try { -- const initiallyRequired = copilotRequired(); -- const initiallyReady = agent.isReadyForAutomation(undefined); -- configurationService.updateRootConfig({ [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }); -- const requiredWithoutByok = copilotRequired(); -- modelSnapshots.fire([{ vendor: 'gemini', id: 'gemini-2.5-pro', modelIdentifier: 'gemini/Gemini/gemini-2.5-pro' }]); -- await waitForState(agent.models, models => models.length === 1); -- const optionalWithByok = copilotRequired(); -- const selectedModel = { id: agent.models.get()[0].id }; -- const readyWithByok = agent.isReadyForAutomation(selectedModel); -- const copilotStillBlocked = !agent.isReadyForAutomation({ id: 'gpt-4o' }); -- modelSnapshots.fire([]); -- await waitForState(agent.models, models => models.length === 0); -- const requiredAfterHide = copilotRequired(); -- configurationService.updateRootConfig({ [AgentHostConfigKey.AllowSignedOutWhenUsable]: false }); -- const requiredAfterDisable = copilotRequired(); -- -- assert.deepStrictEqual({ -- initiallyRequired, -- requiredWithoutByok, -- optionalWithByok, -- requiredAfterHide, -- requiredAfterDisable, -- initiallyReady, -- readyWithByok, -- copilotStillBlocked, -- readyAfterHide: agent.isReadyForAutomation(selectedModel), -- }, { -- initiallyRequired: true, -- requiredWithoutByok: true, -- optionalWithByok: false, -- requiredAfterHide: true, -- requiredAfterDisable: true, -- initiallyReady: false, -- readyWithByok: true, -- copilotStillBlocked: true, -- readyAfterHide: false, -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const initiallyRequired = copilotRequired(); +- const initiallyReady = agent.isReadyForAutomation(undefined); +- modelSnapshots.fire([{ vendor: 'gemini', id: 'gemini-2.5-pro', modelIdentifier: 'gemini/Gemini/gemini-2.5-pro' }]); +- await waitForState(agent.models, models => models.length === 1); +- const optionalWithByok = copilotRequired(); +- const selectedModel = { id: agent.models.get()[0].id }; +- const readyWithByok = agent.isReadyForAutomation(selectedModel); +- const copilotStillBlocked = !agent.isReadyForAutomation({ id: 'gpt-4o' }); +- modelSnapshots.fire([]); +- await waitForState(agent.models, models => models.length === 0); +- const requiredAfterHide = copilotRequired(); +- +- assert.deepStrictEqual({ +- initiallyRequired, +- optionalWithByok, +- requiredAfterHide, +- initiallyReady, +- readyWithByok, +- copilotStillBlocked, +- readyAfterHide: agent.isReadyForAutomation(selectedModel), +- }, { +- initiallyRequired: true, +- optionalWithByok: false, +- requiredAfterHide: true, +- initiallyReady: false, +- readyWithByok: true, +- copilotStillBlocked: true, +- readyAfterHide: false, +- }); - }); - - test('publishes HydraFusion beside BYOK models only while signed in to GitHub', async () => { @@ -65416,7 +67552,6 @@ index f24a97a6296..00000000000 - copilotClient: new TestCopilotClient([], [{ id: 'gpt-5', name: 'GPT-5' }]), - byokBridgeRegistry, - rootConfig: { -- [AgentHostConfigKey.AllowSignedOutWhenUsable]: true, - [CopilotCliConfigKey.HydraFusion]: true, - [CopilotCliConfigKey.HydraFusionV2]: true, - }, @@ -65428,24 +67563,21 @@ index f24a97a6296..00000000000 - })); - const publishedModels = () => agent.models.get().map(model => model.id); - -- try { -- modelSnapshots.fire([{ vendor: 'acme', id: 'model', name: 'Model' }]); -- await waitForState(agent.models, models => models.some(model => model.id === 'acme/model')); -- const signedOut = publishedModels(); -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.refreshModels(); -- const signedIn = publishedModels(); -- await agent.authenticate('https://api.github.com', ''); -- await agent.refreshModels(); +- afterTest(() => disposeAgent(agent)); +- modelSnapshots.fire([{ vendor: 'acme', id: 'model', name: 'Model' }]); +- await waitForState(agent.models, models => models.some(model => model.id === 'acme/model')); +- const signedOut = publishedModels(); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.refreshModels(); +- const signedIn = publishedModels(); +- await agent.authenticate('https://api.github.com', ''); +- await agent.refreshModels(); - -- assert.deepStrictEqual({ signedOut, signedIn, signedOutAgain: publishedModels() }, { -- signedOut: ['acme/model'], -- signedIn: ['gpt-5', 'hydrafusion', 'hydrafusion-max', 'acme/model'], -- signedOutAgain: ['acme/model'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ signedOut, signedIn, signedOutAgain: publishedModels() }, { +- signedOut: ['acme/model'], +- signedIn: ['gpt-5', 'hydrafusion', 'hydrafusion-max', 'acme/model'], +- signedOutAgain: ['acme/model'], +- }); - }); - - test('with DYNAMIC_AUTO_TIERS, publishes only BYOK models while signed out, and only the runtime\'s catalog and the HydraFusion tiers it can run while signed in', async () => { @@ -65462,7 +67594,6 @@ index f24a97a6296..00000000000 - copilotClient: client, - byokBridgeRegistry, - rootConfig: { -- [AgentHostConfigKey.AllowSignedOutWhenUsable]: true, - [CopilotCliConfigKey.DynamicAutoTiers]: true, - [CopilotCliConfigKey.HydraFusion]: true, - [CopilotCliConfigKey.HydraFusionV2]: true, @@ -65513,14 +67644,11 @@ index f24a97a6296..00000000000 - }); - } - -- try { -- onDidCapTools.fire({ sessionId: 'sdk-b', requestedToolCount: 200, sentToolCount: 128 }); -- onDidCapTools.fire({ sessionId: 'sdk-unknown', requestedToolCount: 150, sentToolCount: 128 }); +- afterTest(() => disposeAgent(agent)); +- onDidCapTools.fire({ sessionId: 'sdk-b', requestedToolCount: 200, sentToolCount: 128 }); +- onDidCapTools.fire({ sessionId: 'sdk-unknown', requestedToolCount: 150, sentToolCount: 128 }); - -- assert.deepStrictEqual(reports, ['sdk-b:200/128']); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(reports, ['sdk-b:200/128']); - }); - - test('BYOK models from multiple Gemini provider groups have unique picker identifiers', async () => { @@ -65533,30 +67661,27 @@ index f24a97a6296..00000000000 - }; - disposables.add(byokBridgeRegistry.register('renderer', connection)); - -- try { -- modelSnapshots.fire([ -- { -- vendor: 'google', -- id: 'gemini-2.5-pro', -- name: 'Gemini 2.5 Pro', -- modelIdentifier: 'google/Gemini Personal/gemini-2.5-pro', -- }, -- { -- vendor: 'google', -- id: 'gemini-2.5-pro', -- name: 'Gemini 2.5 Pro', -- modelIdentifier: 'google/Gemini Work/gemini-2.5-pro', -- }, -- ]); -- const models = await waitForState(agent.models, models => models.length === 2); +- afterTest(() => disposeAgent(agent)); +- modelSnapshots.fire([ +- { +- vendor: 'google', +- id: 'gemini-2.5-pro', +- name: 'Gemini 2.5 Pro', +- modelIdentifier: 'google/Gemini Personal/gemini-2.5-pro', +- }, +- { +- vendor: 'google', +- id: 'gemini-2.5-pro', +- name: 'Gemini 2.5 Pro', +- modelIdentifier: 'google/Gemini Work/gemini-2.5-pro', +- }, +- ]); +- const models = await waitForState(agent.models, models => models.length === 2); - -- assert.deepStrictEqual(models.map(model => model.id), [ -- 'google/Gemini Personal/gemini-2.5-pro', -- 'google/Gemini Work/gemini-2.5-pro', -- ]); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(models.map(model => model.id), [ +- 'google/Gemini Personal/gemini-2.5-pro', +- 'google/Gemini Work/gemini-2.5-pro', +- ]); - }); - - test('a session resumed before the first BYOK snapshot registers BYOK models once they arrive (#337742)', async () => { @@ -65595,29 +67720,26 @@ index f24a97a6296..00000000000 - const agent = createTestAgent(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService, byokBridgeRegistry, byokProxyService }); - chatBackings(agent).set(chat.toString(), { sdkSessionId: sessionId, model: { id: byokModelId } }); - chatScopes(agent).set(chat.toString(), session); -- try { -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- // Startup restore resumes the SDK session before the renderer pushes BYOK models. -- await agent.chats.getMessages(chat, exactChatContext(session, chat, session)); -- modelSnapshots.fire([{ vendor: 'customendpoint', id: 'deepseek-v4.1-flash', name: 'DeepSeek', modelIdentifier: byokModelId }]); -- await waitForState(agent.models, models => models.some(m => m.id === byokModelId)); -- // The live session registers the new models without waiting for a model change. -- await sdk.providerSynced.p; -- const syncedBeforeModelChange = [...sdk.providerSyncCalls]; -- -- const changeError = await agent.chats.changeModel(chat, { id: byokModelId }, exactChatContext(session, chat, session)) -- .then(() => undefined, (err: Error) => err.message); -- -- assert.deepStrictEqual({ resumedByokModels, syncedBeforeModelChange, providerSyncCalls: sdk.providerSyncCalls, changeError, setModelCalls: sdk.setModelCalls.map(([model]) => model) }, { -- resumedByokModels: [[]], -- syncedBeforeModelChange: [[byokModelId]], -- providerSyncCalls: [[byokModelId]], -- changeError: undefined, -- setModelCalls: [byokModelId], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); +- // Startup restore resumes the SDK session before the renderer pushes BYOK models. +- await agent.chats.getMessages(chat, exactChatContext(session, chat, session)); +- modelSnapshots.fire([{ vendor: 'customendpoint', id: 'deepseek-v4.1-flash', name: 'DeepSeek', modelIdentifier: byokModelId }]); +- await waitForState(agent.models, models => models.some(m => m.id === byokModelId)); +- // The live session registers the new models without waiting for a model change. +- await sdk.providerSynced.p; +- const syncedBeforeModelChange = [...sdk.providerSyncCalls]; +- +- const changeError = await agent.chats.changeModel(chat, { id: byokModelId }, exactChatContext(session, chat, session)) +- .then(() => undefined, (err: Error) => err.message); +- +- assert.deepStrictEqual({ resumedByokModels, syncedBeforeModelChange, providerSyncCalls: sdk.providerSyncCalls, changeError, setModelCalls: sdk.setModelCalls.map(([model]) => model) }, { +- resumedByokModels: [[]], +- syncedBeforeModelChange: [[byokModelId]], +- providerSyncCalls: [[byokModelId]], +- changeError: undefined, +- setModelCalls: [byokModelId], +- }); - }); - - test('configSchema emits a numeric contextSize property when long_context tier exceeds default', async () => { @@ -65635,18 +67757,15 @@ index f24a97a6296..00000000000 - }, - }]), - }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const models = await waitForState(agent.models, models => models.length > 0); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const models = await waitForState(agent.models, models => models.length > 0); - -- const contextSize = models[0].configSchema?.properties.contextSize; -- assert.strictEqual(contextSize?.type, 'number'); -- assert.deepStrictEqual(contextSize?.enum, [200_000, 1_000_000]); -- assert.strictEqual(contextSize?.default, 200_000); -- assert.deepStrictEqual(contextSize?.enumLabels, ['200K', '1M']); -- } finally { -- await disposeAgent(agent); -- } +- const contextSize = models[0].configSchema?.properties.contextSize; +- assert.strictEqual(contextSize?.type, 'number'); +- assert.deepStrictEqual(contextSize?.enum, [200_000, 1_000_000]); +- assert.strictEqual(contextSize?.default, 200_000); +- assert.deepStrictEqual(contextSize?.enumLabels, ['200K', '1M']); - }); - - test('configSchema omits contextSize when long_context tier is missing or not larger', async () => { @@ -65667,15 +67786,12 @@ index f24a97a6296..00000000000 - }, - ]), - }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const models = await waitForState(agent.models, models => models.length > 0); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const models = await waitForState(agent.models, models => models.length > 0); - -- assert.strictEqual(models[0].configSchema, undefined); -- assert.strictEqual(models[1].configSchema, undefined); -- } finally { -- await disposeAgent(agent); -- } +- assert.strictEqual(models[0].configSchema, undefined); +- assert.strictEqual(models[1].configSchema, undefined); - }); - - test('configSchema shows both context options with the longer window as default when long_context tier has no surcharge', async () => { @@ -65693,18 +67809,15 @@ index f24a97a6296..00000000000 - }, - }]), - }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const models = await waitForState(agent.models, models => models.length > 0); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const models = await waitForState(agent.models, models => models.length > 0); - -- const contextSize = models[0].configSchema?.properties?.contextSize; -- assert.strictEqual(contextSize?.type, 'number'); -- assert.deepStrictEqual(contextSize?.enum, [200_000, 1_000_000]); -- assert.strictEqual(contextSize?.default, 1_000_000); -- assert.deepStrictEqual(contextSize?.enumLabels, ['200K', '1M']); -- } finally { -- await disposeAgent(agent); -- } +- const contextSize = models[0].configSchema?.properties?.contextSize; +- assert.strictEqual(contextSize?.type, 'number'); +- assert.deepStrictEqual(contextSize?.enum, [200_000, 1_000_000]); +- assert.strictEqual(contextSize?.default, 1_000_000); +- assert.deepStrictEqual(contextSize?.enumLabels, ['200K', '1M']); - }); - - test('configSchema offers the Auto routing profile by default only to the Auto model', async () => { @@ -65715,34 +67828,31 @@ index f24a97a6296..00000000000 - const agent = createTestAgent(disposables, { - copilotClient: new TestCopilotClient([], models), - }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const published = await waitForState(agent.models, published => published.length === models.length); -- const [auto, concrete] = published.map(model => model.configSchema?.properties.tier); -- assert.deepStrictEqual(published.map(model => model.configSchema?.properties.autoTier), [ -- { ...auto, default: undefined }, -- undefined, -- ]); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const published = await waitForState(agent.models, published => published.length === models.length); +- const [auto, concrete] = published.map(model => model.configSchema?.properties.tier); +- assert.deepStrictEqual(published.map(model => model.configSchema?.properties.autoTier), [ +- { ...auto, default: undefined }, +- undefined, +- ]); - -- assert.deepStrictEqual({ -- auto: { enum: auto?.enum, default: auto?.default, enumLabels: auto?.enumLabels, enumDescriptions: auto?.enumDescriptions }, -- concrete, -- }, { -- auto: { -- enum: ['efficiency', 'balance', 'intelligence'], -- default: 'balance', -- enumLabels: ['Efficiency', 'Balance', 'Intelligence'], -- enumDescriptions: [ -- 'Optimizes for cost and speed, using more capable models only when needed.', -- 'Balances cost/speed and capability based on task complexity.', -- 'Optimizes for capability, using faster models only when the task allows it.', -- ], -- }, -- concrete: undefined, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- auto: { enum: auto?.enum, default: auto?.default, enumLabels: auto?.enumLabels, enumDescriptions: auto?.enumDescriptions }, +- concrete, +- }, { +- auto: { +- enum: ['efficiency', 'balance', 'intelligence'], +- default: 'balance', +- enumLabels: ['Efficiency', 'Balance', 'Intelligence'], +- enumDescriptions: [ +- 'Optimizes for cost and speed, using more capable models only when needed.', +- 'Balances cost/speed and capability based on task complexity.', +- 'Optimizes for capability, using faster models only when the task allows it.', +- ], +- }, +- concrete: undefined, +- }); - }); - - test('configSchema offers the provider Auto tiers from models.list in its order and default, naming and describing any it leaves unnamed or undescribed', async () => { @@ -65941,21 +68051,18 @@ index f24a97a6296..00000000000 - - test('offers Auto with its routing profiles when the runtime lists no models', async () => { - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([], []) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const published = await waitForState(agent.models, published => published.length > 0); -- const tier = published[0].configSchema?.properties.tier; +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const published = await waitForState(agent.models, published => published.length > 0); +- const tier = published[0].configSchema?.properties.tier; - -- assert.deepStrictEqual({ -- models: published.map(model => ({ id: model.id, name: model.name })), -- tier: { enum: tier?.enum, default: tier?.default }, -- }, { -- models: [{ id: 'auto', name: 'Auto' }], -- tier: { enum: ['efficiency', 'balance', 'intelligence'], default: 'balance' }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- models: published.map(model => ({ id: model.id, name: model.name })), +- tier: { enum: tier?.enum, default: tier?.default }, +- }, { +- models: [{ id: 'auto', name: 'Auto' }], +- tier: { enum: ['efficiency', 'balance', 'intelligence'], default: 'balance' }, +- }); - }); - - test('does not retry an empty refresh with only fallback Auto and HydraFusion and accepts a later catalog', async () => { @@ -65998,20 +68105,17 @@ index f24a97a6296..00000000000 - copilotClient: new TestCopilotClient([], []), - rootConfig: { [CopilotCliConfigKey.HydraFusion]: true }, - }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.refreshModels(); -- const validation = await (agent as unknown as { _validateModelSelection(model: ModelSelection): Promise }) -- ._validateModelSelection({ id: 'claude-haiku-4.5' }) -- .then(() => 'accepted', () => 'rejected'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.refreshModels(); +- const validation = await (agent as unknown as { _validateModelSelection(model: ModelSelection): Promise }) +- ._validateModelSelection({ id: 'claude-haiku-4.5' }) +- .then(() => 'accepted', () => 'rejected'); - -- assert.deepStrictEqual({ models: agent.models.get().map(model => model.id), validation }, { -- models: ['auto', 'hydrafusion'], -- validation: 'accepted', -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ models: agent.models.get().map(model => model.id), validation }, { +- models: ['auto', 'hydrafusion'], +- validation: 'accepted', +- }); - }); - - suite('contextSize to contextTier mapping', () => { @@ -66155,29 +68259,26 @@ index f24a97a6296..00000000000 - }; - const sessionDataService = disposables.add(new TestSessionDataService()); - const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await waitForState(agent.models, m => m.length > 0); -- const result = await provisionSession(agent, { -- session: AgentSession.uri('copilotcli', 'long-context-to-auto'), -- workingDirectories: [URI.file('/workspace')], -- model: { id: 'claude-sonnet', config: { contextSize: '1000000' } }, -- }); -- const chat = defaultChatUri(result.session); -- const context = exactChatContext(result.session, chat, result.session); -- await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, context); -- await agent.chats.changeModel(chat, { id: 'auto', config: { tier: 'hydrafusion' } }, context); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await waitForState(agent.models, m => m.length > 0); +- const result = await provisionSession(agent, { +- session: AgentSession.uri('copilotcli', 'long-context-to-auto'), +- workingDirectories: [URI.file('/workspace')], +- model: { id: 'claude-sonnet', config: { contextSize: '1000000' } }, +- }); +- const chat = defaultChatUri(result.session); +- const context = exactChatContext(result.session, chat, result.session); +- await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, context); +- await agent.chats.changeModel(chat, { id: 'auto', config: { tier: 'hydrafusion' } }, context); - -- assert.deepStrictEqual({ -- initialContextTier: capturedConfig?.contextTier, -- modelCalls: sdkSession.setModelCalls.map(([model, options]) => [model, options?.contextTier]), -- }, { -- initialContextTier: 'long_context', -- modelCalls: [['auto', 'default']], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- initialContextTier: capturedConfig?.contextTier, +- modelCalls: sdkSession.setModelCalls.map(([model, options]) => [model, options?.contextTier]), +- }, { +- initialContextTier: 'long_context', +- modelCalls: [['auto', 'default']], +- }); - }); - }); - @@ -66216,14 +68317,11 @@ index f24a97a6296..00000000000 - productService: { ...TEST_PRODUCT_SERVICE, quality }, - rootConfig: enabled === undefined ? {} : { [CopilotCliConfigKey.ImageGeneration]: enabled }, - }); -- try { -- const created = createAgentSessionThroughAgent(agent, instantiationService); -- const session = disposables.add(created.session); -- await session.initializeSession(); -- assert.deepStrictEqual(created.createOptions()?.imageGeneration, { enabled: enabled === true, watermark: false }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const created = createAgentSessionThroughAgent(agent, instantiationService); +- const session = disposables.add(created.session); +- await session.initializeSession(); +- assert.deepStrictEqual(created.createOptions()?.imageGeneration, { enabled: enabled === true, watermark: false }); - }); - } - } @@ -66235,15 +68333,12 @@ index f24a97a6296..00000000000 - sessionDataService: disposables.add(new TestSessionDataService()), - rootConfig: { [CopilotCliConfigKey.HydraFusion]: enabled }, - }); -- try { -- const createdSession = createAgentSessionThroughAgent(agent, instantiationService); -- const session = disposables.add(createdSession.session); -- await session.initializeSession(); +- afterTest(() => disposeAgent(agent)); +- const createdSession = createAgentSessionThroughAgent(agent, instantiationService); +- const session = disposables.add(createdSession.session); +- await session.initializeSession(); - -- assert.strictEqual(createdSession.createOptions()?.enableExperimentalMode, enabled ? true : undefined); -- } finally { -- await disposeAgent(agent); -- } +- assert.strictEqual(createdSession.createOptions()?.enableExperimentalMode, enabled ? true : undefined); - }); - } - @@ -66253,28 +68348,25 @@ index f24a97a6296..00000000000 - environmentServiceRegistration: 'native', - sessionDataService, - }); -- try { -- const createdSession = createAgentSessionThroughAgent(agent, instantiationService); -- const agentSession = disposables.add(createdSession.session); -- await agentSession.initializeSession(); -- const onPermissionRequest = createdSession.createOptions()?.onPermissionRequest; -- assert.ok(onPermissionRequest); -- -- const result = await onPermissionRequest({ -- kind: 'read', -- intention: 'read plan', -- path: URI.file('/mock-home/.copilot/session-state/test-session-1/plan.md').fsPath, -- toolCallId: 'tc-read-plan-agent-composition', -- }, { sessionId: 'test-session-1' }); -- -- assert.deepStrictEqual(result, { -- kind: 'attributed', -- result: { kind: 'approve-once' }, -- decisionContext: { source: 'host_policy', surface: 'sdk', outcome: 'auto_approved' }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const createdSession = createAgentSessionThroughAgent(agent, instantiationService); +- const agentSession = disposables.add(createdSession.session); +- await agentSession.initializeSession(); +- const onPermissionRequest = createdSession.createOptions()?.onPermissionRequest; +- assert.ok(onPermissionRequest); +- +- const result = await onPermissionRequest({ +- kind: 'read', +- intention: 'read plan', +- path: URI.file('/mock-home/.copilot/session-state/test-session-1/plan.md').fsPath, +- toolCallId: 'tc-read-plan-agent-composition', +- }, { sessionId: 'test-session-1' }); +- +- assert.deepStrictEqual(result, { +- kind: 'attributed', +- result: { kind: 'approve-once' }, +- decisionContext: { source: 'host_policy', surface: 'sdk', outcome: 'auto_approved' }, +- }); - }); - - test('client tool call contributor prefers the message sender when it provides the tool', async () => { @@ -66297,23 +68389,20 @@ index f24a97a6296..00000000000 - snapshot: { discoveredAgents: [], additionalRootInstructions: [], tools: activeClientToolSet.merged(), plugins: [], mcpServers: {} }, - }); - const agentSession = disposables.add(createdSession.session); -- try { -- await agentSession.initializeSession(); -- agentSession.resetTurnState('turn-1', 'client-B'); +- afterTest(() => disposeAgent(agent)); +- await agentSession.initializeSession(); +- agentSession.resetTurnState('turn-1', 'client-B'); - -- mockSession.emit({ -- type: 'tool.execution_start', -- data: { toolCallId: 'tool-1', toolName: 'shared', arguments: {} }, -- } as SessionEventPayload<'tool.execution_start'>); +- mockSession.emit({ +- type: 'tool.execution_start', +- data: { toolCallId: 'tool-1', toolName: 'shared', arguments: {} }, +- } as SessionEventPayload<'tool.execution_start'>); - -- const toolStart = actions.find(action => action.type === ActionType.ChatToolCallStart); -- assert.deepStrictEqual(toolStart?.type === ActionType.ChatToolCallStart ? toolStart.contributor : undefined, { -- kind: ToolCallContributorKind.Client, -- clientId: 'client-B', -- }); -- } finally { -- await disposeAgent(agent); -- } +- const toolStart = actions.find(action => action.type === ActionType.ChatToolCallStart); +- assert.deepStrictEqual(toolStart?.type === ActionType.ChatToolCallStart ? toolStart.contributor : undefined, { +- kind: ToolCallContributorKind.Client, +- clientId: 'client-B', +- }); - }); - - test('client tool completion unblocks a pending permission request', async () => { @@ -66331,44 +68420,41 @@ index f24a97a6296..00000000000 - } - } - })); -- try { -- await agentSession.initializeSession(); -- const onPermissionRequest = createdSession.createOptions()?.onPermissionRequest; -- assert.ok(onPermissionRequest); -- -- const permissionRequestResult = onPermissionRequest({ -- kind: 'write', -- toolCallId: 'tool-1', -- canOfferSessionApproval: false, -- diff: '--- a/file.txt\n+++ b/file.txt\n@@ -0,0 +1 @@\n+after', -- fileName: URI.file('/workspace/file.txt').fsPath, -- intention: 'write file', -- newFileContents: 'after', -- }, { sessionId: 'test-session-1' }); -- const editContentUri = await pendingEditContentUri.p; +- afterTest(() => disposeAgent(agent)); +- await agentSession.initializeSession(); +- const onPermissionRequest = createdSession.createOptions()?.onPermissionRequest; +- assert.ok(onPermissionRequest); - -- agentSession.handleClientToolCallComplete('tool-1', { -- success: false, -- pastTenseMessage: 'Client tool failed', -- content: [{ type: ToolResultContentType.Text, text: 'failed before approval' }], -- error: { message: 'failed before approval' }, -- }); -- await timeout(0); +- const permissionRequestResult = onPermissionRequest({ +- kind: 'write', +- toolCallId: 'tool-1', +- canOfferSessionApproval: false, +- diff: '--- a/file.txt\n+++ b/file.txt\n@@ -0,0 +1 @@\n+after', +- fileName: URI.file('/workspace/file.txt').fsPath, +- intention: 'write file', +- newFileContents: 'after', +- }, { sessionId: 'test-session-1' }); +- const editContentUri = await pendingEditContentUri.p; +- +- agentSession.handleClientToolCallComplete('tool-1', { +- success: false, +- pastTenseMessage: 'Client tool failed', +- content: [{ type: ToolResultContentType.Text, text: 'failed before approval' }], +- error: { message: 'failed before approval' }, +- }); +- await timeout(0); - -- assert.deepStrictEqual({ -- permissionResult: await permissionRequestResult, -- pendingEditContentExists: await fileService.exists(editContentUri), -- }, { -- permissionResult: { -- kind: 'attributed', -- result: { kind: 'approve-once' }, -- decisionContext: { source: 'host_policy', surface: 'sdk', outcome: 'auto_approved' }, -- }, -- pendingEditContentExists: false, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- permissionResult: await permissionRequestResult, +- pendingEditContentExists: await fileService.exists(editContentUri), +- }, { +- permissionResult: { +- kind: 'attributed', +- result: { kind: 'approve-once' }, +- decisionContext: { source: 'host_policy', surface: 'sdk', outcome: 'auto_approved' }, +- }, +- pendingEditContentExists: false, +- }); - }); - - test('auto-approves one duplicate write permission request after approval', async () => { @@ -66385,46 +68471,43 @@ index f24a97a6296..00000000000 - nextPendingPermission.complete(); - } - })); -- try { -- await agentSession.initializeSession(); -- const onPermissionRequest = createdSession.createOptions()?.onPermissionRequest; -- assert.ok(onPermissionRequest); -- const request: PermissionRequest = { -- kind: 'write', -- toolCallId: 'tool-1', -- canOfferSessionApproval: true, -- diff: '--- a/file.txt\n+++ b/file.txt\n@@ -0,0 +1 @@\n+after', -- fileName: URI.file('/outside/file.txt').fsPath, -- intention: 'write file', -- newFileContents: 'after', -- }; +- afterTest(() => disposeAgent(agent)); +- await agentSession.initializeSession(); +- const onPermissionRequest = createdSession.createOptions()?.onPermissionRequest; +- assert.ok(onPermissionRequest); +- const request: PermissionRequest = { +- kind: 'write', +- toolCallId: 'tool-1', +- canOfferSessionApproval: true, +- diff: '--- a/file.txt\n+++ b/file.txt\n@@ -0,0 +1 @@\n+after', +- fileName: URI.file('/outside/file.txt').fsPath, +- intention: 'write file', +- newFileContents: 'after', +- }; - -- const firstResultPromise = onPermissionRequest(request, { sessionId: 'test-session-1' }); -- await nextPendingPermission.p; -- agentSession.respondToPermissionRequest('tool-1', true); -- const firstResult = await firstResultPromise; -- const duplicateResult = await onPermissionRequest({ ...request }, { sessionId: 'test-session-1' }); +- const firstResultPromise = onPermissionRequest(request, { sessionId: 'test-session-1' }); +- await nextPendingPermission.p; +- agentSession.respondToPermissionRequest('tool-1', true); +- const firstResult = await firstResultPromise; +- const duplicateResult = await onPermissionRequest({ ...request }, { sessionId: 'test-session-1' }); - -- nextPendingPermission = new DeferredPromise(); -- const thirdResultPromise = onPermissionRequest({ ...request }, { sessionId: 'test-session-1' }); -- await nextPendingPermission.p; -- agentSession.respondToPermissionRequest('tool-1', false); -- const thirdResult = await thirdResultPromise; +- nextPendingPermission = new DeferredPromise(); +- const thirdResultPromise = onPermissionRequest({ ...request }, { sessionId: 'test-session-1' }); +- await nextPendingPermission.p; +- agentSession.respondToPermissionRequest('tool-1', false); +- const thirdResult = await thirdResultPromise; - -- assert.deepStrictEqual({ -- results: [firstResult, duplicateResult, thirdResult], -- pendingPermissionCount, -- }, { -- results: [ -- { kind: 'approve-once' }, -- { kind: 'attributed', result: { kind: 'approve-once' }, decisionContext: { source: 'host_policy', surface: 'sdk', outcome: 'auto_approved' } }, -- { kind: 'reject', feedback: 'The user denied permission.' }, -- ], -- pendingPermissionCount: 2, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- results: [firstResult, duplicateResult, thirdResult], +- pendingPermissionCount, +- }, { +- results: [ +- { kind: 'approve-once' }, +- { kind: 'attributed', result: { kind: 'approve-once' }, decisionContext: { source: 'host_policy', surface: 'sdk', outcome: 'auto_approved' } }, +- { kind: 'reject', feedback: 'The user denied permission.' }, +- ], +- pendingPermissionCount: 2, +- }); - }); - - test('requires confirmation when a second write permission request differs', async () => { @@ -66441,46 +68524,43 @@ index f24a97a6296..00000000000 - nextPendingPermission.complete(); - } - })); -- try { -- await agentSession.initializeSession(); -- const onPermissionRequest = createdSession.createOptions()?.onPermissionRequest; -- assert.ok(onPermissionRequest); -- const request: PermissionRequest = { -- kind: 'write', -- toolCallId: 'tool-1', -- canOfferSessionApproval: true, -- diff: '--- a/file.txt\n+++ b/file.txt\n@@ -0,0 +1 @@\n+first', -- fileName: URI.file('/outside/file.txt').fsPath, -- intention: 'write file', -- newFileContents: 'first', -- }; -- -- const firstResultPromise = onPermissionRequest(request, { sessionId: 'test-session-1' }); -- await nextPendingPermission.p; -- agentSession.respondToPermissionRequest('tool-1', true); -- const firstResult = await firstResultPromise; +- afterTest(() => disposeAgent(agent)); +- await agentSession.initializeSession(); +- const onPermissionRequest = createdSession.createOptions()?.onPermissionRequest; +- assert.ok(onPermissionRequest); +- const request: PermissionRequest = { +- kind: 'write', +- toolCallId: 'tool-1', +- canOfferSessionApproval: true, +- diff: '--- a/file.txt\n+++ b/file.txt\n@@ -0,0 +1 @@\n+first', +- fileName: URI.file('/outside/file.txt').fsPath, +- intention: 'write file', +- newFileContents: 'first', +- }; - -- nextPendingPermission = new DeferredPromise(); -- const changedResultPromise = onPermissionRequest({ -- ...request, -- diff: '--- a/other.txt\n+++ b/other.txt\n@@ -0,0 +1 @@\n+second', -- fileName: URI.file('/outside/other.txt').fsPath, -- newFileContents: 'second', -- }, { sessionId: 'test-session-1' }); -- await nextPendingPermission.p; -- agentSession.respondToPermissionRequest('tool-1', false); -- const changedResult = await changedResultPromise; +- const firstResultPromise = onPermissionRequest(request, { sessionId: 'test-session-1' }); +- await nextPendingPermission.p; +- agentSession.respondToPermissionRequest('tool-1', true); +- const firstResult = await firstResultPromise; +- +- nextPendingPermission = new DeferredPromise(); +- const changedResultPromise = onPermissionRequest({ +- ...request, +- diff: '--- a/other.txt\n+++ b/other.txt\n@@ -0,0 +1 @@\n+second', +- fileName: URI.file('/outside/other.txt').fsPath, +- newFileContents: 'second', +- }, { sessionId: 'test-session-1' }); +- await nextPendingPermission.p; +- agentSession.respondToPermissionRequest('tool-1', false); +- const changedResult = await changedResultPromise; - -- assert.deepStrictEqual({ -- results: [firstResult, changedResult], -- pendingPermissionCount, -- }, { -- results: [{ kind: 'approve-once' }, { kind: 'reject', feedback: 'The user denied permission.' }], -- pendingPermissionCount: 2, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- results: [firstResult, changedResult], +- pendingPermissionCount, +- }, { +- results: [{ kind: 'approve-once' }, { kind: 'reject', feedback: 'The user denied permission.' }], +- pendingPermissionCount: 2, +- }); - }); - - test('auto-approves one duplicate read permission request after approval', async () => { @@ -66497,52 +68577,46 @@ index f24a97a6296..00000000000 - nextPendingPermission.complete(); - } - })); -- try { -- await agentSession.initializeSession(); -- const onPermissionRequest = createdSession.createOptions()?.onPermissionRequest; -- assert.ok(onPermissionRequest); -- const request: PermissionRequest = { -- kind: 'read', -- toolCallId: 'tool-1', -- intention: 'read file', -- path: URI.file('/outside/file.txt').fsPath, -- }; +- afterTest(() => disposeAgent(agent)); +- await agentSession.initializeSession(); +- const onPermissionRequest = createdSession.createOptions()?.onPermissionRequest; +- assert.ok(onPermissionRequest); +- const request: PermissionRequest = { +- kind: 'read', +- toolCallId: 'tool-1', +- intention: 'read file', +- path: URI.file('/outside/file.txt').fsPath, +- }; - -- const firstResultPromise = onPermissionRequest(request, { sessionId: 'test-session-1' }); -- await nextPendingPermission.p; -- agentSession.respondToPermissionRequest('tool-1', true); -- const firstResult = await firstResultPromise; -- const duplicateResult = await onPermissionRequest({ ...request }, { sessionId: 'test-session-1' }); +- const firstResultPromise = onPermissionRequest(request, { sessionId: 'test-session-1' }); +- await nextPendingPermission.p; +- agentSession.respondToPermissionRequest('tool-1', true); +- const firstResult = await firstResultPromise; +- const duplicateResult = await onPermissionRequest({ ...request }, { sessionId: 'test-session-1' }); - -- nextPendingPermission = new DeferredPromise(); -- const thirdResultPromise = onPermissionRequest({ ...request }, { sessionId: 'test-session-1' }); -- await nextPendingPermission.p; -- agentSession.respondToPermissionRequest('tool-1', false); -- const thirdResult = await thirdResultPromise; +- nextPendingPermission = new DeferredPromise(); +- const thirdResultPromise = onPermissionRequest({ ...request }, { sessionId: 'test-session-1' }); +- await nextPendingPermission.p; +- agentSession.respondToPermissionRequest('tool-1', false); +- const thirdResult = await thirdResultPromise; - -- assert.deepStrictEqual({ -- results: [firstResult, duplicateResult, thirdResult], -- pendingPermissionCount, -- }, { -- results: [ -- { kind: 'approve-once' }, -- { kind: 'attributed', result: { kind: 'approve-once' }, decisionContext: { source: 'host_policy', surface: 'sdk', outcome: 'auto_approved' } }, -- { kind: 'reject', feedback: 'The user denied permission.' }, -- ], -- pendingPermissionCount: 2, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- results: [firstResult, duplicateResult, thirdResult], +- pendingPermissionCount, +- }, { +- results: [ +- { kind: 'approve-once' }, +- { kind: 'attributed', result: { kind: 'approve-once' }, decisionContext: { source: 'host_policy', surface: 'sdk', outcome: 'auto_approved' } }, +- { kind: 'reject', feedback: 'The user denied permission.' }, +- ], +- pendingPermissionCount: 2, +- }); - }); - - test('does not support external chat discovery', async () => { - const agent = createTestAgent(disposables); -- try { -- assert.strictEqual((agent as { listExternalChats?: object }).listExternalChats, undefined); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- assert.strictEqual((agent as { listExternalChats?: object }).listExternalChats, undefined); - }); - - test('listChatsToMigrate returns only Agent Host-owned sessions without legacy markers', async () => { @@ -66558,14 +68632,11 @@ index f24a97a6296..00000000000 - - const client = new TestCopilotClient([sdkSession('owned'), sdkSession('ghost'), sdkSession('external')]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); - -- const catalog = await agent.listChatsToMigrate(); -- assert.deepStrictEqual(catalog?.map(s => sessionIdOfChat(s.chat)), ['owned']); -- } finally { -- await disposeAgent(agent); -- } +- const catalog = await agent.listChatsToMigrate(); +- assert.deepStrictEqual(catalog?.map(s => sessionIdOfChat(s.chat)), ['owned']); - }); - - test('listSessions reads stored metadata from sessions with a database', async () => { @@ -66576,21 +68647,18 @@ index f24a97a6296..00000000000 - legacyDb.dispose(); - - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([sdkSession('legacy')]) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- -- const catalog = await agent.listChatsToMigrate(); -- assert.deepStrictEqual(catalog?.map(withoutUndefinedProperties), [{ -- session: legacySession, -- chat: defaultChatUri(legacySession), -- startTime: 1000, -- modifiedTime: 2000, -- summary: 'SDK legacy', -- workingDirectories: [URI.file('/workspace')], -- }]); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- +- const catalog = await agent.listChatsToMigrate(); +- assert.deepStrictEqual(catalog?.map(withoutUndefinedProperties), [{ +- session: legacySession, +- chat: defaultChatUri(legacySession), +- startTime: 1000, +- modifiedTime: 2000, +- summary: 'SDK legacy', +- workingDirectories: [URI.file('/workspace')], +- }]); - }); - - test('listSessions does not itself re-emit the workspaceless tag (AgentService overlays it centrally)', async () => { @@ -66606,21 +68674,18 @@ index f24a97a6296..00000000000 - db.dispose(); - - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([sdkSession('quick')]) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); - -- const catalog = await agent.listChatsToMigrate(); -- assert.deepStrictEqual(catalog?.map(withoutUndefinedProperties), [{ -- session, -- chat: defaultChatUri(session), -- startTime: 1000, -- modifiedTime: 2000, -- summary: 'SDK quick', -- workingDirectories: [URI.file('/scratch/quick')], -- }]); -- } finally { -- await disposeAgent(agent); -- } +- const catalog = await agent.listChatsToMigrate(); +- assert.deepStrictEqual(catalog?.map(withoutUndefinedProperties), [{ +- session, +- chat: defaultChatUri(session), +- startTime: 1000, +- modifiedTime: 2000, +- summary: 'SDK quick', +- workingDirectories: [URI.file('/scratch/quick')], +- }]); - }); - - test('getChatMetadata reads one SDK session and stored metadata without listing sessions', async () => { @@ -66632,24 +68697,21 @@ index f24a97a6296..00000000000 - - const client = new TestCopilotClient([sdkSession('target')]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); - -- const chat = defaultChatUri(session); -- const metadata = await agent.getChatMetadata(chat, exactChatContext(session, chat, session)); -- assert.ok(metadata); -- assert.deepStrictEqual(withoutUndefinedProperties(metadata), { -- chat, -- startTime: 1000, -- modifiedTime: 2000, -- summary: 'SDK target', -- workingDirectories: [URI.file('/workspace')], -- }); -- assert.deepStrictEqual(client.getSessionMetadataCalls, ['target']); -- assert.strictEqual(client.listSessionCallCount, 0); -- } finally { -- await disposeAgent(agent); -- } +- const chat = defaultChatUri(session); +- const metadata = await agent.getChatMetadata(chat, exactChatContext(session, chat, session)); +- assert.ok(metadata); +- assert.deepStrictEqual(withoutUndefinedProperties(metadata), { +- chat, +- startTime: 1000, +- modifiedTime: 2000, +- summary: 'SDK target', +- workingDirectories: [URI.file('/workspace')], +- }); +- assert.deepStrictEqual(client.getSessionMetadataCalls, ['target']); +- assert.strictEqual(client.listSessionCallCount, 0); - }); - - test('getChatMetadata reads a moved default chat from its own session\'s database and never a peer from its owner\'s', async () => { @@ -66663,22 +68725,19 @@ index f24a97a6296..00000000000 - await deferCopilotSdkExecution(sessionDataService, storage, 'stored-here', new NullLogService()); - const client = new TestCopilotClient([sdkSession('owned-elsewhere')]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const moved = defaultChatUri(storage); -- const peer = URI.parse(buildChatUri(owner, 'peer-without-backing')); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const moved = defaultChatUri(storage); +- const peer = URI.parse(buildChatUri(owner, 'peer-without-backing')); - -- const movedMetadata = await agent.getChatMetadata(moved, exactChatContext(owner, moved, storage)); -- const peerMetadata = await agent.getChatMetadata(peer, exactChatContext(owner, peer)); +- const movedMetadata = await agent.getChatMetadata(moved, exactChatContext(owner, moved, storage)); +- const peerMetadata = await agent.getChatMetadata(peer, exactChatContext(owner, peer)); - -- assert.deepStrictEqual({ moved: movedMetadata?.workingDirectories, peer: peerMetadata, sdkLookups: client.getSessionMetadataCalls }, { -- moved: [URI.file('/stored-workspace')], -- peer: undefined, -- sdkLookups: ['stored-here'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ moved: movedMetadata?.workingDirectories, peer: peerMetadata, sdkLookups: client.getSessionMetadataCalls }, { +- moved: [URI.file('/stored-workspace')], +- peer: undefined, +- sdkLookups: ['stored-here'], +- }); - }); - - test('getChatMetadata returns a provider-native session without a database', async () => { @@ -66686,25 +68745,22 @@ index f24a97a6296..00000000000 - const session = AgentSession.uri('copilotcli', 'external'); - const client = new TestCopilotClient([sdkSession('external', '/workspace')]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); - -- const chat = defaultChatUri(session); -- const metadata = await agent.getChatMetadata(chat, exactChatContext(session, chat, session)); -- assert.ok(metadata); -- assert.deepStrictEqual(withoutUndefinedProperties(metadata), { -- chat, -- startTime: 1000, -- modifiedTime: 2000, -- summary: 'SDK external', -- workingDirectories: [URI.file('/workspace')], -- }); -- assert.deepStrictEqual(client.getSessionMetadataCalls, ['external']); -- assert.strictEqual(client.listSessionCallCount, 0); -- assert.deepStrictEqual(sessionDataService.openedSessions, []); -- } finally { -- await disposeAgent(agent); -- } +- const chat = defaultChatUri(session); +- const metadata = await agent.getChatMetadata(chat, exactChatContext(session, chat, session)); +- assert.ok(metadata); +- assert.deepStrictEqual(withoutUndefinedProperties(metadata), { +- chat, +- startTime: 1000, +- modifiedTime: 2000, +- summary: 'SDK external', +- workingDirectories: [URI.file('/workspace')], +- }); +- assert.deepStrictEqual(client.getSessionMetadataCalls, ['external']); +- assert.strictEqual(client.listSessionCallCount, 0); +- assert.deepStrictEqual(sessionDataService.openedSessions, []); - }); - - for (const metadataKind of ['empty', 'project', 'native'] as const) { @@ -66731,34 +68787,33 @@ index f24a97a6296..00000000000 - copilotClient: new TestCopilotClient([sdkSession(sessionId, workingDirectory)]), - userHome, - }); -- try { -- const chat = defaultChatUri(session); -- const metadata = await agent.getChatMetadata(chat, exactChatContext(session, chat, session)); -- const catalog = await agent.listChatsToMigrate(); -- const stored = await sessionDataService.tryOpenDatabase(session); -- try { -- assert.deepStrictEqual({ -- adoptable: readSessionEhcliAdoptable(metadata?._meta) === true, -- archived: metadata?.status !== undefined && (metadata.status & SessionStatus.IsArchived) !== 0, -- migrated: catalog?.map(entry => sessionIdOfChat(entry.chat)), -- projectWrites: sessionDataService.metadataWrites(session).filter(entry => entry.key === 'copilot.project.resolved').length, -- persistedArchive: await stored?.object.getMetadata(AH_META_IS_ARCHIVED_DB_KEY), -- adopted: await stored?.object.getMetadata(AH_META_EHCLI_ADOPTED_DB_KEY), -- }, { -- adoptable: metadataKind !== 'native', -- archived: metadataKind !== 'native', -- migrated: metadataKind === 'native' ? [sessionId] : [], -- projectWrites: metadataKind === 'empty' ? 0 : 1, -- persistedArchive: '', -- adopted: undefined, -- }); -- } finally { -- stored?.dispose(); -- } -- } finally { +- afterTest(async () => { - await disposeAgent(agent); - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); +- }); +- const chat = defaultChatUri(session); +- const metadata = await agent.getChatMetadata(chat, exactChatContext(session, chat, session)); +- const catalog = await agent.listChatsToMigrate(); +- const stored = await sessionDataService.tryOpenDatabase(session); +- try { +- assert.deepStrictEqual({ +- adoptable: readSessionEhcliAdoptable(metadata?._meta) === true, +- archived: metadata?.status !== undefined && (metadata.status & SessionStatus.IsArchived) !== 0, +- migrated: catalog?.map(entry => sessionIdOfChat(entry.chat)), +- projectWrites: sessionDataService.metadataWrites(session).filter(entry => entry.key === 'copilot.project.resolved').length, +- persistedArchive: await stored?.object.getMetadata(AH_META_IS_ARCHIVED_DB_KEY), +- adopted: await stored?.object.getMetadata(AH_META_EHCLI_ADOPTED_DB_KEY), +- }, { +- adoptable: metadataKind !== 'native', +- archived: metadataKind !== 'native', +- migrated: metadataKind === 'native' ? [sessionId] : [], +- projectWrites: metadataKind === 'empty' ? 0 : 1, +- persistedArchive: '', +- adopted: undefined, +- }); +- } finally { +- stored?.dispose(); - } - }); - } @@ -66772,31 +68827,25 @@ index f24a97a6296..00000000000 - db.dispose(); - const client = new TestCopilotClient([sdkSession('unowned', chatDirectory), sdkSession('host-owned', chatDirectory)]); - const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); - -- const workspaceless = await Promise.all(['unowned', 'host-owned'].map(async sessionId => { -- const session = AgentSession.uri('copilotcli', sessionId); -- const chat = defaultChatUri(session); -- return readSessionWorkspaceless((await agent.getChatMetadata(chat, exactChatContext(session, chat, session)))?._meta); -- })); -- assert.deepStrictEqual(workspaceless, [true, false]); -- } finally { -- await disposeAgent(agent); -- } +- const workspaceless = await Promise.all(['unowned', 'host-owned'].map(async sessionId => { +- const session = AgentSession.uri('copilotcli', sessionId); +- const chat = defaultChatUri(session); +- return readSessionWorkspaceless((await agent.getChatMetadata(chat, exactChatContext(session, chat, session)))?._meta); +- })); +- assert.deepStrictEqual(workspaceless, [true, false]); - }); - - test('listChatsToMigrate checks but does not create databases for unowned SDK sessions', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([sdkSession('external', '/workspace')]) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); - -- assert.deepStrictEqual(await agent.listChatsToMigrate(), []); -- assert.deepStrictEqual(sessionDataService.openedSessions, []); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(await agent.listChatsToMigrate(), []); +- assert.deepStrictEqual(sessionDataService.openedSessions, []); - }); - - suite('listSessions legacy-CLI surfacing (migration)', () => { @@ -66886,6 +68935,72 @@ index f24a97a6296..00000000000 - } - }); - +- test('uses the host gate passed to discovery over the setting a client mirrored after host startup', async () => { +- const discovered = async (hostGate: boolean, mirroredSetting: boolean) => { +- const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/host-gate-home-`)); +- const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/host-gate-cwd-`); +- const sessionId = `host-gate-${hostGate}`; +- const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); +- await writeExtensionHostMarker(userHome, sessionId); +- const { agent } = createTestAgentContext(disposables, { +- copilotClient: client, +- userHome, +- rootConfig: { [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: mirroredSetting }, +- }); +- const discoveredChats: Array = []; +- const listener = agent.onDidDiscoverChats(chats => discoveredChats.push(chats)); +- try { +- await agent.startChatDiscovery({ migrateLegacyChats: hostGate }); +- return discoveredChats.flatMap(chats => chats.map(chat => sessionIdOfChat(chat.chat))); +- } finally { +- listener.dispose(); +- await fs.rm(userHome.fsPath, { recursive: true, force: true }); +- await fs.rm(workingDirectory, { recursive: true, force: true }); +- await disposeAgent(agent); +- } +- }; +- +- assert.deepStrictEqual({ +- hostOffMirroredOn: await discovered(false, true), +- hostOnMirroredOff: await discovered(true, false), +- }, { +- hostOffMirroredOn: [], +- hostOnMirroredOff: ['host-gate-true'], +- }); +- }); +- +- test('discovers a legacy chat recorded only in the bulk metadata, restoring its marker only while migration is on', async () => { +- const discovered = async (migrate: boolean) => { +- const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/bulk-only-home-`)); +- const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/bulk-only-cwd-`); +- const sessionId = `bulk-only-${migrate}`; +- const copilotHome = getCopilotHomePath(userHome.fsPath, {}); +- const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); +- await fs.mkdir(join(copilotHome, 'session-state', sessionId), { recursive: true }); +- await fs.writeFile(join(copilotHome, 'vscode.session.metadata.cache.json'), JSON.stringify({ +- [sessionId]: { origin: 'vscode', workspaceFolder: { folderPath: workingDirectory, timestamp: 1 } }, +- })); +- const { agent } = createTestAgentContext(disposables, { copilotClient: client, userHome }); +- const discoveredChats: Array = []; +- const listener = agent.onDidDiscoverChats(chats => discoveredChats.push(chats)); +- try { +- await agent.startChatDiscovery({ migrateLegacyChats: migrate }); +- const markerWritten = await fs.stat(join(copilotHome, 'session-state', sessionId, 'vscode.metadata.json')).then(() => true, () => false); +- return { chats: discoveredChats.flatMap(chats => chats.map(chat => sessionIdOfChat(chat.chat))), markerWritten }; +- } finally { +- listener.dispose(); +- await fs.rm(userHome.fsPath, { recursive: true, force: true }); +- await fs.rm(workingDirectory, { recursive: true, force: true }); +- await disposeAgent(agent); +- } +- }; +- +- assert.deepStrictEqual({ on: await discovered(true), off: await discovered(false) }, { +- on: { chats: ['bulk-only-true'], markerWritten: true }, +- off: { chats: [], markerWritten: false }, +- }); +- }); +- - test('does not discover an extension-host chat with an empty Agent Host database', async () => { - const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/ghost-migration-event-home-`)); - const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/ghost-migration-event-cwd-`); @@ -66920,18 +69035,17 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); - const { agent, configurationService } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await writeExtensionHostMarker(userHome, sessionId); -- configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); -- -- const catalog = await agent.listChatsToMigrate(); -- assert.deepStrictEqual(catalog, []); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await writeExtensionHostMarker(userHome, sessionId); +- configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); +- +- const catalog = await agent.listChatsToMigrate(); +- assert.deepStrictEqual(catalog, []); - }); - - test('getChatMetadata re-derives the adoptable marker for a discovered extension-host chat', async () => { @@ -66942,18 +69056,17 @@ index f24a97a6296..00000000000 - const chat = defaultChatUri(session); - const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); - const agent = createTestAgent(disposables, { copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await writeExtensionHostMarker(userHome, sessionId); -- -- const metadata = await agent.getChatMetadata(chat, exactChatContext(session, chat, session)); -- -- assert.strictEqual(readSessionEhcliAdoptable(metadata?._meta), true); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await writeExtensionHostMarker(userHome, sessionId); +- +- const metadata = await agent.getChatMetadata(chat, exactChatContext(session, chat, session)); +- +- assert.strictEqual(readSessionEhcliAdoptable(metadata?._meta), true); - }); - - test('does not list the extension-host CLI session when migrate is OFF', async () => { @@ -66963,15 +69076,14 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await writeExtensionHostMarker(userHome, sessionId); -- assert.deepStrictEqual(await agent.listChatsToMigrate(), []); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await writeExtensionHostMarker(userHome, sessionId); +- assert.deepStrictEqual(await agent.listChatsToMigrate(), []); - }); - - test('does not list an un-owned SDK session without the extension-host marker', async () => { @@ -66981,16 +69093,15 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); - const { agent, configurationService } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); -- // No marker written: standalone SDK chats are not legacy extension-host sessions. -- assert.deepStrictEqual(await agent.listChatsToMigrate(), []); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); +- // No marker written: standalone SDK chats are not legacy extension-host sessions. +- assert.deepStrictEqual(await agent.listChatsToMigrate(), []); - }); - - test('does not surface a legacy CLI session whose marker originates from another Copilot host', async () => { @@ -67000,18 +69111,17 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); - const { agent, configurationService } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- // The GitHub Copilot app writes the same marker with `origin: 'other'`. -- await writeExtensionHostMarker(userHome, sessionId, { origin: 'other' }); -- configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); -- -- assert.deepStrictEqual(await agent.listChatsToMigrate(), []); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- // The GitHub Copilot app writes the same marker with `origin: 'other'`. +- await writeExtensionHostMarker(userHome, sessionId, { origin: 'other' }); +- configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); +- +- assert.deepStrictEqual(await agent.listChatsToMigrate(), []); - }); - - test('lists an already-adopted (native) session normally, not as adoptable', async () => { @@ -67025,20 +69135,19 @@ index f24a97a6296..00000000000 - db.dispose(); - const client = new TestCopilotClient([sdkSession(sessionId, '/workspace')]); - const { agent, configurationService } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await writeExtensionHostMarker(userHome, sessionId); // marker present but already adopted -- configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); -- -- const catalog = await agent.listChatsToMigrate(); -- assert.deepStrictEqual( -- catalog?.map(s => ({ id: sessionIdOfChat(s.chat), adoptable: readSessionEhcliAdoptable(s._meta) })), -- [{ id: sessionId, adoptable: false }], -- ); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await writeExtensionHostMarker(userHome, sessionId); // marker present but already adopted +- configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); +- +- const catalog = await agent.listChatsToMigrate(); +- assert.deepStrictEqual( +- catalog?.map(s => ({ id: sessionIdOfChat(s.chat), adoptable: readSessionEhcliAdoptable(s._meta) })), +- [{ id: sessionId, adoptable: false }], +- ); - }); - }); - @@ -67066,88 +69175,84 @@ index f24a97a6296..00000000000 - rootConfig: { [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: migrationEnabled }, - }); - agent.setKnownSessionsFilter(async () => new Set([AgentSession.uri('copilotcli', 'known').toString()])); -- try { -- await writeExtensionHostMarker(userHome, 'legacy'); -- await writeExtensionHostMarker(userHome, 'client', { origin: 'other' }); -- await writeExtensionHostMarker(userHome, 'requests', { origin: 'other' }); -- await writeExtensionHostMarker(userHome, 'both', { origin: 'other' }); -- await writeExtensionHostMarker(userHome, 'originless', { customTitle: 'Title only' }); -- const root = join(getCopilotHomePath(userHome.fsPath, {}), 'session-state'); -- for (const id of ['requests', 'both']) { -- await fs.writeFile(join(root, id, 'vscode.requests.metadata.json'), '[]'); -- } -- await fs.mkdir(join(root, 'invalid'), { recursive: true }); -- await fs.writeFile(join(root, 'invalid', 'vscode.metadata.json'), '{'); -- await fs.mkdir(join(root, 'directory', 'vscode.requests.metadata.json'), { recursive: true }); +- afterTest(async () => { +- await disposeAgent(agent); +- await fs.rm(userHome.fsPath, { recursive: true, force: true }); +- }); +- await writeExtensionHostMarker(userHome, 'legacy'); +- await writeExtensionHostMarker(userHome, 'client', { origin: 'other' }); +- await writeExtensionHostMarker(userHome, 'requests', { origin: 'other' }); +- await writeExtensionHostMarker(userHome, 'both', { origin: 'other' }); +- await writeExtensionHostMarker(userHome, 'originless', { customTitle: 'Title only' }); +- const root = join(getCopilotHomePath(userHome.fsPath, {}), 'session-state'); +- for (const id of ['requests', 'both']) { +- await fs.writeFile(join(root, id, 'vscode.requests.metadata.json'), '[]'); +- } +- await fs.mkdir(join(root, 'invalid'), { recursive: true }); +- await fs.writeFile(join(root, 'invalid', 'vscode.metadata.json'), '{'); +- await fs.mkdir(join(root, 'directory', 'vscode.requests.metadata.json'), { recursive: true }); - -- const discovered = await collectDiscoveredChats(agent); -- await runDiscoveryPass(agent); +- const discovered = await collectDiscoveredChats(agent); +- await runDiscoveryPass(agent); - +- assert.deepStrictEqual({ +- discovered: discovered.sort((a, b) => a.id.localeCompare(b.id)), +- events: telemetryService.events.filter(event => event.eventName === 'agentHost.copilotCliRecoveryEstimate'), +- }, { +- discovered: [ +- { id: 'external', external: true, adoptable: false }, +- ...(migrationEnabled ? [{ id: 'legacy', external: false, adoptable: true }] : []), +- { id: 'requests', external: true, adoptable: false }, +- ], +- events: [{ +- eventName: 'agentHost.copilotCliRecoveryEstimate', +- data: { +- catalogSessionCount: 11, +- evaluatedSessionCount: 9, +- alreadyEligibleSessionCount: 1, +- recoverableByClientNameSessionCount: 3, +- recoverableByRequestFileSessionCount: 1, +- notRecoverableSessionCount: 3, +- failedSessionCount: 1, +- migrationEnabled, +- }, +- }], +- }); +- }); +- } +- +- test('counts old and workspace-less candidates before visibility filters, and reports empty catalogs', async () => { +- const userHome = URI.file(await fs.mkdtemp(join(os.tmpdir(), 'recovery-estimate-old-'))); +- afterTest(() => fs.rm(userHome.fsPath, { recursive: true, force: true })); +- for (const sessions of [[], [sdkSession('old', undefined, { clientName: 'vscode' })]]) { +- const telemetryService = createRecoveryTelemetry(); +- const { agent } = createTestAgentContext(disposables, { +- copilotClient: new TestCopilotClient(sessions), userHome, telemetryService, +- }); +- try { +- const discovered = await collectDiscoveredChats(agent); - assert.deepStrictEqual({ -- discovered: discovered.sort((a, b) => a.id.localeCompare(b.id)), +- discovered, - events: telemetryService.events.filter(event => event.eventName === 'agentHost.copilotCliRecoveryEstimate'), - }, { -- discovered: [ -- { id: 'external', external: true, adoptable: false }, -- ...(migrationEnabled ? [{ id: 'legacy', external: false, adoptable: true }] : []), -- { id: 'requests', external: true, adoptable: false }, -- ], +- discovered: [], - events: [{ - eventName: 'agentHost.copilotCliRecoveryEstimate', - data: { -- catalogSessionCount: 11, -- evaluatedSessionCount: 9, -- alreadyEligibleSessionCount: 1, -- recoverableByClientNameSessionCount: 3, -- recoverableByRequestFileSessionCount: 1, -- notRecoverableSessionCount: 3, -- failedSessionCount: 1, -- migrationEnabled, +- catalogSessionCount: sessions.length, +- evaluatedSessionCount: sessions.length, +- alreadyEligibleSessionCount: 0, +- recoverableByClientNameSessionCount: sessions.length, +- recoverableByRequestFileSessionCount: 0, +- notRecoverableSessionCount: 0, +- failedSessionCount: 0, +- migrationEnabled: false, - }, - }], - }); - } finally { - await disposeAgent(agent); -- await fs.rm(userHome.fsPath, { recursive: true, force: true }); -- } -- }); -- } -- -- test('counts old and workspace-less candidates before visibility filters, and reports empty catalogs', async () => { -- const userHome = URI.file(await fs.mkdtemp(join(os.tmpdir(), 'recovery-estimate-old-'))); -- try { -- for (const sessions of [[], [sdkSession('old', undefined, { clientName: 'vscode' })]]) { -- const telemetryService = createRecoveryTelemetry(); -- const { agent } = createTestAgentContext(disposables, { -- copilotClient: new TestCopilotClient(sessions), userHome, telemetryService, -- }); -- try { -- const discovered = await collectDiscoveredChats(agent); -- assert.deepStrictEqual({ -- discovered, -- events: telemetryService.events.filter(event => event.eventName === 'agentHost.copilotCliRecoveryEstimate'), -- }, { -- discovered: [], -- events: [{ -- eventName: 'agentHost.copilotCliRecoveryEstimate', -- data: { -- catalogSessionCount: sessions.length, -- evaluatedSessionCount: sessions.length, -- alreadyEligibleSessionCount: 0, -- recoverableByClientNameSessionCount: sessions.length, -- recoverableByRequestFileSessionCount: 0, -- notRecoverableSessionCount: 0, -- failedSessionCount: 0, -- migrationEnabled: false, -- }, -- }], -- }); -- } finally { -- await disposeAgent(agent); -- } - } -- } finally { -- await fs.rm(userHome.fsPath, { recursive: true, force: true }); - } - }); - @@ -67176,21 +69281,18 @@ index f24a97a6296..00000000000 - const telemetryService = createRecoveryTelemetry(); - const client = new TestCopilotClient([]); - const { agent } = createTestAgentContext(disposables, { copilotClient: client, telemetryService }); -- try { -- client.sessionListError = new Error('catalog unavailable'); -- const failed = await runDiscoveryPass(agent); -- client.sessionListError = undefined; -- await runDiscoveryPass(agent, new Set()); -- const before = telemetryService.events.filter(event => event.eventName === 'agentHost.copilotCliRecoveryEstimate').length; -- await runDiscoveryPass(agent); -- await runDiscoveryPass(agent); -- assert.deepStrictEqual({ -- failed, before, -- after: telemetryService.events.filter(event => event.eventName === 'agentHost.copilotCliRecoveryEstimate').length, -- }, { failed: false, before: 0, after: 1 }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- client.sessionListError = new Error('catalog unavailable'); +- const failed = await runDiscoveryPass(agent); +- client.sessionListError = undefined; +- await runDiscoveryPass(agent, new Set()); +- const before = telemetryService.events.filter(event => event.eventName === 'agentHost.copilotCliRecoveryEstimate').length; +- await runDiscoveryPass(agent); +- await runDiscoveryPass(agent); +- assert.deepStrictEqual({ +- failed, before, +- after: telemetryService.events.filter(event => event.eventName === 'agentHost.copilotCliRecoveryEstimate').length, +- }, { failed: false, before: 0, after: 1 }); - }); - - test('retries the estimate after a synchronous telemetry failure', async () => { @@ -67349,25 +69451,22 @@ index f24a97a6296..00000000000 - return create(config.sessionId); - }; - context.client.resumeSession = sessionId => create(sessionId); -- try { -- await context.agent.startChatDiscovery(); -- await provisionSession(context.agent, { -- session, workingDirectories: [context.userHome], -- ...(kind === 'imported' ? { importConversation: { turns: [] } } : {}), -- }, { deferBacking: kind === 'deferred' }); -- if (kind === 'deferred') { -- await context.agent.chats.sendMessage(defaultChatUri(session), 'hello', undefined, undefined, undefined, undefined, -- exactChatContext(session, defaultChatUri(session), session)); -- } -- await timeout(500); -- assert.deepStrictEqual({ -- creationCalls, scans: context.client.sessionListRequests.length, -- published: context.published.length, -- watches: [...context.watches.keys()].filter(resource => isEqualOrParent(URI.parse(resource), context.root)), -- }, { creationCalls: 1, scans: 1, published: 0, watches: [context.root.toString()] }); -- } finally { -- await context.dispose(); -- } +- afterTest(() => context.dispose()); +- await context.agent.startChatDiscovery(); +- await provisionSession(context.agent, { +- session, workingDirectories: [context.userHome], +- ...(kind === 'imported' ? { importConversation: { turns: [] } } : {}), +- }, { deferBacking: kind === 'deferred' }); +- if (kind === 'deferred') { +- await context.agent.chats.sendMessage(defaultChatUri(session), 'hello', undefined, undefined, undefined, undefined, +- exactChatContext(session, defaultChatUri(session), session)); +- } +- await timeout(500); +- assert.deepStrictEqual({ +- creationCalls, scans: context.client.sessionListRequests.length, +- published: context.published.length, +- watches: [...context.watches.keys()].filter(resource => isEqualOrParent(URI.parse(resource), context.root)), +- }, { creationCalls: 1, scans: 1, published: 0, watches: [context.root.toString()] }); - }); - } - @@ -67382,15 +69481,12 @@ index f24a97a6296..00000000000 - filterCalls.push(candidates.map(candidate => AgentSession.id(candidate))); - return new Set(); - }); -- try { -- await context.agent.startChatDiscovery(); -- assert.deepStrictEqual({ -- filterCalls, published: context.published.map(chat => sessionIdOfChat(chat.chat)), -- scans: context.client.sessionListRequests.length, -- }, { filterCalls: [['external']], published: ['external'], scans: 1 }); -- } finally { -- await context.dispose(); -- } +- afterTest(() => context.dispose()); +- await context.agent.startChatDiscovery(); +- assert.deepStrictEqual({ +- filterCalls, published: context.published.map(chat => sessionIdOfChat(chat.chat)), +- scans: context.client.sessionListRequests.length, +- }, { filterCalls: [['external']], published: ['external'], scans: 1 }); - }); - - test('ineligible history with incomplete metadata does not repeatedly scan the SDK catalog', async () => { @@ -67405,26 +69501,25 @@ index f24a97a6296..00000000000 - sessions.push(...ineligible); - const context = await createLiveDiscoveryAgent(sessions); - const clock = useFakeTimers({ now: Date.now(), toFake: ['Date', 'setTimeout', 'clearTimeout'] }); -- try { -- for (const session of ineligible) { -- const directory = URI.joinPath(context.root, session.sessionId); -- await fs.mkdir(directory.fsPath); -- await fs.writeFile(URI.joinPath(directory, 'events.jsonl').fsPath, ''); -- } -- await context.agent.startChatDiscovery(); -- await clock.tickAsync(3 * 60_000); -- assert.deepStrictEqual({ -- scans: context.client.sessionListRequests.length, -- published: context.published.length, -- watches: [...context.watches.keys()], -- }, { scans: 1, published: 0, watches: [context.root.toString()] }); -- } finally { +- afterTest(async () => { - try { - await context.dispose(); - } finally { - clock.restore(); - } +- }); +- for (const session of ineligible) { +- const directory = URI.joinPath(context.root, session.sessionId); +- await fs.mkdir(directory.fsPath); +- await fs.writeFile(URI.joinPath(directory, 'events.jsonl').fsPath, ''); - } +- await context.agent.startChatDiscovery(); +- await clock.tickAsync(3 * 60_000); +- assert.deepStrictEqual({ +- scans: context.client.sessionListRequests.length, +- published: context.published.length, +- watches: [...context.watches.keys()], +- }, { scans: 1, published: 0, watches: [context.root.toString()] }); - }); - - test('recent sessions missing cwd or client name wait for workspace metadata instead of rescanning', async () => { @@ -67435,32 +69530,31 @@ index f24a97a6296..00000000000 - const context = await createLiveDiscoveryAgent(sessions); - const clock = useFakeTimers({ now: Date.now(), toFake: ['Date', 'setTimeout', 'clearTimeout'] }); - const ids = ['missing-cwd', 'missing-client']; -- try { -- for (const id of ids) { -- const directory = URI.joinPath(context.root, id); -- await fs.mkdir(directory.fsPath); -- await fs.writeFile(URI.joinPath(directory, 'events.jsonl').fsPath, ''); -- } -- await context.agent.startChatDiscovery(); -- await clock.tickAsync(10 * 60_000); -- const idleScans = context.client.sessionListRequests.length; -- sessions.splice(0, sessions.length, ...ids.map(id => sdkSession(id, context.userHome.fsPath, { clientName: 'github/cli', modifiedTime: new Date() }))); -- for (const id of ids) { -- const directory = URI.joinPath(context.root, id); -- context.change(directory, URI.joinPath(directory, 'workspace.yaml'), FileChangeType.UPDATED); -- } -- await clock.tickAsync(501); -- await raceTimeout(context.added.p, 5_000); -- assert.deepStrictEqual({ -- idleScans, published: context.published.map(chat => sessionIdOfChat(chat.chat)).sort(), -- }, { idleScans: 1, published: ['missing-client', 'missing-cwd'] }); -- } finally { +- afterTest(async () => { - try { - await context.dispose(); - } finally { - clock.restore(); - } +- }); +- for (const id of ids) { +- const directory = URI.joinPath(context.root, id); +- await fs.mkdir(directory.fsPath); +- await fs.writeFile(URI.joinPath(directory, 'events.jsonl').fsPath, ''); +- } +- await context.agent.startChatDiscovery(); +- await clock.tickAsync(10 * 60_000); +- const idleScans = context.client.sessionListRequests.length; +- sessions.splice(0, sessions.length, ...ids.map(id => sdkSession(id, context.userHome.fsPath, { clientName: 'github/cli', modifiedTime: new Date() }))); +- for (const id of ids) { +- const directory = URI.joinPath(context.root, id); +- context.change(directory, URI.joinPath(directory, 'workspace.yaml'), FileChangeType.UPDATED); - } +- await clock.tickAsync(501); +- await raceTimeout(context.added.p, 5_000); +- assert.deepStrictEqual({ +- idleScans, published: context.published.map(chat => sessionIdOfChat(chat.chat)).sort(), +- }, { idleScans: 1, published: ['missing-client', 'missing-cwd'] }); - }); - - test('discovers runtime sessions with candidate-only classification and a local-only catalog', async () => { @@ -67623,24 +69717,21 @@ index f24a97a6296..00000000000 - batches.push(chats.length); - now += 10; - })); -- try { -- await agent.startChatDiscovery(); -- await agent.startChatDiscovery(); -- assert.deepStrictEqual({ -- batches, -- markers: telemetry.events.filter(event => ['sessionDiscoveryScan', 'firstSessionDiscoveryResult'].includes(String(event.data?.name))).map(({ data }) => [ -- data?.name, data?.timestampMs, data?.since, data?.scannedSessionCount, data?.candidateSessionCount, data?.externalSessionCount, data?.filteredSessionCount, data?.failedSessionCount, -- ]), -- }, { -- batches: [250, 1], -- markers: [ -- ['sessionDiscoveryScan', 10, 'sessionDiscoveryScanStart', 253, undefined, undefined, undefined, undefined], -- ['firstSessionDiscoveryResult', 30, 'processStart', undefined, 251, 251, 1, 1], -- ], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.startChatDiscovery(); +- await agent.startChatDiscovery(); +- assert.deepStrictEqual({ +- batches, +- markers: telemetry.events.filter(event => ['sessionDiscoveryScan', 'firstSessionDiscoveryResult'].includes(String(event.data?.name))).map(({ data }) => [ +- data?.name, data?.timestampMs, data?.since, data?.scannedSessionCount, data?.candidateSessionCount, data?.externalSessionCount, data?.filteredSessionCount, data?.failedSessionCount, +- ]), +- }, { +- batches: [250, 1], +- markers: [ +- ['sessionDiscoveryScan', 10, 'sessionDiscoveryScanStart', 253, undefined, undefined, undefined, undefined], +- ['firstSessionDiscoveryResult', 30, 'processStart', undefined, 251, 251, 1, 1], +- ], +- }); - }); - - test('startup telemetry records a processed empty Copilot result after scan errors exhaust sampling', async () => { @@ -67650,25 +69741,22 @@ index f24a97a6296..00000000000 - client.sessionListError = new Error('catalog unavailable'); - const { agent } = createTestAgentContext(disposables, { copilotClient: client, startupPerformance }); - const results: boolean[] = []; -- try { -- for (let i = 0; i < 4; i++) { -- results.push(await runDiscoveryPass(agent)); -- } -- const before = telemetry.events.filter(event => event.data?.name === 'firstSessionDiscoveryResult').length; -- client.sessionListError = undefined; +- afterTest(() => disposeAgent(agent)); +- for (let i = 0; i < 4; i++) { - results.push(await runDiscoveryPass(agent)); -- await runDiscoveryPass(agent); -- assert.deepStrictEqual({ -- before, -- results, -- scans: telemetry.events.filter(event => event.data?.name === 'sessionDiscoveryScan').map(({ data }) => data?.outcome), -- first: telemetry.events.filter(event => event.data?.name === 'firstSessionDiscoveryResult').map(({ data }) => [ -- data?.durationMs, data?.candidateSessionCount, data?.externalSessionCount, data?.filteredSessionCount, data?.failedSessionCount, -- ]), -- }, { before: 0, results: [false, false, false, false, true], scans: ['error', 'error', 'error'], first: [[50, 0, 0, 0, 0]] }); -- } finally { -- await disposeAgent(agent); - } +- const before = telemetry.events.filter(event => event.data?.name === 'firstSessionDiscoveryResult').length; +- client.sessionListError = undefined; +- results.push(await runDiscoveryPass(agent)); +- await runDiscoveryPass(agent); +- assert.deepStrictEqual({ +- before, +- results, +- scans: telemetry.events.filter(event => event.data?.name === 'sessionDiscoveryScan').map(({ data }) => data?.outcome), +- first: telemetry.events.filter(event => event.data?.name === 'firstSessionDiscoveryResult').map(({ data }) => [ +- data?.durationMs, data?.candidateSessionCount, data?.externalSessionCount, data?.filteredSessionCount, data?.failedSessionCount, +- ]), +- }, { before: 0, results: [false, false, false, false, true], scans: ['error', 'error', 'error'], first: [[50, 0, 0, 0, 0]] }); - }); - - test('startup telemetry does not mistake unavailable Copilot discovery for an empty result', async () => { @@ -67677,15 +69765,12 @@ index f24a97a6296..00000000000 - const client = new TestCopilotClient([]); - client.sessionListError = new CancellationError(); - const { agent } = createTestAgentContext(disposables, { copilotClient: client, startupPerformance }); -- try { -- const completed = await runDiscoveryPass(agent); -- assert.deepStrictEqual({ -- completed, -- markers: telemetry.events.filter(event => ['sessionDiscoveryScan', 'firstSessionDiscoveryResult'].includes(String(event.data?.name))).map(({ data }) => [data?.name, data?.outcome, data?.scannedSessionCount, data?.candidateSessionCount]), -- }, { completed: false, markers: [['sessionDiscoveryScan', 'unavailable', undefined, undefined]] }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const completed = await runDiscoveryPass(agent); +- assert.deepStrictEqual({ +- completed, +- markers: telemetry.events.filter(event => ['sessionDiscoveryScan', 'firstSessionDiscoveryResult'].includes(String(event.data?.name))).map(({ data }) => [data?.name, data?.outcome, data?.scannedSessionCount, data?.candidateSessionCount]), +- }, { completed: false, markers: [['sessionDiscoveryScan', 'unavailable', undefined, undefined]] }); - }); - - test('surfaces a standalone Copilot CLI SDK session as external', async () => { @@ -67699,15 +69784,14 @@ index f24a97a6296..00000000000 - })]); - // Migration stays off: external discovery must not depend on it. - const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- assert.deepStrictEqual(await collectDiscoveredChats(agent), [ -- { id: 'external-cli', external: true, adoptable: false }, -- ]); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- assert.deepStrictEqual(await collectDiscoveredChats(agent), [ +- { id: 'external-cli', external: true, adoptable: false }, +- ]); - }); - - test('surfaces a GitHub Copilot app session as external rather than adoptable', async () => { @@ -67720,18 +69804,17 @@ index f24a97a6296..00000000000 - modifiedTime: new Date(), - })]); - const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- // The GitHub Copilot app writes the same sidecar with `origin: 'other'`. -- await writeExtensionHostMarker(userHome, 'other-origin', { origin: 'other' }); -- -- assert.deepStrictEqual(await collectDiscoveredChats(agent), [ -- { id: 'other-origin', external: true, adoptable: false }, -- ]); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- // The GitHub Copilot app writes the same sidecar with `origin: 'other'`. +- await writeExtensionHostMarker(userHome, 'other-origin', { origin: 'other' }); +- +- assert.deepStrictEqual(await collectDiscoveredChats(agent), [ +- { id: 'other-origin', external: true, adoptable: false }, +- ]); - }); - - test('logs the raw SDK client name only for eligible external sessions', async () => { @@ -67753,22 +69836,21 @@ index f24a97a6296..00000000000 - sdkSession('rejected-external-log', workingDirectory, { clientName: 'other/client', modifiedTime: new Date() }), - ]); - const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome, logService }); -- try { -- await collectDiscoveredChats(agent); -- -- assert.deepStrictEqual( -- logService.messages.filter(message => message.startsWith('[CopilotDiscovery] Publishing ')) -- .map(message => message.replace(/, scan=.*$/, '')).sort(), -- [ -- `[CopilotDiscovery] Publishing ${AgentSession.uri(agent.id, 'external-autopilot-log').toString()}: external=true, clientName=github/autopilot`, -- `[CopilotDiscovery] Publishing ${AgentSession.uri(agent.id, 'external-cli-log').toString()}: external=true, clientName=github/cli`, -- ].sort(), -- ); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await collectDiscoveredChats(agent); +- +- assert.deepStrictEqual( +- logService.messages.filter(message => message.startsWith('[CopilotDiscovery] Publishing ')) +- .map(message => message.replace(/, scan=.*$/, '')).sort(), +- [ +- `[CopilotDiscovery] Publishing ${AgentSession.uri(agent.id, 'external-autopilot-log').toString()}: external=true, clientName=github/autopilot`, +- `[CopilotDiscovery] Publishing ${AgentSession.uri(agent.id, 'external-cli-log').toString()}: external=true, clientName=github/cli`, +- ].sort(), +- ); - }); - - test('does not surface SDK sessions with an unknown or missing client name', async () => { @@ -67780,13 +69862,12 @@ index f24a97a6296..00000000000 - sdkSession('missing-client', workingDirectory, { modifiedTime: new Date() }), - ]); - const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- assert.deepStrictEqual(await collectDiscoveredChats(agent), []); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- assert.deepStrictEqual(await collectDiscoveredChats(agent), []); - }); - - test('surfaces only sessions modified within the seven-day boundary', async () => { @@ -67800,15 +69881,14 @@ index f24a97a6296..00000000000 - sdkSession('outside-boundary', workingDirectory, { clientName: 'github/cli', modifiedTime: new Date(sevenDaysAgo - 1) }), - ]); - const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome, now: () => now }); -- try { -- assert.deepStrictEqual(await collectDiscoveredChats(agent), [ -- { id: 'at-boundary', external: true, adoptable: false }, -- ]); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- assert.deepStrictEqual(await collectDiscoveredChats(agent), [ +- { id: 'at-boundary', external: true, adoptable: false }, +- ]); - }); - - test('surfaces sessions with missing repository metadata', async () => { @@ -67820,16 +69900,15 @@ index f24a97a6296..00000000000 - sdkSession('missing-repository-autopilot', workingDirectory, { clientName: 'github/autopilot', modifiedTime: new Date() }), - ]); - const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- assert.deepStrictEqual(await collectDiscoveredChats(agent), [ -- { id: 'missing-repository-cli', external: true, adoptable: false }, -- { id: 'missing-repository-autopilot', external: true, adoptable: false }, -- ]); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- assert.deepStrictEqual(await collectDiscoveredChats(agent), [ +- { id: 'missing-repository-cli', external: true, adoptable: false }, +- { id: 'missing-repository-autopilot', external: true, adoptable: false }, +- ]); - }); - - test('surfaces a repository-less session', async () => { @@ -67840,15 +69919,14 @@ index f24a97a6296..00000000000 - sdkSession('repository-less', workingDirectory, { clientName: 'github/autopilot', repository: '', modifiedTime: new Date() }), - ]); - const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- assert.deepStrictEqual(await collectDiscoveredChats(agent), [ -- { id: 'repository-less', external: true, adoptable: false }, -- ]); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- assert.deepStrictEqual(await collectDiscoveredChats(agent), [ +- { id: 'repository-less', external: true, adoptable: false }, +- ]); - }); - - test('marks a session working under the Copilot chats folder as workspace-less', async () => { @@ -67863,16 +69941,15 @@ index f24a97a6296..00000000000 - sdkSession('cli-folder', workingDirectory, { clientName: 'github/cli', modifiedTime: new Date() }), - ]); - const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- assert.deepStrictEqual(await collectDiscoveredChats(agent), [ -- { id: 'app-chat', external: true, adoptable: false, workspaceless: true }, -- { id: 'cli-folder', external: true, adoptable: false }, -- ]); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- assert.deepStrictEqual(await collectDiscoveredChats(agent), [ +- { id: 'app-chat', external: true, adoptable: false, workspaceless: true }, +- { id: 'cli-folder', external: true, adoptable: false }, +- ]); - }); - - test('keeps a legacy extension-host chat internal and adoptable', async () => { @@ -67887,15 +69964,14 @@ index f24a97a6296..00000000000 - userHome, - rootConfig: { [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }, - }); -- try { -- assert.deepStrictEqual(await collectDiscoveredChats(agent), [ -- { id: 'ehcli-discovery', external: false, adoptable: true }, -- ]); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- assert.deepStrictEqual(await collectDiscoveredChats(agent), [ +- { id: 'ehcli-discovery', external: false, adoptable: true }, +- ]); - }); - - test('surfaces a legacy chat the user archived in the extension host with archived status', async () => { @@ -67910,15 +69986,14 @@ index f24a97a6296..00000000000 - userHome, - rootConfig: { [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }, - }); -- try { -- assert.deepStrictEqual(await collectDiscoveredChats(agent), [ -- { id: 'ehcli-archived', external: false, adoptable: true, archived: true }, -- ]); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- assert.deepStrictEqual(await collectDiscoveredChats(agent), [ +- { id: 'ehcli-archived', external: false, adoptable: true, archived: true }, +- ]); - }); - - test('titles a legacy chat by its CLI name, then its staged custom title, then the SDK summary', async () => { @@ -67979,13 +70054,12 @@ index f24a97a6296..00000000000 - sdkSession('workspaceless', undefined, { clientName: 'github/cli', modifiedTime: new Date() }), - ]); - const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- assert.deepStrictEqual(await collectDiscoveredChats(agent), []); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- assert.deepStrictEqual(await collectDiscoveredChats(agent), []); - }); - /** Discovered chats with the working directory each one resolved to. */ - async function collectDiscoveredWorkingDirectories(agent: CopilotAgent): Promise> { @@ -68014,15 +70088,14 @@ index f24a97a6296..00000000000 - userHome, - rootConfig: { [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }, - }); -- try { -- assert.deepStrictEqual(await collectDiscoveredWorkingDirectories(agent), [ -- { id: 'marker-cwd', workingDirectory: URI.file(workingDirectory).fsPath }, -- ]); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- assert.deepStrictEqual(await collectDiscoveredWorkingDirectories(agent), [ +- { id: 'marker-cwd', workingDirectory: URI.file(workingDirectory).fsPath }, +- ]); - }); - - test('prefers the worktree checkout over the repository root when recovering a legacy chat', async () => { @@ -68044,16 +70117,15 @@ index f24a97a6296..00000000000 - userHome, - rootConfig: { [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }, - }); -- try { -- assert.deepStrictEqual(await collectDiscoveredWorkingDirectories(agent), [ -- { id: 'marker-worktree', workingDirectory: URI.file(worktree).fsPath }, -- ]); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(repository, { recursive: true, force: true }); - await fs.rm(worktree, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- assert.deepStrictEqual(await collectDiscoveredWorkingDirectories(agent), [ +- { id: 'marker-worktree', workingDirectory: URI.file(worktree).fsPath }, +- ]); - }); - - test('a marker written after an initial read miss is picked up without a restart', async () => { @@ -68062,20 +70134,19 @@ index f24a97a6296..00000000000 - const client = new TestCopilotClient([]); - const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); - const isLegacy = (agent as unknown as { _isExtensionHostCliSession(id: string): Promise })._isExtensionHostCliSession.bind(agent); -- try { -- const beforeMarker = await isLegacy('late-marker'); -- await writeExtensionHostMarker(userHome, 'late-marker'); -- -- // A miss must not be memoized: the extension host can write the marker -- // after the probe, and the session would stay non-adoptable until restart. -- assert.deepStrictEqual( -- { beforeMarker, afterMarker: await isLegacy('late-marker') }, -- { beforeMarker: false, afterMarker: true }, -- ); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- const beforeMarker = await isLegacy('late-marker'); +- await writeExtensionHostMarker(userHome, 'late-marker'); +- +- // A miss must not be memoized: the extension host can write the marker +- // after the probe, and the session would stay non-adoptable until restart. +- assert.deepStrictEqual( +- { beforeMarker, afterMarker: await isLegacy('late-marker') }, +- { beforeMarker: false, afterMarker: true }, +- ); - }); - - test('a chat whose database cannot be read is skipped without withholding the rest of the catalog', async () => { @@ -68095,15 +70166,14 @@ index f24a97a6296..00000000000 - sdkSession('healthy', workingDirectory, { clientName: 'github/cli', repository: 'owner/repository', modifiedTime: new Date() }), - ]); - const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- assert.deepStrictEqual(await collectDiscoveredChats(agent), [ -- { id: 'healthy', external: true, adoptable: false }, -- ]); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- assert.deepStrictEqual(await collectDiscoveredChats(agent), [ +- { id: 'healthy', external: true, adoptable: false }, +- ]); - }); - - test('registry-known candidates are dropped without opening any session database', async () => { @@ -68131,21 +70201,20 @@ index f24a97a6296..00000000000 - AgentSession.uri('copilotcli', 'known-b').toString(), - ]); - }); -- try { -- assert.deepStrictEqual({ -- discovered: await collectDiscoveredChats(agent), -- filterCalls, -- tryOpened, -- }, { -- discovered: [{ id: 'fresh', external: true, adoptable: false }], -- filterCalls: [['known-a', 'known-b', 'fresh']], -- tryOpened: [], -- }); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- assert.deepStrictEqual({ +- discovered: await collectDiscoveredChats(agent), +- filterCalls, +- tryOpened, +- }, { +- discovered: [{ id: 'fresh', external: true, adoptable: false }], +- filterCalls: [['known-a', 'known-b', 'fresh']], +- tryOpened: [], +- }); - }); - - test('does not resolve projects for adoptable chats that migration will not emit', async () => { @@ -68162,21 +70231,20 @@ index f24a97a6296..00000000000 - const gitService = new CountingGitService(); - const client = new TestCopilotClient([sdkSession('ehcli-skipped', workingDirectory)]); - const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome, gitService }); -- try { -- await writeExtensionHostMarker(userHome, 'ehcli-skipped'); -- -- assert.deepStrictEqual({ -- discovered: await collectDiscoveredChats(agent), -- repositoryRootCalls, -- }, { -- discovered: [], -- repositoryRootCalls: [], -- }); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await writeExtensionHostMarker(userHome, 'ehcli-skipped'); +- +- assert.deepStrictEqual({ +- discovered: await collectDiscoveredChats(agent), +- repositoryRootCalls, +- }, { +- discovered: [], +- repositoryRootCalls: [], +- }); - }); - - test('reads stored session metadata with a single bulk metadata query', async () => { @@ -68204,15 +70272,14 @@ index f24a97a6296..00000000000 - db.dispose(); - const client = new TestCopilotClient([sdkSession('bulk-metadata', workingDirectory)]); - const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await collectDiscoveredChats(agent); -- -- assert.deepStrictEqual(calls, ['getMetadataObject']); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await collectDiscoveredChats(agent); +- +- assert.deepStrictEqual(calls, ['getMetadataObject']); - }); - }); - @@ -68338,32 +70405,26 @@ index f24a97a6296..00000000000 - const agent = createTestAgent(disposables); - const forkCalls: ForkRequest[] = []; - const { source, boundaryCalls } = makeForkSource({}); -- try { -- await forkSdkChat(agent, makeForkClient(forkCalls), source); +- afterTest(() => disposeAgent(agent)); +- await forkSdkChat(agent, makeForkClient(forkCalls), source); - -- assert.deepStrictEqual({ forkCalls, boundaryCalls }, { -- forkCalls: [{ sessionId: 'source-sdk-session' }], -- boundaryCalls: ['source-turn'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ forkCalls, boundaryCalls }, { +- forkCalls: [{ sessionId: 'source-sdk-session' }], +- boundaryCalls: ['source-turn'], +- }); - }); - - test('uses an already-resolved SDK boundary without waiting', async () => { - const agent = createTestAgent(disposables); - const forkCalls: ForkRequest[] = []; - const { source, boundaryCalls } = makeForkSource({ boundaryEventId: 'next-turn-event' }); -- try { -- await forkSdkChat(agent, makeForkClient(forkCalls), source); +- afterTest(() => disposeAgent(agent)); +- await forkSdkChat(agent, makeForkClient(forkCalls), source); - -- assert.deepStrictEqual({ forkCalls, boundaryCalls }, { -- forkCalls: [{ sessionId: 'source-sdk-session', toEventId: 'next-turn-event' }], -- boundaryCalls: ['source-turn'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ forkCalls, boundaryCalls }, { +- forkCalls: [{ sessionId: 'source-sdk-session', toEventId: 'next-turn-event' }], +- boundaryCalls: ['source-turn'], +- }); - }); - - test('waits for the source session to resolve the SDK boundary before forking', async () => { @@ -68375,16 +70436,13 @@ index f24a97a6296..00000000000 - return 'active-next-turn-event'; - }, - }); -- try { -- await forkSdkChat(agent, makeForkClient(forkCalls), source); +- afterTest(() => disposeAgent(agent)); +- await forkSdkChat(agent, makeForkClient(forkCalls), source); - -- assert.deepStrictEqual({ forkCalls, boundaryCalls }, { -- forkCalls: [{ sessionId: 'source-sdk-session', toEventId: 'active-next-turn-event' }], -- boundaryCalls: ['source-turn'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ forkCalls, boundaryCalls }, { +- forkCalls: [{ sessionId: 'source-sdk-session', toEventId: 'active-next-turn-event' }], +- boundaryCalls: ['source-turn'], +- }); - }); - - test('fails the fork when an active next turn never produces an SDK boundary', async () => { @@ -68422,14 +70480,11 @@ index f24a97a6296..00000000000 - const session = AgentSession.uri('copilotcli', 'same-session'); - const chat = defaultChatUri(session); - -- try { -- await assert.rejects(() => agent.chats.createChat(chat, exactChatContext(session, chat), { -- fork: { source: chat, turnId: 'turn-1' }, -- }), /Cannot fork Copilot chat .* onto itself/); -- assert.strictEqual(client.startCallCount, 0); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await assert.rejects(() => agent.chats.createChat(chat, exactChatContext(session, chat), { +- fork: { source: chat, turnId: 'turn-1' }, +- }), /Cannot fork Copilot chat .* onto itself/); +- assert.strictEqual(client.startCallCount, 0); - }); - - test('a fork from another session inherits its process root and reports the forked backing', async () => { @@ -68443,70 +70498,67 @@ index f24a97a6296..00000000000 - setDefaultSessionStub(agent, AgentSession.id(source), sourceStub); - const seams = stubForkSeams(agent); - -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const forkedTurnId = '11111111-1111-4111-8111-111111111111'; -- const result = await provisionSession(agent, { -- session: target, -- workingDirectories: [URI.file('/ignored-client-workspace')], -- }, { -- fork: { -- source: defaultChatUri(source), -- turnId: sourceTurn.id, -- turnIdMapping: new Map([[sourceTurn.id, forkedTurnId]]), -- }, -- }); -- const retried = await provisionSession(agent, { -- session: target, -- workingDirectories: [URI.file('/different-retry-workspace')], -- }, { -- fork: { -- source: defaultChatUri(source), -- turnId: sourceTurn.id, -- }, -- }); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const forkedTurnId = '11111111-1111-4111-8111-111111111111'; +- const result = await provisionSession(agent, { +- session: target, +- workingDirectories: [URI.file('/ignored-client-workspace')], +- }, { +- fork: { +- source: defaultChatUri(source), +- turnId: sourceTurn.id, +- turnIdMapping: new Map([[sourceTurn.id, forkedTurnId]]), +- }, +- }); +- const retried = await provisionSession(agent, { +- session: target, +- workingDirectories: [URI.file('/different-retry-workspace')], +- }, { +- fork: { +- source: defaultChatUri(source), +- turnId: sourceTurn.id, +- }, +- }); - -- const storedWorkingDirectory = await sessionDataService.openDatabase(target).object.getMetadata('copilot.workingDirectory'); -- assert.deepStrictEqual({ -- resultSession: result.session.toString(), -- resolvedWorkingDirectory: result.resolvedWorkingDirectory?.toString(), -- providerData: JSON.parse(result.providerData!), -- backingSession: result.backingSession?.toString(), -- recordedBacking: chatBackings(agent).get(defaultChatUri(target).toString()), -- forkedFromSource: seams.forks.map(fork => ({ isSourceEntry: fork.sourceEntry === sourceStub, turnId: fork.turnId })), -- launches: seams.launches, -- remaps: seams.remaps.map(mapping => [...mapping]), -- storedWorkingDirectory, -- retried: { -- session: retried.session?.toString(), -- resolvedWorkingDirectory: retried.resolvedWorkingDirectory?.toString(), -- providerData: JSON.parse(retried.providerData!), -- }, -- }, { -- resultSession: target.toString(), +- const storedWorkingDirectory = await sessionDataService.openDatabase(target).object.getMetadata('copilot.workingDirectory'); +- assert.deepStrictEqual({ +- resultSession: result.session.toString(), +- resolvedWorkingDirectory: result.resolvedWorkingDirectory?.toString(), +- providerData: JSON.parse(result.providerData!), +- backingSession: result.backingSession?.toString(), +- recordedBacking: chatBackings(agent).get(defaultChatUri(target).toString()), +- forkedFromSource: seams.forks.map(fork => ({ isSourceEntry: fork.sourceEntry === sourceStub, turnId: fork.turnId })), +- launches: seams.launches, +- remaps: seams.remaps.map(mapping => [...mapping]), +- storedWorkingDirectory, +- retried: { +- session: retried.session?.toString(), +- resolvedWorkingDirectory: retried.resolvedWorkingDirectory?.toString(), +- providerData: JSON.parse(retried.providerData!), +- }, +- }, { +- resultSession: target.toString(), +- resolvedWorkingDirectory: sourceWorkingDirectory.toString(), +- providerData: { sdkSessionId: 'forked-sdk-id' }, +- backingSession: AgentSession.uri('copilotcli', 'forked-sdk-id').toString(), +- recordedBacking: { sdkSessionId: 'forked-sdk-id' }, +- forkedFromSource: [{ isSourceEntry: true, turnId: sourceTurn.id }], +- launches: [{ +- kind: 'resume', +- sessionId: 'forked-sdk-id', +- workingDirectory: sourceWorkingDirectory.toString(), +- chatChannelUri: defaultChatUri(target).toString(), +- resource: target.toString(), +- }], +- remaps: [[[sourceTurn.id, forkedTurnId]]], +- storedWorkingDirectory: sourceWorkingDirectory.toString(), +- retried: { +- session: target.toString(), - resolvedWorkingDirectory: sourceWorkingDirectory.toString(), - providerData: { sdkSessionId: 'forked-sdk-id' }, -- backingSession: AgentSession.uri('copilotcli', 'forked-sdk-id').toString(), -- recordedBacking: { sdkSessionId: 'forked-sdk-id' }, -- forkedFromSource: [{ isSourceEntry: true, turnId: sourceTurn.id }], -- launches: [{ -- kind: 'resume', -- sessionId: 'forked-sdk-id', -- workingDirectory: sourceWorkingDirectory.toString(), -- chatChannelUri: defaultChatUri(target).toString(), -- resource: target.toString(), -- }], -- remaps: [[[sourceTurn.id, forkedTurnId]]], -- storedWorkingDirectory: sourceWorkingDirectory.toString(), -- retried: { -- session: target.toString(), -- resolvedWorkingDirectory: sourceWorkingDirectory.toString(), -- providerData: { sdkSessionId: 'forked-sdk-id' }, -- }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- }, +- }); - }); - - test('a fork copies the source database into the storage scope of the chat it creates', async () => { @@ -68518,25 +70570,22 @@ index f24a97a6296..00000000000 - setDefaultSessionStub(agent, AgentSession.id(source), makeSourceStub(URI.file('/source-workspace'), sourceTurn)); - const seams = stubForkSeams(agent); - -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await provisionSession(agent, { -- session: target, -- }, { -- fork: { source: defaultChatUri(source), turnId: sourceTurn.id }, -- }); -- await agent.chats.createChat(peerChat, exactChatContext(target, peerChat), { -- workingDirectories: [URI.file('/target-workspace')], -- fork: { source: defaultChatUri(target), turnId: sourceTurn.id }, -- }); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await provisionSession(agent, { +- session: target, +- }, { +- fork: { source: defaultChatUri(source), turnId: sourceTurn.id }, +- }); +- await agent.chats.createChat(peerChat, exactChatContext(target, peerChat), { +- workingDirectories: [URI.file('/target-workspace')], +- fork: { source: defaultChatUri(target), turnId: sourceTurn.id }, +- }); - -- assert.deepStrictEqual(seams.forks.map(fork => fork.targetDbDir), [ -- sessionDataService.getSessionDataDir(target).toString(), -- sessionDataService.getSessionDataDir(peerChat).toString(), -- ]); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(seams.forks.map(fork => fork.targetDbDir), [ +- sessionDataService.getSessionDataDir(target).toString(), +- sessionDataService.getSessionDataDir(peerChat).toString(), +- ]); - }); - - test('createChat forks directly onto the exact target chat (no bindSessionChat needed)', async () => { @@ -68548,34 +70597,106 @@ index f24a97a6296..00000000000 - setDefaultSessionStub(agent, AgentSession.id(source), makeSourceStub(URI.file('/source-workspace'), sourceTurn)); - const seams = stubForkSeams(agent, 'bound-fork-sdk-id'); - -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const result = await provisionSession(agent, { -- session: target, -- }, { -- fork: { -- source: defaultChatUri(source), -- turnId: sourceTurn.id, -- }, -- }); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const result = await provisionSession(agent, { +- session: target, +- }, { +- fork: { +- source: defaultChatUri(source), +- turnId: sourceTurn.id, +- }, +- }); - -- assert.deepStrictEqual({ -- resultSession: result.session.toString(), -- boundChat: seams.launches.map(launch => launch.chatChannelUri), -- live: hasLiveChat(agent, targetChat), -- }, { -- resultSession: target.toString(), -- boundChat: [targetChat.toString()], -- live: true, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- resultSession: result.session.toString(), +- boundChat: seams.launches.map(launch => launch.chatChannelUri), +- live: hasLiveChat(agent, targetChat), +- }, { +- resultSession: target.toString(), +- boundChat: [targetChat.toString()], +- live: true, +- }); - }); - }); - - suite('createChat activeClient eager-claim', () => { - +- for (const parseFailure of [false, true]) { +- test(`client plugin stays loading after transfer until discovery ${parseFailure ? 'fails' : 'completes'}`, async () => { +- const fileService = disposables.add(new FileService(new NullLogService())); +- disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); +- const root = URI.file('/cache/discovery-readiness'); +- if (!parseFailure) { +- await fileService.writeFile(URI.joinPath(root, '.plugin', 'plugin.json'), VSBuffer.fromString('{"name":"Review"}')); +- await fileService.writeFile(URI.joinPath(root, 'agents', 'reviewer.agent.md'), VSBuffer.fromString('---\nname: Reviewer\n---\nReview.')); +- } +- const transferred = new DeferredPromise(); +- const release = new DeferredPromise(); +- class PluginManager extends TestAgentPluginManager { +- override async syncCustomizations(_clientId: string, inputs: ClientPluginCustomization[], progress?: (status: PluginCustomization) => void): Promise { +- if (inputs.length === 0) { +- return []; +- } +- const customization: PluginCustomization = { ...inputs[0], load: { kind: CustomizationLoadStatus.Loaded } }; +- progress?.(customization); +- transferred.complete(); +- await release.p; +- return [{ customization, pluginDir: root }]; +- } +- } +- const agent = createTestAgent(disposables, { +- sessionDataService: disposables.add(new TestSessionDataService()), +- copilotClient: new TestCopilotClient([]), fileService, pluginManager: new PluginManager(), +- }); +- if (parseFailure) { +- const parsing = stub(agent['_plugins'], 'tryParsePlugin').resolves(undefined); +- disposables.add(toDisposable(() => parsing.restore())); +- } +- const updates: { load: CustomizationLoadStatus | undefined; agents: number | undefined }[] = []; +- disposables.add(agent.onDidChatProgress(progress => { +- if (progress.kind !== 'action') { +- return; +- } +- const action = progress.action; +- const items = action.type === ActionType.SessionCustomizationUpdated ? [action.customization] +- : action.type === ActionType.SessionCustomizationsChanged ? action.customizations : []; +- for (const item of items) { +- if (item.type === CustomizationType.Plugin && item.uri === 'client-plugin:/review') { +- updates.push({ load: item.load?.kind, agents: item.children?.filter(child => child.type === CustomizationType.Agent).length }); +- } +- } +- })); +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', `readiness-${parseFailure}`); +- await provisionSession(agent, { +- session, workingDirectories: [URI.file('/workspace')], +- }); +- const activeClient = agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client' }); +- activeClient.customizations = [{ +- type: CustomizationType.Plugin, id: 'review', uri: 'client-plugin:/review', name: 'Review', +- }]; +- const resolving = getDefaultChatCustomizations(agent, session); +- await transferred.p; +- const prematureLoaded = updates.some(update => update.load === CustomizationLoadStatus.Loaded); +- release.complete(); +- await resolving; +- assert.deepStrictEqual({ +- prematureLoaded, +- loadedWithoutDiscovery: updates.some(update => update.load === CustomizationLoadStatus.Loaded && update.agents === undefined), +- final: updates.at(-1), +- }, { +- prematureLoaded: false, loadedWithoutDiscovery: false, +- final: { load: parseFailure ? CustomizationLoadStatus.Error : CustomizationLoadStatus.Loaded, agents: parseFailure ? undefined : 1 }, +- }); +- } finally { +- release.complete(); +- await disposeAgent(agent); +- } +- }); +- } +- - class SpyingPluginManager extends TestAgentPluginManager { - public readonly calls: { clientId: string; customizations: ClientPluginCustomization[] }[] = []; - @@ -68595,25 +70716,22 @@ index f24a97a6296..00000000000 - client.createSession = async () => { throw new Error('SDK should not be touched on provisional create'); }; - - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, pluginManager }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); - -- const customizations: ClientPluginCustomization[] = [{ type: CustomizationType.Plugin, id: customizationId('file:///plugin-a'), uri: 'file:///plugin-a', name: 'Plugin A', }]; -- const result = await provisionSession(agent, { -- session: AgentSession.uri('copilotcli', 'test-session'), -- workingDirectories: [URI.file('/workspace')], -- activeClient: { -- clientId: 'client-1', -- tools: [{ name: 't1', description: 'd', inputSchema: { type: 'object' } }], -- customizations, -- }, -- }); +- const customizations: ClientPluginCustomization[] = [{ type: CustomizationType.Plugin, id: customizationId('file:///plugin-a'), uri: 'file:///plugin-a', name: 'Plugin A', }]; +- const result = await provisionSession(agent, { +- session: AgentSession.uri('copilotcli', 'test-session'), +- workingDirectories: [URI.file('/workspace')], +- activeClient: { +- clientId: 'client-1', +- tools: [{ name: 't1', description: 'd', inputSchema: { type: 'object' } }], +- customizations, +- }, +- }); - -- assert.strictEqual(result.provisional, true); -- assert.deepStrictEqual(pluginManager.calls, [{ clientId: 'client-1', customizations }]); -- } finally { -- await disposeAgent(agent); -- } +- assert.strictEqual(result.provisional, true); +- assert.deepStrictEqual(pluginManager.calls, [{ clientId: 'client-1', customizations }]); - }); - - test('createChat without activeClient publishes built-in agents without syncing customizations', async () => { @@ -68623,57 +70741,51 @@ index f24a97a6296..00000000000 - client.createSession = async () => { throw new Error('SDK should not be touched on provisional create'); }; - - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, pluginManager }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); - -- const result = await provisionSession(agent, { -- session: AgentSession.uri('copilotcli', 'test-session-2'), -- workingDirectories: [URI.file('/workspace')], -- }); +- const result = await provisionSession(agent, { +- session: AgentSession.uri('copilotcli', 'test-session-2'), +- workingDirectories: [URI.file('/workspace')], +- }); - -- const builtin = buildCopilotBuiltinAgentsContainer(); -- assert.deepStrictEqual({ -- provisional: result.provisional, -- syncCalls: pluginManager.calls, -- builtin: (await getDefaultChatCustomizations(agent, result.session)).filter(customization => customization.uri === builtin.uri), -- }, { -- provisional: true, -- syncCalls: [], -- builtin: [builtin], -- }); -- } finally { -- await disposeAgent(agent); -- } +- const builtin = buildCopilotBuiltinAgentsContainer(); +- assert.deepStrictEqual({ +- provisional: result.provisional, +- syncCalls: pluginManager.calls, +- builtin: (await getDefaultChatCustomizations(agent, result.session)).filter(customization => customization.uri === builtin.uri), +- }, { +- provisional: true, +- syncCalls: [], +- builtin: [builtin], +- }); - }); - - test('provisional session anchors customization discovery to the additional roots', async () => { - const { agent } = createTestAgentContext(disposables); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const repoA = URI.file('/repo-a'); -- const repoB = URI.file('/repo-b'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const repoA = URI.file('/repo-a'); +- const repoB = URI.file('/repo-b'); - -- const additionalDirsAfterCreate = async (workingDirectories: readonly URI[]): Promise => { -- const uri = AgentSession.uri('copilotcli', `mrp-${workingDirectories.length}`); -- await provisionSession(agent, { -- session: uri, -- workingDirectories, -- activeClient: { clientId: 'client-1', tools: [], customizations: [] }, -- }); -- const activeClients = (agent as unknown as { _activeClients: { get(u: URI): { pluginController: { additionalDirectories: readonly URI[] } } | undefined } })._activeClients; -- return (activeClients.get(uri)?.pluginController.additionalDirectories ?? []).map(d => d.toString()); -- }; +- const additionalDirsAfterCreate = async (workingDirectories: readonly URI[]): Promise => { +- const uri = AgentSession.uri('copilotcli', `mrp-${workingDirectories.length}`); +- await provisionSession(agent, { +- session: uri, +- workingDirectories, +- activeClient: { clientId: 'client-1', tools: [], customizations: [] }, +- }); +- const activeClients = (agent as unknown as { _activeClients: { get(u: URI): { pluginController: { additionalDirectories: readonly URI[] } } | undefined } })._activeClients; +- return (activeClients.get(uri)?.pluginController.additionalDirectories ?? []).map(d => d.toString()); +- }; - -- const multiRoot = await additionalDirsAfterCreate([repoA, repoB]); -- const singleRoot = await additionalDirsAfterCreate([repoA]); +- const multiRoot = await additionalDirsAfterCreate([repoA, repoB]); +- const singleRoot = await additionalDirsAfterCreate([repoA]); - -- assert.deepStrictEqual({ multiRoot, singleRoot }, { -- multiRoot: [repoB.toString()], -- singleRoot: [], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ multiRoot, singleRoot }, { +- multiRoot: [repoB.toString()], +- singleRoot: [], +- }); - }); - - test('create passes additional roots to the SDK by default', async () => { @@ -68717,27 +70829,24 @@ index f24a97a6296..00000000000 - return new MockCopilotSession() as unknown as CopilotSession; - }; - const { agent } = createTestAgentContext(disposables, { copilotClient: client, sessionDataService }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'ah-session'); -- const chat = defaultChatUri(session); -- const result = await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); -- await agent.chats.sendMessage(chat, 'hello', [URI.file('/workspace')], undefined, 'turn-1', undefined, exactChatContext(session, chat, session)); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'ah-session'); +- const chat = defaultChatUri(session); +- const result = await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); +- await agent.chats.sendMessage(chat, 'hello', [URI.file('/workspace')], undefined, 'turn-1', undefined, exactChatContext(session, chat, session)); - -- const sdkSessionId = JSON.parse(result.providerData!).sdkSessionId as string; -- assert.deepStrictEqual({ -- ahSessionId: AgentSession.id(result.session), -- sdkSessionId, -- createdSdkSessionId, -- }, { -- ahSessionId: 'ah-session', -- sdkSessionId, -- createdSdkSessionId: sdkSessionId, -- }); -- assert.notStrictEqual(sdkSessionId, 'ah-session'); -- } finally { -- await disposeAgent(agent); -- } +- const sdkSessionId = JSON.parse(result.providerData!).sdkSessionId as string; +- assert.deepStrictEqual({ +- ahSessionId: AgentSession.id(result.session), +- sdkSessionId, +- createdSdkSessionId, +- }, { +- ahSessionId: 'ah-session', +- sdkSessionId, +- createdSdkSessionId: sdkSessionId, +- }); +- assert.notStrictEqual(sdkSessionId, 'ah-session'); - }); - - test('createChat binds a fresh session-backed chat directly and materializes it on first send, with no bindSessionChat call', async () => { @@ -68745,47 +70854,44 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - client.createSession = async () => new MockCopilotSession() as unknown as CopilotSession; - const { agent } = createTestAgentContext(disposables, { copilotClient: client, sessionDataService }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'fresh-exact-chat'); -- const chat = defaultChatUri(session); -- const workingDirectory = URI.file('/workspace'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'fresh-exact-chat'); +- const chat = defaultChatUri(session); +- const workingDirectory = URI.file('/workspace'); - -- const materialized: IAgentMaterializeChatEvent[] = []; -- disposables.add(agent.onDidMaterializeChat(e => materialized.push(e))); +- const materialized: IAgentMaterializeChatEvent[] = []; +- disposables.add(agent.onDidMaterializeChat(e => materialized.push(e))); - -- const result = await provisionSession(agent, { -- session, -- workingDirectories: [workingDirectory], -- }); +- const result = await provisionSession(agent, { +- session, +- workingDirectories: [workingDirectory], +- }); - -- // The exact target chat is bound directly at provisional creation -- // time — before any live SDK session exists — with no separate -- // `bindSessionChat` call anywhere in this test. -- assert.deepStrictEqual({ -- provisional: result.provisional, -- boundBeforeSend: chatBackings(agent).has(chat.toString()), -- liveBeforeSend: hasLiveChat(agent, chat), -- }, { -- provisional: true, -- boundBeforeSend: true, -- liveBeforeSend: false, -- }); +- // The exact target chat is bound directly at provisional creation +- // time — before any live SDK session exists — with no separate +- // `bindSessionChat` call anywhere in this test. +- assert.deepStrictEqual({ +- provisional: result.provisional, +- boundBeforeSend: chatBackings(agent).has(chat.toString()), +- liveBeforeSend: hasLiveChat(agent, chat), +- }, { +- provisional: true, +- boundBeforeSend: true, +- liveBeforeSend: false, +- }); - -- await agent.chats.sendMessage(chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, exactChatContext(result.session, chat, result.session)); +- await agent.chats.sendMessage(chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, exactChatContext(result.session, chat, result.session)); - -- assert.strictEqual(hasLiveChat(agent, chat), true); -- assert.strictEqual(materialized.length, 1); -- assert.deepStrictEqual({ -- chat: materialized[0].chat.toString(), -- workingDirectories: materialized[0].workingDirectories?.map(d => d.toString()), -- }, { -- chat: chat.toString(), -- workingDirectories: [workingDirectory.toString()], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.strictEqual(hasLiveChat(agent, chat), true); +- assert.strictEqual(materialized.length, 1); +- assert.deepStrictEqual({ +- chat: materialized[0].chat.toString(), +- workingDirectories: materialized[0].workingDirectories?.map(d => d.toString()), +- }, { +- chat: chat.toString(), +- workingDirectories: [workingDirectory.toString()], +- }); - }); - - test('prepareTurn materializes a deferred chat once, and only that turn\'s send revalidates from in-memory state', async () => { @@ -68797,38 +70903,35 @@ index f24a97a6296..00000000000 - return new MockCopilotSession() as unknown as CopilotSession; - }; - const { agent } = createTestAgentContext(disposables, { copilotClient: client, sessionDataService }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'prepare-turn'); -- const chat = defaultChatUri(session); -- const workingDirectory = URI.file('/workspace'); -- const result = await provisionSession(agent, { session, workingDirectories: [workingDirectory] }); -- const context = exactChatContext(result.session, chat, result.session); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'prepare-turn'); +- const chat = defaultChatUri(session); +- const workingDirectory = URI.file('/workspace'); +- const result = await provisionSession(agent, { session, workingDirectories: [workingDirectory] }); +- const context = exactChatContext(result.session, chat, result.session); - -- await agent.chats.prepareTurn!(chat, 'turn-1', [workingDirectory], context); -- const afterPrepare = { creates, live: hasLiveChat(agent, chat) }; -- await agent.chats.prepareTurn!(chat, 'turn-1', [workingDirectory], context); -- // Count configuration re-checks: the refresh path re-derives the client snapshot. -- const activeClient = (agent as unknown as { _activeClients: { get(session: URI): { snapshot(chatKey?: string): Promise } | undefined } })._activeClients.get(result.session)!; -- const snapshot = activeClient.snapshot; -- let snapshots = 0; -- activeClient.snapshot = chatKey => { -- snapshots++; -- return snapshot.call(activeClient, chatKey); -- }; -- await agent.chats.sendMessage(chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, context); -- const preparedTurnSnapshots = snapshots; -- await agent.chats.sendMessage(chat, 'again', [workingDirectory], undefined, 'turn-2', undefined, context); +- await agent.chats.prepareTurn!(chat, 'turn-1', [workingDirectory], context); +- const afterPrepare = { creates, live: hasLiveChat(agent, chat) }; +- await agent.chats.prepareTurn!(chat, 'turn-1', [workingDirectory], context); +- // Count configuration re-checks: the refresh path re-derives the client snapshot. +- const activeClient = (agent as unknown as { _activeClients: { get(session: URI): { snapshot(chatKey?: string): Promise } | undefined } })._activeClients.get(result.session)!; +- const snapshot = activeClient.snapshot; +- let snapshots = 0; +- activeClient.snapshot = chatKey => { +- snapshots++; +- return snapshot.call(activeClient, chatKey); +- }; +- await agent.chats.sendMessage(chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, context); +- const preparedTurnSnapshots = snapshots; +- await agent.chats.sendMessage(chat, 'again', [workingDirectory], undefined, 'turn-2', undefined, context); - -- assert.deepStrictEqual({ afterPrepare, creates, preparedTurnSnapshots, laterTurnSnapshots: snapshots - preparedTurnSnapshots }, { -- afterPrepare: { creates: 1, live: true }, -- creates: 1, -- preparedTurnSnapshots: 0, -- laterTurnSnapshots: 1, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ afterPrepare, creates, preparedTurnSnapshots, laterTurnSnapshots: snapshots - preparedTurnSnapshots }, { +- afterPrepare: { creates: 1, live: true }, +- creates: 1, +- preparedTurnSnapshots: 0, +- laterTurnSnapshots: 1, +- }); - }); - - test('the prepared turn still relaunches its session when root MCP servers change before it is sent', async () => { @@ -68848,22 +70951,19 @@ index f24a97a6296..00000000000 - sessionDataService, - rootConfig: { [AgentHostGitHubMcpServerEnabledConfigKey]: false }, - }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'prepare-turn-stale'); -- const chat = defaultChatUri(session); -- const workingDirectory = URI.file('/workspace'); -- const result = await provisionSession(agent, { session, workingDirectories: [workingDirectory] }); -- const context = exactChatContext(result.session, chat, result.session); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'prepare-turn-stale'); +- const chat = defaultChatUri(session); +- const workingDirectory = URI.file('/workspace'); +- const result = await provisionSession(agent, { session, workingDirectories: [workingDirectory] }); +- const context = exactChatContext(result.session, chat, result.session); - -- await agent.chats.prepareTurn!(chat, 'turn-1', [workingDirectory], context); -- configurationService.updateRootConfig({ [AgentHostMcpServersConfigKey]: { playwright: { type: 'stdio', command: 'node' } } }); -- await agent.chats.sendMessage(chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, context); +- await agent.chats.prepareTurn!(chat, 'turn-1', [workingDirectory], context); +- configurationService.updateRootConfig({ [AgentHostMcpServersConfigKey]: { playwright: { type: 'stdio', command: 'node' } } }); +- await agent.chats.sendMessage(chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, context); - -- assert.deepStrictEqual({ launches, live: hasLiveChat(agent, chat) }, { launches: ['create', 'resume'], live: true }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ launches, live: hasLiveChat(agent, chat) }, { launches: ['create', 'resume'], live: true }); - }); - - class BlockingPluginManager extends TestAgentPluginManager { @@ -68913,8 +71013,7 @@ index f24a97a6296..00000000000 - const preparedPlugin: ClientPluginCustomization = { type: CustomizationType.Plugin, id: preparedPluginDir.toString(), uri: preparedPluginDir.toString(), name: 'Plugin A' }; - - test('the prepared turn awaits a plugin sync that started after its preparation and relaunches with the plugin', async () => { -- const fileService = disposables.add(new FileService(new NullLogService())); -- disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); +- const fileService = createInMemoryFileService(disposables, Schemas.file); - await fileService.writeFile(URI.joinPath(preparedPluginDir, '.plugin', 'plugin.json'), VSBuffer.fromString(JSON.stringify({ name: 'Plugin A' }))); - const client = new TestCopilotClient([], [{ id: 'claude-sonnet', name: 'Claude Sonnet' }]); - const launches: { kind: string; pluginDirectories: readonly string[] }[] = []; @@ -69019,38 +71118,35 @@ index f24a97a6296..00000000000 - const client = new TestCopilotClient([], [{ id: 'claude-sonnet', name: 'Claude Sonnet' }]); - client.createSession = async () => new MockCopilotSession() as unknown as CopilotSession; - const { agent } = createTestAgentContext(disposables, { copilotClient: client, sessionDataService: disposables.add(new TestSessionDataService()) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'prepare-turn-discovery'); -- const chat = defaultChatUri(session); -- const workingDirectory = URI.file('/workspace'); -- const result = await provisionSession(agent, { session, workingDirectories: [workingDirectory] }); -- const context = exactChatContext(result.session, chat, result.session); -- await agent.chats.prepareTurn!(chat, 'turn-1', [workingDirectory], context); -- const pluginController = (agent as unknown as { _activeClients: { get(session: URI): { pluginController: { isSettled(): boolean; getCustomizationsSettled(): Promise } } | undefined } })._activeClients.get(result.session)!.pluginController; -- // The discovery's change event is what a watched workspace file change raises. -- await pluginController.getCustomizationsSettled(); -- // The published view shares the default chat's anchor set, so this is the discovery the turn waits for. -- const discovery = (pluginController as unknown as { _published: { value: { object: { _discovered: { value: { _discovery: { _onDidChange: Emitter } } } } } } })._published.value.object._discovered.value._discovery; -- -- const beforeChange = pluginController.isSettled(); -- discovery._onDidChange.fire(); -- const whileRefreshing = pluginController.isSettled(); -- let sent = false; -- const send = agent.chats.sendMessage(chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, context).then(() => { sent = true; }); -- await timeout(20); -- const sentWhileRefreshing = sent; -- await send; +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'prepare-turn-discovery'); +- const chat = defaultChatUri(session); +- const workingDirectory = URI.file('/workspace'); +- const result = await provisionSession(agent, { session, workingDirectories: [workingDirectory] }); +- const context = exactChatContext(result.session, chat, result.session); +- await agent.chats.prepareTurn!(chat, 'turn-1', [workingDirectory], context); +- const pluginController = (agent as unknown as { _activeClients: { get(session: URI): { pluginController: { isSettled(): boolean; getCustomizationsSettled(): Promise } } | undefined } })._activeClients.get(result.session)!.pluginController; +- // The discovery's change event is what a watched workspace file change raises. +- await pluginController.getCustomizationsSettled(); +- // The published view shares the default chat's anchor set, so this is the discovery the turn waits for. +- const discovery = (pluginController as unknown as { _published: { value: { object: { _discovered: { value: { _discovery: { _onDidChange: Emitter } } } } } } })._published.value.object._discovered.value._discovery; +- +- const beforeChange = pluginController.isSettled(); +- discovery._onDidChange.fire(); +- const whileRefreshing = pluginController.isSettled(); +- let sent = false; +- const send = agent.chats.sendMessage(chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, context).then(() => { sent = true; }); +- await timeout(20); +- const sentWhileRefreshing = sent; +- await send; - -- assert.deepStrictEqual({ beforeChange, whileRefreshing, sentWhileRefreshing, afterRefresh: pluginController.isSettled() }, { -- beforeChange: true, -- whileRefreshing: false, -- sentWhileRefreshing: false, -- afterRefresh: true, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ beforeChange, whileRefreshing, sentWhileRefreshing, afterRefresh: pluginController.isSettled() }, { +- beforeChange: true, +- whileRefreshing: false, +- sentWhileRefreshing: false, +- afterRefresh: true, +- }); - }); - - test('Stop releases a preparation waiting on a plugin sync', async () => { @@ -69204,23 +71300,20 @@ index f24a97a6296..00000000000 - markerRef.dispose(); - } - -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.materializeChat(chat, context, providerData); -- const metadata = await agent.getChatMetadata(chat, context, providerData); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.materializeChat(chat, context, providerData); +- const metadata = await agent.getChatMetadata(chat, context, providerData); - -- assert.deepStrictEqual({ -- provisional: (agent as unknown as { _provisionalSessions: Map })._provisionalSessions.has(AgentSession.id(session)), -- startTime: metadata?.startTime, -- modifiedTime: metadata?.modifiedTime, -- }, { -- provisional: false, -- startTime: startedAt.getTime(), -- modifiedTime: startedAt.getTime(), -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- provisional: (agent as unknown as { _provisionalSessions: Map })._provisionalSessions.has(AgentSession.id(session)), +- startTime: metadata?.startTime, +- modifiedTime: metadata?.modifiedTime, +- }, { +- provisional: false, +- startTime: startedAt.getTime(), +- modifiedTime: startedAt.getTime(), +- }); - }); - - test('preserves the deferred marker start time when rematerializing the same SDK backing', async () => { @@ -69275,14 +71368,11 @@ index f24a97a6296..00000000000 - markerRef.dispose(); - } - -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.materializeChat(chat, context, JSON.stringify({ sdkSessionId })); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.materializeChat(chat, context, JSON.stringify({ sdkSessionId })); - -- assert.strictEqual((agent as unknown as { _provisionalSessions: Map })._provisionalSessions.has(AgentSession.id(session)), false); -- } finally { -- await disposeAgent(agent); -- } +- assert.strictEqual((agent as unknown as { _provisionalSessions: Map })._provisionalSessions.has(AgentSession.id(session)), false); - }); - - test('session plugin enablement is projected from the host snapshot supplied at the boundary, per session', async () => { @@ -69296,6 +71386,7 @@ index f24a97a6296..00000000000 - _serviceBrand: undefined, - onDidChange: Event.None, - initializeSession: async () => { }, +- adoptMcpServerOwnership: () => { }, - getWorkingDirectoryState: () => ({ kind: 'pending' }), - resolve: () => ({ kind: 'pending', reason: 'session' }), - applyClientGlobalEnablement: () => ({ kind: 'pending', reason: 'session' }), @@ -69304,70 +71395,70 @@ index f24a97a6296..00000000000 - whenIdle: async () => { }, - }; - const { agent, stateManager } = createTestAgentContext(disposables, { pluginManager: new PassthroughPluginManager(), customizationEnablementService: pendingEnablementService }); -- try { -- const firstSession = AgentSession.uri('copilotcli', 'first-enable-state'); -- const secondSession = AgentSession.uri('copilotcli', 'second-enable-state'); -- const now = new Date().toISOString(); -- for (const session of [firstSession, secondSession]) { -- stateManager.createSession({ -- resource: session.toString(), -- provider: 'copilotcli', -- title: 'Test', -- status: SessionStatus.Idle, -- createdAt: now, -- modifiedAt: now, -- defaultChat: buildDefaultChatUri(session.toString()), -- }); -- } -- // The host's published snapshot for a session — the exact value -- // `AgentService`/`AgentSideEffects` hand to the provider. The -- // provider must never read this itself. -- const hostSnapshot = (session: URI) => stateManager.getSessionState(session.toString())?.customizations; +- afterTest(() => disposeAgent(agent)); +- const firstSession = AgentSession.uri('copilotcli', 'first-enable-state'); +- const secondSession = AgentSession.uri('copilotcli', 'second-enable-state'); +- const now = new Date().toISOString(); +- for (const session of [firstSession, secondSession]) { +- stateManager.createSession({ +- resource: session.toString(), +- provider: 'copilotcli', +- title: 'Test', +- status: SessionStatus.Idle, +- createdAt: now, +- modifiedAt: now, +- defaultChat: buildDefaultChatUri(session.toString()), +- }); +- } +- // The host's published snapshot for a session — the exact value +- // `AgentService`/`AgentSideEffects` hand to the provider. The +- // provider must never read this itself. +- const hostSnapshot = (session: URI) => stateManager.getSessionState(session.toString())?.customizations; - -- const plugin: ClientPluginCustomization = { -- type: CustomizationType.Plugin, -- id: 'file:///plugin-a', -- uri: 'file:///plugin-a', -- name: 'Plugin A', -- }; -- agent.getOrCreateActiveClient(defaultChatUri(firstSession), firstSession, { clientId: 'client-1' }).customizations = [plugin]; -- agent.getOrCreateActiveClient(defaultChatUri(secondSession), secondSession, { clientId: 'client-2' }).customizations = [plugin]; +- const plugin: ClientPluginCustomization = { +- type: CustomizationType.Plugin, +- id: 'file:///plugin-a', +- uri: 'file:///plugin-a', +- name: 'Plugin A', +- }; +- agent.getOrCreateActiveClient(defaultChatUri(firstSession), firstSession, { clientId: 'client-1' }).customizations = [plugin]; +- agent.getOrCreateActiveClient(defaultChatUri(secondSession), secondSession, { clientId: 'client-2' }).customizations = [plugin]; - -- const [firstInitial, secondInitial] = await Promise.all([ -- getDefaultChatCustomizations(agent, firstSession, hostSnapshot(firstSession)), -- getDefaultChatCustomizations(agent, secondSession, hostSnapshot(secondSession)), -- ]); -- stateManager.dispatchServerAction(firstSession.toString(), { type: ActionType.SessionCustomizationsChanged, customizations: [...firstInitial] }); -- stateManager.dispatchServerAction(secondSession.toString(), { type: ActionType.SessionCustomizationsChanged, customizations: [...secondInitial] }); -- stateManager.dispatchServerAction(firstSession.toString(), { type: ActionType.SessionCustomizationToggled, id: plugin.id, enablement: [{ kind: CustomizationEnablementKind.Session, enabled: false }] }); +- const [firstInitial, secondInitial] = await Promise.all([ +- getDefaultChatCustomizations(agent, firstSession, hostSnapshot(firstSession)), +- getDefaultChatCustomizations(agent, secondSession, hostSnapshot(secondSession)), +- ]); +- stateManager.dispatchServerAction(firstSession.toString(), { type: ActionType.SessionCustomizationsChanged, customizations: [...firstInitial] }); +- stateManager.dispatchServerAction(secondSession.toString(), { type: ActionType.SessionCustomizationsChanged, customizations: [...secondInitial] }); +- stateManager.dispatchServerAction(firstSession.toString(), { type: ActionType.SessionCustomizationToggled, id: plugin.id, enablement: [{ kind: CustomizationEnablementKind.Session, enabled: false }] }); - -- const [first, second] = await Promise.all([ -- getDefaultChatCustomizations(agent, firstSession, hostSnapshot(firstSession)), -- getDefaultChatCustomizations(agent, secondSession, hostSnapshot(secondSession)), -- ]); -- assert.deepStrictEqual({ -- first: (() => { -- const customization = first.find(customization => customization.id === plugin.id); -- return customization?.type === CustomizationType.Plugin ? isCustomizationEnabled(customization) : undefined; -- })(), -- second: (() => { -- const customization = second.find(customization => customization.id === plugin.id); -- return customization?.type === CustomizationType.Plugin ? isCustomizationEnabled(customization) : undefined; -- })(), -- }, { -- first: false, -- second: true, -- }); -- } finally { -- await disposeAgent(agent); -- } +- const [first, second] = await Promise.all([ +- getDefaultChatCustomizations(agent, firstSession, hostSnapshot(firstSession)), +- getDefaultChatCustomizations(agent, secondSession, hostSnapshot(secondSession)), +- ]); +- assert.deepStrictEqual({ +- first: (() => { +- const customization = first.find(customization => customization.id === plugin.id); +- return customization?.type === CustomizationType.Plugin ? isCustomizationEnabled(customization) : undefined; +- })(), +- second: (() => { +- const customization = second.find(customization => customization.id === plugin.id); +- return customization?.type === CustomizationType.Plugin ? isCustomizationEnabled(customization) : undefined; +- })(), +- }, { +- first: false, +- second: true, +- }); - }); - - test('setClientCustomizations publishes parsed agents in SessionCustomizationUpdated', async () => { +- const root = await fs.mkdtemp(join(os.tmpdir(), 'copilot-source-meta-')); - const fileService = disposables.add(new FileService(new NullLogService())); -- disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); +- disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new TestDiskFileSystemProvider(new NullLogService())))); - -- const pluginDir = URI.from({ scheme: Schemas.inMemory, path: '/plugin-a' }); +- // Serialized file URIs lowercase the drive letter, unlike this materialized root. +- const pluginDir = URI.file(isWindows ? root[0].toUpperCase() + root.slice(1) : root); +- const sourceRoot = URI.parse('client-customizations:/window/plugin-a'); - await fileService.createFolder(URI.joinPath(pluginDir, 'agents')); - await fileService.writeFile( - URI.joinPath(pluginDir, 'agents', 'helper.md'), @@ -69395,31 +71486,32 @@ index f24a97a6296..00000000000 - } - })); - -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(async () => { +- await disposeAgent(agent); +- await fs.rm(root, { recursive: true, force: true }); +- }); +- await agent.authenticate('https://api.github.com', 'token'); - -- const session = AgentSession.uri('copilotcli', 'sync-customizations-test'); -- agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client-1' }).customizations = [{ type: CustomizationType.Plugin, id: customizationId(pluginDir.toString()), uri: pluginDir.toString(), name: 'Plugin A' }]; +- const session = AgentSession.uri('copilotcli', 'sync-customizations-test'); +- agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client-1' }).customizations = [{ type: CustomizationType.Plugin, id: customizationId(sourceRoot.toString()), uri: sourceRoot.toString(), name: 'Plugin A' }]; - -- // Wait for the deferred resolution chain in PluginController.sync. -- await new Promise(r => setTimeout(r, 50)); +- // Wait for the deferred resolution chain in PluginController.sync. +- await new Promise(r => setTimeout(r, 50)); - -- const updatesWithChildren = actions -- .filter((a): a is Extract => a.type === ActionType.SessionCustomizationUpdated) -- .filter(a => (a.customization as PluginCustomization).children !== undefined); +- const updatesWithChildren = actions +- .filter((a): a is Extract => a.type === ActionType.SessionCustomizationUpdated) +- .filter(a => (a.customization as PluginCustomization).children !== undefined); - -- assert.strictEqual(updatesWithChildren.length > 0, true, 'expected SessionCustomizationUpdated to carry parsed children'); -- const agentChildren = (updatesWithChildren.at(-1)!.customization as PluginCustomization).children!.filter(c => c.type === CustomizationType.Agent); -- assert.deepStrictEqual(agentChildren, [{ -- type: CustomizationType.Agent, -- id: customizationId(URI.joinPath(pluginDir, 'agents', 'helper.md').toString()), -- uri: URI.joinPath(pluginDir, 'agents', 'helper.md').toString(), -- name: 'helper-agent', -- description: 'helps out', -- }]); -- } finally { -- await disposeAgent(agent); -- } +- assert.strictEqual(updatesWithChildren.length > 0, true, 'expected SessionCustomizationUpdated to carry parsed children'); +- const agentChildren = (updatesWithChildren.at(-1)!.customization as PluginCustomization).children!.filter(c => c.type === CustomizationType.Agent); +- assert.deepStrictEqual(agentChildren, [{ +- type: CustomizationType.Agent, +- id: customizationId(URI.joinPath(pluginDir, 'agents', 'helper.md').toString()), +- uri: URI.joinPath(pluginDir, 'agents', 'helper.md').toString(), +- name: 'helper-agent', +- description: 'helps out', +- _meta: { 'vscode.sourceUri': URI.joinPath(sourceRoot, 'agents', 'helper.md').toString() }, +- }]); - }); - - test('identical active-client republishes do not retry failed customization sync', async () => { @@ -69443,31 +71535,27 @@ index f24a97a6296..00000000000 - pluginManager: new FailingPluginManager(), - }); - -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'failed-customization-republish'); -- const customization: ClientPluginCustomization = { -- type: CustomizationType.Plugin, -- id: 'file:///failed-plugin', -- uri: 'file:///failed-plugin', -- name: 'Failed Plugin', -- }; -- const activeClient = agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client-1' }); -- activeClient.customizations = [customization]; -- await firstSync.p; -- await timeout(0); -- activeClient.customizations = [{ ...customization }]; -- await timeout(0); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'failed-customization-republish'); +- const customization: ClientPluginCustomization = { +- type: CustomizationType.Plugin, +- id: 'file:///failed-plugin', +- uri: 'file:///failed-plugin', +- name: 'Failed Plugin', +- }; +- const activeClient = agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client-1' }); +- activeClient.customizations = [customization]; +- await firstSync.p; +- await timeout(0); +- activeClient.customizations = [{ ...customization }]; +- await timeout(0); - -- assert.strictEqual(syncCalls, 1); -- } finally { -- await disposeAgent(agent); -- } +- assert.strictEqual(syncCalls, 1); - }); - - test('client plugin states shadow matching discovered plugins without hiding different same-named plugins', async () => { -- const fileService = disposables.add(new FileService(new NullLogService())); -- disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); +- const fileService = createInMemoryFileService(disposables, Schemas.file); - - const workspace = URI.file('/workspace'); - const sourcePlugin = URI.joinPath(workspace, 'plugins', 'azure-exp'); @@ -69511,7 +71599,13 @@ index f24a97a6296..00000000000 - { id: 'other-agent', name: 'discovered-agent', displayName: 'discovered-agent', description: 'discovered', path: sameNamedAgent.fsPath, source: 'plugin' }, - ], - }); -- const { agent } = createTestAgentContext(disposables, { +- client.discoverMcp = async () => ({ +- servers: [{ +- name: 'cached-server', type: 'stdio', source: 'plugin', enabled: true, +- effectiveSource: { kind: 'plugin', id: 'source-server', editability: 'read-only', file: { uri: URI.joinPath(sourcePlugin, '.mcp.json').toString() } }, +- }] +- }); +- const { agent, stateManager } = createTestAgentContext(disposables, { - sessionDataService, - copilotClient: client, - fileService, @@ -69525,117 +71619,124 @@ index f24a97a6296..00000000000 - } - })); - -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'client-plugin-discovery-shadow'); -- const chat = defaultChatUri(session); -- await provisionSession(agent, { session, workingDirectories: [workspace] }); -- assert.deepStrictEqual( -- (await getDefaultChatCustomizations(agent, session)) -- .filter((customization): customization is PluginCustomization => customization.type === CustomizationType.Plugin) -- .map(customization => ({ uri: customization.uri, name: customization.name })) -- .sort((a, b) => a.uri.localeCompare(b.uri)), -- [ -- { uri: sourcePlugin.toString(), name: 'Azure exp' }, -- { uri: sameNamedPlugin.toString(), name: 'Azure exp' }, -- ].sort((a, b) => a.uri.localeCompare(b.uri)), -- ); -- -- const activeClient = agent.getOrCreateActiveClient(chat, session, { clientId: 'client-1' }); -- const sourcePluginInput = (nonce: string, disabled = false): ClientPluginCustomization => ({ -- type: CustomizationType.Plugin, -- id: customizationId(sourcePlugin.toString()), -- uri: sourcePlugin.toString(), -- name: 'Azure exp', -- nonce, -- enablement: [{ kind: CustomizationEnablementKind.Global, enabled: !disabled }], -- }); -- const snapshot = () => getDefaultChatCustomizations(agent, session).then(customizations => customizations +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'client-plugin-discovery-shadow'); +- const chat = defaultChatUri(session); +- stateManager.createSession({ +- resource: session.toString(), provider: 'copilotcli', title: 'Test', status: SessionStatus.Idle, +- createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), +- workingDirectories: [workspace.toString()], defaultChat: chat.toString(), +- }); +- await provisionSession(agent, { session, workingDirectories: [workspace] }); +- assert.deepStrictEqual( +- (await getDefaultChatCustomizations(agent, session)) - .filter((customization): customization is PluginCustomization => customization.type === CustomizationType.Plugin) -- .map(customization => ({ -- uri: customization.uri, -- name: customization.name, -- load: customization.load?.kind, -- enabled: isCustomizationEnabled(customization), -- children: customization.children?.map(child => ({ id: child.id, type: child.type, uri: child.uri, name: child.name })), -- })) -- .sort((a, b) => a.uri.localeCompare(b.uri))); -- -- for (const [nonce, disabled, expected] of [ -- ['loading', false, { load: CustomizationLoadStatus.Loading, enabled: true, children: undefined }], -- ['error', false, { load: CustomizationLoadStatus.Error, enabled: true, children: undefined }], -- ['loaded', false, { load: CustomizationLoadStatus.Loaded, enabled: true, children: [{ id: `${URI.joinPath(cachedPlugin, '.mcp.json')}#mcp=cached-server`, type: CustomizationType.McpServer, uri: URI.joinPath(cachedPlugin, '.mcp.json').toString(), name: 'cached-server' }] }], -- ['disabled', true, { load: CustomizationLoadStatus.Loaded, enabled: false, children: [{ id: `${URI.joinPath(cachedPlugin, '.mcp.json')}#mcp=cached-server`, type: CustomizationType.McpServer, uri: URI.joinPath(cachedPlugin, '.mcp.json').toString(), name: 'cached-server' }] }], -- ] as const) { -- activeClient.customizations = [sourcePluginInput(nonce, disabled)]; -- const customizations = await snapshot(); -- assert.deepStrictEqual(customizations, [ -- { -- uri: sourcePlugin.toString(), -- name: 'Azure exp', -- ...expected, -- }, -- { -- uri: sameNamedPlugin.toString(), -- name: 'Azure exp', -- load: CustomizationLoadStatus.Loaded, -- enabled: true, -- children: [{ id: customizationId(sameNamedAgent.toString()), type: CustomizationType.Agent, uri: sameNamedAgent.toString(), name: 'discovered-agent' }], -- }, -- ].sort((a, b) => a.uri.localeCompare(b.uri))); -- } +- .map(customization => ({ uri: customization.uri, name: customization.name })) +- .sort((a, b) => a.uri.localeCompare(b.uri)), +- [ +- { uri: sourcePlugin.toString(), name: 'Azure exp' }, +- { uri: sameNamedPlugin.toString(), name: 'Azure exp' }, +- ].sort((a, b) => a.uri.localeCompare(b.uri)), +- ); - -- const hasPublishedLoad = (kind: CustomizationLoadStatus): boolean => actions.some(action => -- action.type === ActionType.SessionCustomizationsChanged -- && action.customizations.some(customization => -- customization.id === customizationId(sourcePlugin.toString()) -- && customization.type === CustomizationType.Plugin -- && customization.load?.kind === kind -- ) -- ); -- assert.deepStrictEqual({ -- loading: hasPublishedLoad(CustomizationLoadStatus.Loading), -- updates: [CustomizationLoadStatus.Error, CustomizationLoadStatus.Loaded].map(kind => actions.some(action => -- action.type === ActionType.SessionCustomizationUpdated -- && action.customization.id === customizationId(sourcePlugin.toString()) -- && action.customization.type === CustomizationType.Plugin -- && action.customization.load?.kind === kind -- )), -- }, { -- loading: true, -- updates: [true, true], -- }); +- const activeClient = agent.getOrCreateActiveClient(chat, session, { clientId: 'client-1' }); +- const sourcePluginInput = (nonce: string, disabled = false): ClientPluginCustomization => ({ +- type: CustomizationType.Plugin, +- id: customizationId(sourcePlugin.toString()), +- uri: sourcePlugin.toString(), +- name: 'Azure exp', +- nonce, +- enablement: [{ kind: CustomizationEnablementKind.Global, enabled: !disabled }], +- }); +- const snapshot = () => getDefaultChatCustomizations(agent, session).then(customizations => customizations +- .filter((customization): customization is PluginCustomization => customization.type === CustomizationType.Plugin) +- .map(customization => ({ +- uri: customization.uri, +- name: customization.name, +- load: customization.load?.kind, +- enabled: isCustomizationEnabled(customization), +- children: customization.children?.map(child => ({ id: child.id, type: child.type, uri: child.uri, name: child.name })), +- })) +- .sort((a, b) => a.uri.localeCompare(b.uri))); - -- activeClient.customizations = [sourcePluginInput('re-enabled')]; -- await snapshot(); -- agent.removeActiveClient(chat, session, 'client-1'); -- assert.deepStrictEqual(await snapshot(), [ +- for (const [nonce, disabled, expected] of [ +- ['loading', false, { load: CustomizationLoadStatus.Loading, enabled: true, children: undefined }], +- ['error', false, { load: CustomizationLoadStatus.Error, enabled: true, children: undefined }], +- ['loaded', false, { load: CustomizationLoadStatus.Loaded, enabled: true, children: [{ id: `${URI.joinPath(cachedPlugin, '.mcp.json')}#mcp=cached-server`, type: CustomizationType.McpServer, uri: URI.joinPath(cachedPlugin, '.mcp.json').toString(), name: 'cached-server' }] }], +- ['disabled', true, { load: CustomizationLoadStatus.Loaded, enabled: false, children: [{ id: `${URI.joinPath(cachedPlugin, '.mcp.json')}#mcp=cached-server`, type: CustomizationType.McpServer, uri: URI.joinPath(cachedPlugin, '.mcp.json').toString(), name: 'cached-server' }] }], +- ] as const) { +- activeClient.customizations = [sourcePluginInput(nonce, disabled)]; +- const customizations = await snapshot(); +- assert.deepStrictEqual(customizations, [ - { - uri: sourcePlugin.toString(), - name: 'Azure exp', -- load: CustomizationLoadStatus.Loaded, -- enabled: true, -- children: [ -- { id: customizationId(sourceAgent.toString()), type: CustomizationType.Agent, uri: sourceAgent.toString(), name: 'discovered-agent' }, -- { id: `${URI.joinPath(sourcePlugin, '.mcp.json')}#mcp=cached-server`, type: CustomizationType.McpServer, uri: URI.joinPath(sourcePlugin, '.mcp.json').toString(), name: 'cached-server' }, -- ], +- ...expected, - }, - { - uri: sameNamedPlugin.toString(), - name: 'Azure exp', - load: CustomizationLoadStatus.Loaded, - enabled: true, -- children: [{ id: customizationId(sameNamedAgent.toString()), type: CustomizationType.Agent, uri: sameNamedAgent.toString(), name: 'discovered-agent' }], +- children: [{ id: 'other-agent', type: CustomizationType.Agent, uri: sameNamedAgent.toString(), name: 'discovered-agent' }], - }, - ].sort((a, b) => a.uri.localeCompare(b.uri))); -- } finally { -- await disposeAgent(agent); -- } +- const applied = await agent['_getOrCreateActiveClient'](session, workspace).snapshot(chat.toString()); +- assert.deepStrictEqual({ +- nativeAgents: applied.nativeAgents?.map(agent => agent.id), +- shadowedSelection: getCopilotAgentSelectionName(applied, sourceAgent.toString()), +- otherSelection: getCopilotAgentSelectionName(applied, sameNamedAgent.toString()), +- }, { nativeAgents: ['other-agent'], shadowedSelection: undefined, otherSelection: 'other-agent' }); +- } +- +- const hasPublishedLoad = (kind: CustomizationLoadStatus): boolean => actions.some(action => +- action.type === ActionType.SessionCustomizationsChanged +- && action.customizations.some(customization => +- customization.id === customizationId(sourcePlugin.toString()) +- && customization.type === CustomizationType.Plugin +- && customization.load?.kind === kind +- ) +- ); +- assert.deepStrictEqual({ +- loading: hasPublishedLoad(CustomizationLoadStatus.Loading), +- updates: [CustomizationLoadStatus.Error, CustomizationLoadStatus.Loaded].map(kind => actions.some(action => +- action.type === ActionType.SessionCustomizationUpdated +- && action.customization.id === customizationId(sourcePlugin.toString()) +- && action.customization.type === CustomizationType.Plugin +- && action.customization.load?.kind === kind +- )), +- }, { +- loading: true, +- updates: [true, true], +- }); +- +- activeClient.customizations = [sourcePluginInput('re-enabled')]; +- await snapshot(); +- agent.removeActiveClient(chat, session, 'client-1'); +- assert.deepStrictEqual(await snapshot(), [ +- { +- uri: sourcePlugin.toString(), +- name: 'Azure exp', +- load: CustomizationLoadStatus.Loaded, +- enabled: true, +- children: [ +- { id: 'source-agent', type: CustomizationType.Agent, uri: sourceAgent.toString(), name: 'discovered-agent' }, +- { id: `${URI.joinPath(sourcePlugin, '.mcp.json')}#mcp=cached-server`, type: CustomizationType.McpServer, uri: URI.joinPath(sourcePlugin, '.mcp.json').toString(), name: 'cached-server' }, +- ], +- }, +- { +- uri: sameNamedPlugin.toString(), +- name: 'Azure exp', +- load: CustomizationLoadStatus.Loaded, +- enabled: true, +- children: [{ id: 'other-agent', type: CustomizationType.Agent, uri: sameNamedAgent.toString(), name: 'discovered-agent' }], +- }, +- ].sort((a, b) => a.uri.localeCompare(b.uri))); - }); - - test('host-configured plugins shadow matching discovered plugins', async () => { -- const fileService = disposables.add(new FileService(new NullLogService())); -- disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); +- const fileService = createInMemoryFileService(disposables, Schemas.file); - - const workspace = URI.file('/workspace'); - const plugin = URI.joinPath(workspace, 'plugins', 'azure-exp'); @@ -69645,7 +71746,7 @@ index f24a97a6296..00000000000 - - const client = new TestCopilotClient([]); - client.discoverAgents = async () => ({ -- agents: [{ id: 'host-agent', name: 'host-agent', displayName: 'host-agent', description: 'host', path: agentFile.fsPath, source: 'plugin' }], +- agents: [{ id: 'native:host-agent', name: 'host-agent', displayName: 'host-agent', description: 'host', path: agentFile.fsPath, source: 'plugin' }], - }); - const { agent } = createTestAgentContext(disposables, { - copilotClient: client, @@ -69655,35 +71756,37 @@ index f24a97a6296..00000000000 - }, - }); - -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'host-plugin-discovery-shadow'); -- await provisionSession(agent, { session, workingDirectories: [workspace] }); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'host-plugin-discovery-shadow'); +- await provisionSession(agent, { session, workingDirectories: [workspace] }); - -- const customizations = await getDefaultChatCustomizations(agent, session); -- assert.deepStrictEqual(customizations -- .filter((customization): customization is PluginCustomization => customization.type === CustomizationType.Plugin) -- .map(customization => ({ -- id: customization.id, -- uri: customization.uri, -- name: customization.name, -- load: customization.load?.kind, -- children: customization.children?.map(child => ({ type: child.type, uri: child.uri, name: child.name })), -- })), [{ -- id: customizationId(plugin.toString()), -- uri: plugin.toString(), -- name: 'Azure exp', -- load: CustomizationLoadStatus.Loaded, -- children: [{ type: CustomizationType.Agent, uri: agentFile.toString(), name: 'host-agent' }], -- }]); -- } finally { -- await disposeAgent(agent); -- } +- const customizations = await getDefaultChatCustomizations(agent, session); +- assert.deepStrictEqual(customizations +- .filter((customization): customization is PluginCustomization => customization.type === CustomizationType.Plugin) +- .map(customization => ({ +- id: customization.id, +- uri: customization.uri, +- name: customization.name, +- load: customization.load?.kind, +- children: customization.children?.map(child => ({ type: child.type, uri: child.uri, name: child.name })), +- })), [{ +- id: customizationId(plugin.toString()), +- uri: plugin.toString(), +- name: 'Azure exp', +- load: CustomizationLoadStatus.Loaded, +- children: [{ type: CustomizationType.Agent, uri: agentFile.toString(), name: 'host-agent' }], +- }]); +- const applied = await agent['_getOrCreateActiveClient'](session, workspace).snapshot(defaultChatUri(session).toString()); +- assert.deepStrictEqual({ +- nativeAgents: applied.nativeAgents ?? [], +- selected: getCopilotAgentSelectionName(applied, agentFile.toString()), +- registered: getCopilotCustomAgents(applied).map(agent => agent.name), +- }, { nativeAgents: [], selected: getSdkCustomAgentName(agentFile), registered: ['host-agent'] }); - }); - - test('getChatCustomizations publishes each discovered and live native MCP server once', async () => { -- const fileService = disposables.add(new FileService(new NullLogService())); -- disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); +- const fileService = createInMemoryFileService(disposables, Schemas.file); - const workspace = URI.file('/workspace'); - const source = URI.joinPath(workspace, '.mcp.json'); - await fileService.writeFile(source, VSBuffer.fromString('{"mcpServers":{"automation":{"command":"node"},"explorer":{"command":"node"}}}')); @@ -69696,100 +71799,93 @@ index f24a97a6296..00000000000 - resolve: () => ({ kind: 'resolved', enablement: [{ kind: CustomizationEnablementKind.Global, enabled: false }], enabled: false, workingDirectory: { kind: 'directory', uri: workspace } }), - }, - }); -- try { -- const sessionUri = AgentSession.uri('copilotcli', 'test-session-1'); -- await provisionSession(agent, { session: sessionUri, workingDirectories: [workspace] }); -- stateManager.createSession({ -- resource: sessionUri.toString(), provider: 'copilotcli', title: 'Test', status: SessionStatus.Idle, -- createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), workingDirectories: [workspace.toString()], -- defaultChat: buildDefaultChatUri(sessionUri.toString()), -- }); -- const declarations = await getDefaultChatCustomizations(agent, sessionUri); -- stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionCustomizationsChanged, customizations: [...declarations] }); -- const { session } = createAgentSessionThroughAgent(agent, instantiationService, { workingDirectory: workspace }); -- agent['_registerLiveChat'](URI.parse(buildDefaultChatUri(sessionUri)), session, agent['_getOrCreateActiveClient'](sessionUri, workspace)); -- session['_mcpCustomizations'].applyAll([ -- { name: 'automation', state: { kind: McpServerStatus.Ready } }, -- { name: 'explorer', state: { kind: McpServerStatus.Stopped } }, -- ]); -- const customizations = await getDefaultChatCustomizations(agent, sessionUri); -- assert.deepStrictEqual(customizations.filter(item => item.type === CustomizationType.McpServer).map(item => ({ -- id: item.id, uri: item.uri, name: item.name, enabled: isCustomizationEnabled(item), state: item.state.kind, hasChannel: !!item.channel, -- })), [ -- { id: `${source}#mcp=automation`, uri: source.toString(), name: 'automation', enabled: false, state: McpServerStatus.Ready, hasChannel: true }, -- { id: `${source}#mcp=explorer`, uri: source.toString(), name: 'explorer', enabled: false, state: McpServerStatus.Stopped, hasChannel: false }, -- ]); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const sessionUri = AgentSession.uri('copilotcli', 'test-session-1'); +- await provisionSession(agent, { session: sessionUri, workingDirectories: [workspace] }); +- stateManager.createSession({ +- resource: sessionUri.toString(), provider: 'copilotcli', title: 'Test', status: SessionStatus.Idle, +- createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), workingDirectories: [workspace.toString()], +- defaultChat: buildDefaultChatUri(sessionUri.toString()), +- }); +- const declarations = await getDefaultChatCustomizations(agent, sessionUri); +- stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionCustomizationsChanged, customizations: [...declarations] }); +- const { session } = createAgentSessionThroughAgent(agent, instantiationService, { workingDirectory: workspace }); +- agent['_registerLiveChat'](URI.parse(buildDefaultChatUri(sessionUri)), session, agent['_getOrCreateActiveClient'](sessionUri, workspace)); +- session['_mcpCustomizations'].applyAll([ +- { name: 'automation', state: { kind: McpServerStatus.Ready } }, +- { name: 'explorer', state: { kind: McpServerStatus.Stopped } }, +- ]); +- const customizations = await getDefaultChatCustomizations(agent, sessionUri); +- assert.deepStrictEqual(customizations.filter(item => item.type === CustomizationType.McpServer).map(item => ({ +- id: item.id, uri: item.uri, name: item.name, enabled: isCustomizationEnabled(item), state: item.state.kind, hasChannel: !!item.channel, +- })), [ +- { id: `${source}#mcp=automation`, uri: source.toString(), name: 'automation', enabled: false, state: McpServerStatus.Ready, hasChannel: true }, +- { id: `${source}#mcp=explorer`, uri: source.toString(), name: 'explorer', enabled: false, state: McpServerStatus.Stopped, hasChannel: false }, +- ]); - }); - - test('whole-list customization publishes derive root MCP provenance from the live session through removal, re-add and session replacement', async () => { -- const fileService = disposables.add(new FileService(new NullLogService())); -- disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); +- const fileService = createInMemoryFileService(disposables, Schemas.file); - const workspace = URI.file('/workspace'); - const { agent, instantiationService, stateManager } = createTestAgentContext(disposables, { - fileService, - sessionDataService: disposables.add(new TestSessionDataService()), - rootConfig: { [AgentHostGitHubMcpServerEnabledConfigKey]: false }, - }); -- try { -- const sessionUri = AgentSession.uri('copilotcli', 'test-session-1'); -- await provisionSession(agent, { session: sessionUri, workingDirectories: [workspace] }); -- stateManager.createSession({ -- resource: sessionUri.toString(), provider: 'copilotcli', title: 'Test', status: SessionStatus.Idle, -- createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), workingDirectories: [workspace.toString()], -- defaultChat: buildDefaultChatUri(sessionUri.toString()), -- }); -- const publications: Customization[][] = []; -- disposables.add(agent.onDidChatProgress(signal => { -- if (signal.kind === 'action' && signal.resource.toString() === sessionUri.toString()) { -- stateManager.dispatchServerAction(sessionUri.toString(), signal.action as SessionAction); -- if (signal.action.type === ActionType.SessionCustomizationsChanged) { -- publications.push(signal.action.customizations); -- } +- afterTest(() => disposeAgent(agent)); +- const sessionUri = AgentSession.uri('copilotcli', 'test-session-1'); +- await provisionSession(agent, { session: sessionUri, workingDirectories: [workspace] }); +- stateManager.createSession({ +- resource: sessionUri.toString(), provider: 'copilotcli', title: 'Test', status: SessionStatus.Idle, +- createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), workingDirectories: [workspace.toString()], +- defaultChat: buildDefaultChatUri(sessionUri.toString()), +- }); +- const publications: Customization[][] = []; +- disposables.add(agent.onDidChatProgress(signal => { +- if (signal.kind === 'action' && signal.resource.toString() === sessionUri.toString()) { +- stateManager.dispatchServerAction(sessionUri.toString(), signal.action as SessionAction); +- if (signal.action.type === ActionType.SessionCustomizationsChanged) { +- publications.push(signal.action.customizations); - } -- })); -- const activeClient = agent['_getOrCreateActiveClient'](sessionUri, workspace); -- const { session } = createAgentSessionThroughAgent(agent, instantiationService, { workingDirectory: workspace }); -- const chat = URI.parse(buildDefaultChatUri(sessionUri)); -- agent['_registerLiveChat'](chat, session, activeClient); -- const inventory = [ -- { name: 'slack-gh', status: 'connected' as const, source: 'user' as const }, -- { name: 'plugin-server', status: 'connected' as const, source: 'plugin' as const, sourcePlugin: 'acme' }, -- { name: 'builtin-server', status: 'connected' as const, source: 'builtin' as const }, -- { name: 'account-server', status: 'connected' as const, source: 'account' as const }, -- ]; -- session['_applyMcpServerList'](inventory); -- const snapshot = (customizations: readonly Customization[]) => customizations -- .filter(item => item.type === CustomizationType.McpServer) -- .map(item => ({ name: item.name, id: item.id, uri: item.uri, source: readMcpServerSource(item), plugin: readMcpServerSourcePlugin(item) })) -- .sort((a, b) => a.name.localeCompare(b.name)); -- const expected = snapshot(session.topLevelMcpCustomizations()); -- await activeClient.pluginController.sync('client', []); -- const afterSync = snapshot(publications.at(-1) ?? []); -- session['_applyMcpServerList'](inventory.slice(1)); -- await activeClient.pluginController.sync('another-client', []); -- const afterRemoval = snapshot(publications.at(-1) ?? []); -- session['_applyMcpServerList'](inventory); -- session['_mcpCustomizations'].applyOne({ name: 'slack-gh', state: { kind: McpServerStatus.Ready } }); -- activeClient.pluginController.removeClient('another-client'); -- const afterReadd = snapshot(publications.at(-1) ?? []); -- -- const { session: replacement } = createAgentSessionThroughAgent(agent, instantiationService, { workingDirectory: workspace }); -- agent['_registerLiveChat'](chat, replacement, activeClient); -- for (const server of inventory) { -- replacement['_mcpCustomizations'].applyOne({ name: server.name, state: { kind: McpServerStatus.Ready } }); -- } -- replacement['_applyMcpServerList'](inventory); -- activeClient.pluginController.removeClient('client'); -- assert.deepStrictEqual({ accountSource: expected.find(server => server.name === 'account-server')?.source, afterSync, afterRemoval, afterReadd, afterReplacement: snapshot(publications.at(-1) ?? []) }, { -- accountSource: 'account', -- afterSync: expected, afterRemoval: expected.filter(server => server.name !== 'slack-gh'), afterReadd: expected, afterReplacement: expected, -- }); -- } finally { -- await disposeAgent(agent); -- } +- } +- })); +- const activeClient = agent['_getOrCreateActiveClient'](sessionUri, workspace); +- const { session } = createAgentSessionThroughAgent(agent, instantiationService, { workingDirectory: workspace }); +- const chat = URI.parse(buildDefaultChatUri(sessionUri)); +- agent['_registerLiveChat'](chat, session, activeClient); +- const inventory = [ +- { name: 'slack-gh', status: 'connected' as const, source: 'user' as const }, +- { name: 'plugin-server', status: 'connected' as const, source: 'plugin' as const, sourcePlugin: 'acme' }, +- { name: 'builtin-server', status: 'connected' as const, source: 'builtin' as const }, +- { name: 'account-server', status: 'connected' as const, source: 'account' as const }, +- ]; +- session['_applyMcpServerList'](inventory); +- const snapshot = (customizations: readonly Customization[]) => customizations +- .filter(item => item.type === CustomizationType.McpServer) +- .map(item => ({ name: item.name, id: item.id, uri: item.uri, source: readMcpServerSource(item), plugin: readMcpServerSourcePlugin(item) })) +- .sort((a, b) => a.name.localeCompare(b.name)); +- const expected = snapshot(session.topLevelMcpCustomizations()); +- await activeClient.pluginController.sync('client', []); +- const afterSync = snapshot(publications.at(-1) ?? []); +- session['_applyMcpServerList'](inventory.slice(1)); +- await activeClient.pluginController.sync('another-client', []); +- const afterRemoval = snapshot(publications.at(-1) ?? []); +- session['_applyMcpServerList'](inventory); +- session['_mcpCustomizations'].applyOne({ name: 'slack-gh', state: { kind: McpServerStatus.Ready } }); +- activeClient.pluginController.removeClient('another-client'); +- const afterReadd = snapshot(publications.at(-1) ?? []); +- +- const { session: replacement } = createAgentSessionThroughAgent(agent, instantiationService, { workingDirectory: workspace }); +- agent['_registerLiveChat'](chat, replacement, activeClient); +- for (const server of inventory) { +- replacement['_mcpCustomizations'].applyOne({ name: server.name, state: { kind: McpServerStatus.Ready } }); +- } +- replacement['_applyMcpServerList'](inventory); +- activeClient.pluginController.removeClient('client'); +- assert.deepStrictEqual({ accountSource: expected.find(server => server.name === 'account-server')?.source, afterSync, afterRemoval, afterReadd, afterReplacement: snapshot(publications.at(-1) ?? []) }, { +- accountSource: 'account', +- afterSync: expected, afterRemoval: expected.filter(server => server.name !== 'slack-gh'), afterReadd: expected, afterReplacement: expected, +- }); - }); - - test('getSessionCustomizations includes root MCP servers before materialization', async () => { @@ -69803,24 +71899,20 @@ index f24a97a6296..00000000000 - }, - }); - -- try { -- const session = AgentSession.uri('copilotcli', 'root-mcp-customizations-before-materialization'); -- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'root-mcp-customizations-before-materialization'); +- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); - -- const customizations = await getDefaultChatCustomizations(agent, session); +- const customizations = await getDefaultChatCustomizations(agent, session); - -- assert.deepStrictEqual(customizations -- .filter(customization => customization.type === CustomizationType.McpServer) -- .map(customization => customization.name) -- .sort(), ['playwright', 'slack']); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(customizations +- .filter(customization => customization.type === CustomizationType.McpServer) +- .map(customization => customization.name) +- .sort(), ['playwright', 'slack']); - }); - - test('getSessionCustomizations only forwards additional-root instructions and agents, leaving skills to SDK discovery', async () => { -- const fileService = disposables.add(new FileService(new NullLogService())); -- disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); +- const fileService = createInMemoryFileService(disposables, Schemas.file); - - const workspace = URI.file('/workspace'); - const additionalWorkspace = URI.file('/additional-workspace'); @@ -69875,91 +71967,88 @@ index f24a97a6296..00000000000 - }); - const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, fileService, userHome }); - -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); - -- const session = AgentSession.uri('copilotcli', 'session-discovery-directories'); -- await provisionSession(agent, { -- session, -- workingDirectories: [workspace, additionalWorkspace], -- }); +- const session = AgentSession.uri('copilotcli', 'session-discovery-directories'); +- await provisionSession(agent, { +- session, +- workingDirectories: [workspace, additionalWorkspace], +- }); +- +- const customizations = await getDefaultChatCustomizations(agent, session); +- const discoveredDirectories = customizations.filter(customization => customization.type === CustomizationType.Directory); +- +- const expectedUris = [ +- workspace.toString(), +- URI.joinPath(workspace, '.github', 'agents').toString(), +- URI.joinPath(workspace, '.github', 'skills').toString(), +- URI.joinPath(workspace, '.github', 'instructions').toString(), +- URI.joinPath(additionalWorkspace, '.github', 'instructions').toString(), +- URI.joinPath(userHome, '.copilot', 'instructions').toString(), +- URI.joinPath(workspace, '.github', 'hooks').toString(), +- URI.joinPath(userHome, '.copilot', 'hooks').toString(), +- buildCopilotBuiltinAgentsContainer().uri, +- ]; +- assert.deepStrictEqual(discoveredDirectories.map(customization => customization.uri).sort(), expectedUris.sort()); - -- const customizations = await getDefaultChatCustomizations(agent, session); -- const discoveredDirectories = customizations.filter(customization => customization.type === CustomizationType.Directory); -- -- const expectedUris = [ -- workspace.toString(), -- URI.joinPath(workspace, '.github', 'agents').toString(), -- URI.joinPath(workspace, '.github', 'skills').toString(), -- URI.joinPath(workspace, '.github', 'instructions').toString(), -- URI.joinPath(additionalWorkspace, '.github', 'instructions').toString(), -- URI.joinPath(userHome, '.copilot', 'instructions').toString(), -- URI.joinPath(workspace, '.github', 'hooks').toString(), -- URI.joinPath(userHome, '.copilot', 'hooks').toString(), -- buildCopilotBuiltinAgentsContainer().uri, -- ]; -- assert.deepStrictEqual(discoveredDirectories.map(customization => customization.uri).sort(), expectedUris.sort()); -- -- const agentDirectory = discoveredDirectories.find(customization => customization.uri === URI.joinPath(workspace, '.github', 'agents').toString()); -- assert.ok(agentDirectory); -- assert.strictEqual(agentDirectory.contents, CustomizationType.Agent); -- assert.deepStrictEqual(agentDirectory.children, [{ -- type: CustomizationType.Agent, -- id: customizationId(agentFile.toString()), -- uri: agentFile.toString(), -- name: 'helper', -- description: 'helps out', -- _meta: undefined, -- }]); +- const agentDirectory = discoveredDirectories.find(customization => customization.uri === URI.joinPath(workspace, '.github', 'agents').toString()); +- assert.ok(agentDirectory); +- assert.strictEqual(agentDirectory.contents, CustomizationType.Agent); +- assert.deepStrictEqual(agentDirectory.children, [{ +- type: CustomizationType.Agent, +- id: customizationId(agentFile.toString()), +- uri: agentFile.toString(), +- name: 'helper', +- description: 'helps out', +- _meta: undefined, +- }]); - -- const instructionDirectory = discoveredDirectories.find(customization => customization.uri === URI.joinPath(workspace, '.github', 'instructions').toString()); -- assert.ok(instructionDirectory); -- assert.strictEqual(instructionDirectory.contents, CustomizationType.Rule); -- assert.deepStrictEqual(instructionDirectory.children, [{ -- type: CustomizationType.Rule, -- id: customizationId(instructionFile.toString()), -- uri: instructionFile.toString(), -- name: 'nested.instructions.md', -- description: 'nested instructions', -- globs: ['*.ts', '*.js'], -- alwaysApply: false, -- }]); +- const instructionDirectory = discoveredDirectories.find(customization => customization.uri === URI.joinPath(workspace, '.github', 'instructions').toString()); +- assert.ok(instructionDirectory); +- assert.strictEqual(instructionDirectory.contents, CustomizationType.Rule); +- assert.deepStrictEqual(instructionDirectory.children, [{ +- type: CustomizationType.Rule, +- id: customizationId(instructionFile.toString()), +- uri: instructionFile.toString(), +- name: 'nested.instructions.md', +- description: 'nested instructions', +- globs: ['*.ts', '*.js'], +- alwaysApply: false, +- }]); - -- const agentInstructionsDirectory = discoveredDirectories.find(customization => customization.uri === workspace.toString()); -- assert.ok(agentInstructionsDirectory); -- assert.strictEqual(agentInstructionsDirectory.contents, CustomizationType.Rule); -- assert.deepStrictEqual(agentInstructionsDirectory.children, [{ -- type: CustomizationType.Rule, -- id: customizationId(agentsMdFile.toString()), -- uri: agentsMdFile.toString(), -- name: 'AGENTS.md', -- description: undefined, -- globs: undefined, -- alwaysApply: true, -- } satisfies RuleCustomization]); -- -- const userInstructionDirectory = discoveredDirectories.find(customization => customization.uri === URI.joinPath(userHome, '.copilot', 'instructions').toString()); -- assert.deepStrictEqual(userInstructionDirectory?.children?.map(child => child.uri), [userInstructionFile.toString()]); -- -- await agent.chats.sendMessage(defaultChatUri(session), 'Hello', [workspace, additionalWorkspace], undefined, undefined, undefined, exactChatContext(session, defaultChatUri(session), session)); -- assert.deepStrictEqual(launchConfigs.map(config => ({ -- instructionDirectories: config.instructionDirectories, -- skillDirectories: config.skillDirectories, -- pluginDirectories: config.pluginDirectories, -- additionalDirectories: config.additionalDirectories, -- enableConfigDiscovery: config.enableConfigDiscovery, -- customAgents: config.customAgents?.filter(agent => !COPILOT_BUILTIN_AGENT_NAMES.has(agent.name)).map(agent => agent.name), -- })), [{ -- instructionDirectories: [URI.joinPath(additionalWorkspace, '.github', 'instructions').fsPath], -- skillDirectories: [], -- pluginDirectories: [], -- additionalDirectories: [additionalWorkspace.fsPath], -- enableConfigDiscovery: true, -- customAgents: ['helper'], -- }]); -- } finally { -- await disposeAgent(agent); -- } +- const agentInstructionsDirectory = discoveredDirectories.find(customization => customization.uri === workspace.toString()); +- assert.ok(agentInstructionsDirectory); +- assert.strictEqual(agentInstructionsDirectory.contents, CustomizationType.Rule); +- assert.deepStrictEqual(agentInstructionsDirectory.children, [{ +- type: CustomizationType.Rule, +- id: customizationId(agentsMdFile.toString()), +- uri: agentsMdFile.toString(), +- name: 'AGENTS.md', +- description: undefined, +- globs: undefined, +- alwaysApply: true, +- } satisfies RuleCustomization]); +- +- const userInstructionDirectory = discoveredDirectories.find(customization => customization.uri === URI.joinPath(userHome, '.copilot', 'instructions').toString()); +- assert.deepStrictEqual(userInstructionDirectory?.children?.map(child => child.uri), [userInstructionFile.toString()]); +- +- await agent.chats.sendMessage(defaultChatUri(session), 'Hello', [workspace, additionalWorkspace], undefined, undefined, undefined, exactChatContext(session, defaultChatUri(session), session)); +- assert.deepStrictEqual(launchConfigs.map(config => ({ +- instructionDirectories: config.instructionDirectories, +- skillDirectories: config.skillDirectories, +- pluginDirectories: config.pluginDirectories, +- additionalDirectories: config.additionalDirectories, +- enableConfigDiscovery: config.enableConfigDiscovery, +- customAgents: config.customAgents?.filter(agent => !COPILOT_BUILTIN_AGENT_NAMES.has(agent.name)).map(agent => agent.name), +- })), [{ +- instructionDirectories: [URI.joinPath(additionalWorkspace, '.github', 'instructions').fsPath], +- skillDirectories: [], +- pluginDirectories: [], +- additionalDirectories: [additionalWorkspace.fsPath], +- enableConfigDiscovery: true, +- customAgents: [getSdkCustomAgentName(agentFile)], +- }]); - }); - - test('getSessionCustomizations starts initial discovery without debounce', async () => { @@ -69985,26 +72074,22 @@ index f24a97a6296..00000000000 - const client = new TestCopilotClient([]); - const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, fileService }); - -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'session-discovery-immediate'); -- await provisionSession(agent, { session, workingDirectories: [workspace] }); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'session-discovery-immediate'); +- await provisionSession(agent, { session, workingDirectories: [workspace] }); - -- provider.trackStats = true; -- const customizations = getDefaultChatCustomizations(agent, session); -- await timeout(REFRESH_DEBOUNCE_MS + 200); +- provider.trackStats = true; +- const customizations = getDefaultChatCustomizations(agent, session); +- await timeout(REFRESH_DEBOUNCE_MS + 200); - -- assert.notEqual(provider.statCalls, 0, 'expected discovery to start before the debounce interval'); -- const resolved = await customizations; -- assert.ok(resolved.length > 0, 'expected discovery to resolve with some customizations'); -- } finally { -- await disposeAgent(agent); -- } +- assert.notEqual(provider.statCalls, 0, 'expected discovery to start before the debounce interval'); +- const resolved = await customizations; +- assert.ok(resolved.length > 0, 'expected discovery to resolve with some customizations'); - }); - - test('getSessionCustomizations clears discovered files when the root disappears', async () => { -- const fileService = disposables.add(new FileService(new NullLogService())); -- disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); +- const fileService = createInMemoryFileService(disposables, Schemas.file); - - const workspace = URI.file('/workspace'); - const userHome = URI.file('/mock-home'); @@ -70026,43 +72111,39 @@ index f24a97a6296..00000000000 - }); - const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, fileService, userHome }); - -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); - -- const session = AgentSession.uri('copilotcli', 'session-discovery-cleared'); -- await provisionSession(agent, { -- session, -- workingDirectories: [workspace], -- }); -- -- const before = await getDefaultChatCustomizations(agent, session); -- const beforeDirs = before.filter(customization => customization.type === CustomizationType.Directory); -- const agentsDirBefore = beforeDirs.find(d => d.uri === agentsRoot.toString()); -- assert.ok(agentsDirBefore); -- assert.strictEqual(agentsDirBefore!.children!.length, 1); // has the helper agent file -- -- includeAgent = false; -- // The refresh after the deletion publishes the agents root without its file. -- const cleared = Event.toPromise(Event.filter(agent.onDidChatProgress, signal => signal.kind === 'action' -- && signal.action.type === ActionType.SessionCustomizationsChanged -- && signal.action.customizations.some(customization => customization.type === CustomizationType.Directory && customization.uri === agentsRoot.toString() && !customization.children?.length))); -- await fileService.del(agentsRoot, { recursive: true }); -- await cleared; -- -- const after = await getDefaultChatCustomizations(agent, session); -- const afterDirs = after.filter(customization => customization.type === CustomizationType.Directory); -- // agentsRoot still appears in discovery (as an empty directory) since it's a discovery root -- const agentsDirAfter = afterDirs.find(d => d.uri === agentsRoot.toString()); -- assert.ok(agentsDirAfter); -- assert.strictEqual(agentsDirAfter.children?.length ?? 0, 0); // files are cleared -- } finally { -- await disposeAgent(agent); -- } +- const session = AgentSession.uri('copilotcli', 'session-discovery-cleared'); +- await provisionSession(agent, { +- session, +- workingDirectories: [workspace], +- }); +- +- const before = await getDefaultChatCustomizations(agent, session); +- const beforeDirs = before.filter(customization => customization.type === CustomizationType.Directory); +- const agentsDirBefore = beforeDirs.find(d => d.uri === agentsRoot.toString()); +- assert.ok(agentsDirBefore); +- assert.strictEqual(agentsDirBefore!.children!.length, 1); // has the helper agent file +- +- includeAgent = false; +- // The refresh after the deletion publishes the agents root without its file. +- const cleared = Event.toPromise(Event.filter(agent.onDidChatProgress, signal => signal.kind === 'action' +- && signal.action.type === ActionType.SessionCustomizationsChanged +- && signal.action.customizations.some(customization => customization.type === CustomizationType.Directory && customization.uri === agentsRoot.toString() && !customization.children?.length))); +- await fileService.del(agentsRoot, { recursive: true }); +- await cleared; +- +- const after = await getDefaultChatCustomizations(agent, session); +- const afterDirs = after.filter(customization => customization.type === CustomizationType.Directory); +- // agentsRoot still appears in discovery (as an empty directory) since it's a discovery root +- const agentsDirAfter = afterDirs.find(d => d.uri === agentsRoot.toString()); +- assert.ok(agentsDirAfter); +- assert.strictEqual(agentsDirAfter.children?.length ?? 0, 0); // files are cleared - }); - - test('getSessionCustomizations does not republish discovered directories when watcher changes are discovery-neutral', async () => { -- const fileService = disposables.add(new FileService(new NullLogService())); -- disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); +- const fileService = createInMemoryFileService(disposables, Schemas.file); - - const workspace = URI.file('/workspace'); - const userHome = URI.file('/mock-home'); @@ -70100,45 +72181,41 @@ index f24a97a6296..00000000000 - return false; - }).length; - -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); - -- const session = AgentSession.uri('copilotcli', 'session-discovery-neutral-watcher-change'); -- await provisionSession(agent, { -- session, -- workingDirectories: [workspace], -- }); +- const session = AgentSession.uri('copilotcli', 'session-discovery-neutral-watcher-change'); +- await provisionSession(agent, { +- session, +- workingDirectories: [workspace], +- }); - -- await getDefaultChatCustomizations(agent, session); -- await new Promise(resolve => setTimeout(resolve, 50)); -- const publishCountBefore = countDirectoryPublishesForAgentsRoot(); -- -- // README.md is intentionally excluded from discovered agents. -- const discoveriesBefore = client.agentDiscoveryRequests.length; -- await fileService.writeFile(URI.joinPath(agentsRoot, 'README.md'), VSBuffer.fromString('ignored')); -- -- // The write starts a discovery refresh. Reading the customizations awaits -- // that refresh, and a republish would happen before it settles. -- await client.waitForCalls(() => client.agentDiscoveryRequests.length > discoveriesBefore); -- const after = await getDefaultChatCustomizations(agent, session); -- assert.strictEqual(countDirectoryPublishesForAgentsRoot(), publishCountBefore, 'expected no republish when discovery output is unchanged'); -- -- const afterDirs = after.filter(customization => customization.type === CustomizationType.Directory); -- const expectedUris = [ -- URI.joinPath(workspace, '.github', 'agents').toString(), -- URI.joinPath(workspace, '.github', 'hooks').toString(), -- URI.joinPath(userHome, '.copilot', 'hooks').toString(), -- buildCopilotBuiltinAgentsContainer().uri, -- ]; -- assert.deepStrictEqual(afterDirs.map(customization => customization.uri).sort(), expectedUris.sort()); -- } finally { -- await disposeAgent(agent); -- } +- await getDefaultChatCustomizations(agent, session); +- await new Promise(resolve => setTimeout(resolve, 50)); +- const publishCountBefore = countDirectoryPublishesForAgentsRoot(); +- +- // README.md is intentionally excluded from discovered agents. +- const discoveriesBefore = client.agentDiscoveryRequests.length; +- await fileService.writeFile(URI.joinPath(agentsRoot, 'README.md'), VSBuffer.fromString('ignored')); +- +- // The write starts a discovery refresh. Reading the customizations awaits +- // that refresh, and a republish would happen before it settles. +- await client.waitForCalls(() => client.agentDiscoveryRequests.length > discoveriesBefore); +- const after = await getDefaultChatCustomizations(agent, session); +- assert.strictEqual(countDirectoryPublishesForAgentsRoot(), publishCountBefore, 'expected no republish when discovery output is unchanged'); +- +- const afterDirs = after.filter(customization => customization.type === CustomizationType.Directory); +- const expectedUris = [ +- URI.joinPath(workspace, '.github', 'agents').toString(), +- URI.joinPath(workspace, '.github', 'hooks').toString(), +- URI.joinPath(userHome, '.copilot', 'hooks').toString(), +- buildCopilotBuiltinAgentsContainer().uri, +- ]; +- assert.deepStrictEqual(afterDirs.map(customization => customization.uri).sort(), expectedUris.sort()); - }); - - test('getSessionCustomizations coalesces burst watcher changes into one discovered refresh publish', async () => { -- const fileService = disposables.add(new FileService(new NullLogService())); -- disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); +- const fileService = createInMemoryFileService(disposables, Schemas.file); - - const workspace = URI.file('/workspace'); - const userHome = URI.file('/mock-home'); @@ -70187,53 +72264,50 @@ index f24a97a6296..00000000000 - && customizations.some(customization => customization.type === CustomizationType.Directory && customization.uri === instructionsRoot.toString()); - }).length; - -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); - -- const session = AgentSession.uri('copilotcli', 'session-discovery-burst-watcher-change'); -- await provisionSession(agent, { -- session, -- workingDirectories: [workspace], -- }); +- const session = AgentSession.uri('copilotcli', 'session-discovery-burst-watcher-change'); +- await provisionSession(agent, { +- session, +- workingDirectories: [workspace], +- }); - -- await getDefaultChatCustomizations(agent, session); -- await new Promise(resolve => setTimeout(resolve, 50)); -- const publishCountBeforeBurst = countDiscoveredRefreshPublishes(); +- await getDefaultChatCustomizations(agent, session); +- await new Promise(resolve => setTimeout(resolve, 50)); +- const publishCountBeforeBurst = countDiscoveredRefreshPublishes(); - -- const addedAgentFiles = [ -- URI.joinPath(agentsRoot, 'helper-1.agent.md'), -- URI.joinPath(agentsRoot, 'helper-2.agent.md'), -- ]; -- const addedInstructionFile = URI.joinPath(instructionsRoot, 'extra.instructions.md'); -- discoveredAgentFiles.push(...addedAgentFiles); -- discoveredInstructionFiles.push(addedInstructionFile); -- await Promise.all([ -- fileService.writeFile(addedAgentFiles[0], VSBuffer.fromString('agent 1')), -- fileService.writeFile(addedAgentFiles[1], VSBuffer.fromString('agent 2')), -- fileService.writeFile(addedInstructionFile, VSBuffer.fromString('---\napplyTo:\n - test/**\n---\nextra instruction')), -- ]); +- const addedAgentFiles = [ +- URI.joinPath(agentsRoot, 'helper-1.agent.md'), +- URI.joinPath(agentsRoot, 'helper-2.agent.md'), +- ]; +- const addedInstructionFile = URI.joinPath(instructionsRoot, 'extra.instructions.md'); +- discoveredAgentFiles.push(...addedAgentFiles); +- discoveredInstructionFiles.push(addedInstructionFile); +- await Promise.all([ +- fileService.writeFile(addedAgentFiles[0], VSBuffer.fromString('agent 1')), +- fileService.writeFile(addedAgentFiles[1], VSBuffer.fromString('agent 2')), +- fileService.writeFile(addedInstructionFile, VSBuffer.fromString('---\napplyTo:\n - test/**\n---\nextra instruction')), +- ]); - -- // Wait for the refresh the burst publishes. Reading the customizations then -- // also awaits any refresh still queued, whose publish would show in the count. -- if (countDiscoveredRefreshPublishes() === publishCountBeforeBurst) { -- await Event.toPromise(Event.filter(agent.onDidChatProgress, () => countDiscoveredRefreshPublishes() > publishCountBeforeBurst)); -- } -- const customizations = await getDefaultChatCustomizations(agent, session); -- const discoveredAgentDirectory = customizations.find((customization): customization is DiscoveredDirectoryCustomization => customization.type === CustomizationType.Directory && customization.uri === agentsRoot.toString()); -- const discoveredInstructionDirectory = customizations.find((customization): customization is DiscoveredDirectoryCustomization => customization.type === CustomizationType.Directory && customization.uri === instructionsRoot.toString()); -- const discoveredAgentCount = discoveredAgentDirectory?.children.filter(child => child.type === CustomizationType.Agent).length ?? 0; -- const discoveredInstructionCount = discoveredInstructionDirectory?.children.filter(child => child.type === CustomizationType.Rule).length ?? 0; -- -- assert.strictEqual(discoveredAgentCount, 3, 'expected agent burst changes to be discovered'); -- assert.strictEqual(discoveredInstructionCount, 2, 'expected instruction burst changes to be discovered'); -- assert.strictEqual( -- countDiscoveredRefreshPublishes(), -- publishCountBeforeBurst + 1, -- 'expected burst watcher changes across folders to result in exactly one discovered refresh publish (_onDidRefresh)' -- ); -- } finally { -- await disposeAgent(agent); +- // Wait for the refresh the burst publishes. Reading the customizations then +- // also awaits any refresh still queued, whose publish would show in the count. +- if (countDiscoveredRefreshPublishes() === publishCountBeforeBurst) { +- await Event.toPromise(Event.filter(agent.onDidChatProgress, () => countDiscoveredRefreshPublishes() > publishCountBeforeBurst)); - } +- const customizations = await getDefaultChatCustomizations(agent, session); +- const discoveredAgentDirectory = customizations.find((customization): customization is DiscoveredDirectoryCustomization => customization.type === CustomizationType.Directory && customization.uri === agentsRoot.toString()); +- const discoveredInstructionDirectory = customizations.find((customization): customization is DiscoveredDirectoryCustomization => customization.type === CustomizationType.Directory && customization.uri === instructionsRoot.toString()); +- const discoveredAgentCount = discoveredAgentDirectory?.children.filter(child => child.type === CustomizationType.Agent).length ?? 0; +- const discoveredInstructionCount = discoveredInstructionDirectory?.children.filter(child => child.type === CustomizationType.Rule).length ?? 0; +- +- assert.strictEqual(discoveredAgentCount, 3, 'expected agent burst changes to be discovered'); +- assert.strictEqual(discoveredInstructionCount, 2, 'expected instruction burst changes to be discovered'); +- assert.strictEqual( +- countDiscoveredRefreshPublishes(), +- publishCountBeforeBurst + 1, +- 'expected burst watcher changes across folders to result in exactly one discovered refresh publish (_onDidRefresh)' +- ); - }); - }); - @@ -70280,274 +72354,228 @@ index f24a97a6296..00000000000 - - test('warming launches and starts MCP without announcing the chat, and the first send claims it', async () => { - const h = await createWarmableAgent('warm-claim'); -- try { -- const warmed = await h.agent.chats.warmChat!(h.chat, h.context); -- await timeout(0); -- const afterWarm = { warmed, ...h.state() }; -- await h.agent.chats.sendMessage(h.chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, h.sendContext); +- afterTest(() => disposeAgent(h.agent)); +- const warmed = await h.agent.chats.warmChat!(h.chat, h.context); +- await timeout(0); +- const afterWarm = { warmed, ...h.state() }; +- await h.agent.chats.sendMessage(h.chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, h.sendContext); - -- assert.deepStrictEqual({ afterWarm, afterSend: h.state(), sends: h.sdkSessions[0].sendCalls }, { -- afterWarm: { warmed: true, creates: 1, mcpLists: 1, materialized: 0, prewarm: [], deleted: 0, live: true }, -- afterSend: { creates: 1, mcpLists: 1, materialized: 1, prewarm: ['used'], deleted: 0, live: true }, -- sends: 1, -- }); -- } finally { -- await disposeAgent(h.agent); -- } +- assert.deepStrictEqual({ afterWarm, afterSend: h.state(), sends: h.sdkSessions[0].sendCalls }, { +- afterWarm: { warmed: true, creates: 1, mcpLists: 1, materialized: 0, prewarm: [], deleted: 0, live: true }, +- afterSend: { creates: 1, mcpLists: 1, materialized: 1, prewarm: ['used'], deleted: 0, live: true }, +- sends: 1, +- }); - }); - - test('a send that arrives during the warm launch waits for it and claims it', async () => { - const gate = new DeferredPromise(); - const h = await createWarmableAgent('warm-send-immediately', gate.p); -- try { -- const warm = h.agent.chats.warmChat!(h.chat, h.context); -- const send = h.agent.chats.sendMessage(h.chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, h.sendContext); -- gate.complete(); -- await Promise.all([warm, send]); +- afterTest(() => disposeAgent(h.agent)); +- const warm = h.agent.chats.warmChat!(h.chat, h.context); +- const send = h.agent.chats.sendMessage(h.chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, h.sendContext); +- gate.complete(); +- await Promise.all([warm, send]); - -- assert.deepStrictEqual(h.state(), { creates: 1, mcpLists: 1, materialized: 1, prewarm: ['used'], deleted: 0, live: true }); -- } finally { -- await disposeAgent(h.agent); -- } +- assert.deepStrictEqual(h.state(), { creates: 1, mcpLists: 1, materialized: 1, prewarm: ['used'], deleted: 0, live: true }); - }); - - test('disposing an unsent warm draft discards its SDK session without announcing it', async () => { - const h = await createWarmableAgent('warm-discard'); -- try { -- await h.agent.chats.warmChat!(h.chat, h.context); -- const sdkSessionId = h.sdkSessionId(); -- await h.agent.chats.disposeChat(h.chat, h.context); +- afterTest(() => disposeAgent(h.agent)); +- await h.agent.chats.warmChat!(h.chat, h.context); +- const sdkSessionId = h.sdkSessionId(); +- await h.agent.chats.disposeChat(h.chat, h.context); - -- assert.deepStrictEqual({ ...h.state(), disconnects: h.sdkSessions[0].disconnectCalls, deleted: h.client.deletedSessionIds }, { -- creates: 1, mcpLists: 1, materialized: 0, prewarm: [], deleted: [sdkSessionId], live: false, disconnects: 1, -- }); -- } finally { -- await disposeAgent(h.agent); -- } +- assert.deepStrictEqual({ ...h.state(), disconnects: h.sdkSessions[0].disconnectCalls, deleted: h.client.deletedSessionIds }, { +- creates: 1, mcpLists: 1, materialized: 0, prewarm: [], deleted: [sdkSessionId], live: false, disconnects: 1, +- }); - }); - - test('a client tool change between warming and send updates and claims the warm session', async () => { - const h = await createWarmableAgent('warm-tools-changed'); -- try { -- await h.agent.chats.warmChat!(h.chat, h.context); -- h.agent.getOrCreateActiveClient(h.chat, h.context, { clientId: 'client-1' }).tools = [tool('t1'), tool('t2')]; -- await h.agent.chats.sendMessage(h.chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, h.sendContext); +- afterTest(() => disposeAgent(h.agent)); +- await h.agent.chats.warmChat!(h.chat, h.context); +- h.agent.getOrCreateActiveClient(h.chat, h.context, { clientId: 'client-1' }).tools = [tool('t1'), tool('t2')]; +- await h.agent.chats.sendMessage(h.chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, h.sendContext); - -- assert.deepStrictEqual({ ...h.state(), sends: h.sdkSessions.map(s => s.sendCalls), tools: h.sdkSessions[0].setToolsCalls.map(tools => tools.filter(tool => /^t\d$/.test(tool.name)).map(tool => tool.name)) }, { -- creates: 1, mcpLists: 1, materialized: 1, prewarm: ['used'], deleted: 0, live: true, sends: [1], tools: [['t1', 't2']], -- }); -- } finally { -- await disposeAgent(h.agent); -- } +- assert.deepStrictEqual({ ...h.state(), sends: h.sdkSessions.map(s => s.sendCalls), tools: h.sdkSessions[0].setToolsCalls.map(tools => tools.filter(tool => /^t\d$/.test(tool.name)).map(tool => tool.name)) }, { +- creates: 1, mcpLists: 1, materialized: 1, prewarm: ['used'], deleted: 0, live: true, sends: [1], tools: [['t1', 't2']], +- }); - }); - - test('a client tool change to launch-only agent tools still replaces the warm session', async () => { - const h = await createWarmableAgent('warm-agent-tools-changed'); -- try { -- await h.agent.chats.warmChat!(h.chat, h.context); -- const sdkSessionId = h.sdkSessionId(); -- h.agent.getOrCreateActiveClient(h.chat, h.context, { clientId: 'client-1' }).tools = [{ ...tool('t1'), annotations: { readOnlyHint: true } }]; -- await h.agent.chats.sendMessage(h.chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, h.sendContext); -- assert.deepStrictEqual({ ...h.state(), deleted: h.client.deletedSessionIds, updates: h.sdkSessions.map(runtime => runtime.setToolsCalls.length) }, { -- creates: 2, mcpLists: 1, materialized: 1, prewarm: ['clientToolsChanged'], deleted: [sdkSessionId], live: true, updates: [0, 0], -- }); -- } finally { -- await disposeAgent(h.agent); -- } +- afterTest(() => disposeAgent(h.agent)); +- await h.agent.chats.warmChat!(h.chat, h.context); +- const sdkSessionId = h.sdkSessionId(); +- h.agent.getOrCreateActiveClient(h.chat, h.context, { clientId: 'client-1' }).tools = [{ ...tool('t1'), annotations: { readOnlyHint: true } }]; +- await h.agent.chats.sendMessage(h.chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, h.sendContext); +- assert.deepStrictEqual({ ...h.state(), deleted: h.client.deletedSessionIds, updates: h.sdkSessions.map(runtime => runtime.setToolsCalls.length) }, { +- creates: 2, mcpLists: 1, materialized: 1, prewarm: ['clientToolsChanged'], deleted: [sdkSessionId], live: true, updates: [0, 0], +- }); - }); - - test('a first send that selects the model the draft was created with claims the warm launch', async () => { - // Every first send selects its model before it runs (`chat/turnStarted`), including an unchanged one. - const model = { id: 'claude-sonnet', config: { thinkingLevel: 'high' } }; - const h = await createWarmableAgent('warm-same-model', undefined, { model }); -- try { -- await h.agent.chats.warmChat!(h.chat, h.context); -- await h.agent.chats.changeModel(h.chat, { ...model, config: { ...model.config } }, h.context); -- await h.agent.chats.sendMessage(h.chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, h.sendContext); +- afterTest(() => disposeAgent(h.agent)); +- await h.agent.chats.warmChat!(h.chat, h.context); +- await h.agent.chats.changeModel(h.chat, { ...model, config: { ...model.config } }, h.context); +- await h.agent.chats.sendMessage(h.chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, h.sendContext); - -- assert.deepStrictEqual(h.state(), { creates: 1, mcpLists: 1, materialized: 1, prewarm: ['used'], deleted: 0, live: true }); -- } finally { -- await disposeAgent(h.agent); -- } +- assert.deepStrictEqual(h.state(), { creates: 1, mcpLists: 1, materialized: 1, prewarm: ['used'], deleted: 0, live: true }); - }); - - test('a model option change before the first send starts it cold instead of applying it live', async () => { - const h = await createWarmableAgent('warm-model-config-change', undefined, { model: { id: 'claude-sonnet' } }); -- try { -- await h.agent.chats.warmChat!(h.chat, h.context); -- const sdkSessionId = h.sdkSessionId(); -- await h.agent.chats.changeModel(h.chat, { id: 'claude-sonnet', config: { thinkingLevel: 'high' } }, h.context); -- const afterModelChange = h.state(); -- await h.agent.chats.sendMessage(h.chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, h.sendContext); +- afterTest(() => disposeAgent(h.agent)); +- await h.agent.chats.warmChat!(h.chat, h.context); +- const sdkSessionId = h.sdkSessionId(); +- await h.agent.chats.changeModel(h.chat, { id: 'claude-sonnet', config: { thinkingLevel: 'high' } }, h.context); +- const afterModelChange = h.state(); +- await h.agent.chats.sendMessage(h.chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, h.sendContext); - -- assert.deepStrictEqual({ afterModelChange, afterSend: { ...h.state(), deleted: h.client.deletedSessionIds, setModel: h.sdkSessions.map(s => s.setModelCalls.length) } }, { -- afterModelChange: { creates: 1, mcpLists: 1, materialized: 0, prewarm: [], deleted: 0, live: true }, -- afterSend: { creates: 2, mcpLists: 1, materialized: 1, prewarm: ['modelChanged'], deleted: [sdkSessionId], live: true, setModel: [0, 0] }, -- }); -- } finally { -- await disposeAgent(h.agent); -- } +- assert.deepStrictEqual({ afterModelChange, afterSend: { ...h.state(), deleted: h.client.deletedSessionIds, setModel: h.sdkSessions.map(s => s.setModelCalls.length) } }, { +- afterModelChange: { creates: 1, mcpLists: 1, materialized: 0, prewarm: [], deleted: 0, live: true }, +- afterSend: { creates: 2, mcpLists: 1, materialized: 1, prewarm: ['modelChanged'], deleted: [sdkSessionId], live: true, setModel: [0, 0] }, +- }); - }); - - test('selecting a different model starts the first send cold, with that model\'s launch overrides', async () => { - const h = await createWarmableAgent('warm-model-change', undefined, { - rootConfig: { [CopilotCliConfigKey.ModelCapabilityOverrides]: { 'claude-sonnet': { modelCapabilities: { supports: { vision: false } } } } }, - }); -- try { -- await h.agent.chats.warmChat!(h.chat, h.context); -- await h.agent.chats.changeModel(h.chat, { id: 'claude-sonnet' }, h.context); -- await h.agent.chats.sendMessage(h.chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, h.sendContext); +- afterTest(() => disposeAgent(h.agent)); +- await h.agent.chats.warmChat!(h.chat, h.context); +- await h.agent.chats.changeModel(h.chat, { id: 'claude-sonnet' }, h.context); +- await h.agent.chats.sendMessage(h.chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, h.sendContext); - -- assert.deepStrictEqual({ -- prewarm: h.state().prewarm, -- launches: h.launchConfigs.map(config => ({ model: config.model, modelCapabilities: config.modelCapabilities })), -- sends: h.sdkSessions.map(s => s.sendCalls), -- }, { -- prewarm: ['modelChanged'], -- launches: [{ model: undefined, modelCapabilities: undefined }, { model: 'claude-sonnet', modelCapabilities: { supports: { vision: false } } }], -- sends: [0, 1], -- }); -- } finally { -- await disposeAgent(h.agent); -- } +- assert.deepStrictEqual({ +- prewarm: h.state().prewarm, +- launches: h.launchConfigs.map(config => ({ model: config.model, modelCapabilities: config.modelCapabilities })), +- sends: h.sdkSessions.map(s => s.sendCalls), +- }, { +- prewarm: ['modelChanged'], +- launches: [{ model: undefined, modelCapabilities: undefined }, { model: 'claude-sonnet', modelCapabilities: { supports: { vision: false } } }], +- sends: [0, 1], +- }); - }); - - test('starting an MCP server on a warm draft reaches its early launch and keeps the draft unsent', async () => { - const h = await createWarmableAgent('warm-start-mcp'); -- try { -- await h.agent.chats.warmChat!(h.chat, h.context); -- const started: string[] = []; -- chatEntriesBySdkId(h.agent).get(h.sdkSessionId()!)!.chatSession.startMcpServer = async (id: string) => { started.push(id); }; -- await h.agent.startMcpServer(AgentSession.uri('copilotcli', 'warm-start-mcp'), 'server-id'); +- afterTest(() => disposeAgent(h.agent)); +- await h.agent.chats.warmChat!(h.chat, h.context); +- const started: string[] = []; +- chatEntriesBySdkId(h.agent).get(h.sdkSessionId()!)!.chatSession.startMcpServer = async (id: string) => { started.push(id); }; +- await h.agent.startMcpServer(AgentSession.uri('copilotcli', 'warm-start-mcp'), 'server-id'); - -- assert.deepStrictEqual({ started, ...h.state() }, { started: ['server-id'], creates: 1, mcpLists: 1, materialized: 0, prewarm: [], deleted: 0, live: true }); -- } finally { -- await disposeAgent(h.agent); -- } +- assert.deepStrictEqual({ started, ...h.state() }, { started: ['server-id'], creates: 1, mcpLists: 1, materialized: 0, prewarm: [], deleted: 0, live: true }); - }); - - test('a create retry for a warm draft keeps it provisional and the send still claims it', async () => { - const h = await createWarmableAgent('warm-create-retry'); -- try { -- await h.agent.chats.warmChat!(h.chat, h.context); -- const retry = await h.agent.chats.createChat(h.chat, h.context, { workingDirectories: [workingDirectory], activeClient: { clientId: 'client-1', tools: [tool('t1')] }, deferBacking: true }); -- const afterRetry = { provisional: retry ? retry.provisional : undefined, ...h.state() }; -- await h.agent.chats.sendMessage(h.chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, h.sendContext); +- afterTest(() => disposeAgent(h.agent)); +- await h.agent.chats.warmChat!(h.chat, h.context); +- const retry = await h.agent.chats.createChat(h.chat, h.context, { workingDirectories: [workingDirectory], activeClient: { clientId: 'client-1', tools: [tool('t1')] }, deferBacking: true }); +- const afterRetry = { provisional: retry ? retry.provisional : undefined, ...h.state() }; +- await h.agent.chats.sendMessage(h.chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, h.sendContext); - -- assert.deepStrictEqual({ afterRetry, afterSend: h.state() }, { -- afterRetry: { provisional: true, creates: 1, mcpLists: 1, materialized: 0, prewarm: [], deleted: 0, live: true }, -- afterSend: { creates: 1, mcpLists: 1, materialized: 1, prewarm: ['used'], deleted: 0, live: true }, -- }); -- } finally { -- await disposeAgent(h.agent); -- } +- assert.deepStrictEqual({ afterRetry, afterSend: h.state() }, { +- afterRetry: { provisional: true, creates: 1, mcpLists: 1, materialized: 0, prewarm: [], deleted: 0, live: true }, +- afterSend: { creates: 1, mcpLists: 1, materialized: 1, prewarm: ['used'], deleted: 0, live: true }, +- }); - }); - - for (const teardown of ['clientRestart', 'connectionClosed', 'release'] as const) { - test(`a warm runtime torn down by ${teardown} is invalidated, and the send launches a fresh one`, async () => { - const h = await createWarmableAgent(`warm-teardown-${teardown}`); -- try { -- await h.agent.chats.warmChat!(h.chat, h.context); -- const sdkSessionId = h.sdkSessionId(); -- const internals = h.agent as unknown as { -- _requestClientRestart(reason: string): Promise; -- _handleClientOperationFailure(error: unknown, operation: string): Promise; -- }; -- switch (teardown) { -- case 'clientRestart': await internals._requestClientRestart('test'); break; -- case 'connectionClosed': await internals._handleClientOperationFailure(new Error('Connection is closed.'), 'modelRefresh'); break; -- case 'release': await h.agent.chats.releaseChat(h.chat, h.context); break; -- } -- const afterTeardown = { live: hasLiveChat(h.agent, h.chat) }; -- await h.agent.chats.sendMessage(h.chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, h.sendContext); -- -- assert.deepStrictEqual({ afterTeardown, afterSend: { ...h.state(), deleted: h.client.deletedSessionIds, sdkSessionIds: h.sdkSessions.map(s => s.sessionId), sends: h.sdkSessions.map(s => s.sendCalls) } }, { -- afterTeardown: { live: false }, -- afterSend: { creates: 2, mcpLists: 1, materialized: 1, prewarm: [], live: true, deleted: [sdkSessionId], sdkSessionIds: [sdkSessionId, sdkSessionId], sends: [0, 1] }, -- }); -- } finally { -- await disposeAgent(h.agent); +- afterTest(() => disposeAgent(h.agent)); +- await h.agent.chats.warmChat!(h.chat, h.context); +- const sdkSessionId = h.sdkSessionId(); +- const internals = h.agent as unknown as { +- _requestClientRestart(reason: string): Promise; +- _handleClientOperationFailure(error: unknown, operation: string): Promise; +- }; +- switch (teardown) { +- case 'clientRestart': await internals._requestClientRestart('test'); break; +- case 'connectionClosed': await internals._handleClientOperationFailure(new Error('Connection is closed.'), 'modelRefresh'); break; +- case 'release': await h.agent.chats.releaseChat(h.chat, h.context); break; - } +- const afterTeardown = { live: hasLiveChat(h.agent, h.chat) }; +- await h.agent.chats.sendMessage(h.chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, h.sendContext); +- +- assert.deepStrictEqual({ afterTeardown, afterSend: { ...h.state(), deleted: h.client.deletedSessionIds, sdkSessionIds: h.sdkSessions.map(s => s.sessionId), sends: h.sdkSessions.map(s => s.sendCalls) } }, { +- afterTeardown: { live: false }, +- afterSend: { creates: 2, mcpLists: 1, materialized: 1, prewarm: [], live: true, deleted: [sdkSessionId], sdkSessionIds: [sdkSessionId, sdkSessionId], sends: [0, 1] }, +- }); - }); - } - - test('a workspace-less draft warms in its scratch folder and the first send claims it', async () => { - const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/warm-qc-home-`)); - const h = await createWarmableAgent('warm-workspaceless', undefined, { workspacelessHome: userHome }); -- try { -- await h.agent.chats.warmChat!(h.chat, h.context); -- const afterWarm = h.state(); -- await h.agent.chats.sendMessage(h.chat, 'hello', undefined, undefined, 'turn-1', undefined, h.sendContext); -- -- assert.deepStrictEqual({ afterWarm, afterSend: h.state(), materializedIn: h.materialized[0]?.workingDirectories?.map(d => d.toString()) }, { -- afterWarm: { creates: 1, mcpLists: 1, materialized: 0, prewarm: [], deleted: 0, live: true }, -- afterSend: { creates: 1, mcpLists: 1, materialized: 1, prewarm: ['used'], deleted: 0, live: true }, -- materializedIn: [h.resolvedWorkingDirectory?.toString()], -- }); -- } finally { +- afterTest(async () => { - await disposeAgent(h.agent); - await fs.rm(userHome.fsPath, { recursive: true, force: true }); -- } +- }); +- await h.agent.chats.warmChat!(h.chat, h.context); +- const afterWarm = h.state(); +- await h.agent.chats.sendMessage(h.chat, 'hello', undefined, undefined, 'turn-1', undefined, h.sendContext); +- +- assert.deepStrictEqual({ afterWarm, afterSend: h.state(), materializedIn: h.materialized[0]?.workingDirectories?.map(d => d.toString()) }, { +- afterWarm: { creates: 1, mcpLists: 1, materialized: 0, prewarm: [], deleted: 0, live: true }, +- afterSend: { creates: 1, mcpLists: 1, materialized: 1, prewarm: ['used'], deleted: 0, live: true }, +- materializedIn: [h.resolvedWorkingDirectory?.toString()], +- }); - }).timeout(30_000); - - test('a worktree send discards the folder warm session and materializes in the worktree', async () => { - const h = await createWarmableAgent('warm-worktree'); -- try { -- await h.agent.chats.warmChat!(h.chat, h.context); -- const worktree = URI.file('/worktrees/agents-test'); -- await h.agent.chats.sendMessage(h.chat, 'hello', [worktree], undefined, 'turn-1', undefined, h.sendContext); +- afterTest(() => disposeAgent(h.agent)); +- await h.agent.chats.warmChat!(h.chat, h.context); +- const worktree = URI.file('/worktrees/agents-test'); +- await h.agent.chats.sendMessage(h.chat, 'hello', [worktree], undefined, 'turn-1', undefined, h.sendContext); - -- assert.deepStrictEqual({ ...h.state(), materializedIn: h.materialized[0]?.workingDirectories?.map(d => d.toString()) }, { -- creates: 2, mcpLists: 1, materialized: 1, prewarm: ['workingDirectoryChanged'], deleted: 1, live: true, materializedIn: [worktree.toString()], -- }); -- } finally { -- await disposeAgent(h.agent); -- } +- assert.deepStrictEqual({ ...h.state(), materializedIn: h.materialized[0]?.workingDirectories?.map(d => d.toString()) }, { +- creates: 2, mcpLists: 1, materialized: 1, prewarm: ['workingDirectoryChanged'], deleted: 1, live: true, materializedIn: [worktree.toString()], +- }); - }); - - test('revoking trust before the first send discards the warm session that was granted it', async () => { - const h = await createWarmableAgent('warm-trust-revoked', undefined, { rootConfig: { [AgentHostWorkspaceTrustConfigKey]: { enabled: true, trustedUris: [workingDirectory.toString()] } } }); -- try { -- await h.agent.chats.warmChat!(h.chat, h.context); -- h.agent['_configurationService'].updateRootConfig({ [AgentHostWorkspaceTrustConfigKey]: { enabled: true, trustedUris: [] } }); -- await h.agent.chats.sendMessage(h.chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, h.sendContext); +- afterTest(() => disposeAgent(h.agent)); +- await h.agent.chats.warmChat!(h.chat, h.context); +- h.agent['_configurationService'].updateRootConfig({ [AgentHostWorkspaceTrustConfigKey]: { enabled: true, trustedUris: [] } }); +- await h.agent.chats.sendMessage(h.chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, h.sendContext); - -- assert.deepStrictEqual({ prewarm: h.state().prewarm, trust: h.launchConfigs.map(config => config.trustWorkingDirectory ?? 'absent') }, { -- prewarm: ['workspaceTrustChanged'], -- trust: ['session', 'absent'], -- }); -- } finally { -- await disposeAgent(h.agent); -- } +- assert.deepStrictEqual({ prewarm: h.state().prewarm, trust: h.launchConfigs.map(config => config.trustWorkingDirectory ?? 'absent') }, { +- prewarm: ['workspaceTrustChanged'], +- trust: ['session', 'absent'], +- }); - }); - - test('a warm session claimed under unchanged trust keeps its grant', async () => { - const h = await createWarmableAgent('warm-trust-unchanged', undefined, { rootConfig: { [AgentHostWorkspaceTrustConfigKey]: { enabled: true, trustedUris: [workingDirectory.toString()] } } }); -- try { -- await h.agent.chats.warmChat!(h.chat, h.context); -- await h.agent.chats.sendMessage(h.chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, h.sendContext); +- afterTest(() => disposeAgent(h.agent)); +- await h.agent.chats.warmChat!(h.chat, h.context); +- await h.agent.chats.sendMessage(h.chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, h.sendContext); - -- assert.deepStrictEqual({ prewarm: h.state().prewarm, trust: h.launchConfigs.map(config => config.trustWorkingDirectory ?? 'absent') }, { -- prewarm: ['used'], -- trust: ['session'], -- }); -- } finally { -- await disposeAgent(h.agent); -- } +- assert.deepStrictEqual({ prewarm: h.state().prewarm, trust: h.launchConfigs.map(config => config.trustWorkingDirectory ?? 'absent') }, { +- prewarm: ['used'], +- trust: ['session'], +- }); - }); - - test('a worktree created on the first send inherits its trusted repository\'s grant', async () => { - const h = await createWarmableAgent('warm-worktree-trust', undefined, { rootConfig: { [AgentHostWorkspaceTrustConfigKey]: { enabled: true, trustedUris: [workingDirectory.toString()] } } }); -- try { -- const worktree = URI.file('/workspace.worktrees/agents-test'); -- await h.agent.chats.sendMessage(h.chat, 'hello', [worktree], undefined, 'turn-1', undefined, h.sendContext); +- afterTest(() => disposeAgent(h.agent)); +- const worktree = URI.file('/workspace.worktrees/agents-test'); +- await h.agent.chats.sendMessage(h.chat, 'hello', [worktree], undefined, 'turn-1', undefined, h.sendContext); - -- assert.deepStrictEqual(h.launchConfigs.map(config => ({ workingDirectory: config.workingDirectory, trust: config.trustWorkingDirectory ?? 'absent' })), [ -- { workingDirectory: worktree.fsPath, trust: 'session' }, -- ]); -- } finally { -- await disposeAgent(h.agent); -- } +- assert.deepStrictEqual(h.launchConfigs.map(config => ({ workingDirectory: config.workingDirectory, trust: config.trustWorkingDirectory ?? 'absent' })), [ +- { workingDirectory: worktree.fsPath, trust: 'session' }, +- ]); - }); - }); - @@ -70616,34 +72644,33 @@ index f24a97a6296..00000000000 - const sessionId = AgentSession.id(session); - const chat = defaultChatUri(session); - const scratchDir = URI.joinPath(userHome, '.copilot', 'chats', sessionId); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- // No workingDirectories: `_resolveCreateWorkingDirectory` mkdir's the -- // stable scratch dir before `_ensureClient()` throws, so the scratch -- // dir exists at the moment of failure — the rollback must remove it. -- await assert.rejects(() => provisionSession(agent, { session }), /Failed to start CLI server/); -- -- assert.deepStrictEqual({ -- chatScope: chatScopes(agent).has(chat.toString()), -- chatBacking: chatBackings(agent).has(chat.toString()), -- provisional: provisionalSessions(agent).has(sessionId), -- activeClient: !!activeClients(agent).get(session), -- lifetime: sessionLifetimes(agent).has(sessionId), -- scratchDirRemoved: await fs.access(scratchDir.fsPath).then(() => false, () => true), -- released: otelService.released, -- }, { -- chatScope: false, -- chatBacking: false, -- provisional: false, -- activeClient: false, -- lifetime: false, -- scratchDirRemoved: true, -- released: [session.toString()], -- }); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- // No workingDirectories: `_resolveCreateWorkingDirectory` mkdir's the +- // stable scratch dir before `_ensureClient()` throws, so the scratch +- // dir exists at the moment of failure — the rollback must remove it. +- await assert.rejects(() => provisionSession(agent, { session }), /Failed to start CLI server/); +- +- assert.deepStrictEqual({ +- chatScope: chatScopes(agent).has(chat.toString()), +- chatBacking: chatBackings(agent).has(chat.toString()), +- provisional: provisionalSessions(agent).has(sessionId), +- activeClient: !!activeClients(agent).get(session), +- lifetime: sessionLifetimes(agent).has(sessionId), +- scratchDirRemoved: await fs.access(scratchDir.fsPath).then(() => false, () => true), +- released: otelService.released, +- }, { +- chatScope: false, +- chatBacking: false, +- provisional: false, +- activeClient: false, +- lifetime: false, +- scratchDirRemoved: true, +- released: [session.toString()], +- }); - }).timeout(30_000); - - test('an import/resume failure disposes the ActiveClient it created and drops the ghost chat backing', async () => { @@ -70657,45 +72684,44 @@ index f24a97a6296..00000000000 - const session = AgentSession.uri('copilotcli', 'rollback-import-session'); - const sessionId = AgentSession.id(session); - const chat = defaultChatUri(session); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const turn: Turn = { -- id: 'rollback-import-turn', -- state: TurnState.Complete, -- message: { text: 'Remember ROLLBACK_IMPORT.', origin: { kind: MessageKind.User } }, -- responseParts: [{ kind: ResponsePartKind.Markdown, id: 'response', content: 'ready' }], -- usage: {}, -- }; -- -- // `_resumeSession` records `_chatBackings` unconditionally before -- // resuming; the failure surfaces from `initializeSession` (via the -- // stubbed `client.resumeSession`) afterwards, leaving a ghost entry -- // the rollback must drop, and an ActiveClient -- // (`_getOrCreateActiveClient` in `_doResumeSession`) it must dispose. -- await assert.rejects(() => provisionSession(agent, { -- session, -- workingDirectories: [workingDirectory], -- importConversation: { turns: [turn] }, -- }), /resume failed/); -- -- assert.deepStrictEqual({ -- chatScope: chatScopes(agent).has(chat.toString()), -- chatBacking: chatBackings(agent).has(chat.toString()), -- activeClient: !!activeClients(agent).get(session), -- lifetimeClosed: sessionLifetimes(agent).get(sessionId)?.isPermanentlyClosed, -- released: otelService.released, -- }, { -- chatScope: false, -- chatBacking: false, -- activeClient: false, -- lifetimeClosed: true, -- released: [session.toString()], -- }); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory.fsPath, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- const turn: Turn = { +- id: 'rollback-import-turn', +- state: TurnState.Complete, +- message: { text: 'Remember ROLLBACK_IMPORT.', origin: { kind: MessageKind.User } }, +- responseParts: [{ kind: ResponsePartKind.Markdown, id: 'response', content: 'ready' }], +- usage: {}, +- }; +- +- // `_resumeSession` records `_chatBackings` unconditionally before +- // resuming; the failure surfaces from `initializeSession` (via the +- // stubbed `client.resumeSession`) afterwards, leaving a ghost entry +- // the rollback must drop, and an ActiveClient +- // (`_getOrCreateActiveClient` in `_doResumeSession`) it must dispose. +- await assert.rejects(() => provisionSession(agent, { +- session, +- workingDirectories: [workingDirectory], +- importConversation: { turns: [turn] }, +- }), /resume failed/); +- +- assert.deepStrictEqual({ +- chatScope: chatScopes(agent).has(chat.toString()), +- chatBacking: chatBackings(agent).has(chat.toString()), +- activeClient: !!activeClients(agent).get(session), +- lifetimeClosed: sessionLifetimes(agent).get(sessionId)?.isPermanentlyClosed, +- released: otelService.released, +- }, { +- chatScope: false, +- chatBacking: false, +- activeClient: false, +- lifetimeClosed: true, +- released: [session.toString()], +- }); - }).timeout(30_000); - - test('a fresh mint failure disposes the ActiveClient and session lifetime it created', async () => { @@ -70708,37 +72734,34 @@ index f24a97a6296..00000000000 - const chat = defaultChatUri(session); - const workingDirectory = URI.file('/rollback-mint-workspace'); - const plugin: Customization = { type: CustomizationType.Plugin, id: 'file:///rollback-plugin', uri: 'file:///rollback-plugin', name: 'Rollback Plugin', enablement: [{ kind: CustomizationEnablementKind.Global, enabled: true }] }; -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- // A non-deferred, non-fork, non-import create dispatches straight to -- // `_mintChatBacking`, which claims an ActiveClient (via -- // `_getOrCreateActiveClient`) and a session lifetime (via -- // `_queueSession`) before `initializeSession` fails. The context also -- // carries a host customization snapshot, so the failure must clear -- // the retained snapshot along with everything else. -- await assert.rejects(() => agent.chats.createChat(chat, { sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(session), resource: session, customizations: [plugin] }, { -- workingDirectories: [workingDirectory], -- deferBacking: false, -- }), /createSession failed/); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- // A non-deferred, non-fork, non-import create dispatches straight to +- // `_mintChatBacking`, which claims an ActiveClient (via +- // `_getOrCreateActiveClient`) and a session lifetime (via +- // `_queueSession`) before `initializeSession` fails. The context also +- // carries a host customization snapshot, so the failure must clear +- // the retained snapshot along with everything else. +- await assert.rejects(() => agent.chats.createChat(chat, { sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(session), resource: session, customizations: [plugin] }, { +- workingDirectories: [workingDirectory], +- deferBacking: false, +- }), /createSession failed/); - -- assert.deepStrictEqual({ -- chatScope: chatScopes(agent).has(chat.toString()), -- chatBacking: chatBackings(agent).has(chat.toString()), -- activeClient: !!activeClients(agent).get(session), -- lifetimeClosed: sessionLifetimes(agent).get(sessionId)?.isPermanentlyClosed, -- hostCustomizations: hostCustomizations(agent, session), -- released: otelService.released, -- }, { -- chatScope: false, -- chatBacking: false, -- activeClient: false, -- lifetimeClosed: true, -- hostCustomizations: [], -- released: [session.toString()], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- chatScope: chatScopes(agent).has(chat.toString()), +- chatBacking: chatBackings(agent).has(chat.toString()), +- activeClient: !!activeClients(agent).get(session), +- lifetimeClosed: sessionLifetimes(agent).get(sessionId)?.isPermanentlyClosed, +- hostCustomizations: hostCustomizations(agent, session), +- released: otelService.released, +- }, { +- chatScope: false, +- chatBacking: false, +- activeClient: false, +- lifetimeClosed: true, +- hostCustomizations: [], +- released: [session.toString()], +- }); - }); - - test('a failing peer mint rolls back its own state but leaves an already-succeeded sibling chat\'s scope resources intact', async () => { @@ -70754,43 +72777,40 @@ index f24a97a6296..00000000000 - const peerChat = URI.parse(buildChatUri(session, 'rollback-sibling-peer')); - const workingDirectory = URI.file('/rollback-sibling-workspace'); - const plugin: Customization = { type: CustomizationType.Plugin, id: 'file:///rollback-sibling-plugin', uri: 'file:///rollback-sibling-plugin', name: 'Rollback Sibling Plugin', enablement: [{ kind: CustomizationEnablementKind.Global, enabled: true }] }; -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); - -- await agent.chats.createChat(defaultChat, { sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(session), resource: session, customizations: [plugin] }, { -- workingDirectories: [workingDirectory], -- deferBacking: false, -- }); -- await assert.rejects(() => agent.chats.createChat(peerChat, exactChatContext(session, peerChat, session), { -- workingDirectories: [workingDirectory], -- deferBacking: false, -- }), /peer mint failed/); +- await agent.chats.createChat(defaultChat, { sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(session), resource: session, customizations: [plugin] }, { +- workingDirectories: [workingDirectory], +- deferBacking: false, +- }); +- await assert.rejects(() => agent.chats.createChat(peerChat, exactChatContext(session, peerChat, session), { +- workingDirectories: [workingDirectory], +- deferBacking: false, +- }), /peer mint failed/); - -- assert.deepStrictEqual({ -- // The failed peer's own bookkeeping is gone. -- peerChatScope: chatScopes(agent).has(peerChat.toString()), -- peerChatBacking: chatBackings(agent).has(peerChat.toString()), -- // The scope is still live because the default chat still shares it, -- // so nothing scope-wide is finalized. -- defaultChatScope: chatScopes(agent).has(defaultChat.toString()), -- defaultChatLive: hasLiveChat(agent, defaultChat), -- activeClient: !!activeClients(agent).get(session), -- lifetimeClosed: sessionLifetimes(agent).get(sessionId)?.isPermanentlyClosed, -- hostCustomizations: hostCustomizations(agent, session).map(c => c.id), -- released: otelService.released, -- }, { -- peerChatScope: false, -- peerChatBacking: false, -- defaultChatScope: true, -- defaultChatLive: true, -- activeClient: true, -- lifetimeClosed: false, -- hostCustomizations: ['file:///rollback-sibling-plugin'], -- released: [], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- // The failed peer's own bookkeeping is gone. +- peerChatScope: chatScopes(agent).has(peerChat.toString()), +- peerChatBacking: chatBackings(agent).has(peerChat.toString()), +- // The scope is still live because the default chat still shares it, +- // so nothing scope-wide is finalized. +- defaultChatScope: chatScopes(agent).has(defaultChat.toString()), +- defaultChatLive: hasLiveChat(agent, defaultChat), +- activeClient: !!activeClients(agent).get(session), +- lifetimeClosed: sessionLifetimes(agent).get(sessionId)?.isPermanentlyClosed, +- hostCustomizations: hostCustomizations(agent, session).map(c => c.id), +- released: otelService.released, +- }, { +- peerChatScope: false, +- peerChatBacking: false, +- defaultChatScope: true, +- defaultChatLive: true, +- activeClient: true, +- lifetimeClosed: false, +- hostCustomizations: ['file:///rollback-sibling-plugin'], +- released: [], +- }); - }); - - test('a client-start failure on a duplicate/reconnect create for an already-reserved chat leaves its existing binding untouched', async () => { @@ -70802,51 +72822,50 @@ index f24a97a6296..00000000000 - const sessionId = AgentSession.id(session); - const chat = defaultChatUri(session); - const scratchDir = URI.joinPath(userHome, '.copilot', 'chats', sessionId); -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(async () => { +- await fs.rm(userHome.fsPath, { recursive: true, force: true }); +- await disposeAgent(agent); +- }); +- await agent.authenticate('https://api.github.com', 'token'); - -- // First create succeeds: reserves the chat's backing, stands up the -- // scope's ActiveClient/lifetime, and (workspace-less) creates the -- // stable scratch dir. -- const first = await provisionSession(agent, { session }); -- assert.strictEqual(first.provisional, true); -- -- const before = { -- chatScope: chatScopes(agent).has(chat.toString()), -- chatBacking: chatBackings(agent).get(chat.toString()), -- activeClient: !!activeClients(agent).get(session), -- lifetimeClosed: sessionLifetimes(agent).get(sessionId)?.isPermanentlyClosed, -- scratchDirExists: await fs.access(scratchDir.fsPath).then(() => true, () => false), -- released: [...otelService.released], -- }; +- // First create succeeds: reserves the chat's backing, stands up the +- // scope's ActiveClient/lifetime, and (workspace-less) creates the +- // stable scratch dir. +- const first = await provisionSession(agent, { session }); +- assert.strictEqual(first.provisional, true); - -- // Force the very next `_ensureClient()` call to attempt a fresh -- // client start — as if the CLI process crashed and a reconnect is -- // underway — and make that restart fail. -- (agent as unknown as { _client: unknown })._client = undefined; -- client.startError = new Error('Failed to reconnect CLI server'); -- -- // A duplicate/reconnect create for the SAME already-reserved chat: -- // `_reserveChatBacking` calls `_ensureClient()` unconditionally -- // before its own idempotency check ever runs, so this failure -- // surfaces before `_createChat` (or `_reserveChatBacking`) can even -- // recognize the chat as already bound. The preexisting reservation -- // must come out exactly as it went in — none of it is this call's -- // to unwind. -- await assert.rejects(() => provisionSession(agent, { session }), /Failed to reconnect CLI server/); +- const before = { +- chatScope: chatScopes(agent).has(chat.toString()), +- chatBacking: chatBackings(agent).get(chat.toString()), +- activeClient: !!activeClients(agent).get(session), +- lifetimeClosed: sessionLifetimes(agent).get(sessionId)?.isPermanentlyClosed, +- scratchDirExists: await fs.access(scratchDir.fsPath).then(() => true, () => false), +- released: [...otelService.released], +- }; - -- assert.deepStrictEqual({ -- chatScope: chatScopes(agent).has(chat.toString()), -- chatBacking: chatBackings(agent).get(chat.toString()), -- activeClient: !!activeClients(agent).get(session), -- lifetimeClosed: sessionLifetimes(agent).get(sessionId)?.isPermanentlyClosed, -- scratchDirExists: await fs.access(scratchDir.fsPath).then(() => true, () => false), -- released: otelService.released, -- }, before); -- } finally { -- await fs.rm(userHome.fsPath, { recursive: true, force: true }); -- await disposeAgent(agent); -- } +- // Force the very next `_ensureClient()` call to attempt a fresh +- // client start — as if the CLI process crashed and a reconnect is +- // underway — and make that restart fail. +- (agent as unknown as { _client: unknown })._client = undefined; +- client.startError = new Error('Failed to reconnect CLI server'); +- +- // A duplicate/reconnect create for the SAME already-reserved chat: +- // `_reserveChatBacking` calls `_ensureClient()` unconditionally +- // before its own idempotency check ever runs, so this failure +- // surfaces before `_createChat` (or `_reserveChatBacking`) can even +- // recognize the chat as already bound. The preexisting reservation +- // must come out exactly as it went in — none of it is this call's +- // to unwind. +- await assert.rejects(() => provisionSession(agent, { session }), /Failed to reconnect CLI server/); +- +- assert.deepStrictEqual({ +- chatScope: chatScopes(agent).has(chat.toString()), +- chatBacking: chatBackings(agent).get(chat.toString()), +- activeClient: !!activeClients(agent).get(session), +- lifetimeClosed: sessionLifetimes(agent).get(sessionId)?.isPermanentlyClosed, +- scratchDirExists: await fs.access(scratchDir.fsPath).then(() => true, () => false), +- released: otelService.released, +- }, before); - }).timeout(30_000); - }); - @@ -70865,29 +72884,26 @@ index f24a97a6296..00000000000 - return { resolvedWorkingDirectory: directory }; - }; - -- try { -- const turn: Turn = { -- id: 'import-turn-1', -- state: TurnState.Complete, -- message: { text: 'Remember IMPORT_ALPHA.', origin: { kind: MessageKind.User } }, -- responseParts: [{ kind: ResponsePartKind.Markdown, id: 'response', content: 'ready' }], -- usage: {}, -- }; +- afterTest(() => disposeAgent(agent)); +- const turn: Turn = { +- id: 'import-turn-1', +- state: TurnState.Complete, +- message: { text: 'Remember IMPORT_ALPHA.', origin: { kind: MessageKind.User } }, +- responseParts: [{ kind: ResponsePartKind.Markdown, id: 'response', content: 'ready' }], +- usage: {}, +- }; - -- await provisionSession(agent, { -- session, -- workingDirectories: [URI.file('/workspace')], -- importConversation: { turns: [turn] }, -- }); +- await provisionSession(agent, { +- session, +- workingDirectories: [URI.file('/workspace')], +- importConversation: { turns: [turn] }, +- }); - -- // Regression guard: session provisioning used to hardcode -- // `undefined` for the plain (non-fork) import branch's `chat` -- // argument, leaving the imported session's chat unbound until a -- // later `bindSessionChat` call. -- assert.strictEqual(importedChat?.toString(), chat.toString()); -- } finally { -- await disposeAgent(agent); -- } +- // Regression guard: session provisioning used to hardcode +- // `undefined` for the plain (non-fork) import branch's `chat` +- // argument, leaving the imported session's chat unbound until a +- // later `bindSessionChat` call. +- assert.strictEqual(importedChat?.toString(), chat.toString()); - }); - - test('createChat imports a conversation directly onto the exact target chat end-to-end, with no bindSessionChat call', async () => { @@ -70902,48 +72918,62 @@ index f24a97a6296..00000000000 - return mockSession as unknown as CopilotSession; - }; - const agent = createTestAgent(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- -- const session = AgentSession.uri('copilotcli', 'import-exact-chat'); -- const chat = defaultChatUri(session); -- const turn: Turn = { -- id: 'imported-turn-1', -- state: TurnState.Complete, -- message: { text: 'Remember IMPORT_ALPHA.', origin: { kind: MessageKind.User } }, -- responseParts: [{ kind: ResponsePartKind.Markdown, id: 'response', content: 'ready' }], -- usage: {}, -- }; -- -- const result = await provisionSession(agent, { -- session, -- workingDirectories: [workingDirectory], -- importConversation: { turns: [turn] }, -- }); -- -- // The imported session's default chat is bound directly by -- // `createChat` itself (via `_importConversation` → -- // `_resumeSession` → `_bindSessionChat`) — no `bindSessionChat` -- // call appears anywhere in this test. -- assert.deepStrictEqual({ -- resumeCalls, -- resultSession: result.session.toString(), -- boundSdkId: chatBackings(agent).get(chat.toString())?.sdkSessionId, -- live: hasLiveChat(agent, chat), -- }, { -- resumeCalls: ['import-exact-chat'], -- resultSession: session.toString(), -- boundSdkId: 'import-exact-chat', -- live: true, -- }); -- -- await agent.chats.sendMessage(chat, 'follow-up', [workingDirectory], undefined, 'turn-2', undefined, exactChatContext(result.session, chat, result.session)); -- assert.strictEqual(hasLiveChat(agent, chat), true); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory.fsPath, { recursive: true, force: true }); - await disposeAgent(agent); +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- +- const session = AgentSession.uri('copilotcli', 'import-exact-chat'); +- const chat = defaultChatUri(session); +- const turn: Turn = { +- id: 'imported-turn-1', +- state: TurnState.Complete, +- message: { text: 'Remember IMPORT_ALPHA.', origin: { kind: MessageKind.User } }, +- responseParts: [ +- { kind: ResponsePartKind.Reasoning, id: 'imported-reasoning', content: 'Considering the request', startedAt: '1970-01-01T00:00:01.000Z', duration: 45_000 }, +- { kind: ResponsePartKind.Markdown, id: 'response', content: 'ready' }, +- ], +- usage: {}, +- }; +- +- const result = await provisionSession(agent, { +- session, +- workingDirectories: [workingDirectory], +- importConversation: { turns: [turn] }, +- }); +- const events: SessionEvent[] = (await fs.readFile(join(getCopilotHomePath(userHome.fsPath, {}), 'session-state', 'import-exact-chat', 'events.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line)); +- const database = sessionDataService.openDatabase(session); +- let importedReasoning: readonly StoredAssistantResponsePart[] | undefined; +- try { +- const parts = await database.object.getAssistantResponseParts(); +- const reasoningEvent = events.find(event => event.type === 'assistant.message' && event.data.reasoningText); +- importedReasoning = reasoningEvent && parts.get(reasoningEvent.id); +- } finally { +- database.dispose(); - } +- +- // The imported session's default chat is bound directly by +- // `createChat` itself (via `_importConversation` → +- // `_resumeSession` → `_bindSessionChat`) — no `bindSessionChat` +- // call appears anywhere in this test. +- assert.deepStrictEqual({ +- resumeCalls, +- resultSession: result.session.toString(), +- boundSdkId: chatBackings(agent).get(chat.toString())?.sdkSessionId, +- live: hasLiveChat(agent, chat), +- importedReasoning, +- }, { +- resumeCalls: ['import-exact-chat'], +- resultSession: session.toString(), +- boundSdkId: 'import-exact-chat', +- live: true, +- importedReasoning: [turn.responseParts[0]], +- }); +- +- await agent.chats.sendMessage(chat, 'follow-up', [workingDirectory], undefined, 'turn-2', undefined, exactChatContext(result.session, chat, result.session)); +- assert.strictEqual(hasLiveChat(agent, chat), true); - }).timeout(30_000); - }); - @@ -70960,21 +72990,18 @@ index f24a97a6296..00000000000 - gitService.addWorktree = async (...args) => { worktreeCalls++; return origAddWorktree(...args); }; - - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, gitService }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); - -- const result = await provisionSession(agent, { -- session: AgentSession.uri('copilotcli', 'prov-1'), -- workingDirectories: [URI.file('/workspace')], -- config: { isolation: 'worktree', branch: 'main' }, -- }); +- const result = await provisionSession(agent, { +- session: AgentSession.uri('copilotcli', 'prov-1'), +- workingDirectories: [URI.file('/workspace')], +- config: { isolation: 'worktree', branch: 'main' }, +- }); - -- assert.strictEqual(result.provisional, true); -- assert.strictEqual(clientCreateCalls, 0, 'client.createSession should not be called for provisional sessions'); -- assert.strictEqual(worktreeCalls, 0, 'no worktree should be created for provisional sessions'); -- } finally { -- await disposeAgent(agent); -- } +- assert.strictEqual(result.provisional, true); +- assert.strictEqual(clientCreateCalls, 0, 'client.createSession should not be called for provisional sessions'); +- assert.strictEqual(worktreeCalls, 0, 'no worktree should be created for provisional sessions'); - }); - - test('canvas inventory does not materialize a provisional session', async () => { @@ -70983,23 +73010,20 @@ index f24a97a6296..00000000000 - let clientCreateCalls = 0; - client.createSession = async () => { clientCreateCalls++; throw new Error('SDK not expected'); }; - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const result = await provisionSession(agent, { -- session: AgentSession.uri('copilotcli', 'prov-canvas-list'), -- workingDirectories: [URI.file('/workspace')], -- }); -- const chat = defaultChatUri(result.session); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const result = await provisionSession(agent, { +- session: AgentSession.uri('copilotcli', 'prov-canvas-list'), +- workingDirectories: [URI.file('/workspace')], +- }); +- const chat = defaultChatUri(result.session); - -- await assert.rejects( -- agent.listSessionCanvases(result.session, chat, exactChatContext(result.session, chat, result.session)), -- /requires an initialized Copilot chat/, -- ); +- await assert.rejects( +- agent.listSessionCanvases(result.session, chat, exactChatContext(result.session, chat, result.session)), +- /requires an initialized Copilot chat/, +- ); - -- assert.strictEqual(clientCreateCalls, 0); -- } finally { -- await disposeAgent(agent); -- } +- assert.strictEqual(clientCreateCalls, 0); - }); - - test('canvas inventory resumes an addressed peer while the parent session remains provisional', async () => { @@ -71037,37 +73061,36 @@ index f24a97a6296..00000000000 - useRealResumePath: true, - rootConfig: { [AgentHostCanvasesEnabledConfigKey]: true }, - }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'provisional-parent-canvas-peer'); -- const peer = URI.parse(buildChatUri(session, 'peer-a')); -- await provisionSession(agent, { session, workingDirectories: [workingDirectory] }); -- await agent.materializeChat(peer, exactChatContext(session, peer), JSON.stringify({ sdkSessionId: sdk.sessionId })); -- -- const canvases = await agent.listSessionCanvases(session, peer, exactChatContext(session, peer)); -- -- assert.deepStrictEqual({ -- canvases, -- resumed, -- clientCreateCalls, -- }, { -- canvases: [{ -- canvasId: 'preview', -- extensionId: 'project:preview', -- extensionSource: 'project', -- extensionName: 'Preview extension', -- displayName: 'Preview', -- description: 'Preview generated content.', -- requiresInput: false, -- actionCount: 0, -- }], -- resumed: ['peer-sdk-id'], -- clientCreateCalls: 0, -- }); -- } finally { +- afterTest(async () => { - await fs.rm(root, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'provisional-parent-canvas-peer'); +- const peer = URI.parse(buildChatUri(session, 'peer-a')); +- await provisionSession(agent, { session, workingDirectories: [workingDirectory] }); +- await agent.materializeChat(peer, exactChatContext(session, peer), JSON.stringify({ sdkSessionId: sdk.sessionId })); +- +- const canvases = await agent.listSessionCanvases(session, peer, exactChatContext(session, peer)); +- +- assert.deepStrictEqual({ +- canvases, +- resumed, +- clientCreateCalls, +- }, { +- canvases: [{ +- canvasId: 'preview', +- extensionId: 'project:preview', +- extensionSource: 'project', +- extensionName: 'Preview extension', +- displayName: 'Preview', +- description: 'Preview generated content.', +- requiresInput: false, +- actionCount: 0, +- }], +- resumed: ['peer-sdk-id'], +- clientCreateCalls: 0, +- }); - }); - - test('sendMessage on the session-backed chat materializes the parent provisional session', async () => { @@ -71079,22 +73102,19 @@ index f24a97a6296..00000000000 - return new MockCopilotSession() as unknown as CopilotSession; - }; - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const result = await provisionSession(agent, { -- session: AgentSession.uri('copilotcli', 'prov-default-chat'), -- workingDirectories: [URI.file('/workspace')], -- }); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const result = await provisionSession(agent, { +- session: AgentSession.uri('copilotcli', 'prov-default-chat'), +- workingDirectories: [URI.file('/workspace')], +- }); - -- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, defaultChatUri(result.session), result.session)); +- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, defaultChatUri(result.session), result.session)); - -- // The provisional session materializes onto the SDK id reserved -- // for its session-backed chat at create time, which is -- // independent of the host-minted AH session id. -- assert.strictEqual(capturedConfig?.sessionId, JSON.parse(result.providerData!).sdkSessionId); -- } finally { -- await disposeAgent(agent); -- } +- // The provisional session materializes onto the SDK id reserved +- // for its session-backed chat at create time, which is +- // independent of the host-minted AH session id. +- assert.strictEqual(capturedConfig?.sessionId, JSON.parse(result.providerData!).sdkSessionId); - }); - - test('getModel reports the creation model while the backing is still deferred', async () => { @@ -71102,29 +73122,26 @@ index f24a97a6296..00000000000 - const client = new TestCopilotClient([]); - client.createSession = async () => new MockCopilotSession() as unknown as CopilotSession; - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const result = await provisionSession(agent, { -- session: AgentSession.uri('copilotcli', 'prov-default-model'), -- model: { id: 'gpt-x' }, -- workingDirectories: [URI.file('/workspace')], -- }); -- const chat = defaultChatUri(result.session); -- const context = exactChatContext(result.session, chat, result.session); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const result = await provisionSession(agent, { +- session: AgentSession.uri('copilotcli', 'prov-default-model'), +- model: { id: 'gpt-x' }, +- workingDirectories: [URI.file('/workspace')], +- }); +- const chat = defaultChatUri(result.session); +- const context = exactChatContext(result.session, chat, result.session); - -- // The first turn's telemetry reads the bound model before the -- // send materializes the session, so the reserved backing must -- // already carry it. -- const beforeSend = agent.chats.getModel?.(chat, context); -- await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, context); +- // The first turn's telemetry reads the bound model before the +- // send materializes the session, so the reserved backing must +- // already carry it. +- const beforeSend = agent.chats.getModel?.(chat, context); +- await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, context); - -- assert.deepStrictEqual({ beforeSend, afterMaterialize: agent.chats.getModel?.(chat, context) }, { -- beforeSend: { id: 'gpt-x' }, -- afterMaterialize: { id: 'gpt-x' }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ beforeSend, afterMaterialize: agent.chats.getModel?.(chat, context) }, { +- beforeSend: { id: 'gpt-x' }, +- afterMaterialize: { id: 'gpt-x' }, +- }); - }); - - test('disposeSession on provisional session does not touch SDK or worktree', async () => { @@ -71136,59 +73153,50 @@ index f24a97a6296..00000000000 - gitService.removeWorktree = async (...args) => { removeWorktreeCalls++; return origRemoveWorktree(...args); }; - - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, gitService }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); - -- const result = await provisionSession(agent, { -- session: AgentSession.uri('copilotcli', 'prov-2'), -- workingDirectories: [URI.file('/workspace')], -- }); +- const result = await provisionSession(agent, { +- session: AgentSession.uri('copilotcli', 'prov-2'), +- workingDirectories: [URI.file('/workspace')], +- }); - -- await disposeProvisionedSession(agent, result.session); +- await disposeProvisionedSession(agent, result.session); - -- assert.strictEqual(removeWorktreeCalls, 0, 'no worktree to remove for provisional'); -- assert.strictEqual(agent.hasSession(result.session), false); -- } finally { -- await disposeAgent(agent); -- } +- assert.strictEqual(removeWorktreeCalls, 0, 'no worktree to remove for provisional'); +- assert.strictEqual(agent.hasSession(result.session), false); - }); - - test('disposeSession removes the session from the SDK on-disk store', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); - -- const session = AgentSession.uri('copilotcli', 'persisted-session-1'); -- await materializeLegacyDefaultChat(agent, session); -- await disposeProvisionedSession(agent, session); +- const session = AgentSession.uri('copilotcli', 'persisted-session-1'); +- await materializeLegacyDefaultChat(agent, session); +- await disposeProvisionedSession(agent, session); - -- assert.deepStrictEqual(client.deletedSessionIds, ['persisted-session-1']); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(client.deletedSessionIds, ['persisted-session-1']); - }); - - test('disposeSession on provisional session does not call client.deleteSession', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); - -- const result = await provisionSession(agent, { -- session: AgentSession.uri('copilotcli', 'prov-3'), -- workingDirectories: [URI.file('/workspace')], -- }); +- const result = await provisionSession(agent, { +- session: AgentSession.uri('copilotcli', 'prov-3'), +- workingDirectories: [URI.file('/workspace')], +- }); - -- await disposeProvisionedSession(agent, result.session); +- await disposeProvisionedSession(agent, result.session); - -- assert.deepStrictEqual(client.deletedSessionIds, []); -- assert.strictEqual(agent.hasSession(result.session), false); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(client.deletedSessionIds, []); +- assert.strictEqual(agent.hasSession(result.session), false); - }); - - test('disposeSession reopens its lifetime after an SDK delete error', async () => { @@ -71204,17 +73212,14 @@ index f24a97a6296..00000000000 - // The SDK session still exists after the first failure, so it is genuine. - client.getSessionMetadata = async id => deleteAttempts === 1 ? sdkSession(id) : undefined; - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); - -- const session = AgentSession.uri('copilotcli', 'persisted-session-2'); -- await materializeLegacyDefaultChat(agent, session); -- await assert.rejects(() => disposeProvisionedSession(agent, session), /boom/); -- await disposeProvisionedSession(agent, session); -- assert.strictEqual(deleteAttempts, 2); -- } finally { -- await disposeAgent(agent); -- } +- const session = AgentSession.uri('copilotcli', 'persisted-session-2'); +- await materializeLegacyDefaultChat(agent, session); +- await assert.rejects(() => disposeProvisionedSession(agent, session), /boom/); +- await disposeProvisionedSession(agent, session); +- assert.strictEqual(deleteAttempts, 2); - }); - - test('disposeChat keeps a live chat backing when SDK deletion fails', async () => { @@ -71224,34 +73229,31 @@ index f24a97a6296..00000000000 - // The SDK session still exists, so the delete failure is genuine. - client.getSessionMetadata = async id => sdkSession(id); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); - -- const session = AgentSession.uri('copilotcli', 'persisted-session-live'); -- const chatUri = URI.parse(buildDefaultChatUri(session)); -- let disposed = false; -- setDefaultSessionStub(agent, AgentSession.id(session), { -- sessionId: AgentSession.id(session), -- sessionUri: session, -- chatChannelUri: chatUri, -- destroySession: async () => { }, -- dispose: () => { disposed = true; }, -- }, chatUri); +- const session = AgentSession.uri('copilotcli', 'persisted-session-live'); +- const chatUri = URI.parse(buildDefaultChatUri(session)); +- let disposed = false; +- setDefaultSessionStub(agent, AgentSession.id(session), { +- sessionId: AgentSession.id(session), +- sessionUri: session, +- chatChannelUri: chatUri, +- destroySession: async () => { }, +- dispose: () => { disposed = true; }, +- }, chatUri); - -- await assert.rejects(() => agent.chats.disposeChat(chatUri, exactChatContext(session, chatUri)), /boom/); +- await assert.rejects(() => agent.chats.disposeChat(chatUri, exactChatContext(session, chatUri)), /boom/); - -- assert.deepStrictEqual({ -- tracked: hasLiveChat(agent, chatUri), -- backing: chatBackings(agent).get(chatUri.toString()), -- disposed, -- }, { -- tracked: true, -- backing: { sdkSessionId: AgentSession.id(session) }, -- disposed: false, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- tracked: hasLiveChat(agent, chatUri), +- backing: chatBackings(agent).get(chatUri.toString()), +- disposed, +- }, { +- tracked: true, +- backing: { sdkSessionId: AgentSession.id(session) }, +- disposed: false, +- }); - }); - - test('disposeChat completes idempotently when the SDK session was already deleted', async () => { @@ -71260,33 +73262,145 @@ index f24a97a6296..00000000000 - const client = new TestCopilotClient([]); - client.deleteSession = async () => { throw new Error('session not found'); }; - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- +- const session = AgentSession.uri('copilotcli', 'persisted-session-gone'); +- const chatUri = URI.parse(buildDefaultChatUri(session)); +- let disposed = false; +- setDefaultSessionStub(agent, AgentSession.id(session), { +- sessionId: AgentSession.id(session), +- sessionUri: session, +- chatChannelUri: chatUri, +- destroySession: async () => { }, +- dispose: () => { disposed = true; }, +- }, chatUri); +- +- // A confirmed-gone SDK session is swallowed so a retried teardown completes. +- await agent.chats.disposeChat(chatUri, exactChatContext(session, chatUri)); +- +- assert.deepStrictEqual({ +- tracked: hasLiveChat(agent, chatUri), +- backing: chatBackings(agent).get(chatUri.toString()), +- disposed, +- }, { +- tracked: false, +- backing: undefined, +- disposed: true, +- }); +- }); +- +- test('ignores config updates after SDK session shutdown during disposal (#344272)', async () => { +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const fileService = disposables.add(new FileService(new NullLogService())); +- const fsProvider = disposables.add(new InMemoryFileSystemProvider()); +- disposables.add(fileService.registerProvider(Schemas.file, fsProvider)); +- const mockSession = new MockCopilotSession(); +- let optionsUpdateAfterShutdown = 0; +- let shutdownEmitted = false; +- const imageSyncBlocked = new DeferredPromise(); +- const allowImageSync = new DeferredPromise(); +- const shellFileWriteBlocked = new DeferredPromise(); +- const allowShellFileWrite = new DeferredPromise(); +- const origWriteFile = fileService.writeFile.bind(fileService); +- let blockNextShellWrite = false; +- fileService.writeFile = async (resource, bufferOrReadableOrStream, opts) => { +- if (blockNextShellWrite && resource.path.includes('shellInit')) { +- blockNextShellWrite = false; +- shellFileWriteBlocked.complete(); +- await allowShellFileWrite.p; +- } +- return origWriteFile(resource, bufferOrReadableOrStream, opts); +- }; +- mockSession.rpc.options.update = async (params?: { imageGeneration?: { enabled?: boolean } }) => { +- if (shutdownEmitted) { +- optionsUpdateAfterShutdown++; +- throw new Error(`Request session.options.update failed with message: Session not found for sessionId: ${mockSession.sessionId}`); +- } +- if (params?.imageGeneration?.enabled === true && !imageSyncBlocked.isSettled) { +- imageSyncBlocked.complete(); +- await allowImageSync.p; +- } +- return { isExperimentalMode: false, success: true }; +- }; +- const client = new TestCopilotClient([]); +- client.createSession = async () => mockSession as unknown as CopilotSession; +- const { agent, stateManager, configurationService } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, fileService }); +- client.deleteSession = async () => { +- shutdownEmitted = true; +- mockSession.emit({ +- id: 'shutdown-1', +- timestamp: '2026-01-01T00:00:00Z', +- parentId: null, +- type: 'session.shutdown', +- data: { +- shutdownType: 'routine', +- totalApiDurationMs: 0, +- sessionStartTime: 0, +- codeChanges: { linesAdded: 0, linesRemoved: 0, filesModified: [] }, +- modelMetrics: {}, +- }, +- }); +- allowImageSync.complete(); +- allowShellFileWrite.complete(); +- configurationService.updateRootConfig({ [AgentHostGlobalAutoApproveEnabledConfigKey]: true }); +- }; - try { - await agent.authenticate('https://api.github.com', 'token'); +- const sessionUri = AgentSession.uri('copilotcli', 'shutdown-config-race'); +- const chatUri = defaultChatUri(sessionUri); +- const now = new Date().toISOString(); +- stateManager.createSession({ +- resource: sessionUri.toString(), +- provider: 'copilotcli', +- title: 'Test', +- status: SessionStatus.Idle, +- createdAt: now, +- modifiedAt: now, +- workingDirectories: [URI.file('/workspace').toString()], +- defaultChat: chatUri.toString(), +- }); +- stateManager.setSessionConfig(sessionUri.toString(), { +- schema: platformSessionSchema.toProtocol(), +- values: { [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'export INIT=1\n' }] }, +- }); +- const created = await provisionSession(agent, { +- session: sessionUri, +- workingDirectories: [URI.file('/workspace')], +- }); +- await agent.chats.sendMessage(chatUri, 'hello', undefined, undefined, undefined, undefined, exactChatContext(created.session, chatUri, created.session)); - -- const session = AgentSession.uri('copilotcli', 'persisted-session-gone'); -- const chatUri = URI.parse(buildDefaultChatUri(session)); -- let disposed = false; -- setDefaultSessionStub(agent, AgentSession.id(session), { -- sessionId: AgentSession.id(session), -- sessionUri: session, -- chatChannelUri: chatUri, -- destroySession: async () => { }, -- dispose: () => { disposed = true; }, -- }, chatUri); +- // Enqueue a first image-generation update that blocks in the sequencer, then a second one, +- // plus a shell-init update that pauses during async file materialization, before shutdown fires. +- blockNextShellWrite = true; +- configurationService.updateRootConfig({ +- [CopilotCliConfigKey.ImageGeneration]: true, +- [CopilotCliConfigKey.EnableShellInitScript]: true, +- [CopilotCliConfigKey.EnableCustomTerminalTool]: false, +- }); +- await Promise.all([imageSyncBlocked.p, shellFileWriteBlocked.p]); +- configurationService.updateRootConfig({ +- [CopilotCliConfigKey.ImageGenerationWatermark]: true, +- }); +- +- await agent.chats.disposeChat(chatUri, exactChatContext(created.session, chatUri, created.session)); - -- // A confirmed-gone SDK session is swallowed so a retried teardown completes. -- await agent.chats.disposeChat(chatUri, exactChatContext(session, chatUri)); +- const querySession = AgentSession.uri('copilotcli', 'shutdown-config-race').with({ query: 'subagentChatResource=ahp-chat%3A%2Fchild' }); +- const queryAdoption = await ensureDefaultChatAdopted(agent, querySession); +- const queryMetadata = await agent.getChatMetadata(defaultChatUri(querySession), exactChatContext(querySession, defaultChatUri(querySession), querySession)); - - assert.deepStrictEqual({ -- tracked: hasLiveChat(agent, chatUri), -- backing: chatBackings(agent).get(chatUri.toString()), -- disposed, +- optionsUpdateAfterShutdown, +- queryAdoption, +- queryMetadata, - }, { -- tracked: false, -- backing: undefined, -- disposed: true, +- optionsUpdateAfterShutdown: 0, +- queryAdoption: { adopted: false, eligible: false, reason: 'notLegacyChat' }, +- queryMetadata: undefined, - }); - } finally { +- allowImageSync.complete(); +- allowShellFileWrite.complete(); - await disposeAgent(agent); - } - }); @@ -71301,41 +73415,38 @@ index f24a97a6296..00000000000 - }; - - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); - -- const result = await provisionSession(agent, { -- session: AgentSession.uri('copilotcli', 'system-message-session'), -- workingDirectories: [URI.file('/workspace')], -- }); -- assert.strictEqual(result.provisional, true); +- const result = await provisionSession(agent, { +- session: AgentSession.uri('copilotcli', 'system-message-session'), +- workingDirectories: [URI.file('/workspace')], +- }); +- assert.strictEqual(result.provisional, true); - -- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, defaultChatUri(result.session), result.session)); +- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, defaultChatUri(result.session), result.session)); - -- assert.ok(capturedConfig, 'SDK createSession should be called during provisional materialization'); -- const systemMessage = capturedConfig.systemMessage; -- assert.deepStrictEqual(systemMessage, { -- ...COPILOT_AGENT_HOST_SYSTEM_MESSAGE, -- sections: { -- ...COPILOT_AGENT_HOST_SYSTEM_MESSAGE.sections, -- tool_instructions: { -- action: 'append', -- content: `\n${COPILOT_AGENT_HOST_LARGE_OUTPUT_TOOL_INSTRUCTION}\n${COPILOT_AGENT_HOST_SUBAGENT_TOOL_INSTRUCTIONS}`, -- }, +- assert.ok(capturedConfig, 'SDK createSession should be called during provisional materialization'); +- const systemMessage = capturedConfig.systemMessage; +- assert.deepStrictEqual(systemMessage, { +- ...COPILOT_AGENT_HOST_SYSTEM_MESSAGE, +- sections: { +- ...COPILOT_AGENT_HOST_SYSTEM_MESSAGE.sections, +- tool_instructions: { +- action: 'append', +- content: `\n${COPILOT_AGENT_HOST_LARGE_OUTPUT_TOOL_INSTRUCTION}\n${COPILOT_AGENT_HOST_SUBAGENT_TOOL_INSTRUCTIONS}`, - }, -- content: AGENT_HOST_FILE_LINK_INSTRUCTIONS, -- }); -- if (!systemMessage || systemMessage.mode !== 'customize') { -- assert.fail('Expected customize-mode system message'); -- } -- assert.strictEqual(systemMessage.sections?.identity?.action, 'replace'); -- assert.strictEqual( -- systemMessage.sections?.identity?.content, -- 'You are an AI assistant using Copilot SDK in VS Code. You help users with software engineering tasks. When asked about your identity, you must state that you are an AI assistant using Copilot SDK in VS Code.' -- ); -- } finally { -- await disposeAgent(agent); +- }, +- content: AGENT_HOST_FILE_LINK_INSTRUCTIONS, +- }); +- if (!systemMessage || systemMessage.mode !== 'customize') { +- assert.fail('Expected customize-mode system message'); - } +- assert.strictEqual(systemMessage.sections?.identity?.action, 'replace'); +- assert.strictEqual( +- systemMessage.sections?.identity?.content, +- 'You are an AI assistant using Copilot SDK in VS Code. You help users with software engineering tasks. When asked about your identity, you must state that you are an AI assistant using Copilot SDK in VS Code.' +- ); - }); - - test('materialization passes prompt and tool description overrides to the SDK', async () => { @@ -71348,49 +73459,46 @@ index f24a97a6296..00000000000 - }; - - const { agent, configurationService } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client }); -- try { -- configurationService.updateRootConfig({ -- [CopilotCliConfigKey.ModelCapabilityOverrides]: { -- '*': { -- promptOverrideString: [ -- 'systemPrompt: You are an evaluation agent.', -- 'toolDescriptions:', -- ' test_tool:', -- ' description: Overridden tool description.', -- ].join('\n'), -- }, +- afterTest(() => disposeAgent(agent)); +- configurationService.updateRootConfig({ +- [CopilotCliConfigKey.ModelCapabilityOverrides]: { +- '*': { +- promptOverrideString: [ +- 'systemPrompt: You are an evaluation agent.', +- 'toolDescriptions:', +- ' test_tool:', +- ' description: Overridden tool description.', +- ].join('\n'), - }, -- }); -- await agent.authenticate('https://api.github.com', 'token'); +- }, +- }); +- await agent.authenticate('https://api.github.com', 'token'); - -- const result = await provisionSession(agent, { -- session: AgentSession.uri('copilotcli', 'system-message-override-session'), -- workingDirectories: [URI.file('/workspace')], -- activeClient: { -- clientId: 'client-1', -- tools: [{ name: 'test_tool', description: 'Original tool description.', inputSchema: { type: 'object' } }], -- customizations: [], -- }, -- }); -- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, defaultChatUri(result.session), result.session)); +- const result = await provisionSession(agent, { +- session: AgentSession.uri('copilotcli', 'system-message-override-session'), +- workingDirectories: [URI.file('/workspace')], +- activeClient: { +- clientId: 'client-1', +- tools: [{ name: 'test_tool', description: 'Original tool description.', inputSchema: { type: 'object' } }], +- customizations: [], +- }, +- }); +- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, defaultChatUri(result.session), result.session)); - -- const testTool = capturedConfig?.tools?.find(tool => tool.name === 'test_tool'); -- assert.deepStrictEqual({ -- systemMessage: capturedConfig?.systemMessage, -- tool: testTool && { name: testTool.name, description: testTool.description }, -- }, { -- systemMessage: { -- mode: 'replace', -- content: 'You are an evaluation agent.', -- }, -- tool: { -- name: 'test_tool', -- description: 'Overridden tool description.', -- }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- const testTool = capturedConfig?.tools?.find(tool => tool.name === 'test_tool'); +- assert.deepStrictEqual({ +- systemMessage: capturedConfig?.systemMessage, +- tool: testTool && { name: testTool.name, description: testTool.description }, +- }, { +- systemMessage: { +- mode: 'replace', +- content: 'You are an evaluation agent.', +- }, +- tool: { +- name: 'test_tool', +- description: 'Overridden tool description.', +- }, +- }); - }); - - test('materialization applies the per-model capability overrides without changing the wire model', async () => { @@ -71403,43 +73511,40 @@ index f24a97a6296..00000000000 - }; - - const { agent, configurationService } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client }); -- try { -- configurationService.updateRootConfig({ -- modelCapabilityOverrides: { -- // Bare '*' expands to the three source wildcards rather than -- // being dropped, so an 'exclude everything' is honoured. -- 'claude-sonnet': { family: 'claude-opus-4.8', reasoningEffort: 'xhigh', availableTools: ['*'], excludedTools: ['mcp:*', '*'], modelCapabilities: { supports: { vision: false } } }, -- }, -- }); -- await agent.authenticate('https://api.github.com', 'token'); -- await waitForState(agent.models, m => m.length > 0); +- afterTest(() => disposeAgent(agent)); +- configurationService.updateRootConfig({ +- modelCapabilityOverrides: { +- // Bare '*' expands to the three source wildcards rather than +- // being dropped, so an 'exclude everything' is honoured. +- 'claude-sonnet': { family: 'claude-opus-4.8', reasoningEffort: 'xhigh', availableTools: ['*'], excludedTools: ['mcp:*', '*'], modelCapabilities: { supports: { vision: false } } }, +- }, +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await waitForState(agent.models, m => m.length > 0); - -- const result = await provisionSession(agent, { -- session: AgentSession.uri('copilotcli', 'capability-override-session'), -- workingDirectories: [URI.file('/workspace')], -- model: { id: 'claude-sonnet', config: { thinkingLevel: 'medium' } }, -- }); -- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, defaultChatUri(result.session), result.session)); +- const result = await provisionSession(agent, { +- session: AgentSession.uri('copilotcli', 'capability-override-session'), +- workingDirectories: [URI.file('/workspace')], +- model: { id: 'claude-sonnet', config: { thinkingLevel: 'medium' } }, +- }); +- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, defaultChatUri(result.session), result.session)); - -- assert.deepStrictEqual({ -- model: capturedConfig?.model, -- reasoningEffort: capturedConfig?.reasoningEffort, -- availableTools: capturedConfig?.availableTools, -- excludedTools: capturedConfig?.excludedTools, -- modelCapabilities: capturedConfig?.modelCapabilities, -- }, { -- // the alias routes the prompt only; the session still runs on the -- // selected model -- model: 'claude-sonnet', -- // the per-model effort beats the picker's 'medium' -- reasoningEffort: 'xhigh', -- availableTools: ['builtin:*', 'mcp:*', 'custom:*'], -- excludedTools: ['mcp:*', 'builtin:*', 'custom:*', 'builtin:run_dynamic_workflow', 'builtin:dynamic_workflows_manage', 'builtin:semantic_code_search', 'builtin:search_code_subagent', `builtin:${SEMANTIC_SEARCH_TOOL_NAME}`], -- modelCapabilities: { supports: { vision: false } }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- model: capturedConfig?.model, +- reasoningEffort: capturedConfig?.reasoningEffort, +- availableTools: capturedConfig?.availableTools, +- excludedTools: capturedConfig?.excludedTools, +- modelCapabilities: capturedConfig?.modelCapabilities, +- }, { +- // the alias routes the prompt only; the session still runs on the +- // selected model +- model: 'claude-sonnet', +- // the per-model effort beats the picker's 'medium' +- reasoningEffort: 'xhigh', +- availableTools: ['builtin:*', 'mcp:*', 'custom:*'], +- excludedTools: ['mcp:*', 'builtin:*', 'custom:*', 'builtin:run_dynamic_workflow', 'builtin:dynamic_workflows_manage', 'builtin:semantic_code_search', 'builtin:search_code_subagent', `builtin:${SEMANTIC_SEARCH_TOOL_NAME}`], +- modelCapabilities: { supports: { vision: false } }, +- }); - }); - - test('materialization forwards the GitHub token to the SDK at the session level (#318693)', async () => { @@ -71452,27 +73557,24 @@ index f24a97a6296..00000000000 - return new MockCopilotSession() as unknown as CopilotSession; - }; - -- try { -- await agent.authenticate('https://api.github.com', 'gh-token-abc'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'gh-token-abc'); - -- const result = await provisionSession(agent, { -- session: AgentSession.uri('copilotcli', 'session-level-token'), -- workingDirectories: [URI.file('/workspace')], -- }); -- assert.strictEqual(result.provisional, true); +- const result = await provisionSession(agent, { +- session: AgentSession.uri('copilotcli', 'session-level-token'), +- workingDirectories: [URI.file('/workspace')], +- }); +- assert.strictEqual(result.provisional, true); - -- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, defaultChatUri(result.session), result.session)); +- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, defaultChatUri(result.session), result.session)); - -- assert.deepStrictEqual({ -- configToken: capturedConfig?.gitHubToken, -- hasTokenProvider: capturedConfig?.gitHubTokenProvider !== undefined, -- }, { -- configToken: 'gh-token-abc', -- hasTokenProvider: false, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- configToken: capturedConfig?.gitHubToken, +- hasTokenProvider: capturedConfig?.gitHubTokenProvider !== undefined, +- }, { +- configToken: 'gh-token-abc', +- hasTokenProvider: false, +- }); - }); - - test('materialization relies on client authentication for GitHub Enterprise sessions', async () => { @@ -71486,26 +73588,23 @@ index f24a97a6296..00000000000 - return new MockCopilotSession() as unknown as CopilotSession; - }; - -- try { -- await agent.authenticate(endpointService.getCopilotResource().resource, 'enterprise-session-token'); -- const result = await provisionSession(agent, { -- session: AgentSession.uri('copilotcli', 'enterprise-client-token'), -- workingDirectories: [URI.file('/workspace')], -- }); -- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, defaultChatUri(result.session), result.session)); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate(endpointService.getCopilotResource().resource, 'enterprise-session-token'); +- const result = await provisionSession(agent, { +- session: AgentSession.uri('copilotcli', 'enterprise-client-token'), +- workingDirectories: [URI.file('/workspace')], +- }); +- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, defaultChatUri(result.session), result.session)); - -- assert.deepStrictEqual({ -- clientToken: getCreatedClientOptions(agent).at(-1)?.gitHubToken, -- configToken: capturedConfig?.gitHubToken, -- hasTokenProvider: capturedConfig?.gitHubTokenProvider !== undefined, -- }, { -- clientToken: 'enterprise-session-token', -- configToken: undefined, -- hasTokenProvider: false, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- clientToken: getCreatedClientOptions(agent).at(-1)?.gitHubToken, +- configToken: capturedConfig?.gitHubToken, +- hasTokenProvider: capturedConfig?.gitHubTokenProvider !== undefined, +- }, { +- clientToken: 'enterprise-session-token', +- configToken: undefined, +- hasTokenProvider: false, +- }); - }); - - test('materialization uses session authentication when Copilot connectors are enabled', async () => { @@ -71522,28 +73621,25 @@ index f24a97a6296..00000000000 - return new MockCopilotSession() as unknown as CopilotSession; - }; - -- try { -- await agent.authenticate('https://api.github.com', 'connector-session-token'); -- const result = await provisionSession(agent, { -- session: AgentSession.uri('copilotcli', 'connector-client-token'), -- workingDirectories: [URI.file('/workspace')], -- }); -- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, defaultChatUri(result.session), result.session)); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'connector-session-token'); +- const result = await provisionSession(agent, { +- session: AgentSession.uri('copilotcli', 'connector-client-token'), +- workingDirectories: [URI.file('/workspace')], +- }); +- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, defaultChatUri(result.session), result.session)); - -- assert.deepStrictEqual({ -- clientToken: getCreatedClientOptions(agent).at(-1)?.gitHubToken, -- configToken: capturedConfig?.gitHubToken, -- hasTokenProvider: capturedConfig?.gitHubTokenProvider !== undefined, -- connectorFlags: capturedConfig?.featureFlags, -- }, { -- clientToken: undefined, -- configToken: 'connector-session-token', -- hasTokenProvider: false, -- connectorFlags: { AUTO_APPROVAL: true, CATALOG_SEARCH: true, AGENT_FINDER_SKILL_INSTALL: true, AGENT_FINDER_PLUGIN_INSTALL: true, CONNECTORS: true, TGREP: false, CONTENT_EXCLUSION: true, DETACH_LONG_LIVED_SERVICES: true, copilot_swe_agent_memory_in_repo_store: false, MANAGED_MCP_SERVERS: true }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- clientToken: getCreatedClientOptions(agent).at(-1)?.gitHubToken, +- configToken: capturedConfig?.gitHubToken, +- hasTokenProvider: capturedConfig?.gitHubTokenProvider !== undefined, +- connectorFlags: capturedConfig?.featureFlags, +- }, { +- clientToken: undefined, +- configToken: 'connector-session-token', +- hasTokenProvider: false, +- connectorFlags: { AUTO_APPROVAL: true, CATALOG_SEARCH: true, AGENT_FINDER_SKILL_INSTALL: true, AGENT_FINDER_PLUGIN_INSTALL: true, CONNECTORS: true, TGREP: true, CONTENT_EXCLUSION: true, DETACH_LONG_LIVED_SERVICES: true, copilot_swe_agent_memory_in_repo_store: false, MANAGED_MCP_SERVERS: true }, +- }); - }); - - test('resumeSession relies on client authentication for GitHub Enterprise sessions', async () => { @@ -71568,28 +73664,27 @@ index f24a97a6296..00000000000 - chatBackings(agent).set(chat.toString(), { sdkSessionId: sessionId }); - chatScopes(agent).set(chat.toString(), session); - -- try { -- await authenticationService.authenticate({ -- resource: endpointService.getCopilotResource().resource, -- token: 'enterprise-resume-token', -- }, [agent]); -- const turns = await agent.chats.getMessages(chat, exactChatContext(session, chat, session)); -- -- assert.deepStrictEqual({ -- turns, -- clientToken: getCreatedClientOptions(agent).at(-1)?.gitHubToken, -- configToken: capturedConfig?.gitHubToken, -- hasTokenProvider: capturedConfig?.gitHubTokenProvider !== undefined, -- }, { -- turns: [], -- clientToken: 'enterprise-resume-token', -- configToken: undefined, -- hasTokenProvider: false, -- }); -- } finally { +- afterTest(async () => { - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await authenticationService.authenticate({ +- resource: endpointService.getCopilotResource().resource, +- token: 'enterprise-resume-token', +- }, [agent]); +- const turns = await agent.chats.getMessages(chat, exactChatContext(session, chat, session)); +- +- assert.deepStrictEqual({ +- turns, +- clientToken: getCreatedClientOptions(agent).at(-1)?.gitHubToken, +- configToken: capturedConfig?.gitHubToken, +- hasTokenProvider: capturedConfig?.gitHubTokenProvider !== undefined, +- }, { +- turns: [], +- clientToken: 'enterprise-resume-token', +- configToken: undefined, +- hasTokenProvider: false, +- }); - }); - - test('materialization uses refreshable GitHub credentials when the lifetime is known', async () => { @@ -71606,43 +73701,40 @@ index f24a97a6296..00000000000 - return mockSession as unknown as CopilotSession; - }; - -- try { -- await agent.authenticate('https://api.github.com', 'initial-token', 3600); -- const result = await provisionSession(agent, { -- session: AgentSession.uri('copilotcli', 'refreshable-token-session'), -- workingDirectories: [URI.file('/workspace')], -- }); -- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, defaultChatUri(result.session), result.session)); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'initial-token', 3600); +- const result = await provisionSession(agent, { +- session: AgentSession.uri('copilotcli', 'refreshable-token-session'), +- workingDirectories: [URI.file('/workspace')], +- }); +- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, defaultChatUri(result.session), result.session)); - -- const provider = capturedConfig?.gitHubTokenProvider; -- assert.ok(provider); -- const refresh = provider({ host: 'github.com', sessionId: 'refreshable-token-session', reason: 'refresh' }); -- const authenticationRequired = agent.authenticationRequired.get(); -- const skuWhileRefreshing = agent.getTelemetryContext().copilotSku; -- await agent.authenticate('https://api.github.com', 'refreshed-token', 7200); -- await refresh; +- const provider = capturedConfig?.gitHubTokenProvider; +- assert.ok(provider); +- const refresh = provider({ host: 'github.com', sessionId: 'refreshable-token-session', reason: 'refresh' }); +- const authenticationRequired = agent.authenticationRequired.get(); +- const skuWhileRefreshing = agent.getTelemetryContext().copilotSku; +- await agent.authenticate('https://api.github.com', 'refreshed-token', 7200); +- await refresh; - -- assert.deepStrictEqual({ -- gitHubToken: capturedConfig?.gitHubToken, -- hasTokenProvider: capturedConfig?.gitHubTokenProvider !== undefined, -- authenticationRequired, -- skuWhileRefreshing, -- credentialUpdates: mockSession.gitHubCredentialUpdates, -- clientStops: client.stopCallCount, -- }, { -- gitHubToken: undefined, -- hasTokenProvider: true, -- authenticationRequired: { -- resource: GITHUB_COPILOT_PROTECTED_RESOURCE, -- reason: AuthRequiredReason.Expired, -- }, -- skuWhileRefreshing: 'sku-a', -- credentialUpdates: [], -- clientStops: 0, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- gitHubToken: capturedConfig?.gitHubToken, +- hasTokenProvider: capturedConfig?.gitHubTokenProvider !== undefined, +- authenticationRequired, +- skuWhileRefreshing, +- credentialUpdates: mockSession.gitHubCredentialUpdates, +- clientStops: client.stopCallCount, +- }, { +- gitHubToken: undefined, +- hasTokenProvider: true, +- authenticationRequired: { +- resource: GITHUB_COPILOT_PROTECTED_RESOURCE, +- reason: AuthRequiredReason.Expired, +- }, +- skuWhileRefreshing: 'sku-a', +- credentialUpdates: [], +- clientStops: 0, +- }); - }); - - test('failed materialization surfaces the create error', async () => { @@ -71650,17 +73742,14 @@ index f24a97a6296..00000000000 - const client = new TestCopilotClient([]); - client.createSession = async () => { throw new Error('create failed'); }; - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'gh-token-abc'); -- const result = await provisionSession(agent, { -- session: AgentSession.uri('copilotcli', 'failed-session-token'), -- workingDirectories: [URI.file('/workspace')], -- }); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'gh-token-abc'); +- const result = await provisionSession(agent, { +- session: AgentSession.uri('copilotcli', 'failed-session-token'), +- workingDirectories: [URI.file('/workspace')], +- }); - -- await assert.rejects(agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, defaultChatUri(result.session), result.session)), /create failed/); -- } finally { -- await disposeAgent(agent); -- } +- await assert.rejects(agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, defaultChatUri(result.session), result.session)), /create failed/); - }); - - test('materialization skips managed shell tools when root config disables the custom terminal tool', async () => { @@ -71673,29 +73762,25 @@ index f24a97a6296..00000000000 - }; - - const { agent, configurationService } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- configurationService.updateRootConfig({ [CopilotCliConfigKey.EnableCustomTerminalTool]: false }); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.EnableCustomTerminalTool]: false }); - -- const result = await provisionSession(agent, { -- session: AgentSession.uri('copilotcli', 'sdk-terminal-defaults'), -- workingDirectories: [URI.file('/workspace')], -- }); -- assert.strictEqual(result.provisional, true); +- const result = await provisionSession(agent, { +- session: AgentSession.uri('copilotcli', 'sdk-terminal-defaults'), +- workingDirectories: [URI.file('/workspace')], +- }); +- assert.strictEqual(result.provisional, true); - -- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, defaultChatUri(result.session), result.session)); +- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, defaultChatUri(result.session), result.session)); - -- assert.deepStrictEqual(capturedConfig?.tools?.map(tool => tool.name), []); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(capturedConfig?.tools?.map(tool => tool.name), []); - }); - }); - - suite('MCP configuration preparation', () => { - async function createHarness(options: { cold?: boolean; failSync?: boolean; syncGate?: Promise; telemetryService?: ITelemetryService; sdkSessionId?: string; workspaceless?: boolean } = {}) { -- const fileService = disposables.add(new FileService(new NullLogService())); -- disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); +- const fileService = createInMemoryFileService(disposables, Schemas.file); - const pluginDirectory = URI.file('/late-plugin'); - const healthyDirectory = URI.file('/healthy-plugin'); - await fileService.writeFile(URI.joinPath(pluginDirectory, '.mcp.json'), VSBuffer.fromString('{"mcpServers":{"late-server":{"command":"late-server"},"other-server":{"command":"other-server"}}}')); @@ -71823,18 +73908,15 @@ index f24a97a6296..00000000000 - appenders: [], - }, TEST_PRODUCT_SERVICE))); - const h = await createHarness({ telemetryService, workspaceless: true }); -- try { -- h.configurationService.updateRootConfig({ -- [AgentHostTelemetryLevelConfigKey]: telemetryLevelToAgentHostConfigValue(TelemetryLevel.NONE), -- }); -- await h.disconnectStarted.p; -- await h.agent.stopMcpServer(h.session, h.serverId); -- assert.strictEqual(chatEntriesBySdkId(h.agent).has('restored-sdk-session'), false); -- await h.agent.startMcpServer(h.session, h.serverId); -- assert.strictEqual(h.live().workspaceless, true); -- } finally { -- await disposeAgent(h.agent); -- } +- afterTest(() => disposeAgent(h.agent)); +- h.configurationService.updateRootConfig({ +- [AgentHostTelemetryLevelConfigKey]: telemetryLevelToAgentHostConfigValue(TelemetryLevel.NONE), +- }); +- await h.disconnectStarted.p; +- await h.agent.stopMcpServer(h.session, h.serverId); +- assert.strictEqual(chatEntriesBySdkId(h.agent).has('restored-sdk-session'), false); +- await h.agent.startMcpServer(h.session, h.serverId); +- assert.strictEqual(h.live().workspaceless, true); - }); - - for (const sdkSessionId of ['mcp-refresh-session', 'restored-sdk-session']) { @@ -71899,48 +73981,45 @@ index f24a97a6296..00000000000 - for (const ending of ['idle', 'stop', 'dispose'] as const) { - test(`defers refresh during an active turn and handles ${ending}`, async () => { - const h = await createHarness({ failSync: true }); -- try { -- const live = h.live(); -- live.resetTurnState('active-turn'); -- const waitStarted = new DeferredPromise(); -- const waitForIdle = live.waitForIdle; -- const waiting = stub(live, 'waitForIdle').callsFake(token => { -- const idle = waitForIdle.call(live, token); -- waitStarted.complete(); -- return idle; -- }); -- disposables.add(toDisposable(() => waiting.restore())); -- const source = disposables.add(new CancellationTokenSource()); -- const start = h.agent.startMcpServer(h.session, h.serverId, source.token); -- const outcome = start.then(() => undefined, error => error); -- await waitStarted.p; -- assert.ok(waiting.calledOnce, 'Start must wait outside the chat queue'); -- assert.strictEqual(h.oldRuntime.disconnectCalls, 0); -- await h.agent.chats.getMessages(h.chat, h.context); -- if (ending === 'stop') { -- source.cancel(); -- assert.ok(isCancellationError(await outcome), 'Cancellation must settle without waiting for the turn to end'); -- await h.agent.stopMcpServer(h.session, h.serverId); -- } -- if (ending === 'dispose') { -- live.dispose(); -- assert.match(String(await outcome), /runtime is no longer available/); -- } else { -- h.oldRuntime.emit({ type: 'session.idle', data: {} } as SessionEventPayload<'session.idle'>); -- if (ending === 'idle') { -- assert.strictEqual(await outcome, undefined); -- } +- afterTest(() => disposeAgent(h.agent)); +- const live = h.live(); +- live.resetTurnState('active-turn'); +- const waitStarted = new DeferredPromise(); +- const waitForIdle = live.waitForIdle; +- const waiting = stub(live, 'waitForIdle').callsFake(token => { +- const idle = waitForIdle.call(live, token); +- waitStarted.complete(); +- return idle; +- }); +- disposables.add(toDisposable(() => waiting.restore())); +- const source = disposables.add(new CancellationTokenSource()); +- const start = h.agent.startMcpServer(h.session, h.serverId, source.token); +- const outcome = start.then(() => undefined, error => error); +- await waitStarted.p; +- assert.ok(waiting.calledOnce, 'Start must wait outside the chat queue'); +- assert.strictEqual(h.oldRuntime.disconnectCalls, 0); +- await h.agent.chats.getMessages(h.chat, h.context); +- if (ending === 'stop') { +- source.cancel(); +- assert.ok(isCancellationError(await outcome), 'Cancellation must settle without waiting for the turn to end'); +- await h.agent.stopMcpServer(h.session, h.serverId); +- } +- if (ending === 'dispose') { +- live.dispose(); +- assert.match(String(await outcome), /runtime is no longer available/); +- } else { +- h.oldRuntime.emit({ type: 'session.idle', data: {} } as SessionEventPayload<'session.idle'>); +- if (ending === 'idle') { +- assert.strictEqual(await outcome, undefined); - } -- assert.deepStrictEqual({ -- resumes: h.resumedIds.length, starts: h.newRuntime.mcpStartCalls, retries: h.syncCalls, -- }, { -- resumes: ending === 'idle' ? 2 : 1, -- starts: ending === 'idle' ? ['late-server'] : [], -- retries: [0, 1].map(() => [h.pluginDirectory.toString(), h.healthyDirectory.toString()]), -- }); -- } finally { -- await disposeAgent(h.agent); - } +- assert.deepStrictEqual({ +- resumes: h.resumedIds.length, starts: h.newRuntime.mcpStartCalls, retries: h.syncCalls, +- }, { +- resumes: ending === 'idle' ? 2 : 1, +- starts: ending === 'idle' ? ['late-server'] : [], +- retries: [0, 1].map(() => [h.pluginDirectory.toString(), h.healthyDirectory.toString()]), +- }); - }); - } - @@ -72042,20 +74121,17 @@ index f24a97a6296..00000000000 - for (const cold of [true, false]) { - test(`Send retries failed sync without dropping healthy plugins (${cold ? 'cold' : 'live'})`, async () => { - const h = await createHarness({ cold, failSync: true }); -- try { -- await h.agent.chats.sendMessage(h.chat, 'hello', undefined, undefined, undefined, undefined, h.context); -- assert.deepStrictEqual({ -- syncs: h.syncCalls, -- plugins: (cold ? h.createConfig() : h.resumeConfigs[1])?.pluginDirectories, -- turns: h.newRuntime.sendCalls, -- }, { -- syncs: [0, 1].map(() => [h.pluginDirectory.toString(), h.healthyDirectory.toString()]), -- plugins: [h.pluginDirectory.fsPath, h.healthyDirectory.fsPath], -- turns: 1, -- }); -- } finally { -- await disposeAgent(h.agent); -- } +- afterTest(() => disposeAgent(h.agent)); +- await h.agent.chats.sendMessage(h.chat, 'hello', undefined, undefined, undefined, undefined, h.context); +- assert.deepStrictEqual({ +- syncs: h.syncCalls, +- plugins: (cold ? h.createConfig() : h.resumeConfigs[1])?.pluginDirectories, +- turns: h.newRuntime.sendCalls, +- }, { +- syncs: [0, 1].map(() => [h.pluginDirectory.toString(), h.healthyDirectory.toString()]), +- plugins: [h.pluginDirectory.fsPath, h.healthyDirectory.fsPath], +- turns: 1, +- }); - }); - } - @@ -72516,11 +74592,8 @@ index f24a97a6296..00000000000 - - test('Start rejects before the session runtime exists', async () => { - const agent = createTestAgent(disposables); -- try { -- await assert.rejects(agent.startMcpServer(AgentSession.uri('copilotcli', 'missing'), 'server'), /before the session runtime has been created/); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await assert.rejects(agent.startMcpServer(AgentSession.uri('copilotcli', 'missing'), 'server'), /before the session runtime has been created/); - }); - }); - @@ -72570,6 +74643,7 @@ index f24a97a6296..00000000000 - _serviceBrand: undefined, - onDidChange: Event.None, - initializeSession: async () => { }, +- adoptMcpServerOwnership: () => { }, - getWorkingDirectoryState: () => ({ kind: 'pending' }), - resolve: () => ({ kind: 'pending', reason: 'session' }), - applyClientGlobalEnablement: () => ({ kind: 'pending', reason: 'session' }), @@ -72584,106 +74658,97 @@ index f24a97a6296..00000000000 - - test('constructs and serves session-addressed calls without the state manager registered', async () => { - const { agent, stateManager } = createSeamOnlyAgent(); -- try { -- const session = AgentSession.uri('copilotcli', 'seam-only-session'); -- const mcpCalls: string[] = []; -- setDefaultSessionStub(agent, AgentSession.id(session), { -- topLevelMcpCustomizations: () => [], -- startMcpServer: async (id: string) => { mcpCalls.push(`start:${id}`); }, -- stopMcpServer: async (id: string) => { mcpCalls.push(`stop:${id}`); }, -- cancelMcpServerAuthentication: () => { }, -- handleMcpRequest: async (serverName: string, method: string) => `${serverName}/${method}`, -- dispose: () => { }, -- }); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'seam-only-session'); +- const mcpCalls: string[] = []; +- setDefaultSessionStub(agent, AgentSession.id(session), { +- topLevelMcpCustomizations: () => [], +- startMcpServer: async (id: string) => { mcpCalls.push(`start:${id}`); }, +- stopMcpServer: async (id: string) => { mcpCalls.push(`stop:${id}`); }, +- cancelMcpServerAuthentication: () => { }, +- handleMcpRequest: async (serverName: string, method: string) => `${serverName}/${method}`, +- dispose: () => { }, +- }); - -- await agent.startMcpServer(session, 'mcp-1'); -- await agent.stopMcpServer(session, 'mcp-1'); +- await agent.startMcpServer(session, 'mcp-1'); +- await agent.stopMcpServer(session, 'mcp-1'); - -- assert.deepStrictEqual({ -- mcpCalls, -- mcpRequest: await agent.handleMcpRequest(defaultChatUri(session), 'srv', 'tools/list', undefined), -- customizations: await getDefaultChatCustomizations(agent, session), -- // Constructing the agent never touched the state manager. -- sessions: stateManager.getSessionUris().length, -- }, { -- mcpCalls: ['start:mcp-1', 'stop:mcp-1'], -- mcpRequest: 'srv/tools/list', -- customizations: [buildCopilotBuiltinAgentsContainer()], -- sessions: 0, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- mcpCalls, +- mcpRequest: await agent.handleMcpRequest(defaultChatUri(session), 'srv', 'tools/list', undefined), +- customizations: await getDefaultChatCustomizations(agent, session), +- // Constructing the agent never touched the state manager. +- sessions: stateManager.getSessionUris().length, +- }, { +- mcpCalls: ['start:mcp-1', 'stop:mcp-1'], +- mcpRequest: 'srv/tools/list', +- customizations: [buildCopilotBuiltinAgentsContainer()], +- sessions: 0, +- }); - }); - - test('MCP requests route to the exact host chat instead of the owning session', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'scope-resolved-session'); -- // The Agent Host session id, chat id, and SDK id intentionally differ. -- const boundChat = URI.parse(buildChatUri(session, 'host-picked')); -- setLiveChatStub(agent, 'unrelated-sdk-id', { -- sessionId: 'unrelated-sdk-id', -- sessionUri: session, -- resourceUri: session, -- chatChannelUri: boundChat, -- topLevelMcpCustomizations: () => [], -- startMcpServer: async () => { }, -- handleMcpRequest: async (serverName: string, method: string) => `${serverName}/${method}`, -- dispose: () => { }, -- }, boundChat); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'scope-resolved-session'); +- // The Agent Host session id, chat id, and SDK id intentionally differ. +- const boundChat = URI.parse(buildChatUri(session, 'host-picked')); +- setLiveChatStub(agent, 'unrelated-sdk-id', { +- sessionId: 'unrelated-sdk-id', +- sessionUri: session, +- resourceUri: session, +- chatChannelUri: boundChat, +- topLevelMcpCustomizations: () => [], +- startMcpServer: async () => { }, +- handleMcpRequest: async (serverName: string, method: string) => `${serverName}/${method}`, +- dispose: () => { }, +- }, boundChat); - -- const staleChat = URI.parse(buildChatUri(session, 'stale')); -- chatBackings(agent).set(staleChat.toString(), { sdkSessionId: 'unrelated-sdk-id' }); +- const staleChat = URI.parse(buildChatUri(session, 'stale')); +- chatBackings(agent).set(staleChat.toString(), { sdkSessionId: 'unrelated-sdk-id' }); - -- assert.deepStrictEqual({ -- result: await agent.handleMcpRequest(boundChat, 'srv', 'tools/call', undefined), -- staleRejected: await agent.handleMcpRequest(staleChat, 'srv', 'tools/call', undefined).then( -- () => false, -- error => error instanceof Error && error.message.startsWith('Method not found: no active chat'), -- ), -- }, { -- result: 'srv/tools/call', -- staleRejected: true, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- result: await agent.handleMcpRequest(boundChat, 'srv', 'tools/call', undefined), +- staleRejected: await agent.handleMcpRequest(staleChat, 'srv', 'tools/call', undefined).then( +- () => false, +- error => error instanceof Error && error.message.startsWith('Method not found: no active chat'), +- ), +- }, { +- result: 'srv/tools/call', +- staleRejected: true, +- }); - }); - - test('MCP requests resume a known chat whose SDK session is not attached', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'cold-mcp-session'); -- const chat = URI.parse('ahp-chat:/opaque-mcp-chat'); -- await agent.materializeChat(chat, exactChatContext(session, chat, session), JSON.stringify({ sdkSessionId: AgentSession.id(session) })); -- const resumes: Array<{ id: string; chat: string }> = []; -- const calls: Array<{ serverName: string; method: string; params: Record | undefined }> = []; -- const internals = agent as unknown as { -- _resumeSession(id: string, chat: URI): Promise; -- }; -- internals._resumeSession = async (id, requestedChat) => { -- resumes.push({ id, chat: requestedChat.toString() }); -- setDefaultSessionStub(agent, id, { -- handleMcpRequest: async (serverName: string, method: string, params: Record | undefined) => { -- calls.push({ serverName, method, params }); -- return { contents: [{ uri: 'demo:/resource', text: 'restored' }] }; -- }, -- dispose: () => { }, -- }, requestedChat); -- return chatEntriesBySdkId(agent).get(id)!.chatSession; -- }; +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'cold-mcp-session'); +- const chat = URI.parse('ahp-chat:/opaque-mcp-chat'); +- await agent.materializeChat(chat, exactChatContext(session, chat, session), JSON.stringify({ sdkSessionId: AgentSession.id(session) })); +- const resumes: Array<{ id: string; chat: string }> = []; +- const calls: Array<{ serverName: string; method: string; params: Record | undefined }> = []; +- const internals = agent as unknown as { +- _resumeSession(id: string, chat: URI): Promise; +- }; +- internals._resumeSession = async (id, requestedChat) => { +- resumes.push({ id, chat: requestedChat.toString() }); +- setDefaultSessionStub(agent, id, { +- handleMcpRequest: async (serverName: string, method: string, params: Record | undefined) => { +- calls.push({ serverName, method, params }); +- return { contents: [{ uri: 'demo:/resource', text: 'restored' }] }; +- }, +- dispose: () => { }, +- }, requestedChat); +- return chatEntriesBySdkId(agent).get(id)!.chatSession; +- }; - -- const result = await agent.handleMcpRequest(chat, 'srv', 'resources/read', { uri: 'demo:/resource' }); +- const result = await agent.handleMcpRequest(chat, 'srv', 'resources/read', { uri: 'demo:/resource' }); - -- assert.deepStrictEqual({ result, resumes, calls }, { -- result: { contents: [{ uri: 'demo:/resource', text: 'restored' }] }, -- resumes: [{ id: AgentSession.id(session), chat: chat.toString() }], -- calls: [{ serverName: 'srv', method: 'resources/read', params: { uri: 'demo:/resource' } }], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ result, resumes, calls }, { +- result: { contents: [{ uri: 'demo:/resource', text: 'restored' }] }, +- resumes: [{ id: AgentSession.id(session), chat: chat.toString() }], +- calls: [{ serverName: 'srv', method: 'resources/read', params: { uri: 'demo:/resource' } }], +- }); - }); - - test('MCP requests do not materialize a provisional chat', async () => { @@ -72717,62 +74782,53 @@ index f24a97a6296..00000000000 - dependencies: [{ todoId: 'second', dependsOn: 'first' }], - }); - const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession }); -- try { -- await created.session.initializeSession(); -- setLiveChatStub(agent, created.session.sessionId, created.session, created.session.chatChannelUri); -- assert.deepStrictEqual(await agent.getSessionPlan(created.session.resourceUri), { -- plan: { exists: true, content: '# Plan', path: '/plan.md' }, -- todos: [ -- { id: 'first', title: 'First', status: 'done', description: 'Prepare' }, -- { id: 'second', title: null, status: null, description: null }, -- ], -- dependencies: [{ todoId: 'second', dependsOn: 'first' }], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await created.session.initializeSession(); +- setLiveChatStub(agent, created.session.sessionId, created.session, created.session.chatChannelUri); +- assert.deepStrictEqual(await agent.getSessionPlan(created.session.resourceUri), { +- plan: { exists: true, content: '# Plan', path: '/plan.md' }, +- todos: [ +- { id: 'first', title: 'First', status: 'done', description: 'Prepare' }, +- { id: 'second', title: null, status: null, description: null }, +- ], +- dependencies: [{ todoId: 'second', dependsOn: 'first' }], +- }); - }); - - test('Copilot host approval toggle applies the runtime mode before publishing the selection', async () => { - const { agent, instantiationService, configurationService, stateManager } = createTestAgentContext(disposables, { sessionDataService: disposables.add(new TestSessionDataService()) }); - const mockSession = new MockCopilotSession(); - const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession }); -- try { -- const now = new Date().toISOString(); -- stateManager.createSession({ resource: created.session.resourceUri.toString(), provider: 'copilotcli', title: 'Test', status: SessionStatus.Idle, createdAt: now, modifiedAt: now, defaultChat: buildDefaultChatUri(created.session.resourceUri.toString()) }); -- stateManager.setSessionConfig(created.session.resourceUri.toString(), { schema: { type: 'object', properties: {} }, values: { autoApprove: 'default' } }); -- await created.session.initializeSession(); -- setLiveChatStub(agent, created.session.sessionId, created.session, created.session.chatChannelUri); -- const calls: string[] = []; -- mockSession.rpc.permissions.setMode = async ({ mode }) => { calls.push(mode); return { success: true, mode }; }; -- await agent.setSessionApproveAll(created.session.resourceUri, true); -- const enabled = configurationService.getSessionConfigValues(created.session.resourceUri.toString())?.autoApprove; -- await agent.setSessionApproveAll(created.session.resourceUri, false); -- assert.deepStrictEqual({ calls, enabled, disabled: configurationService.getSessionConfigValues(created.session.resourceUri.toString())?.autoApprove }, { -- calls: ['allow-all', 'manual'], enabled: 'autoApprove', disabled: 'default', -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const now = new Date().toISOString(); +- stateManager.createSession({ resource: created.session.resourceUri.toString(), provider: 'copilotcli', title: 'Test', status: SessionStatus.Idle, createdAt: now, modifiedAt: now, defaultChat: buildDefaultChatUri(created.session.resourceUri.toString()) }); +- stateManager.setSessionConfig(created.session.resourceUri.toString(), { schema: { type: 'object', properties: {} }, values: { autoApprove: 'default' } }); +- await created.session.initializeSession(); +- setLiveChatStub(agent, created.session.sessionId, created.session, created.session.chatChannelUri); +- const calls: string[] = []; +- mockSession.rpc.permissions.setMode = async ({ mode }) => { calls.push(mode); return { success: true, mode }; }; +- await agent.setSessionApproveAll(created.session.resourceUri, true); +- const enabled = configurationService.getSessionConfigValues(created.session.resourceUri.toString())?.autoApprove; +- await agent.setSessionApproveAll(created.session.resourceUri, false); +- assert.deepStrictEqual({ calls, enabled, disabled: configurationService.getSessionConfigValues(created.session.resourceUri.toString())?.autoApprove }, { +- calls: ['allow-all', 'manual'], enabled: 'autoApprove', disabled: 'default', +- }); - }); - - test('Copilot host approval refusal does not publish an unapplied allow-all selection', async () => { - const { agent, instantiationService, configurationService, stateManager } = createTestAgentContext(disposables, { sessionDataService: disposables.add(new TestSessionDataService()) }); - const mockSession = new MockCopilotSession(); - const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession }); -- try { -- const now = new Date().toISOString(); -- stateManager.createSession({ resource: created.session.resourceUri.toString(), provider: 'copilotcli', title: 'Test', status: SessionStatus.Idle, createdAt: now, modifiedAt: now, defaultChat: buildDefaultChatUri(created.session.resourceUri.toString()) }); -- stateManager.setSessionConfig(created.session.resourceUri.toString(), { schema: { type: 'object', properties: {} }, values: { autoApprove: 'default' } }); -- await created.session.initializeSession(); -- setLiveChatStub(agent, created.session.sessionId, created.session, created.session.chatChannelUri); -- configurationService.updateSessionConfig(created.session.resourceUri.toString(), { autoApprove: 'default' }); -- mockSession.rpc.permissions.setMode = async ({ mode }) => ({ success: false, mode }); -- await assert.rejects(agent.setSessionApproveAll(created.session.resourceUri, true), /SDK rejected permission mode/); -- assert.strictEqual(configurationService.getSessionConfigValues(created.session.resourceUri.toString())?.autoApprove, 'default'); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const now = new Date().toISOString(); +- stateManager.createSession({ resource: created.session.resourceUri.toString(), provider: 'copilotcli', title: 'Test', status: SessionStatus.Idle, createdAt: now, modifiedAt: now, defaultChat: buildDefaultChatUri(created.session.resourceUri.toString()) }); +- stateManager.setSessionConfig(created.session.resourceUri.toString(), { schema: { type: 'object', properties: {} }, values: { autoApprove: 'default' } }); +- await created.session.initializeSession(); +- setLiveChatStub(agent, created.session.sessionId, created.session, created.session.chatChannelUri); +- configurationService.updateSessionConfig(created.session.resourceUri.toString(), { autoApprove: 'default' }); +- mockSession.rpc.permissions.setMode = async ({ mode }) => ({ success: false, mode }); +- await assert.rejects(agent.setSessionApproveAll(created.session.resourceUri, true), /SDK rejected permission mode/); +- assert.strictEqual(configurationService.getSessionConfigValues(created.session.resourceUri.toString())?.autoApprove, 'default'); - }); - - test('Copilot host approval toggle honors native mode policy replacing the legacy restriction', async () => { @@ -72787,65 +74843,53 @@ index f24a97a6296..00000000000 - }); - const mockSession = new MockCopilotSession(); - const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, managedSettings }); -- try { -- const now = new Date().toISOString(); -- stateManager.createSession({ resource: created.session.resourceUri.toString(), provider: 'copilotcli', title: 'Test', status: SessionStatus.Idle, createdAt: now, modifiedAt: now, defaultChat: buildDefaultChatUri(created.session.resourceUri.toString()) }); -- stateManager.setSessionConfig(created.session.resourceUri.toString(), { schema: { type: 'object', properties: {} }, values: { autoApprove: 'default' } }); -- await created.session.initializeSession(); -- setLiveChatStub(agent, created.session.sessionId, created.session, created.session.chatChannelUri); -- const calls: string[] = []; -- mockSession.rpc.permissions.setMode = async ({ mode }) => { calls.push(mode); return { success: true, mode }; }; -- await agent.setSessionApproveAll(created.session.resourceUri, true); -- assert.deepStrictEqual({ calls, selection: configurationService.getSessionConfigValues(created.session.resourceUri.toString())?.autoApprove }, { -- calls: ['allow-all'], selection: 'autoApprove', -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const now = new Date().toISOString(); +- stateManager.createSession({ resource: created.session.resourceUri.toString(), provider: 'copilotcli', title: 'Test', status: SessionStatus.Idle, createdAt: now, modifiedAt: now, defaultChat: buildDefaultChatUri(created.session.resourceUri.toString()) }); +- stateManager.setSessionConfig(created.session.resourceUri.toString(), { schema: { type: 'object', properties: {} }, values: { autoApprove: 'default' } }); +- await created.session.initializeSession(); +- setLiveChatStub(agent, created.session.sessionId, created.session, created.session.chatChannelUri); +- const calls: string[] = []; +- mockSession.rpc.permissions.setMode = async ({ mode }) => { calls.push(mode); return { success: true, mode }; }; +- await agent.setSessionApproveAll(created.session.resourceUri, true); +- assert.deepStrictEqual({ calls, selection: configurationService.getSessionConfigValues(created.session.resourceUri.toString())?.autoApprove }, { +- calls: ['allow-all'], selection: 'autoApprove', +- }); - }); - - test('Copilot host approval toggles reject non-live runtimes without publishing a selection', async () => { - const { agent, configurationService, stateManager } = createTestAgentContext(disposables); -- try { -- const session = URI.parse('ahp-session:/deferred-copilot-session'); -- const now = new Date().toISOString(); -- stateManager.createSession({ resource: session.toString(), provider: 'copilotcli', title: 'Test', status: SessionStatus.Idle, createdAt: now, modifiedAt: now, defaultChat: buildDefaultChatUri(session.toString()) }); -- stateManager.setSessionConfig(session.toString(), { schema: { type: 'object', properties: {} }, values: { autoApprove: 'default' } }); -- const changes: Record[] = []; -- disposables.add(configurationService.onDidSessionConfigChange(event => changes.push(event.config))); -- for (const enabled of [true, false]) { -- await assert.rejects(agent.setSessionApproveAll(session, enabled), /requires a live Copilot session/); -- } -- assert.deepStrictEqual({ changes, selection: configurationService.getSessionConfigValues(session.toString())?.autoApprove }, { -- changes: [], selection: 'default', -- }); -- } finally { -- await disposeAgent(agent); +- afterTest(() => disposeAgent(agent)); +- const session = URI.parse('ahp-session:/deferred-copilot-session'); +- const now = new Date().toISOString(); +- stateManager.createSession({ resource: session.toString(), provider: 'copilotcli', title: 'Test', status: SessionStatus.Idle, createdAt: now, modifiedAt: now, defaultChat: buildDefaultChatUri(session.toString()) }); +- stateManager.setSessionConfig(session.toString(), { schema: { type: 'object', properties: {} }, values: { autoApprove: 'default' } }); +- const changes: Record[] = []; +- disposables.add(configurationService.onDidSessionConfigChange(event => changes.push(event.config))); +- for (const enabled of [true, false]) { +- await assert.rejects(agent.setSessionApproveAll(session, enabled), /requires a live Copilot session/); - } +- assert.deepStrictEqual({ changes, selection: configurationService.getSessionConfigValues(session.toString())?.autoApprove }, { +- changes: [], selection: 'default', +- }); - }); - - test('Copilot host approval toggle respects the host policy restriction', async () => { - const { agent, configurationService } = createTestAgentContext(disposables, { rootConfig: { [AgentHostAutoApprovePolicyRestrictedConfigKey]: true } }); -- try { -- const session = URI.parse('ahp-session:/opaque-provider-session'); -- await assert.rejects(agent.setSessionApproveAll(session, true), /restricted by policy/); -- assert.strictEqual(configurationService.getSessionConfigValues(session.toString())?.autoApprove, undefined); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const session = URI.parse('ahp-session:/opaque-provider-session'); +- await assert.rejects(agent.setSessionApproveAll(session, true), /restricted by policy/); +- assert.strictEqual(configurationService.getSessionConfigValues(session.toString())?.autoApprove, undefined); - }); - - test('handleMcpRequest rejects an unknown chat', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'no-live-chat'); -- await assert.rejects( -- () => agent.handleMcpRequest(defaultChatUri(session), 'srv', 'tools/list', undefined), -- /Method not found: no active chat/, -- ); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'no-live-chat'); +- await assert.rejects( +- () => agent.handleMcpRequest(defaultChatUri(session), 'srv', 'tools/list', undefined), +- /Method not found: no active chat/, +- ); - }); - - test('emits the session-title OTel span from the title signal, filtered to this provider', async () => { @@ -72888,115 +74932,103 @@ index f24a97a6296..00000000000 - const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); - services.set(IInstantiationService, instantiationService); - const agent = instantiationService.createInstance(CopilotAgent); -- try { -- const copilotSession = AgentSession.uri('copilotcli', 'titled-session'); -- const claudeSession = AgentSession.uri('claude', 'other-provider-session'); -- const now = new Date().toISOString(); -- for (const [session, provider] of [[copilotSession, 'copilotcli'], [claudeSession, 'claude']] as const) { -- stateManager.createSession({ resource: session.toString(), provider, title: 'Test', status: SessionStatus.Idle, createdAt: now, modifiedAt: now, defaultChat: buildDefaultChatUri(session.toString()) }); -- stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionTitleChanged, title: `Renamed ${provider}` }); -- } -- -- assert.deepStrictEqual(otel.titleCalls, [{ -- conversationId: AgentSession.id(copilotSession), -- sessionUri: copilotSession.toString(), -- title: 'Renamed copilotcli', -- }]); -- } finally { -- await disposeAgent(agent); +- afterTest(() => disposeAgent(agent)); +- const copilotSession = AgentSession.uri('copilotcli', 'titled-session'); +- const claudeSession = AgentSession.uri('claude', 'other-provider-session'); +- const now = new Date().toISOString(); +- for (const [session, provider] of [[copilotSession, 'copilotcli'], [claudeSession, 'claude']] as const) { +- stateManager.createSession({ resource: session.toString(), provider, title: 'Test', status: SessionStatus.Idle, createdAt: now, modifiedAt: now, defaultChat: buildDefaultChatUri(session.toString()) }); +- stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionTitleChanged, title: `Renamed ${provider}` }); - } +- +- assert.deepStrictEqual(otel.titleCalls, [{ +- conversationId: AgentSession.id(copilotSession), +- sessionUri: copilotSession.toString(), +- title: 'Renamed copilotcli', +- }]); - }); - - test('emits the session-title span under the SDK conversation id when it diverges from the AH session id', async () => { - const otel = new RecordingTitleOTelService(); - const { agent, stateManager } = createTestAgentContext(disposables, { otelService: otel }); -- try { -- const session = AgentSession.uri('copilotcli', 'ah-session-id'); -- // Model a fork/import whose live SDK id differs from its AH session id. -- setLiveChatStub(agent, 'sdk-conversation-id', { sessionId: 'sdk-conversation-id', resourceUri: session }); -- const now = new Date().toISOString(); -- stateManager.createSession({ resource: session.toString(), provider: 'copilotcli', title: 'Test', status: SessionStatus.Idle, createdAt: now, modifiedAt: now, defaultChat: buildDefaultChatUri(session.toString()) }); -- stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionTitleChanged, title: 'Renamed' }); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'ah-session-id'); +- // Model a fork/import whose live SDK id differs from its AH session id. +- setLiveChatStub(agent, 'sdk-conversation-id', { sessionId: 'sdk-conversation-id', resourceUri: session }); +- const now = new Date().toISOString(); +- stateManager.createSession({ resource: session.toString(), provider: 'copilotcli', title: 'Test', status: SessionStatus.Idle, createdAt: now, modifiedAt: now, defaultChat: buildDefaultChatUri(session.toString()) }); +- stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionTitleChanged, title: 'Renamed' }); - -- assert.deepStrictEqual(otel.titleCalls, [{ -- conversationId: 'sdk-conversation-id', -- sessionUri: session.toString(), -- title: 'Renamed', -- }]); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(otel.titleCalls, [{ +- conversationId: 'sdk-conversation-id', +- sessionUri: session.toString(), +- title: 'Renamed', +- }]); - }); - - test('emits the session-title span under the SDK conversation id for a divergent session after a cold restart', async () => { - const otel = new RecordingTitleOTelService(); - const { agent, stateManager } = createTestAgentContext(disposables, { otelService: otel }); -- try { -- const session = AgentSession.uri('copilotcli', 'ah-session-id'); -- // Cold restart: the divergent SDK id survives only in the persisted -- // default-chat backing. The session has not been resumed into a live -- // entry yet, so a rename in this window must still correlate on the -- // SDK id, recovered from the backing rather than a live session. -- await agent.materializeChat(defaultChatUri(session), { sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(session), resource: session }, JSON.stringify({ sdkSessionId: 'sdk-conversation-id' })); -- const now = new Date().toISOString(); -- stateManager.createSession({ resource: session.toString(), provider: 'copilotcli', title: 'Test', status: SessionStatus.Idle, createdAt: now, modifiedAt: now, defaultChat: buildDefaultChatUri(session.toString()) }); -- stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionTitleChanged, title: 'Renamed' }); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'ah-session-id'); +- // Cold restart: the divergent SDK id survives only in the persisted +- // default-chat backing. The session has not been resumed into a live +- // entry yet, so a rename in this window must still correlate on the +- // SDK id, recovered from the backing rather than a live session. +- await agent.materializeChat(defaultChatUri(session), { sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(session), resource: session }, JSON.stringify({ sdkSessionId: 'sdk-conversation-id' })); +- const now = new Date().toISOString(); +- stateManager.createSession({ resource: session.toString(), provider: 'copilotcli', title: 'Test', status: SessionStatus.Idle, createdAt: now, modifiedAt: now, defaultChat: buildDefaultChatUri(session.toString()) }); +- stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionTitleChanged, title: 'Renamed' }); - -- assert.deepStrictEqual(otel.titleCalls, [{ -- conversationId: 'sdk-conversation-id', -- sessionUri: session.toString(), -- title: 'Renamed', -- }]); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(otel.titleCalls, [{ +- conversationId: 'sdk-conversation-id', +- sessionUri: session.toString(), +- title: 'Renamed', +- }]); - }); - - test('retains the host customization snapshot supplied at each boundary, and never clears it on "no snapshot yet"', async () => { - const { agent, stateManager } = createTestAgentContext(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'retained-snapshot-session'); -- const chat = defaultChatUri(session); -- const plugin: Customization = { -- type: CustomizationType.Plugin, -- id: 'file:///plugin-a', -- uri: 'file:///plugin-a', -- name: 'Plugin A', -- enablement: [{ kind: CustomizationEnablementKind.Global, enabled: false }], -- }; -- const internals = agent as unknown as { _retainedHostCustomizations(session: URI): readonly Customization[] }; -- -- // No boundary has carried a snapshot yet. -- const beforeAnyBoundary = internals._retainedHostCustomizations(session); -- // An active-client fan-out carries one... -- agent.getOrCreateActiveClient(chat, session, { clientId: 'client-1' }, [plugin]); -- const afterActiveClient = internals._retainedHostCustomizations(session).map(c => c.id); -- // ...and `undefined` ("no snapshot yet") never clears it. -- agent.getOrCreateActiveClient(chat, session, { clientId: 'client-1' }, undefined); -- const afterUndefined = internals._retainedHostCustomizations(session).map(c => c.id); -- // An addressed chat operation's context refreshes it. -- await agent.chats.getMessages(chat, { sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(session), resource: session, customizations: [{ ...plugin, enablement: [{ kind: CustomizationEnablementKind.Global, enabled: true }] }] }); -- const afterChatContext = internals._retainedHostCustomizations(session).map(c => -- c.type === CustomizationType.Plugin || c.type === CustomizationType.McpServer ? isCustomizationEnabled(c) : c.enabled -- ); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'retained-snapshot-session'); +- const chat = defaultChatUri(session); +- const plugin: Customization = { +- type: CustomizationType.Plugin, +- id: 'file:///plugin-a', +- uri: 'file:///plugin-a', +- name: 'Plugin A', +- enablement: [{ kind: CustomizationEnablementKind.Global, enabled: false }], +- }; +- const internals = agent as unknown as { _retainedHostCustomizations(session: URI): readonly Customization[] }; +- +- // No boundary has carried a snapshot yet. +- const beforeAnyBoundary = internals._retainedHostCustomizations(session); +- // An active-client fan-out carries one... +- agent.getOrCreateActiveClient(chat, session, { clientId: 'client-1' }, [plugin]); +- const afterActiveClient = internals._retainedHostCustomizations(session).map(c => c.id); +- // ...and `undefined` ("no snapshot yet") never clears it. +- agent.getOrCreateActiveClient(chat, session, { clientId: 'client-1' }, undefined); +- const afterUndefined = internals._retainedHostCustomizations(session).map(c => c.id); +- // An addressed chat operation's context refreshes it. +- await agent.chats.getMessages(chat, { sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(session), resource: session, customizations: [{ ...plugin, enablement: [{ kind: CustomizationEnablementKind.Global, enabled: true }] }] }); +- const afterChatContext = internals._retainedHostCustomizations(session).map(c => +- c.type === CustomizationType.Plugin || c.type === CustomizationType.McpServer ? isCustomizationEnabled(c) : c.enabled +- ); - -- assert.deepStrictEqual({ -- beforeAnyBoundary, -- afterActiveClient, -- afterUndefined, -- afterChatContext, -- // The provider never read the snapshot out of host state. -- hostState: stateManager.getSessionState(session.toString())?.customizations, -- }, { -- beforeAnyBoundary: [], -- afterActiveClient: ['file:///plugin-a'], -- afterUndefined: ['file:///plugin-a'], -- afterChatContext: [true], -- hostState: undefined, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- beforeAnyBoundary, +- afterActiveClient, +- afterUndefined, +- afterChatContext, +- // The provider never read the snapshot out of host state. +- hostState: stateManager.getSessionState(session.toString())?.customizations, +- }, { +- beforeAnyBoundary: [], +- afterActiveClient: ['file:///plugin-a'], +- afterUndefined: ['file:///plugin-a'], +- afterChatContext: [true], +- hostState: undefined, +- }); - }); - - test('reads and writes prompt-cache metadata through IAgentHostPromptCache', async () => { @@ -73055,30 +75087,24 @@ index f24a97a6296..00000000000 - - test('routes the exact default chat to its runtime', async () => { - const agent = createTestAgent(disposables); -- try { -- const sessionUri = AgentSession.uri('copilotcli', 'session-top'); -- const defaultChat = URI.parse(buildDefaultChatUri(sessionUri)); -- const { calls } = installStubSession(agent, AgentSession.id(sessionUri)); +- afterTest(() => disposeAgent(agent)); +- const sessionUri = AgentSession.uri('copilotcli', 'session-top'); +- const defaultChat = URI.parse(buildDefaultChatUri(sessionUri)); +- const { calls } = installStubSession(agent, AgentSession.id(sessionUri)); - -- const result: ToolCallResult = { success: true, pastTenseMessage: 'did it' }; -- agent.onClientToolCallComplete(defaultChat, 'tc-top', result); +- const result: ToolCallResult = { success: true, pastTenseMessage: 'did it' }; +- agent.onClientToolCallComplete(defaultChat, 'tc-top', result); - -- assert.deepStrictEqual(calls, [{ toolCallId: 'tc-top', result }]); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(calls, [{ toolCallId: 'tc-top', result }]); - }); - - test('is a no-op when no session entry exists for the resolved id', async () => { - const agent = createTestAgent(disposables); -- try { -- const sessionUri = AgentSession.uri('copilotcli', 'session-missing'); -- const defaultChat = URI.parse(buildDefaultChatUri(sessionUri)); -- // No stub installed — the call should be silently ignored. -- agent.onClientToolCallComplete(defaultChat, 'tc-x', { success: true, pastTenseMessage: 'noop' }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const sessionUri = AgentSession.uri('copilotcli', 'session-missing'); +- const defaultChat = URI.parse(buildDefaultChatUri(sessionUri)); +- // No stub installed — the call should be silently ignored. +- agent.onClientToolCallComplete(defaultChat, 'tc-x', { success: true, pastTenseMessage: 'noop' }); - }); - - test('routes an exact chat URI to its chat-session entry', async () => { @@ -73086,38 +75112,32 @@ index f24a97a6296..00000000000 - // carry both the owning session URI and the exact chat URI. The agent - // must route by that URI to the addressed chat leaf. - const agent = createTestAgent(disposables); -- try { -- const sessionUri = AgentSession.uri('copilotcli', 'session-with-peer'); -- const chatUri = URI.parse(buildChatUri(sessionUri, 'peer-1')); -- const calls: { toolCallId: string; result: ToolCallResult }[] = []; -- const stub = { -- handleClientToolCallComplete(toolCallId: string, result: ToolCallResult) { calls.push({ toolCallId, result }); }, -- dispose() { }, -- }; -- setPeerChatStub(agent, chatUri, stub); +- afterTest(() => disposeAgent(agent)); +- const sessionUri = AgentSession.uri('copilotcli', 'session-with-peer'); +- const chatUri = URI.parse(buildChatUri(sessionUri, 'peer-1')); +- const calls: { toolCallId: string; result: ToolCallResult }[] = []; +- const stub = { +- handleClientToolCallComplete(toolCallId: string, result: ToolCallResult) { calls.push({ toolCallId, result }); }, +- dispose() { }, +- }; +- setPeerChatStub(agent, chatUri, stub); - -- const result: ToolCallResult = { success: true, pastTenseMessage: 'peer done' }; -- agent.onClientToolCallComplete(chatUri, 'tc-peer', result); +- const result: ToolCallResult = { success: true, pastTenseMessage: 'peer done' }; +- agent.onClientToolCallComplete(chatUri, 'tc-peer', result); - -- assert.deepStrictEqual(calls, [{ toolCallId: 'tc-peer', result }]); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(calls, [{ toolCallId: 'tc-peer', result }]); - }); - test('routes the session-backed chat URI to the session entry, not a chat leaf', async () => { - const agent = createTestAgent(disposables); -- try { -- const sessionUri = AgentSession.uri('copilotcli', 'session-default'); -- const defaultChatUri = URI.parse(buildDefaultChatUri(sessionUri)); -- const { calls } = installStubSession(agent, AgentSession.id(sessionUri)); +- afterTest(() => disposeAgent(agent)); +- const sessionUri = AgentSession.uri('copilotcli', 'session-default'); +- const defaultChatUri = URI.parse(buildDefaultChatUri(sessionUri)); +- const { calls } = installStubSession(agent, AgentSession.id(sessionUri)); - -- const result: ToolCallResult = { success: true, pastTenseMessage: 'default done' }; -- agent.onClientToolCallComplete(defaultChatUri, 'tc-default', result); +- const result: ToolCallResult = { success: true, pastTenseMessage: 'default done' }; +- agent.onClientToolCallComplete(defaultChatUri, 'tc-default', result); - -- assert.deepStrictEqual(calls, [{ toolCallId: 'tc-default', result }]); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(calls, [{ toolCallId: 'tc-default', result }]); - }); - - test('routes a subagent completion through the spawning chat named by its host-supplied origin', async () => { @@ -73126,50 +75146,44 @@ index f24a97a6296..00000000000 - // recover the spawning chat from the origin the host stamped on the - // *addressed* chat's context — never by parsing the subagent URI. - const agent = createTestAgent(disposables); -- try { -- const sessionUri = AgentSession.uri('copilotcli', 'session-subagent-origin'); -- const spawningChat = URI.parse(buildChatUri(sessionUri, 'peer-1')); -- const subagentChat = URI.parse(buildSubagentChatUri(spawningChat.toString(), 'tool-1')); -- const calls: { toolCallId: string; result: ToolCallResult }[] = []; -- setPeerChatStub(agent, spawningChat, { -- handleClientToolCallComplete(toolCallId: string, result: ToolCallResult) { calls.push({ toolCallId, result }); }, -- dispose() { }, -- }); +- afterTest(() => disposeAgent(agent)); +- const sessionUri = AgentSession.uri('copilotcli', 'session-subagent-origin'); +- const spawningChat = URI.parse(buildChatUri(sessionUri, 'peer-1')); +- const subagentChat = URI.parse(buildSubagentChatUri(spawningChat.toString(), 'tool-1')); +- const calls: { toolCallId: string; result: ToolCallResult }[] = []; +- setPeerChatStub(agent, spawningChat, { +- handleClientToolCallComplete(toolCallId: string, result: ToolCallResult) { calls.push({ toolCallId, result }); }, +- dispose() { }, +- }); - -- const result: ToolCallResult = { success: true, pastTenseMessage: 'subagent done' }; -- agent.onClientToolCallComplete(subagentChat, 'tc-subagent', result, { -- sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(sessionUri), -- resource: subagentChat, -- origin: { kind: ChatOriginKind.Tool, chat: spawningChat.toString(), toolCallId: 'tool-1' }, -- }); +- const result: ToolCallResult = { success: true, pastTenseMessage: 'subagent done' }; +- agent.onClientToolCallComplete(subagentChat, 'tc-subagent', result, { +- sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(sessionUri), +- resource: subagentChat, +- origin: { kind: ChatOriginKind.Tool, chat: spawningChat.toString(), toolCallId: 'tool-1' }, +- }); - -- assert.deepStrictEqual(calls, [{ toolCallId: 'tc-subagent', result }]); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(calls, [{ toolCallId: 'tc-subagent', result }]); - }); - - test('never completes on another chat of the owner when neither the chat nor its spawning chat has a runtime', async () => { - const agent = createTestAgent(disposables); -- try { -- const sessionUri = AgentSession.uri('copilotcli', 'session-subagent-fallback'); -- const { calls } = installStubSession(agent, AgentSession.id(sessionUri)); -- // Addressed to a subagent chat whose spawning chat has no live -- // backing: the owner's session-backed chat did not make the call -- // (after a move it is another session's), so nothing completes. -- const subagentChat = URI.parse(buildSubagentChatUri(buildChatUri(sessionUri, 'gone'), 'tool-9')); +- afterTest(() => disposeAgent(agent)); +- const sessionUri = AgentSession.uri('copilotcli', 'session-subagent-fallback'); +- const { calls } = installStubSession(agent, AgentSession.id(sessionUri)); +- // Addressed to a subagent chat whose spawning chat has no live +- // backing: the owner's session-backed chat did not make the call +- // (after a move it is another session's), so nothing completes. +- const subagentChat = URI.parse(buildSubagentChatUri(buildChatUri(sessionUri, 'gone'), 'tool-9')); - -- const result: ToolCallResult = { success: true, pastTenseMessage: 'fallback done' }; -- agent.onClientToolCallComplete(subagentChat, 'tc-fallback', result, { -- sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(sessionUri), -- resource: subagentChat, -- origin: { kind: ChatOriginKind.Tool, chat: buildChatUri(sessionUri, 'gone'), toolCallId: 'tool-9' }, -- }); +- const result: ToolCallResult = { success: true, pastTenseMessage: 'fallback done' }; +- agent.onClientToolCallComplete(subagentChat, 'tc-fallback', result, { +- sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(sessionUri), +- resource: subagentChat, +- origin: { kind: ChatOriginKind.Tool, chat: buildChatUri(sessionUri, 'gone'), toolCallId: 'tool-9' }, +- }); - -- assert.deepStrictEqual(calls, []); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(calls, []); - }); - }); - @@ -73203,137 +75217,119 @@ index f24a97a6296..00000000000 - - test('respondToPermissionRequest routes to the addressed chat session', async () => { - const agent = createTestAgent(disposables); -- try { -- const sessionUri = AgentSession.uri('copilotcli', 'session-perm'); -- const chatUri = URI.parse(buildChatUri(sessionUri, 'peer-perm')); -- const chat = installStubChat(agent, chatUri, { permissionOwner: 'req-1' }); +- afterTest(() => disposeAgent(agent)); +- const sessionUri = AgentSession.uri('copilotcli', 'session-perm'); +- const chatUri = URI.parse(buildChatUri(sessionUri, 'peer-perm')); +- const chat = installStubChat(agent, chatUri, { permissionOwner: 'req-1' }); - -- agent.respondToPermissionRequest('req-1', true); +- agent.respondToPermissionRequest('req-1', true); - -- assert.deepStrictEqual(chat.events, ['perm:req-1:true']); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(chat.events, ['perm:req-1:true']); - }); - - test('respondToUserInputRequest routes to the addressed chat session', async () => { - const agent = createTestAgent(disposables); -- try { -- const sessionUri = AgentSession.uri('copilotcli', 'session-input'); -- const chatUri = URI.parse(buildChatUri(sessionUri, 'peer-input')); -- const chat = installStubChat(agent, chatUri, { inputOwner: 'req-2' }); +- afterTest(() => disposeAgent(agent)); +- const sessionUri = AgentSession.uri('copilotcli', 'session-input'); +- const chatUri = URI.parse(buildChatUri(sessionUri, 'peer-input')); +- const chat = installStubChat(agent, chatUri, { inputOwner: 'req-2' }); - -- agent.respondToUserInputRequest('req-2', 'submit' as never); +- agent.respondToUserInputRequest('req-2', 'submit' as never); - -- assert.deepStrictEqual(chat.events, ['input:req-2']); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(chat.events, ['input:req-2']); - }); - - test('disposeSession leaves other chat leaves intact', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); - -- const result = await provisionSession(agent, { -- session: AgentSession.uri('copilotcli', 'parent-with-peers'), -- workingDirectories: [URI.file('/workspace')], -- }); -- const chatUri = URI.parse(buildChatUri(result.session, 'peer-x')); -- const chat = installStubChat(agent, chatUri); +- const result = await provisionSession(agent, { +- session: AgentSession.uri('copilotcli', 'parent-with-peers'), +- workingDirectories: [URI.file('/workspace')], +- }); +- const chatUri = URI.parse(buildChatUri(result.session, 'peer-x')); +- const chat = installStubChat(agent, chatUri); - -- await disposeProvisionedSession(agent, result.session); +- await disposeProvisionedSession(agent, result.session); - -- assert.deepStrictEqual({ -- disposed: chat.isDisposed(), -- live: hasLiveChat(agent, chatUri), -- backing: chatBackings(agent).get(chatUri.toString()), -- }, { -- disposed: false, -- live: true, -- backing: { sdkSessionId: 'sdk-' + chatUri.toString() }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- disposed: chat.isDisposed(), +- live: hasLiveChat(agent, chatUri), +- backing: chatBackings(agent).get(chatUri.toString()), +- }, { +- disposed: false, +- live: true, +- backing: { sdkSessionId: 'sdk-' + chatUri.toString() }, +- }); - }); - - test('materializeChat does not assign the default backing to a peer without providerData', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'peer-without-backing'); -- const chat = URI.parse(buildChatUri(session, 'peer')); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'peer-without-backing'); +- const chat = URI.parse(buildChatUri(session, 'peer')); - -- const result = await agent.materializeChat(chat, exactChatContext(session, chat), undefined); +- const result = await agent.materializeChat(chat, exactChatContext(session, chat), undefined); - -- assert.deepStrictEqual({ -- result, -- backing: chatBackings(agent).get(chat.toString()), -- }, { -- result: undefined, -- backing: undefined, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- result, +- backing: chatBackings(agent).get(chat.toString()), +- }, { +- result: undefined, +- backing: undefined, +- }); - }); - - test('materializeChat derives a session-backed chat\'s SDK id from its storage, not its owner', async () => { - const agent = createTestAgent(disposables); -- try { -- const storage = AgentSession.uri('copilotcli', 'stored-here'); -- const owner = AgentSession.uri('copilotcli', 'owned-elsewhere'); -- const chat = defaultChatUri(storage); +- afterTest(() => disposeAgent(agent)); +- const storage = AgentSession.uri('copilotcli', 'stored-here'); +- const owner = AgentSession.uri('copilotcli', 'owned-elsewhere'); +- const chat = defaultChatUri(storage); - -- const result = await agent.materializeChat(chat, exactChatContext(owner, chat, storage), undefined); +- const result = await agent.materializeChat(chat, exactChatContext(owner, chat, storage), undefined); - -- assert.deepStrictEqual({ result: result ? JSON.parse(result.providerData!) : undefined, backing: chatBackings(agent).get(chat.toString()) }, { -- result: { sdkSessionId: 'stored-here' }, -- backing: { sdkSessionId: 'stored-here' }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ result: result ? JSON.parse(result.providerData!) : undefined, backing: chatBackings(agent).get(chat.toString()) }, { +- result: { sdkSessionId: 'stored-here' }, +- backing: { sdkSessionId: 'stored-here' }, +- }); - }); - - test('disposeChat deletes the SDK chat (via legacy fallback) and drops the live backing without rewriting copilot.chats', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'session-dispose-chat'); -- const db = sessionDataService.openDatabase(session); -- // A legacy session whose backing still lives in copilot.chats. -- await db.object.setMetadata('copilot.chats', JSON.stringify({ -- 'peer-a': { sdkSessionId: 'sdk-a' }, -- })); -- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); -- const internals = agent as unknown as { _chatBackings: Map }; -- // Materialize the backing first, mirroring the orchestrator's -- // restore handing back the persisted providerData. -- await agent.materializeChat(chatUri, session, JSON.stringify({ sdkSessionId: 'sdk-a' })); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'session-dispose-chat'); +- const db = sessionDataService.openDatabase(session); +- // A legacy session whose backing still lives in copilot.chats. +- await db.object.setMetadata('copilot.chats', JSON.stringify({ +- 'peer-a': { sdkSessionId: 'sdk-a' }, +- })); +- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); +- const internals = agent as unknown as { _chatBackings: Map }; +- // Materialize the backing first, mirroring the orchestrator's +- // restore handing back the persisted providerData. +- await agent.materializeChat(chatUri, session, JSON.stringify({ sdkSessionId: 'sdk-a' })); - -- await agent.chats.disposeChat(chatUri, exactChatContext(session, chatUri)); +- await agent.chats.disposeChat(chatUri, exactChatContext(session, chatUri)); - -- const remaining = await db.object.getMetadata('copilot.chats'); -- assert.deepStrictEqual({ -- backingCleared: internals._chatBackings.has(chatUri.toString()), -- deleted: client.deletedSessionIds, -- // The agent no longer owns the durable catalog, so it leaves -- // the legacy blob untouched (orchestrator drops the entry). -- legacyUntouched: remaining ? JSON.parse(remaining) : {}, -- }, { -- backingCleared: false, -- deleted: ['sdk-a'], -- legacyUntouched: { 'peer-a': { sdkSessionId: 'sdk-a' } }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- const remaining = await db.object.getMetadata('copilot.chats'); +- assert.deepStrictEqual({ +- backingCleared: internals._chatBackings.has(chatUri.toString()), +- deleted: client.deletedSessionIds, +- // The agent no longer owns the durable catalog, so it leaves +- // the legacy blob untouched (orchestrator drops the entry). +- legacyUntouched: remaining ? JSON.parse(remaining) : {}, +- }, { +- backingCleared: false, +- deleted: ['sdk-a'], +- legacyUntouched: { 'peer-a': { sdkSessionId: 'sdk-a' } }, +- }); - }); - }); - @@ -73445,151 +75441,139 @@ index f24a97a6296..00000000000 - - test('collectDebugLogs targets the selected peer chat', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'debug-peer'); -- const defaultChat = URI.parse(buildDefaultChatUri(session)); -- const peerChat = URI.parse(buildChatUri(session, 'peer-a')); -- const defaultSession = makeFakeChatSession(session, 'sdk-default'); -- const peerSession = makeFakeChatSession(session, 'sdk-peer'); -- setDefaultSessionStub(agent, AgentSession.id(session), defaultSession.fake, defaultChat); -- setPeerChatStub(agent, peerChat, peerSession.fake); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'debug-peer'); +- const defaultChat = URI.parse(buildDefaultChatUri(session)); +- const peerChat = URI.parse(buildChatUri(session, 'peer-a')); +- const defaultSession = makeFakeChatSession(session, 'sdk-default'); +- const peerSession = makeFakeChatSession(session, 'sdk-peer'); +- setDefaultSessionStub(agent, AgentSession.id(session), defaultSession.fake, defaultChat); +- setPeerChatStub(agent, peerChat, peerSession.fake); - -- const included = await agent.collectDebugLogs(session, URI.file('/debug-output'), peerChat); +- const included = await agent.collectDebugLogs(session, URI.file('/debug-output'), peerChat); - -- assert.deepStrictEqual({ -- included, -- defaultChat: defaultSession.rec.debugLogCalls, -- peerChat: peerSession.rec.debugLogCalls, -- }, { -- included: true, -- defaultChat: [], -- peerChat: [{ outputDirectory: 'file:///debug-output', includeSessionLogs: true }], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- included, +- defaultChat: defaultSession.rec.debugLogCalls, +- peerChat: peerSession.rec.debugLogCalls, +- }, { +- included: true, +- defaultChat: [], +- peerChat: [{ outputDirectory: 'file:///debug-output', includeSessionLogs: true }], +- }); - }); - - test('collectDebugLogs includes persisted events for a closed session', async () => { - const { agent, fileService } = createTestAgentContext(disposables, { userHome: URI.file('/home/test') }); -- try { -- const provider = disposables.add(new InMemoryFileSystemProvider()); -- disposables.add(fileService.registerProvider(Schemas.file, provider)); -- const session = AgentSession.uri('copilotcli', 'closed-session'); -- await agent.materializeChat(URI.parse(buildDefaultChatUri(session)), { sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(session), resource: session }, JSON.stringify({ sdkSessionId: 'closed-sdk-session' })); -- const stateFile = URI.file('/home/test/.copilot/session-state/closed-sdk-session/events.jsonl'); -- await fileService.writeFile(stateFile, VSBuffer.fromString('persisted event')); -- const outputDirectory = URI.file('/debug-output'); -- await fileService.createFolder(outputDirectory); +- afterTest(() => disposeAgent(agent)); +- const provider = disposables.add(new InMemoryFileSystemProvider()); +- disposables.add(fileService.registerProvider(Schemas.file, provider)); +- const session = AgentSession.uri('copilotcli', 'closed-session'); +- await agent.materializeChat(URI.parse(buildDefaultChatUri(session)), { sessionSettings: undefined, launchWorkingDirectories: undefined, workspaceless: false, owner: fixedChatOwnerRef(session), resource: session }, JSON.stringify({ sdkSessionId: 'closed-sdk-session' })); +- const stateFile = URI.file('/home/test/.copilot/session-state/closed-sdk-session/events.jsonl'); +- await fileService.writeFile(stateFile, VSBuffer.fromString('persisted event')); +- const outputDirectory = URI.file('/debug-output'); +- await fileService.createFolder(outputDirectory); - -- const liveSession = makeFakeChatSession(AgentSession.uri('copilotcli', 'live-session'), 'live-sdk-session'); -- setDefaultSessionStub(agent, 'live-session', liveSession.fake); +- const liveSession = makeFakeChatSession(AgentSession.uri('copilotcli', 'live-session'), 'live-sdk-session'); +- setDefaultSessionStub(agent, 'live-session', liveSession.fake); - -- const included = await agent.collectDebugLogs(session, outputDirectory); +- const included = await agent.collectDebugLogs(session, outputDirectory); - -- assert.deepStrictEqual({ -- included, -- events: (await fileService.readFile(URI.joinPath(outputDirectory, 'events.jsonl'))).value.toString(), -- liveSession: liveSession.rec.debugLogCalls, -- }, { -- included: true, -- events: 'persisted event', -- liveSession: [{ outputDirectory: 'file:///debug-output', includeSessionLogs: false }], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- included, +- events: (await fileService.readFile(URI.joinPath(outputDirectory, 'events.jsonl'))).value.toString(), +- liveSession: liveSession.rec.debugLogCalls, +- }, { +- included: true, +- events: 'persisted event', +- liveSession: [{ outputDirectory: 'file:///debug-output', includeSessionLogs: false }], +- }); - }); - - test('applies managed plugins that become ready while the chat initializes', async () => { - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'managed-plugins-ready'); -- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); -- const calls: string[] = []; -- (agent as unknown as { _slashCommandProvider: { clearCache(): void } })._slashCommandProvider.clearCache = () => calls.push('clearAgentCommandCache'); -- const runtimePlugins = disposables.add(new Emitter()); -- (agent as unknown as ChatInternals)._createAgentSession = (launchPlan, _dir, activeClient, identity) => { -- (activeClient as unknown as { pluginController: { refreshDiscovery(): void } }).pluginController.refreshDiscovery = () => calls.push('refreshDiscovery'); -- const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); -- Object.assign(built.fake, { -- chatChannelUri: identity?.chatChannelUri, -- onDidChangeRuntimePlugins: runtimePlugins.event, -- initializeSession: async () => { -- runtimePlugins.fire(); -- calls.push('initialized'); -- }, -- }); -- return built.fake; -- }; +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'managed-plugins-ready'); +- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); +- const calls: string[] = []; +- (agent as unknown as { _slashCommandProvider: { clearCache(): void } })._slashCommandProvider.clearCache = () => calls.push('clearAgentCommandCache'); +- const runtimePlugins = disposables.add(new Emitter()); +- (agent as unknown as ChatInternals)._createAgentSession = (launchPlan, _dir, activeClient, identity) => { +- (activeClient as unknown as { pluginController: { refreshDiscovery(): void } }).pluginController.refreshDiscovery = () => calls.push('refreshDiscovery'); +- const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); +- Object.assign(built.fake, { +- chatChannelUri: identity?.chatChannelUri, +- onDidChangeRuntimePlugins: runtimePlugins.event, +- initializeSession: async () => { +- runtimePlugins.fire(); +- calls.push('initialized'); +- }, +- }); +- return built.fake; +- }; - -- await agent.chats.createChat(URI.parse(buildChatUri(session, 'peer-a')), session, { model: { id: 'gpt-x' }, workingDirectories: [URI.file('/workspace')] }); -- const afterRegistration = [...calls]; -- runtimePlugins.fire(); +- await agent.chats.createChat(URI.parse(buildChatUri(session, 'peer-a')), session, { model: { id: 'gpt-x' }, workingDirectories: [URI.file('/workspace')] }); +- const afterRegistration = [...calls]; +- runtimePlugins.fire(); - -- assert.deepStrictEqual({ afterRegistration, afterLaterChange: calls.slice(afterRegistration.length) }, { -- afterRegistration: ['initialized', 'clearAgentCommandCache', 'refreshDiscovery'], -- afterLaterChange: ['clearAgentCommandCache', 'refreshDiscovery'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ afterRegistration, afterLaterChange: calls.slice(afterRegistration.length) }, { +- afterRegistration: ['initialized', 'clearAgentCommandCache', 'refreshDiscovery'], +- afterLaterChange: ['clearAgentCommandCache', 'refreshDiscovery'], +- }); - }); - - test('createChat materializes an addressed chat, records its backing, and returns providerData (no copilot.chats write)', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'create-peer'); -- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); -- -- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); -- const internals = agent as unknown as ChatInternals; -- let captured: CopilotSessionLaunchPlan | undefined; -- let capturedChannel: URI | undefined; -- let capturedSession: URI | undefined; -- let rec: IFakeChatRecorder | undefined; -- internals._createAgentSession = (launchPlan, _dir, _ac, identity) => { -- captured = launchPlan; -- capturedChannel = identity?.chatChannelUri; -- capturedSession = identity?.owner.get(); -- const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); -- (built.fake as { chatChannelUri?: URI }).chatChannelUri = identity?.chatChannelUri; -- rec = built.rec; -- return built.fake; -- }; +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'create-peer'); +- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); +- +- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); +- const internals = agent as unknown as ChatInternals; +- let captured: CopilotSessionLaunchPlan | undefined; +- let capturedChannel: URI | undefined; +- let capturedSession: URI | undefined; +- let rec: IFakeChatRecorder | undefined; +- internals._createAgentSession = (launchPlan, _dir, _ac, identity) => { +- captured = launchPlan; +- capturedChannel = identity?.chatChannelUri; +- capturedSession = identity?.owner.get(); +- const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); +- (built.fake as { chatChannelUri?: URI }).chatChannelUri = identity?.chatChannelUri; +- rec = built.rec; +- return built.fake; +- }; - -- const model: ModelSelection = { id: 'gpt-x' }; -- const result = await agent.chats.createChat(chatUri, session, { model, workingDirectories: [URI.file('/workspace')] }); +- const model: ModelSelection = { id: 'gpt-x' }; +- const result = await agent.chats.createChat(chatUri, session, { model, workingDirectories: [URI.file('/workspace')] }); - -- const db = sessionDataService.openDatabase(session); -- const raw = await db.object.getMetadata('copilot.chats'); -- assert.deepStrictEqual({ -- tracked: hasLiveChat(agent, chatUri), -- initialized: rec?.initialized, -- session: capturedSession?.toString(), -- channel: capturedChannel?.toString(), -- kind: captured?.kind, -- backing: internals._chatBackings.get(chatUri.toString()), -- providerData: result ? JSON.parse(result.providerData!) : undefined, -- // The orchestrator now owns the durable catalog; the agent no -- // longer writes its private `copilot.chats` metadata. -- legacyCatalogWritten: raw !== undefined, -- }, { -- tracked: true, -- initialized: true, -- session: session.toString(), -- channel: chatUri.toString(), -- kind: 'create', -- backing: { sdkSessionId: captured!.sessionId, model: { id: 'gpt-x' } }, -- providerData: { sdkSessionId: captured!.sessionId, model: { id: 'gpt-x' } }, -- legacyCatalogWritten: false, -- }); -- } finally { -- await disposeAgent(agent); -- } +- const db = sessionDataService.openDatabase(session); +- const raw = await db.object.getMetadata('copilot.chats'); +- assert.deepStrictEqual({ +- tracked: hasLiveChat(agent, chatUri), +- initialized: rec?.initialized, +- session: capturedSession?.toString(), +- channel: capturedChannel?.toString(), +- kind: captured?.kind, +- backing: internals._chatBackings.get(chatUri.toString()), +- providerData: result ? JSON.parse(result.providerData!) : undefined, +- // The orchestrator now owns the durable catalog; the agent no +- // longer writes its private `copilot.chats` metadata. +- legacyCatalogWritten: raw !== undefined, +- }, { +- tracked: true, +- initialized: true, +- session: session.toString(), +- channel: chatUri.toString(), +- kind: 'create', +- backing: { sdkSessionId: captured!.sessionId, model: { id: 'gpt-x' } }, +- providerData: { sdkSessionId: captured!.sessionId, model: { id: 'gpt-x' } }, +- legacyCatalogWritten: false, +- }); - }); - - test('resumes distinct peer chats in parallel while coalescing duplicate requests', async () => { @@ -73652,260 +75636,245 @@ index f24a97a6296..00000000000 - - test('retries a peer resume after initialization fails', async () => { - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'retry-peer-resume'); -- setDefaultSessionStub(agent, AgentSession.id(session), { workingDirectory: URI.file('/workspace'), dispose() { } }); -- const chat = URI.parse(buildChatUri(session, 'peer-a')); -- await agent.materializeChat(chat, peerLaunchContext(session, chat), JSON.stringify({ sdkSessionId: 'sdk-a' })); -- let initializationAttempts = 0; -- const internals = agent as unknown as ChatInternals; -- internals._createAgentSession = (launchPlan, _directory, _activeClient, identity) => { -- const built = makeFakeChatSession(identity!.owner.get(), launchPlan.sessionId, undefined, launchPlan.shellManager); -- built.fake.initializeSession = async () => { -- initializationAttempts++; -- if (initializationAttempts === 1) { -- throw new Error('first initialization failed'); -- } -- }; -- return built.fake; +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'retry-peer-resume'); +- setDefaultSessionStub(agent, AgentSession.id(session), { workingDirectory: URI.file('/workspace'), dispose() { } }); +- const chat = URI.parse(buildChatUri(session, 'peer-a')); +- await agent.materializeChat(chat, peerLaunchContext(session, chat), JSON.stringify({ sdkSessionId: 'sdk-a' })); +- let initializationAttempts = 0; +- const internals = agent as unknown as ChatInternals; +- internals._createAgentSession = (launchPlan, _directory, _activeClient, identity) => { +- const built = makeFakeChatSession(identity!.owner.get(), launchPlan.sessionId, undefined, launchPlan.shellManager); +- built.fake.initializeSession = async () => { +- initializationAttempts++; +- if (initializationAttempts === 1) { +- throw new Error('first initialization failed'); +- } - }; +- return built.fake; +- }; - -- await assert.rejects(() => agent.chats.getMessages(chat, peerLaunchContext(session, chat)), /first initialization failed/); -- await agent.chats.getMessages(chat, peerLaunchContext(session, chat)); +- await assert.rejects(() => agent.chats.getMessages(chat, peerLaunchContext(session, chat)), /first initialization failed/); +- await agent.chats.getMessages(chat, peerLaunchContext(session, chat)); - -- assert.strictEqual(initializationAttempts, 2); -- } finally { -- await disposeAgent(agent); -- } +- assert.strictEqual(initializationAttempts, 2); - }); - - test('waits for an in-flight peer resume lease before releasing the exact chat', async () => { - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'release-during-peer-resume'); -- setDefaultSessionStub(agent, AgentSession.id(session), { -- workingDirectory: URI.file('/workspace'), -- hasActiveTurn: false, -- async destroySession() { }, -- dispose() { }, -- }); -- const chat = URI.parse(buildChatUri(session, 'peer-a')); -- await agent.materializeChat(chat, peerLaunchContext(session, chat), JSON.stringify({ sdkSessionId: 'sdk-a' })); -- const gate = new DeferredPromise(); -- let initialized = false; -- const initializeStarted = new DeferredPromise(); -- const internals = agent as unknown as ChatInternals; -- internals._createAgentSession = (launchPlan, _directory, _activeClient, identity) => { -- const built = makeFakeChatSession(identity!.owner.get(), launchPlan.sessionId, undefined, launchPlan.shellManager); -- built.fake.initializeSession = async () => { -- initialized = true; -- initializeStarted.complete(); -- await gate.p; -- }; -- return built.fake; +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'release-during-peer-resume'); +- setDefaultSessionStub(agent, AgentSession.id(session), { +- workingDirectory: URI.file('/workspace'), +- hasActiveTurn: false, +- async destroySession() { }, +- dispose() { }, +- }); +- const chat = URI.parse(buildChatUri(session, 'peer-a')); +- await agent.materializeChat(chat, peerLaunchContext(session, chat), JSON.stringify({ sdkSessionId: 'sdk-a' })); +- const gate = new DeferredPromise(); +- let initialized = false; +- const initializeStarted = new DeferredPromise(); +- const internals = agent as unknown as ChatInternals; +- internals._createAgentSession = (launchPlan, _directory, _activeClient, identity) => { +- const built = makeFakeChatSession(identity!.owner.get(), launchPlan.sessionId, undefined, launchPlan.shellManager); +- built.fake.initializeSession = async () => { +- initialized = true; +- initializeStarted.complete(); +- await gate.p; - }; +- return built.fake; +- }; - -- const messages = agent.chats.getMessages(chat, peerLaunchContext(session, chat)); -- await initializeStarted.p; -- let released = false; -- const release = agent.chats.releaseChat(chat, peerLaunchContext(session, chat)).then(() => { released = true; }); -- await timeout(0); -- const releasedBeforeResume = released; -- gate.complete(); -- await Promise.all([messages, release]); +- const messages = agent.chats.getMessages(chat, peerLaunchContext(session, chat)); +- await initializeStarted.p; +- let released = false; +- const release = agent.chats.releaseChat(chat, peerLaunchContext(session, chat)).then(() => { released = true; }); +- await timeout(0); +- const releasedBeforeResume = released; +- gate.complete(); +- await Promise.all([messages, release]); - -- assert.deepStrictEqual({ -- initialized, -- releasedBeforeResume, -- peerStillRegistered: hasLiveChat(agent, chat), -- }, { -- initialized: true, -- releasedBeforeResume: false, -- peerStillRegistered: false, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- initialized, +- releasedBeforeResume, +- peerStillRegistered: hasLiveChat(agent, chat), +- }, { +- initialized: true, +- releasedBeforeResume: false, +- peerStillRegistered: false, +- }); - }); - - test('waits for peer resume leases in consecutive release cycles', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'consecutive-peer-releases'); -- setDefaultSessionStub(agent, AgentSession.id(session), { -- workingDirectory: URI.file('/workspace'), -- hasActiveTurn: false, -- async destroySession() { }, -- dispose() { }, -- }); -- const firstPeer = URI.parse(buildChatUri(session, 'peer-a')); -- const secondPeer = URI.parse(buildChatUri(session, 'peer-b')); -- const db = sessionDataService.openDatabase(session); -- await db.object.setMetadata('copilot.chats', JSON.stringify({ -- 'peer-a': { sdkSessionId: 'sdk-a' }, -- 'peer-b': { sdkSessionId: 'sdk-b' }, -- })); -- await agent.materializeChat(firstPeer, peerLaunchContext(session, firstPeer), JSON.stringify({ sdkSessionId: 'sdk-a' })); -- await agent.materializeChat(secondPeer, peerLaunchContext(session, secondPeer), JSON.stringify({ sdkSessionId: 'sdk-b' })); -- const firstGate = new DeferredPromise(); -- const secondGate = new DeferredPromise(); -- const initialized = new Set(); -- const initializeStarted = new Map([['sdk-a', new DeferredPromise()], ['sdk-b', new DeferredPromise()]]); -- const internals = agent as unknown as ChatInternals; -- internals._createAgentSession = (launchPlan, _directory, _activeClient, identity) => { -- const built = makeFakeChatSession(identity!.owner.get(), launchPlan.sessionId, undefined, launchPlan.shellManager); -- built.fake.initializeSession = async () => { -- initialized.add(launchPlan.sessionId); -- initializeStarted.get(launchPlan.sessionId)?.complete(); -- await (launchPlan.sessionId === 'sdk-a' ? firstGate : secondGate).p; -- }; -- return built.fake; +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'consecutive-peer-releases'); +- setDefaultSessionStub(agent, AgentSession.id(session), { +- workingDirectory: URI.file('/workspace'), +- hasActiveTurn: false, +- async destroySession() { }, +- dispose() { }, +- }); +- const firstPeer = URI.parse(buildChatUri(session, 'peer-a')); +- const secondPeer = URI.parse(buildChatUri(session, 'peer-b')); +- const db = sessionDataService.openDatabase(session); +- await db.object.setMetadata('copilot.chats', JSON.stringify({ +- 'peer-a': { sdkSessionId: 'sdk-a' }, +- 'peer-b': { sdkSessionId: 'sdk-b' }, +- })); +- await agent.materializeChat(firstPeer, peerLaunchContext(session, firstPeer), JSON.stringify({ sdkSessionId: 'sdk-a' })); +- await agent.materializeChat(secondPeer, peerLaunchContext(session, secondPeer), JSON.stringify({ sdkSessionId: 'sdk-b' })); +- const firstGate = new DeferredPromise(); +- const secondGate = new DeferredPromise(); +- const initialized = new Set(); +- const initializeStarted = new Map([['sdk-a', new DeferredPromise()], ['sdk-b', new DeferredPromise()]]); +- const internals = agent as unknown as ChatInternals; +- internals._createAgentSession = (launchPlan, _directory, _activeClient, identity) => { +- const built = makeFakeChatSession(identity!.owner.get(), launchPlan.sessionId, undefined, launchPlan.shellManager); +- built.fake.initializeSession = async () => { +- initialized.add(launchPlan.sessionId); +- initializeStarted.get(launchPlan.sessionId)?.complete(); +- await (launchPlan.sessionId === 'sdk-a' ? firstGate : secondGate).p; - }; +- return built.fake; +- }; - -- const firstMessages = agent.chats.getMessages(firstPeer, peerLaunchContext(session, firstPeer)); -- await initializeStarted.get('sdk-a')!.p; -- let firstReleased = false; -- const firstRelease = agent.chats.releaseChat(firstPeer, peerLaunchContext(session, firstPeer)).then(() => { firstReleased = true; }); -- await timeout(0); -- const firstReleasedBeforeResume = firstReleased; -- firstGate.complete(); -- await Promise.all([firstMessages, firstRelease]); -- -- const secondMessages = agent.chats.getMessages(secondPeer, peerLaunchContext(session, secondPeer)); -- await initializeStarted.get('sdk-b')!.p; -- let secondReleased = false; -- const secondRelease = agent.chats.releaseChat(secondPeer, peerLaunchContext(session, secondPeer)).then(() => { secondReleased = true; }); -- await timeout(0); -- const secondReleasedBeforeResume = secondReleased; -- secondGate.complete(); -- await Promise.all([secondMessages, secondRelease]); +- const firstMessages = agent.chats.getMessages(firstPeer, peerLaunchContext(session, firstPeer)); +- await initializeStarted.get('sdk-a')!.p; +- let firstReleased = false; +- const firstRelease = agent.chats.releaseChat(firstPeer, peerLaunchContext(session, firstPeer)).then(() => { firstReleased = true; }); +- await timeout(0); +- const firstReleasedBeforeResume = firstReleased; +- firstGate.complete(); +- await Promise.all([firstMessages, firstRelease]); - -- assert.deepStrictEqual({ -- firstReleasedBeforeResume, -- secondReleasedBeforeResume, -- initialized: [...initialized].sort(), -- }, { -- firstReleasedBeforeResume: false, -- secondReleasedBeforeResume: false, -- initialized: ['sdk-a', 'sdk-b'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- const secondMessages = agent.chats.getMessages(secondPeer, peerLaunchContext(session, secondPeer)); +- await initializeStarted.get('sdk-b')!.p; +- let secondReleased = false; +- const secondRelease = agent.chats.releaseChat(secondPeer, peerLaunchContext(session, secondPeer)).then(() => { secondReleased = true; }); +- await timeout(0); +- const secondReleasedBeforeResume = secondReleased; +- secondGate.complete(); +- await Promise.all([secondMessages, secondRelease]); +- +- assert.deepStrictEqual({ +- firstReleasedBeforeResume, +- secondReleasedBeforeResume, +- initialized: [...initialized].sort(), +- }, { +- firstReleasedBeforeResume: false, +- secondReleasedBeforeResume: false, +- initialized: ['sdk-a', 'sdk-b'], +- }); - }); - - test('resumes a peer access that starts during parent release', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'peer-access-during-release'); -- const releaseGate = new DeferredPromise(); -- let releaseStarted = false; -- const destroyStarted = new DeferredPromise(); -- setDefaultSessionStub(agent, AgentSession.id(session), { workingDirectory: URI.file('/workspace'), dispose() { } }); -- const chat = URI.parse(buildChatUri(session, 'peer-a')); -- setPeerChatStub(agent, chat, { -- workingDirectory: URI.file('/workspace'), -- hasActiveTurn: false, -- async hasRunningDetachedShells() { return false; }, -- async getMessages() { return []; }, -- async destroySession() { -- releaseStarted = true; -- destroyStarted.complete(); -- await releaseGate.p; -- }, -- dispose() { }, -- }, 'sdk-a'); -- const db = sessionDataService.openDatabase(session); -- await db.object.setMetadata('copilot.chats', JSON.stringify({ 'peer-a': { sdkSessionId: 'sdk-a' } })); -- await agent.materializeChat(chat, peerLaunchContext(session, chat), JSON.stringify({ sdkSessionId: 'sdk-a' })); -- const internals = agent as unknown as ChatInternals; -- let initializations = 0; -- internals._createAgentSession = (launchPlan, _directory, _activeClient, identity) => { -- const built = makeFakeChatSession(identity!.owner.get(), launchPlan.sessionId, undefined, launchPlan.shellManager); -- built.fake.initializeSession = async () => { initializations++; }; -- return built.fake; -- }; +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'peer-access-during-release'); +- const releaseGate = new DeferredPromise(); +- let releaseStarted = false; +- const destroyStarted = new DeferredPromise(); +- setDefaultSessionStub(agent, AgentSession.id(session), { workingDirectory: URI.file('/workspace'), dispose() { } }); +- const chat = URI.parse(buildChatUri(session, 'peer-a')); +- setPeerChatStub(agent, chat, { +- workingDirectory: URI.file('/workspace'), +- hasActiveTurn: false, +- async hasRunningDetachedShells() { return false; }, +- async getMessages() { return []; }, +- async destroySession() { +- releaseStarted = true; +- destroyStarted.complete(); +- await releaseGate.p; +- }, +- dispose() { }, +- }, 'sdk-a'); +- const db = sessionDataService.openDatabase(session); +- await db.object.setMetadata('copilot.chats', JSON.stringify({ 'peer-a': { sdkSessionId: 'sdk-a' } })); +- await agent.materializeChat(chat, peerLaunchContext(session, chat), JSON.stringify({ sdkSessionId: 'sdk-a' })); +- const internals = agent as unknown as ChatInternals; +- let initializations = 0; +- internals._createAgentSession = (launchPlan, _directory, _activeClient, identity) => { +- const built = makeFakeChatSession(identity!.owner.get(), launchPlan.sessionId, undefined, launchPlan.shellManager); +- built.fake.initializeSession = async () => { initializations++; }; +- return built.fake; +- }; - -- const release = agent.chats.releaseChat(chat, peerLaunchContext(session, chat)); -- await destroyStarted.p; -- const messages = agent.chats.getMessages(chat, peerLaunchContext(session, chat)); -- await timeout(0); -- const initializedDuringRelease = initializations; -- releaseGate.complete(); -- await Promise.all([release, messages]); +- const release = agent.chats.releaseChat(chat, peerLaunchContext(session, chat)); +- await destroyStarted.p; +- const messages = agent.chats.getMessages(chat, peerLaunchContext(session, chat)); +- await timeout(0); +- const initializedDuringRelease = initializations; +- releaseGate.complete(); +- await Promise.all([release, messages]); - -- assert.deepStrictEqual({ -- releaseStarted, -- initializedDuringRelease, -- initializations, -- }, { -- releaseStarted: true, -- initializedDuringRelease: 0, -- initializations: 1, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- releaseStarted, +- initializedDuringRelease, +- initializations, +- }, { +- releaseStarted: true, +- initializedDuringRelease: 0, +- initializations: 1, +- }); - }); - - test('does not block a session-sequenced peer resume behind a queued release', async () => { - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'sequenced-peer-resume-before-release'); -- const sessionId = AgentSession.id(session); -- setDefaultSessionStub(agent, sessionId, { -- workingDirectory: URI.file('/workspace'), -- hasActiveTurn: false, -- async destroySession() { }, -- dispose() { }, -- }); -- const chat = URI.parse(buildChatUri(session, 'peer-a')); -- await agent.materializeChat(chat, peerLaunchContext(session, chat), JSON.stringify({ sdkSessionId: 'sdk-a' })); -- const enterPeerResume = new DeferredPromise(); -- const initialize = new DeferredPromise(); -- let sequencedOperationStarted = false; -- let initialized = false; -- const sequencedOperationEntered = new DeferredPromise(); -- const initializeStarted = new DeferredPromise(); -- const internals = agent as unknown as ChatInternals; -- internals._createAgentSession = (launchPlan, _directory, _activeClient, identity) => { -- const built = makeFakeChatSession(identity!.owner.get(), launchPlan.sessionId, undefined, launchPlan.shellManager); -- built.fake.initializeSession = async () => { -- initialized = true; -- initializeStarted.complete(); -- await initialize.p; -- }; -- return built.fake; +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'sequenced-peer-resume-before-release'); +- const sessionId = AgentSession.id(session); +- setDefaultSessionStub(agent, sessionId, { +- workingDirectory: URI.file('/workspace'), +- hasActiveTurn: false, +- async destroySession() { }, +- dispose() { }, +- }); +- const chat = URI.parse(buildChatUri(session, 'peer-a')); +- await agent.materializeChat(chat, peerLaunchContext(session, chat), JSON.stringify({ sdkSessionId: 'sdk-a' })); +- const enterPeerResume = new DeferredPromise(); +- const initialize = new DeferredPromise(); +- let sequencedOperationStarted = false; +- let initialized = false; +- const sequencedOperationEntered = new DeferredPromise(); +- const initializeStarted = new DeferredPromise(); +- const internals = agent as unknown as ChatInternals; +- internals._createAgentSession = (launchPlan, _directory, _activeClient, identity) => { +- const built = makeFakeChatSession(identity!.owner.get(), launchPlan.sessionId, undefined, launchPlan.shellManager); +- built.fake.initializeSession = async () => { +- initialized = true; +- initializeStarted.complete(); +- await initialize.p; - }; +- return built.fake; +- }; - -- const sequencedPeerResume = internals._getOrCreateSessionLifetime('sdk-a')!.queueSession(async () => { -- sequencedOperationStarted = true; -- sequencedOperationEntered.complete(); -- await enterPeerResume.p; -- return agent.chats.getMessages(chat, peerLaunchContext(session, chat)); -- }); -- await sequencedOperationEntered.p; -- const release = agent.chats.releaseChat(chat, peerLaunchContext(session, chat)); -- enterPeerResume.complete(); -- await initializeStarted.p; -- const initializedBeforeRelease = initialized; -- initialize.complete(); -- await Promise.all([sequencedPeerResume, release]); +- const sequencedPeerResume = internals._getOrCreateSessionLifetime('sdk-a')!.queueSession(async () => { +- sequencedOperationStarted = true; +- sequencedOperationEntered.complete(); +- await enterPeerResume.p; +- return agent.chats.getMessages(chat, peerLaunchContext(session, chat)); +- }); +- await sequencedOperationEntered.p; +- const release = agent.chats.releaseChat(chat, peerLaunchContext(session, chat)); +- enterPeerResume.complete(); +- await initializeStarted.p; +- const initializedBeforeRelease = initialized; +- initialize.complete(); +- await Promise.all([sequencedPeerResume, release]); - -- assert.deepStrictEqual({ sequencedOperationStarted, initializedBeforeRelease }, { -- sequencedOperationStarted: true, -- initializedBeforeRelease: true, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ sequencedOperationStarted, initializedBeforeRelease }, { +- sequencedOperationStarted: true, +- initializedBeforeRelease: true, +- }); - }); - - test('wakes peer resume waiters and refuses new leases during shutdown', async () => { @@ -73960,404 +75929,377 @@ index f24a97a6296..00000000000 - - test('resumes peers after recreating a disposed session ID', async () => { - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'reused-session-lifetime'); -- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); -- await disposeProvisionedSession(agent, session); -- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); -- setDefaultSessionStub(agent, AgentSession.id(session), { workingDirectory: URI.file('/workspace'), dispose() { } }); -- const chat = URI.parse(buildChatUri(session, 'peer-a')); -- await agent.materializeChat(chat, exactChatContext(session, chat), JSON.stringify({ sdkSessionId: 'sdk-a' })); -- let initialized = false; -- const internals = agent as unknown as ChatInternals; -- internals._createAgentSession = (launchPlan, _directory, _activeClient, identity) => { -- const built = makeFakeChatSession(identity!.owner.get(), launchPlan.sessionId, undefined, launchPlan.shellManager); -- built.fake.initializeSession = async () => { initialized = true; }; -- return built.fake; -- }; +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'reused-session-lifetime'); +- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); +- await disposeProvisionedSession(agent, session); +- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); +- setDefaultSessionStub(agent, AgentSession.id(session), { workingDirectory: URI.file('/workspace'), dispose() { } }); +- const chat = URI.parse(buildChatUri(session, 'peer-a')); +- await agent.materializeChat(chat, exactChatContext(session, chat), JSON.stringify({ sdkSessionId: 'sdk-a' })); +- let initialized = false; +- const internals = agent as unknown as ChatInternals; +- internals._createAgentSession = (launchPlan, _directory, _activeClient, identity) => { +- const built = makeFakeChatSession(identity!.owner.get(), launchPlan.sessionId, undefined, launchPlan.shellManager); +- built.fake.initializeSession = async () => { initialized = true; }; +- return built.fake; +- }; - -- await agent.chats.getMessages(chat, exactChatContext(session, chat)); -- assert.strictEqual(initialized, true); -- } finally { -- await disposeAgent(agent); -- } +- await agent.chats.getMessages(chat, exactChatContext(session, chat)); +- assert.strictEqual(initialized, true); - }); - - test('waits for an in-flight peer resume lease before disposing the exact chat', async () => { - const client = new TestCopilotClient([]); - const agent = createTestAgent(disposables, { copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'dispose-during-peer-resume'); -- setDefaultSessionStub(agent, AgentSession.id(session), { -- workingDirectory: URI.file('/workspace'), -- async destroySession() { }, -- dispose() { }, -- }); -- const chat = URI.parse(buildChatUri(session, 'peer-a')); -- await agent.materializeChat(chat, peerLaunchContext(session, chat), JSON.stringify({ sdkSessionId: 'sdk-a' })); -- const gate = new DeferredPromise(); -- let initialized = false; -- const initializeStarted = new DeferredPromise(); -- const internals = agent as unknown as ChatInternals; -- internals._createAgentSession = (launchPlan, _directory, _activeClient, identity) => { -- const built = makeFakeChatSession(identity!.owner.get(), launchPlan.sessionId, undefined, launchPlan.shellManager); -- built.fake.initializeSession = async () => { -- initialized = true; -- initializeStarted.complete(); -- await gate.p; -- }; -- return built.fake; +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'dispose-during-peer-resume'); +- setDefaultSessionStub(agent, AgentSession.id(session), { +- workingDirectory: URI.file('/workspace'), +- async destroySession() { }, +- dispose() { }, +- }); +- const chat = URI.parse(buildChatUri(session, 'peer-a')); +- await agent.materializeChat(chat, peerLaunchContext(session, chat), JSON.stringify({ sdkSessionId: 'sdk-a' })); +- const gate = new DeferredPromise(); +- let initialized = false; +- const initializeStarted = new DeferredPromise(); +- const internals = agent as unknown as ChatInternals; +- internals._createAgentSession = (launchPlan, _directory, _activeClient, identity) => { +- const built = makeFakeChatSession(identity!.owner.get(), launchPlan.sessionId, undefined, launchPlan.shellManager); +- built.fake.initializeSession = async () => { +- initialized = true; +- initializeStarted.complete(); +- await gate.p; - }; +- return built.fake; +- }; - -- const messages = agent.chats.getMessages(chat, peerLaunchContext(session, chat)); -- await initializeStarted.p; -- const dispose = agent.chats.disposeChat(chat, peerLaunchContext(session, chat)); -- await timeout(0); -- const deletedBeforeResume = client.deletedSessionIds.includes('sdk-a'); -- gate.complete(); -- await Promise.all([messages, dispose]); +- const messages = agent.chats.getMessages(chat, peerLaunchContext(session, chat)); +- await initializeStarted.p; +- const dispose = agent.chats.disposeChat(chat, peerLaunchContext(session, chat)); +- await timeout(0); +- const deletedBeforeResume = client.deletedSessionIds.includes('sdk-a'); +- gate.complete(); +- await Promise.all([messages, dispose]); - -- assert.deepStrictEqual({ -- initialized, -- deletedBeforeResume, -- peerStillRegistered: hasLiveChat(agent, chat), -- }, { -- initialized: true, -- deletedBeforeResume: false, -- peerStillRegistered: false, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- initialized, +- deletedBeforeResume, +- peerStillRegistered: hasLiveChat(agent, chat), +- }, { +- initialized: true, +- deletedBeforeResume: false, +- peerStillRegistered: false, +- }); - }); - - test('createChat returns the existing backing without inferring a chat role from the resource', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'create-default'); -- const created = await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); -- const internals = agent as unknown as ChatInternals; -- internals._createAgentSession = () => { throw new Error('_createAgentSession must not be called for the session-backed chat'); }; +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'create-default'); +- const created = await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); +- const internals = agent as unknown as ChatInternals; +- internals._createAgentSession = () => { throw new Error('_createAgentSession must not be called for the session-backed chat'); }; - -- const result = await agent.chats.createChat(defaultChatUri(session), exactChatContext(session, defaultChatUri(session), session), {}); +- const result = await agent.chats.createChat(defaultChatUri(session), exactChatContext(session, defaultChatUri(session), session), {}); - -- assert.deepStrictEqual({ -- backings: chatBackings(agent).size, -- providerData: result?.providerData ? JSON.parse(result.providerData) : undefined, -- }, { -- backings: 1, -- providerData: JSON.parse(created.providerData!), -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- backings: chatBackings(agent).size, +- providerData: result?.providerData ? JSON.parse(result.providerData) : undefined, +- }, { +- backings: 1, +- providerData: JSON.parse(created.providerData!), +- }); - }); - - test('createChat forks the source chat into a new addressed chat and returns the forked chat providerData', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'fork-peer'); -- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); -- -- const internals = agent as unknown as ChatInternals; -- // Install the session-backed chat as the fork source so resolution stays -- // in-memory (no SDK resume). -- const source = makeFakeChatSession(session, 'source-sdk'); -- setDefaultSessionStub(agent, AgentSession.id(session), source.fake, defaultChatUri(session)); -- -- // Stub the SDK/fs fork seam: assert the inputs and hand back a -- // deterministic forked chat id. -- let forkArgs: { sourceEntry: unknown; turnId: string } | undefined; -- internals._forkSdkChat = async (_client, sourceEntry, turnId) => { -- forkArgs = { sourceEntry, turnId }; -- return { sessionId: 'forked-sdk-id', inheritedTurnId: undefined }; -- }; -- let captured: CopilotSessionLaunchPlan | undefined; -- internals._createAgentSession = (launchPlan, _dir, _ac, identity) => { -- captured = launchPlan; -- const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); -- (built.fake as { chatChannelUri?: URI }).chatChannelUri = identity?.chatChannelUri; -- return built.fake; -- }; +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'fork-peer'); +- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); +- +- const internals = agent as unknown as ChatInternals; +- // Install the session-backed chat as the fork source so resolution stays +- // in-memory (no SDK resume). +- const source = makeFakeChatSession(session, 'source-sdk'); +- setDefaultSessionStub(agent, AgentSession.id(session), source.fake, defaultChatUri(session)); +- +- // Stub the SDK/fs fork seam: assert the inputs and hand back a +- // deterministic forked chat id. +- let forkArgs: { sourceEntry: unknown; turnId: string } | undefined; +- internals._forkSdkChat = async (_client, sourceEntry, turnId) => { +- forkArgs = { sourceEntry, turnId }; +- return { sessionId: 'forked-sdk-id', inheritedTurnId: undefined }; +- }; +- let captured: CopilotSessionLaunchPlan | undefined; +- internals._createAgentSession = (launchPlan, _dir, _ac, identity) => { +- captured = launchPlan; +- const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); +- (built.fake as { chatChannelUri?: URI }).chatChannelUri = identity?.chatChannelUri; +- return built.fake; +- }; - -- const chatUri = URI.parse(buildChatUri(session, 'peer-fork')); -- const result = await agent.chats.createChat(chatUri, session, { fork: { source: URI.parse(buildDefaultChatUri(session)), turnId: 't1' }, workingDirectories: [URI.file('/workspace')] }); +- const chatUri = URI.parse(buildChatUri(session, 'peer-fork')); +- const result = await agent.chats.createChat(chatUri, session, { fork: { source: URI.parse(buildDefaultChatUri(session)), turnId: 't1' }, workingDirectories: [URI.file('/workspace')] }); - -- const db = sessionDataService.openDatabase(session); -- const raw = await db.object.getMetadata('copilot.chats'); -- assert.deepStrictEqual({ -- sourceIsDefaultSession: forkArgs?.sourceEntry === source.fake, -- forkedTurnId: forkArgs?.turnId, -- launchKind: captured?.kind, -- launchSessionId: captured?.sessionId, -- tracked: hasLiveChat(agent, chatUri), -- backing: internals._chatBackings.get(chatUri.toString()), -- providerData: result ? JSON.parse(result.providerData!) : undefined, -- legacyCatalogWritten: raw !== undefined, -- }, { -- sourceIsDefaultSession: true, -- forkedTurnId: 't1', -- launchKind: 'resume', -- launchSessionId: 'forked-sdk-id', -- tracked: true, -- backing: { sdkSessionId: 'forked-sdk-id' }, -- providerData: { sdkSessionId: 'forked-sdk-id' }, -- legacyCatalogWritten: false, -- }); -- } finally { -- await disposeAgent(agent); -- } +- const db = sessionDataService.openDatabase(session); +- const raw = await db.object.getMetadata('copilot.chats'); +- assert.deepStrictEqual({ +- sourceIsDefaultSession: forkArgs?.sourceEntry === source.fake, +- forkedTurnId: forkArgs?.turnId, +- launchKind: captured?.kind, +- launchSessionId: captured?.sessionId, +- tracked: hasLiveChat(agent, chatUri), +- backing: internals._chatBackings.get(chatUri.toString()), +- providerData: result ? JSON.parse(result.providerData!) : undefined, +- legacyCatalogWritten: raw !== undefined, +- }, { +- sourceIsDefaultSession: true, +- forkedTurnId: 't1', +- launchKind: 'resume', +- launchSessionId: 'forked-sdk-id', +- tracked: true, +- backing: { sdkSessionId: 'forked-sdk-id' }, +- providerData: { sdkSessionId: 'forked-sdk-id' }, +- legacyCatalogWritten: false, +- }); - }); - - test('createChat fork with an independent queue does not block the source queue', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'side-peer'); -- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); -- const sourceTurn: Turn = { -- id: 't1', -- state: TurnState.Complete, -- message: { text: 'source', origin: { kind: MessageKind.User } }, -- responseParts: [], -- usage: undefined, -- }; -- const sideTurn: Turn = { -- id: 't2', -- state: TurnState.Complete, -- message: { text: 'side', origin: { kind: MessageKind.User } }, -- responseParts: [], -- usage: undefined, -- }; -- const source = makeFakeChatSession(session, 'source-sdk', async () => [sourceTurn]); -- setDefaultSessionStub(agent, AgentSession.id(session), source.fake, defaultChatUri(session)); -- const internals = agent as unknown as ChatInternals; -- internals._forkSdkChat = async () => ({ sessionId: 'side-sdk-id', inheritedTurnId: 't1' }); -- let sideRecorder: IFakeChatRecorder | undefined; -- internals._createAgentSession = (launchPlan, _directory, _activeClient, identity) => { -- const side = makeFakeChatSession(session, launchPlan.sessionId, async () => ( -- sideRecorder && sideRecorder.sends.length > 0 ? [sourceTurn, sideTurn] : [sourceTurn] -- ), launchPlan.shellManager); -- Object.assign(side.fake, { resourceUri: identity?.resource, workingDirectory: launchPlan.workingDirectory }); -- sideRecorder = side.rec; -- return side.fake; -- }; -- -- const chatUri = URI.parse(buildChatUri(session, 'peer-side')); -- const sourceLockEntered = new DeferredPromise(); -- const releaseSourceLock = new DeferredPromise(); -- const sourceLock = internals._getOrCreateSessionLifetime(AgentSession.id(session))!.queueSession(async () => { -- sourceLockEntered.complete(); -- await releaseSourceLock.p; -- }); -- await sourceLockEntered.p; -- let result; -- const createTimeout = timeout(5_000); -- try { -- result = await Promise.race([ -- agent.chats.createChat(chatUri, exactChatContext(session, chatUri), { -- fork: { source: URI.parse(buildDefaultChatUri(session)), turnId: 'active-turn', independentQueue: true }, -- workingDirectories: [URI.file('/workspace')], -- }), -- createTimeout.then(() => { throw new Error('Independent fork creation waited for the source turn lock'); }), -- ]); -- } finally { -- createTimeout.cancel(); -- releaseSourceLock.complete(); -- await sourceLock; -- } -- await agent.chats.sendMessage(chatUri, 'side', undefined, undefined, 't2'); -- await agent.chats.sendMessage(chatUri, 'follow-up', undefined, undefined, 't3'); -- await agent.chats.changeModel(chatUri, { id: 'gpt-y' }, exactChatContext(session, chatUri)); -- const turns = await agent.chats.getMessages(chatUri, exactChatContext(session, chatUri)); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'side-peer'); +- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); +- const sourceTurn: Turn = { +- id: 't1', +- state: TurnState.Complete, +- message: { text: 'source', origin: { kind: MessageKind.User } }, +- responseParts: [], +- usage: undefined, +- }; +- const sideTurn: Turn = { +- id: 't2', +- state: TurnState.Complete, +- message: { text: 'side', origin: { kind: MessageKind.User } }, +- responseParts: [], +- usage: undefined, +- }; +- const source = makeFakeChatSession(session, 'source-sdk', async () => [sourceTurn]); +- setDefaultSessionStub(agent, AgentSession.id(session), source.fake, defaultChatUri(session)); +- const internals = agent as unknown as ChatInternals; +- internals._forkSdkChat = async () => ({ sessionId: 'side-sdk-id', inheritedTurnId: 't1' }); +- let sideRecorder: IFakeChatRecorder | undefined; +- internals._createAgentSession = (launchPlan, _directory, _activeClient, identity) => { +- const side = makeFakeChatSession(session, launchPlan.sessionId, async () => ( +- sideRecorder && sideRecorder.sends.length > 0 ? [sourceTurn, sideTurn] : [sourceTurn] +- ), launchPlan.shellManager); +- Object.assign(side.fake, { resourceUri: identity?.resource, workingDirectory: launchPlan.workingDirectory }); +- sideRecorder = side.rec; +- return side.fake; +- }; - -- assert.deepStrictEqual({ -- sentPrompts: sideRecorder?.sends.map(send => send.prompt), -- turns: turns.map(turn => turn.id), -- providerData: result ? JSON.parse(result.providerData!) : undefined, -- inheritedTurnId: result?.inheritedTurnId, -- }, { -- sentPrompts: ['side', 'follow-up'], -- turns: ['t1', 't2'], -- providerData: { sdkSessionId: 'side-sdk-id' }, -- inheritedTurnId: 't1', -- }); +- const chatUri = URI.parse(buildChatUri(session, 'peer-side')); +- const sourceLockEntered = new DeferredPromise(); +- const releaseSourceLock = new DeferredPromise(); +- const sourceLock = internals._getOrCreateSessionLifetime(AgentSession.id(session))!.queueSession(async () => { +- sourceLockEntered.complete(); +- await releaseSourceLock.p; +- }); +- await sourceLockEntered.p; +- let result; +- const createTimeout = timeout(5_000); +- try { +- result = await Promise.race([ +- agent.chats.createChat(chatUri, exactChatContext(session, chatUri), { +- fork: { source: URI.parse(buildDefaultChatUri(session)), turnId: 'active-turn', independentQueue: true }, +- workingDirectories: [URI.file('/workspace')], +- }), +- createTimeout.then(() => { throw new Error('Independent fork creation waited for the source turn lock'); }), +- ]); - } finally { -- await disposeAgent(agent); +- createTimeout.cancel(); +- releaseSourceLock.complete(); +- await sourceLock; - } +- await agent.chats.sendMessage(chatUri, 'side', undefined, undefined, 't2'); +- await agent.chats.sendMessage(chatUri, 'follow-up', undefined, undefined, 't3'); +- await agent.chats.changeModel(chatUri, { id: 'gpt-y' }, exactChatContext(session, chatUri)); +- const turns = await agent.chats.getMessages(chatUri, exactChatContext(session, chatUri)); +- +- assert.deepStrictEqual({ +- sentPrompts: sideRecorder?.sends.map(send => send.prompt), +- turns: turns.map(turn => turn.id), +- providerData: result ? JSON.parse(result.providerData!) : undefined, +- inheritedTurnId: result?.inheritedTurnId, +- }, { +- sentPrompts: ['side', 'follow-up'], +- turns: ['t1', 't2'], +- providerData: { sdkSessionId: 'side-sdk-id' }, +- inheritedTurnId: 't1', +- }); - }); - - test('createChat fork uses the supplied provider anchor', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'side-local-peer'); -- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); -- const sourceTurn: Turn = { -- id: 't1', -- state: TurnState.Complete, -- message: { text: 'source', origin: { kind: MessageKind.User } }, -- responseParts: [], -- usage: undefined, -- }; -- const sideTurn: Turn = { -- id: 't2', -- state: TurnState.Complete, -- message: { text: 'side', origin: { kind: MessageKind.User } }, -- responseParts: [], -- usage: undefined, -- }; -- const source = makeFakeChatSession(session, 'source-sdk', async () => [sourceTurn]); -- setDefaultSessionStub(agent, AgentSession.id(session), source.fake, defaultChatUri(session)); -- const internals = agent as unknown as ChatInternals; -- let forkTurnId: string | undefined; -- internals._forkSdkChat = async (_client, _sourceEntry, turnId) => { -- forkTurnId = turnId; -- return { sessionId: 'side-sdk-id', inheritedTurnId: 't1' }; -- }; -- let sideRecorder: IFakeChatRecorder | undefined; -- internals._createAgentSession = (launchPlan, _directory, _activeClient, identity) => { -- const side = makeFakeChatSession(session, launchPlan.sessionId, async () => ( -- sideRecorder && sideRecorder.sends.length > 0 ? [sourceTurn, sideTurn] : [sourceTurn] -- ), launchPlan.shellManager); -- Object.assign(side.fake, { resourceUri: identity?.resource, workingDirectory: launchPlan.workingDirectory }); -- sideRecorder = side.rec; -- return side.fake; -- }; +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'side-local-peer'); +- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); +- const sourceTurn: Turn = { +- id: 't1', +- state: TurnState.Complete, +- message: { text: 'source', origin: { kind: MessageKind.User } }, +- responseParts: [], +- usage: undefined, +- }; +- const sideTurn: Turn = { +- id: 't2', +- state: TurnState.Complete, +- message: { text: 'side', origin: { kind: MessageKind.User } }, +- responseParts: [], +- usage: undefined, +- }; +- const source = makeFakeChatSession(session, 'source-sdk', async () => [sourceTurn]); +- setDefaultSessionStub(agent, AgentSession.id(session), source.fake, defaultChatUri(session)); +- const internals = agent as unknown as ChatInternals; +- let forkTurnId: string | undefined; +- internals._forkSdkChat = async (_client, _sourceEntry, turnId) => { +- forkTurnId = turnId; +- return { sessionId: 'side-sdk-id', inheritedTurnId: 't1' }; +- }; +- let sideRecorder: IFakeChatRecorder | undefined; +- internals._createAgentSession = (launchPlan, _directory, _activeClient, identity) => { +- const side = makeFakeChatSession(session, launchPlan.sessionId, async () => ( +- sideRecorder && sideRecorder.sends.length > 0 ? [sourceTurn, sideTurn] : [sourceTurn] +- ), launchPlan.shellManager); +- Object.assign(side.fake, { resourceUri: identity?.resource, workingDirectory: launchPlan.workingDirectory }); +- sideRecorder = side.rec; +- return side.fake; +- }; - -- const chatUri = URI.parse(buildChatUri(session, 'peer-side-local')); -- const result = await agent.chats.createChat(chatUri, exactChatContext(session, chatUri), { -- fork: { -- source: URI.parse(buildDefaultChatUri(session)), -- turnId: 't1', -- independentQueue: true, -- }, -- workingDirectories: [URI.file('/workspace')], -- }); -- await agent.chats.sendMessage(chatUri, 'side', undefined, undefined, 't2'); -- await agent.chats.sendMessage(chatUri, 'follow-up', undefined, undefined, 't3'); -- const turns = await agent.chats.getMessages(chatUri, exactChatContext(session, chatUri)); +- const chatUri = URI.parse(buildChatUri(session, 'peer-side-local')); +- const result = await agent.chats.createChat(chatUri, exactChatContext(session, chatUri), { +- fork: { +- source: URI.parse(buildDefaultChatUri(session)), +- turnId: 't1', +- independentQueue: true, +- }, +- workingDirectories: [URI.file('/workspace')], +- }); +- await agent.chats.sendMessage(chatUri, 'side', undefined, undefined, 't2'); +- await agent.chats.sendMessage(chatUri, 'follow-up', undefined, undefined, 't3'); +- const turns = await agent.chats.getMessages(chatUri, exactChatContext(session, chatUri)); - -- assert.deepStrictEqual({ -- forkTurnId, -- sentPrompts: sideRecorder?.sends.map(send => send.prompt), -- turns: turns.map(turn => turn.id), -- providerData: result ? JSON.parse(result.providerData!) : undefined, -- inheritedTurnId: result?.inheritedTurnId, -- }, { -- forkTurnId: 't1', -- sentPrompts: ['side', 'follow-up'], -- turns: ['t1', 't2'], -- providerData: { sdkSessionId: 'side-sdk-id' }, -- inheritedTurnId: 't1', -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- forkTurnId, +- sentPrompts: sideRecorder?.sends.map(send => send.prompt), +- turns: turns.map(turn => turn.id), +- providerData: result ? JSON.parse(result.providerData!) : undefined, +- inheritedTurnId: result?.inheritedTurnId, +- }, { +- forkTurnId: 't1', +- sentPrompts: ['side', 'follow-up'], +- turns: ['t1', 't2'], +- providerData: { sdkSessionId: 'side-sdk-id' }, +- inheritedTurnId: 't1', +- }); - }); - - test('sendMessage routes a turn to the targeted peer chat only', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'route-msg'); -- const chatA = URI.parse(buildChatUri(session, 'peer-a')); -- const chatB = URI.parse(buildChatUri(session, 'peer-b')); -- const a = makeFakeChatSession(session, 'sdk-a'); -- const b = makeFakeChatSession(session, 'sdk-b'); -- setPeerChatStub(agent, chatA, a.fake); -- setPeerChatStub(agent, chatB, b.fake); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'route-msg'); +- const chatA = URI.parse(buildChatUri(session, 'peer-a')); +- const chatB = URI.parse(buildChatUri(session, 'peer-b')); +- const a = makeFakeChatSession(session, 'sdk-a'); +- const b = makeFakeChatSession(session, 'sdk-b'); +- setPeerChatStub(agent, chatA, a.fake); +- setPeerChatStub(agent, chatB, b.fake); - -- await agent.chats.sendMessage(chatA, 'hello-a', undefined, undefined, 'turn-a', 'client-1'); +- await agent.chats.sendMessage(chatA, 'hello-a', undefined, undefined, 'turn-a', 'client-1'); - -- assert.deepStrictEqual({ -- aSends: a.rec.sends.map(s => ({ prompt: s.prompt, turnId: s.turnId, senderClientId: s.senderClientId })), -- aResets: a.rec.resets, -- bSends: b.rec.sends, -- bResets: b.rec.resets, -- }, { -- aSends: [{ prompt: 'hello-a', turnId: 'turn-a', senderClientId: 'client-1' }], -- aResets: [{ turnId: 'turn-a', senderClientId: 'client-1' }], -- bSends: [], -- bResets: [], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- aSends: a.rec.sends.map(s => ({ prompt: s.prompt, turnId: s.turnId, senderClientId: s.senderClientId })), +- aResets: a.rec.resets, +- bSends: b.rec.sends, +- bResets: b.rec.resets, +- }, { +- aSends: [{ prompt: 'hello-a', turnId: 'turn-a', senderClientId: 'client-1' }], +- aResets: [{ turnId: 'turn-a', senderClientId: 'client-1' }], +- bSends: [], +- bResets: [], +- }); - }); - - test('sendMessage on an addressed chat resumes the addressed backing even when the parent session is provisional', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'route-provisional-peer'); -- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); -- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); -- await agent.materializeChat(chatUri, session, JSON.stringify({ sdkSessionId: 'peer-sdk-id' })); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'route-provisional-peer'); +- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); +- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); +- await agent.materializeChat(chatUri, session, JSON.stringify({ sdkSessionId: 'peer-sdk-id' })); - -- const internals = agent as unknown as ChatInternals; -- const launches: { kind: string; sessionId: string; chat: string | undefined }[] = []; -- internals._createAgentSession = (launchPlan, _dir, _ac, identity) => { -- launches.push({ kind: launchPlan.kind, sessionId: launchPlan.sessionId, chat: identity?.chatChannelUri.toString() }); -- const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); -- (built.fake as { chatChannelUri?: URI }).chatChannelUri = identity?.chatChannelUri; -- return built.fake; -- }; +- const internals = agent as unknown as ChatInternals; +- const launches: { kind: string; sessionId: string; chat: string | undefined }[] = []; +- internals._createAgentSession = (launchPlan, _dir, _ac, identity) => { +- launches.push({ kind: launchPlan.kind, sessionId: launchPlan.sessionId, chat: identity?.chatChannelUri.toString() }); +- const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); +- (built.fake as { chatChannelUri?: URI }).chatChannelUri = identity?.chatChannelUri; +- return built.fake; +- }; - -- await agent.chats.sendMessage(chatUri, 'hello peer', undefined, undefined, undefined, undefined, exactChatContext(session, chatUri)); +- await agent.chats.sendMessage(chatUri, 'hello peer', undefined, undefined, undefined, undefined, exactChatContext(session, chatUri)); - -- assert.deepStrictEqual({ -- launches, -- tracked: hasLiveChat(agent, chatUri), -- parentResumeCalls: (agent as TestableCopilotAgent).resumeCalls, -- }, { -- launches: [{ kind: 'resume', sessionId: 'peer-sdk-id', chat: chatUri.toString() }], -- tracked: true, -- parentResumeCalls: [], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- launches, +- tracked: hasLiveChat(agent, chatUri), +- parentResumeCalls: (agent as TestableCopilotAgent).resumeCalls, +- }, { +- launches: [{ kind: 'resume', sessionId: 'peer-sdk-id', chat: chatUri.toString() }], +- tracked: true, +- parentResumeCalls: [], +- }); - }); - - test('sendMessage attributes a peer chat resume to launch stages and forwards the recorder to the launch plan', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'stage-peer'); -- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); -- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); -- await agent.materializeChat(chatUri, session, JSON.stringify({ sdkSessionId: 'peer-sdk-id' })); -- -- const marks: string[] = []; -- const sendStageRecorder = { mark: (stage: string) => { marks.push(stage); } }; -- const internals = agent as unknown as ChatInternals; -- const forwardedRecorders: boolean[] = []; -- internals._createAgentSession = (launchPlan, _dir, _ac, identity) => { -- forwardedRecorders.push(launchPlan.stageRecorder === sendStageRecorder); -- const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); -- (built.fake as { chatChannelUri?: URI }).chatChannelUri = identity?.chatChannelUri; -- return built.fake; -- }; +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'stage-peer'); +- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); +- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); +- await agent.materializeChat(chatUri, session, JSON.stringify({ sdkSessionId: 'peer-sdk-id' })); +- +- const marks: string[] = []; +- const sendStageRecorder = { mark: (stage: string) => { marks.push(stage); } }; +- const internals = agent as unknown as ChatInternals; +- const forwardedRecorders: boolean[] = []; +- internals._createAgentSession = (launchPlan, _dir, _ac, identity) => { +- forwardedRecorders.push(launchPlan.stageRecorder === sendStageRecorder); +- const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); +- (built.fake as { chatChannelUri?: URI }).chatChannelUri = identity?.chatChannelUri; +- return built.fake; +- }; - -- await agent.chats.sendMessage(chatUri, 'hello peer', undefined, undefined, undefined, undefined, { ...exactChatContext(session, chatUri), sendStageRecorder }); +- await agent.chats.sendMessage(chatUri, 'hello peer', undefined, undefined, undefined, undefined, { ...exactChatContext(session, chatUri), sendStageRecorder }); - -- assert.deepStrictEqual({ marks, forwardedRecorders }, { -- marks: ['queue', 'refresh', 'snapshot', 'client', 'snapshot', 'persist', 'turnPrepare'], -- forwardedRecorders: [true], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ marks, forwardedRecorders }, { +- marks: ['queue', 'refresh', 'snapshot', 'client', 'snapshot', 'persist', 'turnPrepare'], +- forwardedRecorders: [true], +- }); - }); - - test('sendMessage rejects the history-only fallback before sending to an addressed backing', async () => { @@ -74368,176 +76310,164 @@ index f24a97a6296..00000000000 - copilotClient: new TestCopilotClient([]), - worktreeIsolation, - }); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'route-resolved-peer'); +- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); +- const persistedWorkingDirectory = URI.file('/missing-worktree'); +- const resolvedWorkingDirectory = URI.file('/repository'); +- const secondaryWorkingDirectory = URI.file('/secondary'); +- const resolveCalls: { session: string; workingDirectory: string }[] = []; +- worktreeIsolation.resolveWorkingDirectoryForResume = async (session, _sessionId, workingDirectory) => { +- resolveCalls.push({ session: session.toString(), workingDirectory: workingDirectory.toString() }); +- return resolvedWorkingDirectory; +- }; +- await provisionSession(agent, { session, workingDirectories: [persistedWorkingDirectory, secondaryWorkingDirectory] }); +- const stored = sessionDataService.openDatabase(session); - try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'route-resolved-peer'); -- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); -- const persistedWorkingDirectory = URI.file('/missing-worktree'); -- const resolvedWorkingDirectory = URI.file('/repository'); -- const secondaryWorkingDirectory = URI.file('/secondary'); -- const resolveCalls: { session: string; workingDirectory: string }[] = []; -- worktreeIsolation.resolveWorkingDirectoryForResume = async (session, _sessionId, workingDirectory) => { -- resolveCalls.push({ session: session.toString(), workingDirectory: workingDirectory.toString() }); -- return resolvedWorkingDirectory; -- }; -- await provisionSession(agent, { session, workingDirectories: [persistedWorkingDirectory, secondaryWorkingDirectory] }); -- const stored = sessionDataService.openDatabase(session); -- try { -- await stored.object.setMetadataValues({ -- 'copilot.workingDirectory': persistedWorkingDirectory.toString(), -- 'copilot.workingDirectories': JSON.stringify([persistedWorkingDirectory.toString(), secondaryWorkingDirectory.toString()]), -- }); -- } finally { -- stored.dispose(); -- } -- await agent.materializeChat(chatUri, session, JSON.stringify({ sdkSessionId: 'peer-sdk-id' })); -- -- const internals = agent as unknown as ChatInternals; -- const launches: { workingDirectory: string | undefined; additionalDirectories: string[] | undefined; customizationDirectory: string | undefined }[] = []; -- internals._createAgentSession = (launchPlan, customizationDirectory, _activeClient, identity) => { -- launches.push({ -- workingDirectory: launchPlan.workingDirectory?.toString(), -- additionalDirectories: launchPlan.additionalDirectories?.map(directory => directory.toString()), -- customizationDirectory: customizationDirectory?.toString(), -- }); -- const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); -- (built.fake as { chatChannelUri?: URI }).chatChannelUri = identity?.chatChannelUri; -- (built.fake as { appliedAdditionalDirectories?: readonly URI[] }).appliedAdditionalDirectories = launchPlan.additionalDirectories; -- (built.fake as { workingDirectory?: URI }).workingDirectory = launchPlan.workingDirectory; -- return built.fake; -- }; -- -- await assert.rejects( -- agent.chats.sendMessage(chatUri, 'hello peer', [persistedWorkingDirectory, secondaryWorkingDirectory], undefined, undefined, undefined, exactChatContext(session, chatUri, session)), -- /fallback directory for history only/, -- ); -- const dbRef = sessionDataService.openDatabase(session); -- const storedWorkingDirectories = await dbRef.object.getMetadata('copilot.workingDirectories'); -- dbRef.dispose(); -- -- assert.deepStrictEqual({ -- resolveCalls, -- launches, -- storedWorkingDirectories: storedWorkingDirectories ? JSON.parse(storedWorkingDirectories) : undefined, -- }, { -- resolveCalls: [{ session: session.toString(), workingDirectory: persistedWorkingDirectory.toString() }], -- launches: [{ -- workingDirectory: resolvedWorkingDirectory.toString(), -- additionalDirectories: [secondaryWorkingDirectory.toString()], -- customizationDirectory: resolvedWorkingDirectory.toString(), -- }], -- storedWorkingDirectories: [persistedWorkingDirectory.toString(), secondaryWorkingDirectory.toString()], +- await stored.object.setMetadataValues({ +- 'copilot.workingDirectory': persistedWorkingDirectory.toString(), +- 'copilot.workingDirectories': JSON.stringify([persistedWorkingDirectory.toString(), secondaryWorkingDirectory.toString()]), - }); - } finally { -- await disposeAgent(agent); +- stored.dispose(); - } +- await agent.materializeChat(chatUri, session, JSON.stringify({ sdkSessionId: 'peer-sdk-id' })); +- +- const internals = agent as unknown as ChatInternals; +- const launches: { workingDirectory: string | undefined; additionalDirectories: string[] | undefined; customizationDirectory: string | undefined }[] = []; +- internals._createAgentSession = (launchPlan, customizationDirectory, _activeClient, identity) => { +- launches.push({ +- workingDirectory: launchPlan.workingDirectory?.toString(), +- additionalDirectories: launchPlan.additionalDirectories?.map(directory => directory.toString()), +- customizationDirectory: customizationDirectory?.toString(), +- }); +- const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); +- (built.fake as { chatChannelUri?: URI }).chatChannelUri = identity?.chatChannelUri; +- (built.fake as { appliedAdditionalDirectories?: readonly URI[] }).appliedAdditionalDirectories = launchPlan.additionalDirectories; +- (built.fake as { workingDirectory?: URI }).workingDirectory = launchPlan.workingDirectory; +- return built.fake; +- }; +- +- await assert.rejects( +- agent.chats.sendMessage(chatUri, 'hello peer', [persistedWorkingDirectory, secondaryWorkingDirectory], undefined, undefined, undefined, exactChatContext(session, chatUri, session)), +- /fallback directory for history only/, +- ); +- const dbRef = sessionDataService.openDatabase(session); +- const storedWorkingDirectories = await dbRef.object.getMetadata('copilot.workingDirectories'); +- dbRef.dispose(); +- +- assert.deepStrictEqual({ +- resolveCalls, +- launches, +- storedWorkingDirectories: storedWorkingDirectories ? JSON.parse(storedWorkingDirectories) : undefined, +- }, { +- resolveCalls: [{ session: session.toString(), workingDirectory: persistedWorkingDirectory.toString() }], +- launches: [{ +- workingDirectory: resolvedWorkingDirectory.toString(), +- additionalDirectories: [secondaryWorkingDirectory.toString()], +- customizationDirectory: resolvedWorkingDirectory.toString(), +- }], +- storedWorkingDirectories: [persistedWorkingDirectory.toString(), secondaryWorkingDirectory.toString()], +- }); - }); - - test('sendMessage preserves provider-owned workspace-less working directories when the caller still supplies the scratch directory', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]), useRealResumePath: true }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'route-converted-quick-chat'); -- const chatUri = defaultChatUri(session); -- const sdkSessionId = 'converted-quick-chat-sdk'; -- const scratchDirectory = URI.file('/quick-chat-scratch'); -- const providerWorkingDirectory = URI.file('/selected-workspace'); -- await agent.materializeChat(chatUri, exactChatContext(session, chatUri, session), JSON.stringify({ sdkSessionId })); -- const dbRef = sessionDataService.openDatabase(session); -- await dbRef.object.setMetadata('copilot.workingDirectory', providerWorkingDirectory.toString()); -- await dbRef.object.setMetadata('copilot.workingDirectories', JSON.stringify([providerWorkingDirectory.toString()])); -- await dbRef.object.setMetadata('copilot.customizationDirectory', providerWorkingDirectory.toString()); -- await dbRef.object.setMetadata('agentHost.workspaceless', 'true'); -- dbRef.dispose(); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'route-converted-quick-chat'); +- const chatUri = defaultChatUri(session); +- const sdkSessionId = 'converted-quick-chat-sdk'; +- const scratchDirectory = URI.file('/quick-chat-scratch'); +- const providerWorkingDirectory = URI.file('/selected-workspace'); +- await agent.materializeChat(chatUri, exactChatContext(session, chatUri, session), JSON.stringify({ sdkSessionId })); +- const dbRef = sessionDataService.openDatabase(session); +- await dbRef.object.setMetadata('copilot.workingDirectory', providerWorkingDirectory.toString()); +- await dbRef.object.setMetadata('copilot.workingDirectories', JSON.stringify([providerWorkingDirectory.toString()])); +- await dbRef.object.setMetadata('copilot.customizationDirectory', providerWorkingDirectory.toString()); +- await dbRef.object.setMetadata('agentHost.workspaceless', 'true'); +- dbRef.dispose(); - -- const internals = agent as unknown as ChatInternals; -- const launches: { sessionId: string; workingDirectory: string | undefined; additionalDirectories: string[] | undefined }[] = []; -- let recorder: IFakeChatRecorder | undefined; -- internals._createAgentSession = (launchPlan, _customizationDirectory, _activeClient, identity) => { -- launches.push({ -- sessionId: launchPlan.sessionId, -- workingDirectory: launchPlan.workingDirectory?.toString(), -- additionalDirectories: launchPlan.additionalDirectories?.map(directory => directory.toString()), -- }); -- const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); -- recorder = built.rec; -- (built.fake as { chatChannelUri?: URI }).chatChannelUri = identity?.chatChannelUri; -- (built.fake as { appliedAdditionalDirectories?: readonly URI[] }).appliedAdditionalDirectories = launchPlan.additionalDirectories; -- (built.fake as { workingDirectory?: URI }).workingDirectory = launchPlan.workingDirectory; -- return built.fake; -- }; +- const internals = agent as unknown as ChatInternals; +- const launches: { sessionId: string; workingDirectory: string | undefined; additionalDirectories: string[] | undefined }[] = []; +- let recorder: IFakeChatRecorder | undefined; +- internals._createAgentSession = (launchPlan, _customizationDirectory, _activeClient, identity) => { +- launches.push({ +- sessionId: launchPlan.sessionId, +- workingDirectory: launchPlan.workingDirectory?.toString(), +- additionalDirectories: launchPlan.additionalDirectories?.map(directory => directory.toString()), +- }); +- const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); +- recorder = built.rec; +- (built.fake as { chatChannelUri?: URI }).chatChannelUri = identity?.chatChannelUri; +- (built.fake as { appliedAdditionalDirectories?: readonly URI[] }).appliedAdditionalDirectories = launchPlan.additionalDirectories; +- (built.fake as { workingDirectory?: URI }).workingDirectory = launchPlan.workingDirectory; +- return built.fake; +- }; - -- await agent.chats.sendMessage(chatUri, 'continue in the selected workspace', [scratchDirectory], undefined, 'turn-1', undefined, { ...exactChatContext(session, chatUri, session), workspaceless: true }); -- const storedRef = sessionDataService.openDatabase(session); -- const metadata = await storedRef.object.getMetadataObject({ -- 'copilot.workingDirectory': true, -- 'copilot.workingDirectories': true, -- 'copilot.customizationDirectory': true, -- 'agentHost.workspaceless': true, -- }); -- storedRef.dispose(); +- await agent.chats.sendMessage(chatUri, 'continue in the selected workspace', [scratchDirectory], undefined, 'turn-1', undefined, { ...exactChatContext(session, chatUri, session), workspaceless: true }); +- const storedRef = sessionDataService.openDatabase(session); +- const metadata = await storedRef.object.getMetadataObject({ +- 'copilot.workingDirectory': true, +- 'copilot.workingDirectories': true, +- 'copilot.customizationDirectory': true, +- 'agentHost.workspaceless': true, +- }); +- storedRef.dispose(); - -- assert.deepStrictEqual({ -- launches, -- sentPrompts: recorder?.sends.map(send => send.prompt), -- metadata, -- }, { -- launches: [{ -- sessionId: sdkSessionId, -- workingDirectory: providerWorkingDirectory.toString(), -- additionalDirectories: [], -- }], -- sentPrompts: ['continue in the selected workspace'], -- metadata: { -- 'copilot.workingDirectory': providerWorkingDirectory.toString(), -- 'copilot.workingDirectories': JSON.stringify([providerWorkingDirectory.toString()]), -- 'copilot.customizationDirectory': providerWorkingDirectory.toString(), -- 'agentHost.workspaceless': 'true', -- }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- launches, +- sentPrompts: recorder?.sends.map(send => send.prompt), +- metadata, +- }, { +- launches: [{ +- sessionId: sdkSessionId, +- workingDirectory: providerWorkingDirectory.toString(), +- additionalDirectories: [], +- }], +- sentPrompts: ['continue in the selected workspace'], +- metadata: { +- 'copilot.workingDirectory': providerWorkingDirectory.toString(), +- 'copilot.workingDirectories': JSON.stringify([providerWorkingDirectory.toString()]), +- 'copilot.customizationDirectory': providerWorkingDirectory.toString(), +- 'agentHost.workspaceless': 'true', +- }, +- }); - }); - - test('sendMessage throws for a chat with no backing chat', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'route-ghost'); -- const chatUri = URI.parse(buildChatUri(session, 'ghost')); -- await assert.rejects( -- () => agent.chats.sendMessage(chatUri, 'hi', undefined, undefined, undefined, undefined, exactChatContext(session, chatUri)), -- /unknown chat/, -- ); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'route-ghost'); +- const chatUri = URI.parse(buildChatUri(session, 'ghost')); +- await assert.rejects( +- () => agent.chats.sendMessage(chatUri, 'hi', undefined, undefined, undefined, undefined, exactChatContext(session, chatUri)), +- /unknown chat/, +- ); - }); - - test('changeModel applies to the targeted chat only', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'model-route'); -- const chatA = URI.parse(buildChatUri(session, 'peer-a')); -- const chatB = URI.parse(buildChatUri(session, 'peer-b')); -- const a = makeFakeChatSession(session, 'sdk-a'); -- const b = makeFakeChatSession(session, 'sdk-b'); -- setPeerChatStub(agent, chatA, a.fake); -- setPeerChatStub(agent, chatB, b.fake); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'model-route'); +- const chatA = URI.parse(buildChatUri(session, 'peer-a')); +- const chatB = URI.parse(buildChatUri(session, 'peer-b')); +- const a = makeFakeChatSession(session, 'sdk-a'); +- const b = makeFakeChatSession(session, 'sdk-b'); +- setPeerChatStub(agent, chatA, a.fake); +- setPeerChatStub(agent, chatB, b.fake); - -- await agent.chats.changeModel(chatA, { id: 'model-x' }, exactChatContext(session, chatA)); +- await agent.chats.changeModel(chatA, { id: 'model-x' }, exactChatContext(session, chatA)); - -- assert.deepStrictEqual({ -- aModels: a.rec.modelCalls.map(m => m.id), -- bModels: b.rec.modelCalls.map(m => m.id), -- }, { -- aModels: ['model-x'], -- bModels: [], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- aModels: a.rec.modelCalls.map(m => m.id), +- bModels: b.rec.modelCalls.map(m => m.id), +- }, { +- aModels: ['model-x'], +- bModels: [], +- }); - }); - - for (const cancel of [false, true]) { @@ -74582,25 +76512,22 @@ index f24a97a6296..00000000000 - - test('changeModel applies the per-model reasoning-effort override from the capability overrides', async () => { - const { agent, configurationService } = createTestAgentContext(disposables); -- try { -- configurationService.updateRootConfig({ modelCapabilityOverrides: { 'model-x': { reasoningEffort: 'low' }, '*': { reasoningEffort: 'high' } } }); -- const session = AgentSession.uri('copilotcli', 'model-effort'); -- const chatA = URI.parse(buildChatUri(session, 'peer-a')); -- const a = makeFakeChatSession(session, 'sdk-a'); -- setPeerChatStub(agent, chatA, a.fake); -- -- await agent.chats.changeModel(chatA, { id: 'model-x' }, exactChatContext(session, chatA)); -- await agent.chats.changeModel(chatA, { id: 'model-y', config: { thinkingLevel: 'medium' } }, exactChatContext(session, chatA)); -- -- assert.deepStrictEqual(a.rec.modelCalls, [ -- // the specific entry wins; the wildcard covers every other model, -- // beating the picker's thinking level -- { id: 'model-x', effort: 'low', tier: undefined }, -- { id: 'model-y', effort: 'high', tier: undefined }, -- ]); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- configurationService.updateRootConfig({ modelCapabilityOverrides: { 'model-x': { reasoningEffort: 'low' }, '*': { reasoningEffort: 'high' } } }); +- const session = AgentSession.uri('copilotcli', 'model-effort'); +- const chatA = URI.parse(buildChatUri(session, 'peer-a')); +- const a = makeFakeChatSession(session, 'sdk-a'); +- setPeerChatStub(agent, chatA, a.fake); +- +- await agent.chats.changeModel(chatA, { id: 'model-x' }, exactChatContext(session, chatA)); +- await agent.chats.changeModel(chatA, { id: 'model-y', config: { thinkingLevel: 'medium' } }, exactChatContext(session, chatA)); +- +- assert.deepStrictEqual(a.rec.modelCalls, [ +- // the specific entry wins; the wildcard covers every other model, +- // beating the picker's thinking level +- { id: 'model-x', effort: 'low', tier: undefined }, +- { id: 'model-y', effort: 'high', tier: undefined }, +- ]); - }); - - test('changeModel passes selected thinking level and context size together to the SDK', async () => { @@ -74617,73 +76544,64 @@ index f24a97a6296..00000000000 - }, - }; - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([], [model]) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await waitForState(agent.models, models => models.length > 0); -- const session = AgentSession.uri('copilotcli', 'model-tuning'); -- const chat = URI.parse(buildChatUri(session, 'peer-a')); -- const sdk = makeFakeChatSession(session, 'sdk-a'); -- setPeerChatStub(agent, chat, sdk.fake); -- -- await agent.chats.changeModel(chat, { -- id: model.id, -- config: { thinkingLevel: 'high', contextSize: 1_000_000 }, -- }, exactChatContext(session, chat)); -- await agent.chats.changeModel(chat, { -- id: model.id, -- config: { reasoningEffort: 'low', contextSize: 272_000 }, -- }, exactChatContext(session, chat)); -- -- assert.deepStrictEqual(sdk.rec.modelCalls, [ -- { id: model.id, effort: 'high', tier: 'long_context' }, -- { id: model.id, effort: 'low', tier: 'default' }, -- ]); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await waitForState(agent.models, models => models.length > 0); +- const session = AgentSession.uri('copilotcli', 'model-tuning'); +- const chat = URI.parse(buildChatUri(session, 'peer-a')); +- const sdk = makeFakeChatSession(session, 'sdk-a'); +- setPeerChatStub(agent, chat, sdk.fake); +- +- await agent.chats.changeModel(chat, { +- id: model.id, +- config: { thinkingLevel: 'high', contextSize: 1_000_000 }, +- }, exactChatContext(session, chat)); +- await agent.chats.changeModel(chat, { +- id: model.id, +- config: { reasoningEffort: 'low', contextSize: 272_000 }, +- }, exactChatContext(session, chat)); +- +- assert.deepStrictEqual(sdk.rec.modelCalls, [ +- { id: model.id, effort: 'high', tier: 'long_context' }, +- { id: model.id, effort: 'low', tier: 'default' }, +- ]); - }); - - test('publishes model catalogue readiness after successful discovery', async () => { - const client = new TestCopilotClient([], [{ id: 'model-a', name: 'Model A' }]); - const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client }); - const status = () => configurationService.getRootConfigValues?.()[agentModelCatalogStatusKey('copilotcli')]; -- try { -- const before = status(); -- await agent.authenticate('https://api.github.com', 'token'); -- await waitForState(agent.models, models => models.length > 0); +- afterTest(() => disposeAgent(agent)); +- const before = status(); +- await agent.authenticate('https://api.github.com', 'token'); +- await waitForState(agent.models, models => models.length > 0); - -- assert.deepStrictEqual({ before, after: status() }, { before: 'pending', after: 'ready' }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ before, after: status() }, { before: 'pending', after: 'ready' }); - }); - - test('changeModel keeps the selected model id and tuning through a family alias', async () => { - const { agent, configurationService } = createTestAgentContext(disposables); -- try { -- configurationService.updateRootConfig({ -- modelCapabilityOverrides: { -- 'preview-model': { family: 'claude-opus-4.8' }, -- 'pinned-model': { family: 'gpt-5', reasoningEffort: 'high' }, -- }, -- }); -- const session = AgentSession.uri('copilotcli', 'model-family'); -- const chatA = URI.parse(buildChatUri(session, 'peer-a')); -- const a = makeFakeChatSession(session, 'sdk-a'); -- setPeerChatStub(agent, chatA, a.fake); -- -- await agent.chats.changeModel(chatA, { id: 'preview-model', config: { thinkingLevel: 'medium' } }, exactChatContext(session, chatA)); -- await agent.chats.changeModel(chatA, { id: 'pinned-model' }, exactChatContext(session, chatA)); -- -- assert.deepStrictEqual(a.rec.modelCalls, [ -- // the alias never reaches the wire; the picker's level survives -- { id: 'preview-model', effort: 'medium', tier: undefined }, -- // a per-model effort override still applies -- { id: 'pinned-model', effort: 'high', tier: undefined }, -- ]); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- configurationService.updateRootConfig({ +- modelCapabilityOverrides: { +- 'preview-model': { family: 'claude-opus-4.8' }, +- 'pinned-model': { family: 'gpt-5', reasoningEffort: 'high' }, +- }, +- }); +- const session = AgentSession.uri('copilotcli', 'model-family'); +- const chatA = URI.parse(buildChatUri(session, 'peer-a')); +- const a = makeFakeChatSession(session, 'sdk-a'); +- setPeerChatStub(agent, chatA, a.fake); +- +- await agent.chats.changeModel(chatA, { id: 'preview-model', config: { thinkingLevel: 'medium' } }, exactChatContext(session, chatA)); +- await agent.chats.changeModel(chatA, { id: 'pinned-model' }, exactChatContext(session, chatA)); +- +- assert.deepStrictEqual(a.rec.modelCalls, [ +- // the alias never reaches the wire; the picker's level survives +- { id: 'preview-model', effort: 'medium', tier: undefined }, +- // a per-model effort override still applies +- { id: 'pinned-model', effort: 'high', tier: undefined }, +- ]); - }); - - test('changeModel persists the model for metadata-fallback resumes', async () => { @@ -74691,25 +76609,22 @@ index f24a97a6296..00000000000 - const client = new TestCopilotClient([], [{ id: 'model-a', name: 'Model A' }, { id: 'model-b', name: 'Model B' }]); - client.createSession = async () => new MockCopilotSession() as unknown as CopilotSession; - const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await waitForState(agent.models, m => m.length > 0); -- const session = AgentSession.uri('copilotcli', 'model-persist-session'); -- const chat = defaultChatUri(session); -- const result = await provisionSession(agent, { -- session, -- workingDirectories: [URI.file('/workspace')], -- model: { id: 'model-a' }, -- }); -- await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, chat, result.session)); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await waitForState(agent.models, m => m.length > 0); +- const session = AgentSession.uri('copilotcli', 'model-persist-session'); +- const chat = defaultChatUri(session); +- const result = await provisionSession(agent, { +- session, +- workingDirectories: [URI.file('/workspace')], +- model: { id: 'model-a' }, +- }); +- await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, chat, result.session)); - -- await agent.chats.changeModel(chat, { id: 'model-b' }, exactChatContext(result.session, chat, result.session)); +- await agent.chats.changeModel(chat, { id: 'model-b' }, exactChatContext(result.session, chat, result.session)); - -- const stored = await sessionDataService.openDatabase(session).object.getMetadata('copilot.model'); -- assert.deepStrictEqual(JSON.parse(stored ?? 'null'), { id: 'model-b' }); -- } finally { -- await disposeAgent(agent); -- } +- const stored = await sessionDataService.openDatabase(session).object.getMetadata('copilot.model'); +- assert.deepStrictEqual(JSON.parse(stored ?? 'null'), { id: 'model-b' }); - }); - - test('changeModel rejects a model outside the populated catalog', async () => { @@ -74718,34 +76633,31 @@ index f24a97a6296..00000000000 - const sdkSession = new MockCopilotSession(); - client.createSession = async () => sdkSession as unknown as CopilotSession; - const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await waitForState(agent.models, models => models.length > 0); -- const session = AgentSession.uri('copilotcli', 'invalid-model-session'); -- const chat = defaultChatUri(session); -- const result = await provisionSession(agent, { -- session, -- workingDirectories: [URI.file('/workspace')], -- model: { id: 'model-a' }, -- }); -- await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, chat, result.session)); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await waitForState(agent.models, models => models.length > 0); +- const session = AgentSession.uri('copilotcli', 'invalid-model-session'); +- const chat = defaultChatUri(session); +- const result = await provisionSession(agent, { +- session, +- workingDirectories: [URI.file('/workspace')], +- model: { id: 'model-a' }, +- }); +- await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, chat, result.session)); - -- await assert.rejects( -- agent.chats.changeModel(chat, { id: 'missing-model' }, exactChatContext(result.session, chat, result.session)), -- /Model 'missing-model' is not available\./, -- ); +- await assert.rejects( +- agent.chats.changeModel(chat, { id: 'missing-model' }, exactChatContext(result.session, chat, result.session)), +- /Model 'missing-model' is not available\./, +- ); - -- const stored = await sessionDataService.openDatabase(session).object.getMetadata('copilot.model'); -- assert.deepStrictEqual({ -- modelCalls: sdkSession.setModelCalls, -- storedModel: JSON.parse(stored ?? 'null'), -- }, { -- modelCalls: [], -- storedModel: { id: 'model-a' }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- const stored = await sessionDataService.openDatabase(session).object.getMetadata('copilot.model'); +- assert.deepStrictEqual({ +- modelCalls: sdkSession.setModelCalls, +- storedModel: JSON.parse(stored ?? 'null'), +- }, { +- modelCalls: [], +- storedModel: { id: 'model-a' }, +- }); - }); - - test('changeModel does not wait on an in-flight catalog refresh for a model already in the catalog', async () => { @@ -74754,38 +76666,35 @@ index f24a97a6296..00000000000 - const sdkSession = new MockCopilotSession(); - client.createSession = async () => sdkSession as unknown as CopilotSession; - const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await waitForState(agent.models, models => models.length > 0); -- const session = AgentSession.uri('copilotcli', 'known-model-session'); -- const chat = defaultChatUri(session); -- const result = await provisionSession(agent, { -- session, -- workingDirectories: [URI.file('/workspace')], -- model: { id: 'model-a' }, -- }); -- await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, chat, result.session)); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await waitForState(agent.models, models => models.length > 0); +- const session = AgentSession.uri('copilotcli', 'known-model-session'); +- const chat = defaultChatUri(session); +- const result = await provisionSession(agent, { +- session, +- workingDirectories: [URI.file('/workspace')], +- model: { id: 'model-a' }, +- }); +- await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, chat, result.session)); - -- // Park a catalog refresh in flight. Selecting a model the catalog -- // already lists must not block the turn behind it. -- const gate = new DeferredPromise(); -- client.modelListGates.push(gate.p); -- client.modelListResponses.push([{ id: 'model-a', name: 'Model A' }]); -- const requestsBefore = client.modelListRequests.length; -- const refresh = agent.refreshModels(); -- await client.waitForCalls(() => client.modelListRequests.length > requestsBefore); -- assert.strictEqual(client.modelListRequests.length, requestsBefore + 1, 'expected the gated model request to start'); +- // Park a catalog refresh in flight. Selecting a model the catalog +- // already lists must not block the turn behind it. +- const gate = new DeferredPromise(); +- client.modelListGates.push(gate.p); +- client.modelListResponses.push([{ id: 'model-a', name: 'Model A' }]); +- const requestsBefore = client.modelListRequests.length; +- const refresh = agent.refreshModels(); +- await client.waitForCalls(() => client.modelListRequests.length > requestsBefore); +- assert.strictEqual(client.modelListRequests.length, requestsBefore + 1, 'expected the gated model request to start'); - -- await agent.chats.changeModel(chat, { id: 'model-a' }, exactChatContext(result.session, chat, result.session)); +- await agent.chats.changeModel(chat, { id: 'model-a' }, exactChatContext(result.session, chat, result.session)); - -- // Asserted while the refresh is still gated: the selection reached -- // the SDK without the catalog round-trip completing. -- assert.strictEqual(sdkSession.setModelCalls.length, 1); -- gate.complete(); -- await refresh; -- } finally { -- await disposeAgent(agent); -- } +- // Asserted while the refresh is still gated: the selection reached +- // the SDK without the catalog round-trip completing. +- assert.strictEqual(sdkSession.setModelCalls.length, 1); +- gate.complete(); +- await refresh; - }); - - test('changeModel still waits for a credential-invalidated catalog refresh before trusting a cached model', async () => { @@ -74794,40 +76703,37 @@ index f24a97a6296..00000000000 - const sdkSession = new MockCopilotSession(); - client.createSession = async () => sdkSession as unknown as CopilotSession; - const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await waitForState(agent.models, models => models.length > 0); -- const session = AgentSession.uri('copilotcli', 'rotated-token-session'); -- const chat = defaultChatUri(session); -- const result = await provisionSession(agent, { -- session, -- workingDirectories: [URI.file('/workspace')], -- model: { id: 'model-a' }, -- }); -- await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, chat, result.session)); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await waitForState(agent.models, models => models.length > 0); +- const session = AgentSession.uri('copilotcli', 'rotated-token-session'); +- const chat = defaultChatUri(session); +- const result = await provisionSession(agent, { +- session, +- workingDirectories: [URI.file('/workspace')], +- model: { id: 'model-a' }, +- }); +- await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, chat, result.session)); - -- // A new credential schedules an invalidating refresh without -- // clearing the catalog, so the published list still belongs to the -- // previous token. The replacement catalog drops `model-a`. -- const gate = new DeferredPromise(); -- client.modelListGates.push(gate.p); -- client.modelListResponses.push([{ id: 'model-b', name: 'Model B' }]); -- await agent.authenticate('https://api.github.com', 'rotated-token'); +- // A new credential schedules an invalidating refresh without +- // clearing the catalog, so the published list still belongs to the +- // previous token. The replacement catalog drops `model-a`. +- const gate = new DeferredPromise(); +- client.modelListGates.push(gate.p); +- client.modelListResponses.push([{ id: 'model-b', name: 'Model B' }]); +- await agent.authenticate('https://api.github.com', 'rotated-token'); - -- const selection = agent.chats.changeModel(chat, { id: 'model-a' }, exactChatContext(result.session, chat, result.session)); -- const rejected = selection.then(() => false, () => true); -- gate.complete(); +- const selection = agent.chats.changeModel(chat, { id: 'model-a' }, exactChatContext(result.session, chat, result.session)); +- const rejected = selection.then(() => false, () => true); +- gate.complete(); - -- assert.deepStrictEqual({ -- rejected: await rejected, -- modelCalls: sdkSession.setModelCalls.length, -- }, { -- rejected: true, -- modelCalls: 0, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- rejected: await rejected, +- modelCalls: sdkSession.setModelCalls.length, +- }, { +- rejected: true, +- modelCalls: 0, +- }); - }); - - test('changeModel still waits for a credential-invalidated catalog refresh after its request has started', async () => { @@ -74836,49 +76742,46 @@ index f24a97a6296..00000000000 - const sdkSession = new MockCopilotSession(); - client.createSession = async () => sdkSession as unknown as CopilotSession; - const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await waitForState(agent.models, models => models.length > 0); -- const session = AgentSession.uri('copilotcli', 'rotated-token-inflight-session'); -- const chat = defaultChatUri(session); -- const result = await provisionSession(agent, { -- session, -- workingDirectories: [URI.file('/workspace')], -- model: { id: 'model-a' }, -- }); -- await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, chat, result.session)); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await waitForState(agent.models, models => models.length > 0); +- const session = AgentSession.uri('copilotcli', 'rotated-token-inflight-session'); +- const chat = defaultChatUri(session); +- const result = await provisionSession(agent, { +- session, +- workingDirectories: [URI.file('/workspace')], +- model: { id: 'model-a' }, +- }); +- await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, chat, result.session)); - -- const gate = new DeferredPromise(); -- client.modelListGates.push(gate.p); -- client.modelListResponses.push([{ id: 'model-b', name: 'Model B' }]); -- const requestsBefore = client.modelListRequests.length; -- await agent.authenticate('https://api.github.com', 'rotated-token'); +- const gate = new DeferredPromise(); +- client.modelListGates.push(gate.p); +- client.modelListResponses.push([{ id: 'model-b', name: 'Model B' }]); +- const requestsBefore = client.modelListRequests.length; +- await agent.authenticate('https://api.github.com', 'rotated-token'); - -- // Wait until the credential-triggered request is actually in -- // flight. The scheduler clears its bookkeeping at this point, so -- // this is the window where a scheduled-only check fails open. -- await client.waitForCalls(() => client.modelListRequests.length > requestsBefore); -- assert.strictEqual(client.modelListRequests.length, requestsBefore + 1, 'expected the credential-triggered request to start'); +- // Wait until the credential-triggered request is actually in +- // flight. The scheduler clears its bookkeeping at this point, so +- // this is the window where a scheduled-only check fails open. +- await client.waitForCalls(() => client.modelListRequests.length > requestsBefore); +- assert.strictEqual(client.modelListRequests.length, requestsBefore + 1, 'expected the credential-triggered request to start'); - -- const selection = agent.chats.changeModel(chat, { id: 'model-a' }, exactChatContext(result.session, chat, result.session)); -- const settled = selection.then(() => 'resolved', () => 'rejected'); -- // Still gated: the selection must not have been answered from the -- // superseded catalog. -- const raced = await Promise.race([settled, timeout(20).then(() => 'pending')]); -- gate.complete(); +- const selection = agent.chats.changeModel(chat, { id: 'model-a' }, exactChatContext(result.session, chat, result.session)); +- const settled = selection.then(() => 'resolved', () => 'rejected'); +- // Still gated: the selection must not have been answered from the +- // superseded catalog. +- const raced = await Promise.race([settled, timeout(20).then(() => 'pending')]); +- gate.complete(); - -- assert.deepStrictEqual({ -- whileGated: raced, -- outcome: await settled, -- modelCalls: sdkSession.setModelCalls.length, -- }, { -- whileGated: 'pending', -- outcome: 'rejected', -- modelCalls: 0, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- whileGated: raced, +- outcome: await settled, +- modelCalls: sdkSession.setModelCalls.length, +- }, { +- whileGated: 'pending', +- outcome: 'rejected', +- modelCalls: 0, +- }); - }); - - test('changeModel forwards and persists Auto routing preferences and resets', async () => { @@ -74890,103 +76793,97 @@ index f24a97a6296..00000000000 - sessionDataService, - copilotClient: client, - }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await waitForState(agent.models, m => m.length > 0); -- const session = AgentSession.uri('copilotcli', 'auto-tier-session'); -- const chat = defaultChatUri(session); -- const result = await provisionSession(agent, { -- session, -- workingDirectories: [URI.file('/workspace')], -- model: { id: 'auto', config: { tier: 'intelligence' } }, -- }); -- await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, chat, result.session)); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await waitForState(agent.models, m => m.length > 0); +- const session = AgentSession.uri('copilotcli', 'auto-tier-session'); +- const chat = defaultChatUri(session); +- const result = await provisionSession(agent, { +- session, +- workingDirectories: [URI.file('/workspace')], +- model: { id: 'auto', config: { tier: 'intelligence' } }, +- }); +- await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, chat, result.session)); - -- const selections: ModelSelection[] = [ -- { id: 'auto', config: { tier: 'efficiency' } }, -- { id: 'auto', config: { tier: 'balance' } }, -- { id: 'auto' }, -- ]; -- const storedModels: (string | undefined)[] = []; -- const database = disposables.add(sessionDataService.openDatabase(session)); -- for (const model of selections) { -- await agent.chats.changeModel(chat, model, exactChatContext(result.session, chat, result.session)); -- storedModels.push(await database.object.getMetadata('copilot.model')); -- } +- const selections: ModelSelection[] = [ +- { id: 'auto', config: { tier: 'efficiency' } }, +- { id: 'auto', config: { tier: 'balance' } }, +- { id: 'auto' }, +- ]; +- const storedModels: (string | undefined)[] = []; +- const database = disposables.add(sessionDataService.openDatabase(session)); +- for (const model of selections) { +- await agent.chats.changeModel(chat, model, exactChatContext(result.session, chat, result.session)); +- storedModels.push(await database.object.getMetadata('copilot.model')); +- } - -- sdkSession.setModelError = new Error('Auto preference rejected'); -- await assert.rejects( -- agent.chats.changeModel(chat, { id: 'auto', config: { tier: 'intelligence' } }, exactChatContext(result.session, chat, result.session)), -- /Auto preference rejected/, -- ); +- sdkSession.setModelError = new Error('Auto preference rejected'); +- await assert.rejects( +- agent.chats.changeModel(chat, { id: 'auto', config: { tier: 'intelligence' } }, exactChatContext(result.session, chat, result.session)), +- /Auto preference rejected/, +- ); - -- assert.deepStrictEqual({ -- modelCalls: sdkSession.setModelCalls, -- storedModels, -- storedAfterRejection: await database.object.getMetadata('copilot.model'), -- }, { -- modelCalls: [ -- ['auto', { reasoningEffort: undefined, contextTier: 'default', autoTier: 'efficiency' }], -- ['auto', { reasoningEffort: undefined, contextTier: 'default', autoTier: 'balance' }], -- ['auto', { reasoningEffort: undefined, contextTier: 'default', autoTier: null }], -- ['auto', { reasoningEffort: undefined, contextTier: 'default', autoTier: 'intelligence' }], -- ], -- storedModels: selections.map(model => JSON.stringify(model)), -- storedAfterRejection: JSON.stringify({ id: 'auto' }), -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- modelCalls: sdkSession.setModelCalls, +- storedModels, +- storedAfterRejection: await database.object.getMetadata('copilot.model'), +- }, { +- modelCalls: [ +- ['auto', { reasoningEffort: undefined, contextTier: 'default', autoTier: 'efficiency' }], +- ['auto', { reasoningEffort: undefined, contextTier: 'default', autoTier: 'balance' }], +- ['auto', { reasoningEffort: undefined, contextTier: 'default', autoTier: null }], +- ['auto', { reasoningEffort: undefined, contextTier: 'default', autoTier: 'intelligence' }], +- ], +- storedModels: selections.map(model => JSON.stringify(model)), +- storedAfterRejection: JSON.stringify({ id: 'auto' }), +- }); - }); - - test('changeModel applies Auto preferences, overrides, and resets only to the targeted chat', async () => { - const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: new TestCopilotClient([]) }); -- try { -- const session = AgentSession.uri('copilotcli', 'auto-tier-peer'); -- const chatA = URI.parse(buildChatUri(session, 'peer-a')); -- const chatB = URI.parse(buildChatUri(session, 'peer-b')); -- const a = makeFakeChatSession(session, 'sdk-a'); -- const b = makeFakeChatSession(session, 'sdk-b'); -- setPeerChatStub(agent, chatA, a.fake); -- setPeerChatStub(agent, chatB, b.fake); -- const changes: string[] = []; -- disposables.add(agent.onDidChangeChatData(e => changes.push(e.providerData))); -- const selections: ModelSelection[] = [ -- { id: 'auto', config: { autoTier: 'intelligence' } }, -- { id: 'auto', config: { tier: 'efficiency' } }, -- { id: 'auto' }, -- { id: 'gpt-5', config: { tier: 'balance' } }, -- { id: 'auto' }, -- ]; +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'auto-tier-peer'); +- const chatA = URI.parse(buildChatUri(session, 'peer-a')); +- const chatB = URI.parse(buildChatUri(session, 'peer-b')); +- const a = makeFakeChatSession(session, 'sdk-a'); +- const b = makeFakeChatSession(session, 'sdk-b'); +- setPeerChatStub(agent, chatA, a.fake); +- setPeerChatStub(agent, chatB, b.fake); +- const changes: string[] = []; +- disposables.add(agent.onDidChangeChatData(e => changes.push(e.providerData))); +- const selections: ModelSelection[] = [ +- { id: 'auto', config: { autoTier: 'intelligence' } }, +- { id: 'auto', config: { tier: 'efficiency' } }, +- { id: 'auto' }, +- { id: 'gpt-5', config: { tier: 'balance' } }, +- { id: 'auto' }, +- ]; - -- await agent.chats.changeModel(chatA, selections[0], exactChatContext(session, chatA)); -- configurationService.updateRootConfig({ [CopilotCliConfigKey.AutoModeTierOverride]: 'balance' }); -- await agent.chats.changeModel(chatA, selections[1], exactChatContext(session, chatA)); -- configurationService.updateRootConfig({ [CopilotCliConfigKey.AutoModeTierOverride]: '' }); -- await agent.chats.changeModel(chatA, selections[2], exactChatContext(session, chatA)); -- await agent.chats.changeModel(chatA, selections[3], exactChatContext(session, chatA)); -- await agent.chats.changeModel(chatA, selections[4], exactChatContext(session, chatA)); +- await agent.chats.changeModel(chatA, selections[0], exactChatContext(session, chatA)); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.AutoModeTierOverride]: 'balance' }); +- await agent.chats.changeModel(chatA, selections[1], exactChatContext(session, chatA)); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.AutoModeTierOverride]: '' }); +- await agent.chats.changeModel(chatA, selections[2], exactChatContext(session, chatA)); +- await agent.chats.changeModel(chatA, selections[3], exactChatContext(session, chatA)); +- await agent.chats.changeModel(chatA, selections[4], exactChatContext(session, chatA)); - -- assert.deepStrictEqual({ -- aModels: a.rec.modelCalls, -- bModels: b.rec.modelCalls, -- changes, -- persisted: chatBackings(agent).get(chatA.toString())?.model, -- }, { -- aModels: [ -- { id: 'auto', effort: undefined, tier: 'default', autoTier: 'intelligence' }, -- { id: 'auto', effort: undefined, tier: 'default', autoTier: 'balance' }, -- { id: 'auto', effort: undefined, tier: 'default', autoTier: null }, -- { id: 'gpt-5', effort: undefined, tier: undefined }, -- { id: 'auto', effort: undefined, tier: 'default', autoTier: null }, -- ], -- bModels: [], -- changes: selections.map(model => JSON.stringify({ sdkSessionId: 'sdk-a', model })), -- persisted: { id: 'auto' }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- aModels: a.rec.modelCalls, +- bModels: b.rec.modelCalls, +- changes, +- persisted: chatBackings(agent).get(chatA.toString())?.model, +- }, { +- aModels: [ +- { id: 'auto', effort: undefined, tier: 'default', autoTier: 'intelligence' }, +- { id: 'auto', effort: undefined, tier: 'default', autoTier: 'balance' }, +- { id: 'auto', effort: undefined, tier: 'default', autoTier: null }, +- { id: 'gpt-5', effort: undefined, tier: undefined }, +- { id: 'auto', effort: undefined, tier: 'default', autoTier: null }, +- ], +- bModels: [], +- changes: selections.map(model => JSON.stringify({ sdkSessionId: 'sdk-a', model })), +- persisted: { id: 'auto' }, +- }); - }); - - test('applies a provisional Auto routing preference on the first send without opting in', async () => { @@ -75001,27 +76898,24 @@ index f24a97a6296..00000000000 - sessionDataService, - copilotClient: client, - }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await waitForState(agent.models, m => m.length > 0); -- const session = AgentSession.uri('copilotcli', 'auto-tier-provisional'); -- const chat = defaultChatUri(session); -- const result = await provisionSession(agent, { -- session, -- workingDirectories: [URI.file('/workspace')], -- model: { id: 'auto', config: { tier: 'intelligence' } }, -- }); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- await waitForState(agent.models, m => m.length > 0); +- const session = AgentSession.uri('copilotcli', 'auto-tier-provisional'); +- const chat = defaultChatUri(session); +- const result = await provisionSession(agent, { +- session, +- workingDirectories: [URI.file('/workspace')], +- model: { id: 'auto', config: { tier: 'intelligence' } }, +- }); - -- await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, chat, result.session)); +- await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, chat, result.session)); - -- const stored = await sessionDataService.openDatabase(session).object.getMetadata('copilot.model'); -- assert.deepStrictEqual({ model: JSON.parse(stored ?? 'null'), capiCalls }, { -- model: { id: 'auto', config: { tier: 'intelligence' } }, -- capiCalls: [{ autoTier: 'intelligence' }], -- }); -- } finally { -- await disposeAgent(agent); -- } +- const stored = await sessionDataService.openDatabase(session).object.getMetadata('copilot.model'); +- assert.deepStrictEqual({ model: JSON.parse(stored ?? 'null'), capiCalls }, { +- model: { id: 'auto', config: { tier: 'intelligence' } }, +- capiCalls: [{ autoTier: 'intelligence' }], +- }); - }); - - for (const selectedTier of ['balance', 'efficiency'] as const) { @@ -75087,103 +76981,91 @@ index f24a97a6296..00000000000 - - test('changeAgent resolves and applies the agent to the targeted chat, and clears it with undefined', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'agent-route'); -- const chatA = URI.parse(buildChatUri(session, 'peer-a')); -- const a = makeFakeChatSession(session, 'sdk-a'); -- const internals = agent as unknown as ChatInternals; -- setPeerChatStub(agent, chatA, a.fake); -- internals._resolveAgentName = (_snapshot, selection) => selection.uri === 'agent://x' ? 'Resolved Agent' : undefined; +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'agent-route'); +- const chatA = URI.parse(buildChatUri(session, 'peer-a')); +- const a = makeFakeChatSession(session, 'sdk-a'); +- const internals = agent as unknown as ChatInternals; +- setPeerChatStub(agent, chatA, a.fake); +- internals._resolveAgentName = (_snapshot, selection) => selection.uri === 'agent://x' ? 'Resolved Agent' : undefined; - -- await agent.chats.changeAgent(chatA, { uri: 'agent://x' }, exactChatContext(session, chatA)); -- await agent.chats.changeAgent(chatA, undefined, exactChatContext(session, chatA)); +- await agent.chats.changeAgent(chatA, { uri: 'agent://x' }, exactChatContext(session, chatA)); +- await agent.chats.changeAgent(chatA, undefined, exactChatContext(session, chatA)); - -- assert.deepStrictEqual(a.rec.agentCalls, ['Resolved Agent', undefined]); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(a.rec.agentCalls, ['Resolved Agent', undefined]); - }); - - test('continues queued sends after a non-settling control-plane RPC times out', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'control-rpc-timeout'); -- const chat = URI.parse(buildChatUri(session, 'peer-a')); -- const target = makeFakeChatSession(session, 'sdk-a'); -- const neverSettles = new Promise(() => { }); -- (target.fake as unknown as { setAgent(): Promise }).setAgent = async () => { -- if (await raceTimeout(neverSettles, 1) === undefined) { -- throw new Error('rpc.agent.deselect timed out'); -- } -- }; -- setPeerChatStub(agent, chat, target.fake); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'control-rpc-timeout'); +- const chat = URI.parse(buildChatUri(session, 'peer-a')); +- const target = makeFakeChatSession(session, 'sdk-a'); +- const neverSettles = new Promise(() => { }); +- (target.fake as unknown as { setAgent(): Promise }).setAgent = async () => { +- if (await raceTimeout(neverSettles, 1) === undefined) { +- throw new Error('rpc.agent.deselect timed out'); +- } +- }; +- setPeerChatStub(agent, chat, target.fake); - -- const change = agent.chats.changeAgent(chat, undefined, exactChatContext(session, chat)); -- const send = agent.chats.sendMessage(chat, 'follow-up', undefined, undefined, 'turn-1', undefined, exactChatContext(session, chat)); -- await assert.rejects(change, /rpc\.agent\.deselect timed out/); -- await send; +- const change = agent.chats.changeAgent(chat, undefined, exactChatContext(session, chat)); +- const send = agent.chats.sendMessage(chat, 'follow-up', undefined, undefined, 'turn-1', undefined, exactChatContext(session, chat)); +- await assert.rejects(change, /rpc\.agent\.deselect timed out/); +- await send; - -- assert.deepStrictEqual(target.rec.sends, [{ prompt: 'follow-up', turnId: 'turn-1', mode: undefined, senderClientId: undefined }]); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(target.rec.sends, [{ prompt: 'follow-up', turnId: 'turn-1', mode: undefined, senderClientId: undefined }]); - }); - - test('aborts while a chat queue task is blocked', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'abort-queue-bypass'); -- const chat = URI.parse(buildChatUri(session, 'peer-a')); -- const target = makeFakeChatSession(session, 'sdk-a'); -- const controlPlaneGate = new DeferredPromise(); -- (target.fake as unknown as { setAgent(): Promise }).setAgent = async () => controlPlaneGate.p; -- setPeerChatStub(agent, chat, target.fake); -- -- const change = agent.chats.changeAgent(chat, undefined, exactChatContext(session, chat)); -- await timeout(0); -- await agent.chats.abort(chat, exactChatContext(session, chat)); -- controlPlaneGate.complete(); -- await change; +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'abort-queue-bypass'); +- const chat = URI.parse(buildChatUri(session, 'peer-a')); +- const target = makeFakeChatSession(session, 'sdk-a'); +- const controlPlaneGate = new DeferredPromise(); +- (target.fake as unknown as { setAgent(): Promise }).setAgent = async () => controlPlaneGate.p; +- setPeerChatStub(agent, chat, target.fake); +- +- const change = agent.chats.changeAgent(chat, undefined, exactChatContext(session, chat)); +- await timeout(0); +- await agent.chats.abort(chat, exactChatContext(session, chat)); +- controlPlaneGate.complete(); +- await change; - -- assert.deepStrictEqual({ aborted: target.rec.aborted, agentCalls: target.rec.agentCalls }, { aborted: 1, agentCalls: [] }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ aborted: target.rec.aborted, agentCalls: target.rec.agentCalls }, { aborted: 1, agentCalls: [] }); - }); - - test('waits for an in-flight abort before sending a replacement turn', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'abort-before-replacement'); -- const chat = URI.parse(buildChatUri(session, 'peer-a')); -- const abortStarted = new DeferredPromise(); -- const abortGate = new DeferredPromise(); -- const target = makeFakeChatSession(session, 'sdk-a', undefined, undefined, async () => { -- abortStarted.complete(); -- await abortGate.p; -- }); -- setPeerChatStub(agent, chat, target.fake); -- -- const abort = agent.chats.abort(chat, exactChatContext(session, chat)); -- await abortStarted.p; -- const send = agent.chats.sendMessage(chat, 'replacement', undefined, undefined, 'turn-2', undefined, exactChatContext(session, chat)); -- await timeout(0); -- const sendsBeforeAbortCompleted = [...target.rec.sends]; -- abortGate.complete(); -- await Promise.all([abort, send]); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'abort-before-replacement'); +- const chat = URI.parse(buildChatUri(session, 'peer-a')); +- const abortStarted = new DeferredPromise(); +- const abortGate = new DeferredPromise(); +- const target = makeFakeChatSession(session, 'sdk-a', undefined, undefined, async () => { +- abortStarted.complete(); +- await abortGate.p; +- }); +- setPeerChatStub(agent, chat, target.fake); - -- assert.deepStrictEqual({ -- aborted: target.rec.aborted, -- sendsBeforeAbortCompleted, -- sends: target.rec.sends, -- }, { -- aborted: 1, -- sendsBeforeAbortCompleted: [], -- sends: [{ prompt: 'replacement', turnId: 'turn-2', mode: undefined, senderClientId: undefined }], -- }); -- } finally { -- await disposeAgent(agent); -- } +- const abort = agent.chats.abort(chat, exactChatContext(session, chat)); +- await abortStarted.p; +- const send = agent.chats.sendMessage(chat, 'replacement', undefined, undefined, 'turn-2', undefined, exactChatContext(session, chat)); +- await timeout(0); +- const sendsBeforeAbortCompleted = [...target.rec.sends]; +- abortGate.complete(); +- await Promise.all([abort, send]); +- +- assert.deepStrictEqual({ +- aborted: target.rec.aborted, +- sendsBeforeAbortCompleted, +- sends: target.rec.sends, +- }, { +- aborted: 1, +- sendsBeforeAbortCompleted: [], +- sends: [{ prompt: 'replacement', turnId: 'turn-2', mode: undefined, senderClientId: undefined }], +- }); - }); - - for (const operation of ['send', 'resume'] as const) { @@ -75237,33 +77119,30 @@ index f24a97a6296..00000000000 - - test('drops a queued send when abort arrives before the session materializes', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'abort-before-materialize'); -- const chat = URI.parse(buildChatUri(session, 'peer-a')); -- const target = makeFakeChatSession(session, 'sdk-a'); -- // Register the backing (which fixes the chat's sequencer key) but no -- // live entry, modelling a chat whose session is still materializing. -- chatBackings(agent).set(chat.toString(), { sdkSessionId: 'sdk-a' }); -- chatScopes(agent).set(chat.toString(), session); -- const materializeGate = new DeferredPromise(); -- (agent as unknown as { _ensureResolvedChatSession(): Promise })._ensureResolvedChatSession = async () => { -- await materializeGate.p; -- return target.fake; -- }; +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'abort-before-materialize'); +- const chat = URI.parse(buildChatUri(session, 'peer-a')); +- const target = makeFakeChatSession(session, 'sdk-a'); +- // Register the backing (which fixes the chat's sequencer key) but no +- // live entry, modelling a chat whose session is still materializing. +- chatBackings(agent).set(chat.toString(), { sdkSessionId: 'sdk-a' }); +- chatScopes(agent).set(chat.toString(), session); +- const materializeGate = new DeferredPromise(); +- (agent as unknown as { _ensureResolvedChatSession(): Promise })._ensureResolvedChatSession = async () => { +- await materializeGate.p; +- return target.fake; +- }; - -- const send = agent.chats.sendMessage(chat, 'cancelled', undefined, undefined, 'turn-1', undefined, exactChatContext(session, chat)); -- await timeout(0); -- await agent.chats.abort(chat, exactChatContext(session, chat)); -- materializeGate.complete(); -- await send; +- const send = agent.chats.sendMessage(chat, 'cancelled', undefined, undefined, 'turn-1', undefined, exactChatContext(session, chat)); +- await timeout(0); +- await agent.chats.abort(chat, exactChatContext(session, chat)); +- materializeGate.complete(); +- await send; - -- assert.deepStrictEqual( -- { sends: target.rec.sends, aborted: target.rec.aborted, resets: target.rec.resets }, -- { sends: [], aborted: 0, resets: [] }, -- ); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual( +- { sends: target.rec.sends, aborted: target.rec.aborted, resets: target.rec.resets }, +- { sends: [], aborted: 0, resets: [] }, +- ); - }); - - test('round-trips addressed chats through providerData + materializeChat and resumes per-chat history after a restart', async () => { @@ -75301,171 +77180,156 @@ index f24a97a6296..00000000000 - - // ---- process #2: fresh agent, empty in-memory state ---- - const agent2 = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); -- try { -- await agent2.authenticate('https://api.github.com', 'token'); -- // The orchestrator re-creates the (provisional) parent session on -- // restore; this seeds the working directory the peer-chat resume -- // path needs. -- await provisionSession(agent2, { session, workingDirectories: [URI.file('/workspace')] }); -- -- const internals2 = agent2 as unknown as ChatInternals; -- const peerA = URI.parse(buildChatUri(session, 'peer-a')); -- const peerB = URI.parse(buildChatUri(session, 'peer-b')); -- // The orchestrator hands each persisted blob back to the agent. -- await agent2.materializeChat(peerA, session, providerData['peer-a']); -- await agent2.materializeChat(peerB, session, providerData['peer-b']); -- -- const peerAHistory: readonly Turn[] = [{ id: 'turn-1' } as unknown as Turn]; -- let resumed: CopilotSessionLaunchPlan | undefined; -- internals2._createAgentSession = (launchPlan, _dir, _ac, identity) => { -- resumed = launchPlan; -- const built = makeFakeChatSession(session, launchPlan.sessionId, async () => peerAHistory, launchPlan.shellManager); -- (built.fake as { chatChannelUri?: URI }).chatChannelUri = identity?.chatChannelUri; -- return built.fake; -- }; +- afterTest(() => disposeAgent(agent2)); +- await agent2.authenticate('https://api.github.com', 'token'); +- // The orchestrator re-creates the (provisional) parent session on +- // restore; this seeds the working directory the peer-chat resume +- // path needs. +- await provisionSession(agent2, { session, workingDirectories: [URI.file('/workspace')] }); +- +- const internals2 = agent2 as unknown as ChatInternals; +- const peerA = URI.parse(buildChatUri(session, 'peer-a')); +- const peerB = URI.parse(buildChatUri(session, 'peer-b')); +- // The orchestrator hands each persisted blob back to the agent. +- await agent2.materializeChat(peerA, session, providerData['peer-a']); +- await agent2.materializeChat(peerB, session, providerData['peer-b']); +- +- const peerAHistory: readonly Turn[] = [{ id: 'turn-1' } as unknown as Turn]; +- let resumed: CopilotSessionLaunchPlan | undefined; +- internals2._createAgentSession = (launchPlan, _dir, _ac, identity) => { +- resumed = launchPlan; +- const built = makeFakeChatSession(session, launchPlan.sessionId, async () => peerAHistory, launchPlan.shellManager); +- (built.fake as { chatChannelUri?: URI }).chatChannelUri = identity?.chatChannelUri; +- return built.fake; +- }; - -- await agent2.chats.sendMessage(peerA, 'after restart', undefined, undefined, undefined, undefined, exactChatContext(session, peerA)); -- const history = await getPeerChatStub(agent2, peerA)!.getMessages(); +- await agent2.chats.sendMessage(peerA, 'after restart', undefined, undefined, undefined, undefined, exactChatContext(session, peerA)); +- const history = await getPeerChatStub(agent2, peerA)!.getMessages(); - -- assert.deepStrictEqual({ -- materializedBackings: [internals2._chatBackings.get(peerA.toString()), internals2._chatBackings.get(peerB.toString())], -- resumeKind: resumed?.kind, -- resumeSessionId: resumed?.sessionId, -- expectedSessionId: created['peer-a'], -- historyLen: history.length, -- tracked: hasLiveChat(agent2, peerA), -- parentResumeCalls: (agent2 as TestableCopilotAgent).resumeCalls, -- }, { -- materializedBackings: [{ sdkSessionId: created['peer-a'] }, { sdkSessionId: created['peer-b'] }], -- resumeKind: 'resume', -- resumeSessionId: created['peer-a'], -- expectedSessionId: created['peer-a'], -- historyLen: 1, -- tracked: true, -- parentResumeCalls: [], -- }); -- } finally { -- await disposeAgent(agent2); -- } +- assert.deepStrictEqual({ +- materializedBackings: [internals2._chatBackings.get(peerA.toString()), internals2._chatBackings.get(peerB.toString())], +- resumeKind: resumed?.kind, +- resumeSessionId: resumed?.sessionId, +- expectedSessionId: created['peer-a'], +- historyLen: history.length, +- tracked: hasLiveChat(agent2, peerA), +- parentResumeCalls: (agent2 as TestableCopilotAgent).resumeCalls, +- }, { +- materializedBackings: [{ sdkSessionId: created['peer-a'] }, { sdkSessionId: created['peer-b'] }], +- resumeKind: 'resume', +- resumeSessionId: created['peer-a'], +- expectedSessionId: created['peer-a'], +- historyLen: 1, +- tracked: true, +- parentResumeCalls: [], +- }); - }); - - test('legacy peer catalog migrates to canonical providerData before materialization', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'legacy-materialize'); -- const db = sessionDataService.openDatabase(session); -- await db.object.setMetadata('copilot.chats', JSON.stringify({ -- 'peer-a': { sdkSessionId: 'legacy-sdk', model: { id: 'gpt-legacy' } }, -- })); -- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); -- const internals = agent as unknown as ChatInternals; +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'legacy-materialize'); +- const db = sessionDataService.openDatabase(session); +- await db.object.setMetadata('copilot.chats', JSON.stringify({ +- 'peer-a': { sdkSessionId: 'legacy-sdk', model: { id: 'gpt-legacy' } }, +- })); +- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); +- const internals = agent as unknown as ChatInternals; - -- const legacy = await agent.listLegacyChatBackings(session); -- await agent.materializeChat(chatUri, session, legacy[0].providerData); -- // A corrupt blob is dropped (no backing recorded). -- const corruptUri = URI.parse(buildChatUri(session, 'peer-corrupt')); -- await agent.materializeChat(corruptUri, session, 'not json'); +- const legacy = await agent.listLegacyChatBackings(session); +- await agent.materializeChat(chatUri, session, legacy[0].providerData); +- // A corrupt blob is dropped (no backing recorded). +- const corruptUri = URI.parse(buildChatUri(session, 'peer-corrupt')); +- await agent.materializeChat(corruptUri, session, 'not json'); - -- assert.deepStrictEqual({ -- legacy: internals._chatBackings.get(chatUri.toString()), -- corrupt: internals._chatBackings.has(corruptUri.toString()), -- }, { -- legacy: { sdkSessionId: 'legacy-sdk', model: { id: 'gpt-legacy' } }, -- corrupt: false, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- legacy: internals._chatBackings.get(chatUri.toString()), +- corrupt: internals._chatBackings.has(corruptUri.toString()), +- }, { +- legacy: { sdkSessionId: 'legacy-sdk', model: { id: 'gpt-legacy' } }, +- corrupt: false, +- }); - }); - - test('changeModel on a chat leaf refreshes its backing and fires onDidChangeChatData', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'model-blob'); -- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); -- const internals = agent as unknown as ChatInternals; -- setPeerChatStub(agent, chatUri, makeFakeChatSession(session, 'sdk-a').fake); -- internals._chatBackings.set(chatUri.toString(), { sdkSessionId: 'sdk-a' }); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'model-blob'); +- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); +- const internals = agent as unknown as ChatInternals; +- setPeerChatStub(agent, chatUri, makeFakeChatSession(session, 'sdk-a').fake); +- internals._chatBackings.set(chatUri.toString(), { sdkSessionId: 'sdk-a' }); - -- const events: { chat: string; providerData: unknown }[] = []; -- disposables.add(agent.onDidChangeChatData(e => events.push({ chat: e.chat.toString(), providerData: JSON.parse(e.providerData) }))); +- const events: { chat: string; providerData: unknown }[] = []; +- disposables.add(agent.onDidChangeChatData(e => events.push({ chat: e.chat.toString(), providerData: JSON.parse(e.providerData) }))); - -- await agent.chats.changeModel(chatUri, { id: 'model-x' }, exactChatContext(session, chatUri)); +- await agent.chats.changeModel(chatUri, { id: 'model-x' }, exactChatContext(session, chatUri)); - -- assert.deepStrictEqual({ -- backing: internals._chatBackings.get(chatUri.toString()), -- events, -- }, { -- backing: { sdkSessionId: 'sdk-a', model: { id: 'model-x' } }, -- events: [{ chat: chatUri.toString(), providerData: { sdkSessionId: 'sdk-a', model: { id: 'model-x' } } }], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- backing: internals._chatBackings.get(chatUri.toString()), +- events, +- }, { +- backing: { sdkSessionId: 'sdk-a', model: { id: 'model-x' } }, +- events: [{ chat: chatUri.toString(), providerData: { sdkSessionId: 'sdk-a', model: { id: 'model-x' } } }], +- }); - }); - - test('changeModel restarts a live SDK session when crossing the HydraFusion boundary', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'hydrafusion-model-change'); -- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); -- const internals = agent as unknown as ChatInternals; -- const chatSession = makeFakeChatSession(session, 'sdk-a'); -- setPeerChatStub(agent, chatUri, chatSession.fake); -- internals._chatBackings.set(chatUri.toString(), { sdkSessionId: 'sdk-a', model: { id: 'gpt-5.6-sol' } }); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'hydrafusion-model-change'); +- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); +- const internals = agent as unknown as ChatInternals; +- const chatSession = makeFakeChatSession(session, 'sdk-a'); +- setPeerChatStub(agent, chatUri, chatSession.fake); +- internals._chatBackings.set(chatUri.toString(), { sdkSessionId: 'sdk-a', model: { id: 'gpt-5.6-sol' } }); +- +- await agent.chats.changeModel(chatUri, { id: COPILOT_HYDRA_FUSION_MODEL_ID }, exactChatContext(session, chatUri)); +- await agent.chats.changeModel(chatUri, { id: COPILOT_HYDRA_FUSION_MODEL_ID }, exactChatContext(session, chatUri)); +- await agent.chats.changeModel(chatUri, { id: 'gpt-5.6-sol' }, exactChatContext(session, chatUri)); - -- await agent.chats.changeModel(chatUri, { id: COPILOT_HYDRA_FUSION_MODEL_ID }, exactChatContext(session, chatUri)); -- await agent.chats.changeModel(chatUri, { id: COPILOT_HYDRA_FUSION_MODEL_ID }, exactChatContext(session, chatUri)); -- await agent.chats.changeModel(chatUri, { id: 'gpt-5.6-sol' }, exactChatContext(session, chatUri)); -- -- assert.deepStrictEqual({ -- modelCalls: chatSession.rec.modelCalls.map(call => call.id), -- modelRestartRequests: chatSession.rec.modelRestartRequests, -- backing: internals._chatBackings.get(chatUri.toString()), -- }, { -- modelCalls: [COPILOT_HYDRA_FUSION_MODEL_ID, COPILOT_HYDRA_FUSION_MODEL_ID, 'gpt-5.6-sol'], -- modelRestartRequests: 2, -- backing: { sdkSessionId: 'sdk-a', model: { id: 'gpt-5.6-sol' } }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- modelCalls: chatSession.rec.modelCalls.map(call => call.id), +- modelRestartRequests: chatSession.rec.modelRestartRequests, +- backing: internals._chatBackings.get(chatUri.toString()), +- }, { +- modelCalls: [COPILOT_HYDRA_FUSION_MODEL_ID, COPILOT_HYDRA_FUSION_MODEL_ID, 'gpt-5.6-sol'], +- modelRestartRequests: 2, +- backing: { sdkSessionId: 'sdk-a', model: { id: 'gpt-5.6-sol' } }, +- }); - }); - - test('sendMessage refreshes the SDK backing after switching to HydraFusion', async () => { - const agent = createTestAgent(disposables, { sessionDataService: disposables.add(new TestSessionDataService()) }); -- try { -- const session = AgentSession.uri('copilotcli', 'hydrafusion-refresh'); -- const chatUri = URI.parse(buildDefaultChatUri(session)); -- const internals = agent as unknown as ChatInternals; -- const current = makeFakeChatSession(session, AgentSession.id(session)); -- const replacement = makeFakeChatSession(session, AgentSession.id(session)); -- setDefaultSessionStub(agent, AgentSession.id(session), current.fake, chatUri); -- internals._chatBackings.set(chatUri.toString(), { sdkSessionId: AgentSession.id(session), model: { id: 'gpt-5.6-sol' } }); -- const refreshes: string[] = []; -- internals._destroyLiveSession = async () => { refreshes.push('destroy'); }; -- internals._resumeSession = async () => { -- refreshes.push('resume'); -- return replacement.fake; -- }; +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'hydrafusion-refresh'); +- const chatUri = URI.parse(buildDefaultChatUri(session)); +- const internals = agent as unknown as ChatInternals; +- const current = makeFakeChatSession(session, AgentSession.id(session)); +- const replacement = makeFakeChatSession(session, AgentSession.id(session)); +- setDefaultSessionStub(agent, AgentSession.id(session), current.fake, chatUri); +- internals._chatBackings.set(chatUri.toString(), { sdkSessionId: AgentSession.id(session), model: { id: 'gpt-5.6-sol' } }); +- const refreshes: string[] = []; +- internals._destroyLiveSession = async () => { refreshes.push('destroy'); }; +- internals._resumeSession = async () => { +- refreshes.push('resume'); +- return replacement.fake; +- }; - -- await agent.chats.changeModel(chatUri, { id: COPILOT_HYDRA_FUSION_MODEL_ID }, exactChatContext(session, chatUri, session)); -- await agent.chats.sendMessage(chatUri, 'continue', undefined, undefined, 'turn-1', undefined, exactChatContext(session, chatUri, session)); +- await agent.chats.changeModel(chatUri, { id: COPILOT_HYDRA_FUSION_MODEL_ID }, exactChatContext(session, chatUri, session)); +- await agent.chats.sendMessage(chatUri, 'continue', undefined, undefined, 'turn-1', undefined, exactChatContext(session, chatUri, session)); - -- assert.deepStrictEqual({ -- refreshes, -- currentSends: current.rec.sends, -- replacementSends: replacement.rec.sends.map(send => send.prompt), -- }, { -- refreshes: ['destroy', 'resume'], -- currentSends: [], -- replacementSends: ['continue'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- refreshes, +- currentSends: current.rec.sends, +- replacementSends: replacement.rec.sends.map(send => send.prompt), +- }, { +- refreshes: ['destroy', 'resume'], +- currentSends: [], +- replacementSends: ['continue'], +- }); - }); - - test('default session resume preserves the selected model in its chat backing', async () => { @@ -75477,20 +77341,19 @@ index f24a97a6296..00000000000 - const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); - client.resumeSession = async () => new MockCopilotSession(sessionId) as unknown as CopilotSession; - const agent = createTestAgent(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- chatBackings(agent).set(chatUri.toString(), { sdkSessionId: sessionId, model: { id: COPILOT_HYDRA_FUSION_MODEL_ID } }); -- -- await (agent as unknown as ChatInternals)._resumeSession(sessionId, chatUri); -- -- assert.deepStrictEqual(chatBackings(agent).get(chatUri.toString()), { -- sdkSessionId: sessionId, -- model: { id: COPILOT_HYDRA_FUSION_MODEL_ID }, -- }); -- } finally { +- afterTest(async () => { - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- chatBackings(agent).set(chatUri.toString(), { sdkSessionId: sessionId, model: { id: COPILOT_HYDRA_FUSION_MODEL_ID } }); +- +- await (agent as unknown as ChatInternals)._resumeSession(sessionId, chatUri); +- +- assert.deepStrictEqual(chatBackings(agent).get(chatUri.toString()), { +- sdkSessionId: sessionId, +- model: { id: COPILOT_HYDRA_FUSION_MODEL_ID }, +- }); - }); - }); - @@ -75584,51 +77447,48 @@ index f24a97a6296..00000000000 - test('one createChat entry serves both a deferred first backing and an eager additional backing', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'uniform-create'); -- const sessionChat = defaultChatUri(session); -- const peerChat = URI.parse(buildChatUri(session, 'peer-a')); -- stubBackingSession(agent); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'uniform-create'); +- const sessionChat = defaultChatUri(session); +- const peerChat = URI.parse(buildChatUri(session, 'peer-a')); +- stubBackingSession(agent); - -- const standsUpSession = await agent.chats.createChat(sessionChat, exactChatContext(session, sessionChat, session), { workingDirectories: [URI.file('/workspace')], deferBacking: true }); -- const independent = await agent.chats.createChat(peerChat, exactChatContext(session, peerChat), { workingDirectories: [URI.file('/workspace')] }); +- const standsUpSession = await agent.chats.createChat(sessionChat, exactChatContext(session, sessionChat, session), { workingDirectories: [URI.file('/workspace')], deferBacking: true }); +- const independent = await agent.chats.createChat(peerChat, exactChatContext(session, peerChat), { workingDirectories: [URI.file('/workspace')] }); - -- const backings = chatBackings(agent); -- assert.deepStrictEqual({ -- deferred: { -- // The provider contract carries no `session`; results are keyed -- // only by the exact chat, never by the session that owns it. -- hasSessionField: standsUpSession ? Object.hasOwn(standsUpSession, 'session') : false, -- resolvedWorkingDirectory: standsUpSession?.resolvedWorkingDirectory?.toString(), -- provisional: standsUpSession?.provisional, -- recordedBacking: JSON.parse(standsUpSession!.providerData!).sdkSessionId === backings.get(sessionChat.toString())?.sdkSessionId, -- backingIsSeparatelyEnumerable: standsUpSession?.backingSession !== undefined, -- }, -- eager: { -- hasSessionField: independent ? Object.hasOwn(independent, 'session') : false, -- provisional: independent?.provisional, -- recordedBacking: JSON.parse(independent!.providerData!).sdkSessionId === backings.get(peerChat.toString())?.sdkSessionId, -- backingIsSeparatelyEnumerable: independent?.backingSession !== undefined, -- }, -- }, { -- deferred: { -- hasSessionField: false, -- resolvedWorkingDirectory: URI.file('/workspace').toString(), -- provisional: true, -- recordedBacking: true, -- backingIsSeparatelyEnumerable: true, -- }, -- eager: { -- hasSessionField: false, -- provisional: undefined, -- recordedBacking: true, -- backingIsSeparatelyEnumerable: true, -- }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- const backings = chatBackings(agent); +- assert.deepStrictEqual({ +- deferred: { +- // The provider contract carries no `session`; results are keyed +- // only by the exact chat, never by the session that owns it. +- hasSessionField: standsUpSession ? Object.hasOwn(standsUpSession, 'session') : false, +- resolvedWorkingDirectory: standsUpSession?.resolvedWorkingDirectory?.toString(), +- provisional: standsUpSession?.provisional, +- recordedBacking: JSON.parse(standsUpSession!.providerData!).sdkSessionId === backings.get(sessionChat.toString())?.sdkSessionId, +- backingIsSeparatelyEnumerable: standsUpSession?.backingSession !== undefined, +- }, +- eager: { +- hasSessionField: independent ? Object.hasOwn(independent, 'session') : false, +- provisional: independent?.provisional, +- recordedBacking: JSON.parse(independent!.providerData!).sdkSessionId === backings.get(peerChat.toString())?.sdkSessionId, +- backingIsSeparatelyEnumerable: independent?.backingSession !== undefined, +- }, +- }, { +- deferred: { +- hasSessionField: false, +- resolvedWorkingDirectory: URI.file('/workspace').toString(), +- provisional: true, +- recordedBacking: true, +- backingIsSeparatelyEnumerable: true, +- }, +- eager: { +- hasSessionField: false, +- provisional: undefined, +- recordedBacking: true, +- backingIsSeparatelyEnumerable: true, +- }, +- }); - }); - - test('an imported creation reports the exact backing it recorded and no separately enumerable backing session', async () => { @@ -75638,340 +77498,303 @@ index f24a97a6296..00000000000 - const client = new TestCopilotClient([]); - client.resumeSession = async () => new MockCopilotSession() as unknown as CopilotSession; - const agent = createTestAgent(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'import-backing'); -- const chat = defaultChatUri(session); -- const turn: Turn = { -- id: 'imported-turn-1', -- state: TurnState.Complete, -- message: { text: 'Remember IMPORT_ALPHA.', origin: { kind: MessageKind.User } }, -- responseParts: [{ kind: ResponsePartKind.Markdown, id: 'response', content: 'ready' }], -- usage: {}, -- }; -- -- const result = await provisionSession(agent, { -- session, -- workingDirectories: [workingDirectory], -- importConversation: { turns: [turn] }, -- }); -- -- assert.deepStrictEqual({ -- providerData: JSON.parse(result.providerData!), -- recordedBacking: chatBackings(agent).get(chat.toString()), -- backingSession: result.backingSession, -- }, { -- providerData: { sdkSessionId: 'import-backing' }, -- recordedBacking: { sdkSessionId: 'import-backing' }, -- backingSession: undefined, -- }); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory.fsPath, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'import-backing'); +- const chat = defaultChatUri(session); +- const turn: Turn = { +- id: 'imported-turn-1', +- state: TurnState.Complete, +- message: { text: 'Remember IMPORT_ALPHA.', origin: { kind: MessageKind.User } }, +- responseParts: [{ kind: ResponsePartKind.Markdown, id: 'response', content: 'ready' }], +- usage: {}, +- }; +- +- const result = await provisionSession(agent, { +- session, +- workingDirectories: [workingDirectory], +- importConversation: { turns: [turn] }, +- }); +- +- assert.deepStrictEqual({ +- providerData: JSON.parse(result.providerData!), +- recordedBacking: chatBackings(agent).get(chat.toString()), +- backingSession: result.backingSession, +- }, { +- providerData: { sdkSessionId: 'import-backing' }, +- recordedBacking: { sdkSessionId: 'import-backing' }, +- backingSession: undefined, +- }); - }).timeout(30_000); - - test('createChat mints a provisional session', async () => { - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); -- try { -- const session = AgentSession.uri('copilotcli', 'scope-create'); -- const result = await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); -- const internals = agent as unknown as ConvInternals; -- assert.deepStrictEqual({ -- session: result.session.toString(), -- provisional: internals._provisionalSessions.has(AgentSession.id(session)), -- }, { -- session: session.toString(), -- provisional: true, -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'scope-create'); +- const result = await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); +- const internals = agent as unknown as ConvInternals; +- assert.deepStrictEqual({ +- session: result.session.toString(), +- provisional: internals._provisionalSessions.has(AgentSession.id(session)), +- }, { +- session: session.toString(), +- provisional: true, +- }); - }); - - test('session teardown tears down a provisional session', async () => { - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); -- try { -- const session = AgentSession.uri('copilotcli', 'scope-dispose'); -- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); -- const internals = agent as unknown as ConvInternals; -- assert.strictEqual(internals._provisionalSessions.has(AgentSession.id(session)), true); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'scope-dispose'); +- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); +- const internals = agent as unknown as ConvInternals; +- assert.strictEqual(internals._provisionalSessions.has(AgentSession.id(session)), true); - -- await disposeProvisionedSession(agent, session); +- await disposeProvisionedSession(agent, session); - -- assert.strictEqual(internals._provisionalSessions.has(AgentSession.id(session)), false); -- } finally { -- await disposeAgent(agent); -- } +- assert.strictEqual(internals._provisionalSessions.has(AgentSession.id(session)), false); - }); - - test('createChat creates a chat leaf and returns its providerData', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'conv-create'); -- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); -- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'conv-create'); +- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); +- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); - -- stubBackingSession(agent); -- const result = await agent.chats.createChat(chatUri, session, { model: { id: 'gpt-x' }, workingDirectories: [URI.file('/workspace')] }); +- stubBackingSession(agent); +- const result = await agent.chats.createChat(chatUri, session, { model: { id: 'gpt-x' }, workingDirectories: [URI.file('/workspace')] }); - -- assert.deepStrictEqual({ -- tracked: hasLiveChat(agent, chatUri), -- hasProviderData: !!(result && result.providerData), -- model: result ? (JSON.parse(result.providerData!) as { model?: ModelSelection }).model : undefined, -- }, { -- tracked: true, -- hasProviderData: true, -- model: { id: 'gpt-x' }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- tracked: hasLiveChat(agent, chatUri), +- hasProviderData: !!(result && result.providerData), +- model: result ? (JSON.parse(result.providerData!) as { model?: ModelSelection }).model : undefined, +- }, { +- tracked: true, +- hasProviderData: true, +- model: { id: 'gpt-x' }, +- }); - }); - - test('a fork source mints the forked backing through the same createChat entry', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'conv-fork'); -- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); -- installFake(agent, AgentSession.id(session), 'session', session); -- -- const forkArgs: { turnId: string }[] = []; -- (agent as unknown as { _forkSdkChat: (client: unknown, sourceEntry: unknown, turnId: string) => Promise<{ sessionId: string; inheritedTurnId: string | undefined }> })._forkSdkChat = async (_c, _s, turnId) => { -- forkArgs.push({ turnId }); -- return { sessionId: 'forked-sdk-id', inheritedTurnId: undefined }; -- }; -- stubBackingSession(agent); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'conv-fork'); +- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); +- installFake(agent, AgentSession.id(session), 'session', session); +- +- const forkArgs: { turnId: string }[] = []; +- (agent as unknown as { _forkSdkChat: (client: unknown, sourceEntry: unknown, turnId: string) => Promise<{ sessionId: string; inheritedTurnId: string | undefined }> })._forkSdkChat = async (_c, _s, turnId) => { +- forkArgs.push({ turnId }); +- return { sessionId: 'forked-sdk-id', inheritedTurnId: undefined }; +- }; +- stubBackingSession(agent); - -- const chatUri = URI.parse(buildChatUri(session, 'peer-fork')); -- const source: IAgentCreateChatForkSource = { source: URI.parse(buildDefaultChatUri(session)), turnId: 't1' }; -- const result = await agent.chats.createChat(chatUri, session, { fork: source, workingDirectories: [URI.file('/workspace')] }); +- const chatUri = URI.parse(buildChatUri(session, 'peer-fork')); +- const source: IAgentCreateChatForkSource = { source: URI.parse(buildDefaultChatUri(session)), turnId: 't1' }; +- const result = await agent.chats.createChat(chatUri, session, { fork: source, workingDirectories: [URI.file('/workspace')] }); - -- assert.deepStrictEqual({ -- forkArgs, -- tracked: hasLiveChat(agent, chatUri), -- providerData: result ? JSON.parse(result.providerData!) : undefined, -- }, { -- forkArgs: [{ turnId: 't1' }], -- tracked: true, -- providerData: { sdkSessionId: 'forked-sdk-id' }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- forkArgs, +- tracked: hasLiveChat(agent, chatUri), +- providerData: result ? JSON.parse(result.providerData!) : undefined, +- }, { +- forkArgs: [{ turnId: 't1' }], +- tracked: true, +- providerData: { sdkSessionId: 'forked-sdk-id' }, +- }); - }); - - test('a cold peer fork reads the source peer storage scope', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'cold-peer-fork-owner'); -- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); -- const source = URI.parse(buildChatUri(session, 'source-peer')); -- await agent.materializeChat(source, exactChatContext(session, source), JSON.stringify({ sdkSessionId: 'source-peer-sdk' })); -- -- let resolvedSourceResource: string | undefined; -- (agent as unknown as { _ensureResolvedChatSession: (context: { resource: URI }) => Promise })._ensureResolvedChatSession = async context => { -- resolvedSourceResource = context.resource.toString(); -- return {} as unknown as CopilotAgentSession; -- }; -- (agent as unknown as { _forkSdkChat: () => Promise<{ sessionId: string; inheritedTurnId: string | undefined }> })._forkSdkChat = async () => { -- return { sessionId: 'forked-peer-sdk', inheritedTurnId: undefined }; -- }; -- stubBackingSession(agent); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'cold-peer-fork-owner'); +- await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); +- const source = URI.parse(buildChatUri(session, 'source-peer')); +- await agent.materializeChat(source, exactChatContext(session, source), JSON.stringify({ sdkSessionId: 'source-peer-sdk' })); +- +- let resolvedSourceResource: string | undefined; +- (agent as unknown as { _ensureResolvedChatSession: (context: { resource: URI }) => Promise })._ensureResolvedChatSession = async context => { +- resolvedSourceResource = context.resource.toString(); +- return {} as unknown as CopilotAgentSession; +- }; +- (agent as unknown as { _forkSdkChat: () => Promise<{ sessionId: string; inheritedTurnId: string | undefined }> })._forkSdkChat = async () => { +- return { sessionId: 'forked-peer-sdk', inheritedTurnId: undefined }; +- }; +- stubBackingSession(agent); - -- const fork = URI.parse(buildChatUri(session, 'forked-peer')); -- await agent.chats.createChat(fork, exactChatContext(session, fork), { -- fork: { source, turnId: 'turn-1' }, -- workingDirectories: [URI.file('/workspace')], -- }); +- const fork = URI.parse(buildChatUri(session, 'forked-peer')); +- await agent.chats.createChat(fork, exactChatContext(session, fork), { +- fork: { source, turnId: 'turn-1' }, +- workingDirectories: [URI.file('/workspace')], +- }); - -- assert.strictEqual(resolvedSourceResource, source.toString()); -- } finally { -- await disposeAgent(agent); -- } +- assert.strictEqual(resolvedSourceResource, source.toString()); - }); - - test('sendMessage routes an exact chat URI to the addressed chat', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'conv-send-peer'); -- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); -- const rec = installFake(agent, chatUri.toString(), 'chat', session); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'conv-send-peer'); +- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); +- const rec = installFake(agent, chatUri.toString(), 'chat', session); - -- await agent.chats.sendMessage(chatUri, 'hello-peer', undefined, undefined, 'turn-1', 'client-1'); +- await agent.chats.sendMessage(chatUri, 'hello-peer', undefined, undefined, 'turn-1', 'client-1'); - -- assert.deepStrictEqual({ -- sends: rec.sends, -- resets: rec.resets, -- }, { -- sends: [{ prompt: 'hello-peer', turnId: 'turn-1', senderClientId: 'client-1' }], -- resets: [{ turnId: 'turn-1', senderClientId: 'client-1' }], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- sends: rec.sends, +- resets: rec.resets, +- }, { +- sends: [{ prompt: 'hello-peer', turnId: 'turn-1', senderClientId: 'client-1' }], +- resets: [{ turnId: 'turn-1', senderClientId: 'client-1' }], +- }); - }); - - test('sendMessage routes a scope (session) URI to the session-backed chat', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'conv-send-default'); -- const rec = installFake(agent, AgentSession.id(session), 'session', session); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'conv-send-default'); +- const rec = installFake(agent, AgentSession.id(session), 'session', session); - -- await agent.chats.sendMessage(defaultChatUri(session), 'hello-default', undefined, undefined, 'turn-d', 'client-d'); +- await agent.chats.sendMessage(defaultChatUri(session), 'hello-default', undefined, undefined, 'turn-d', 'client-d'); - -- assert.deepStrictEqual(rec.sends, [{ prompt: 'hello-default', turnId: 'turn-d', senderClientId: 'client-d' }]); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(rec.sends, [{ prompt: 'hello-default', turnId: 'turn-d', senderClientId: 'client-d' }]); - }); - - test('abort, changeModel, and changeAgent route an exact chat URI to the addressed chat', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'conv-ops'); -- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); -- const rec = installFake(agent, chatUri.toString(), 'chat', session); -- (agent as unknown as { _resolveAgentName: (snap: IActiveClientSnapshot, a: AgentSelection) => string | undefined })._resolveAgentName = (_snap, sel) => sel.uri === 'agent://x' ? 'Resolved Agent' : undefined; +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'conv-ops'); +- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); +- const rec = installFake(agent, chatUri.toString(), 'chat', session); +- (agent as unknown as { _resolveAgentName: (snap: IActiveClientSnapshot, a: AgentSelection) => string | undefined })._resolveAgentName = (_snap, sel) => sel.uri === 'agent://x' ? 'Resolved Agent' : undefined; - -- await agent.chats.abort(chatUri, exactChatContext(session, chatUri)); -- await agent.chats.changeModel(chatUri, { id: 'model-x' }, exactChatContext(session, chatUri)); -- await agent.chats.changeAgent(chatUri, { uri: 'agent://x' }, exactChatContext(session, chatUri)); -- await agent.chats.changeAgent(chatUri, undefined, exactChatContext(session, chatUri)); +- await agent.chats.abort(chatUri, exactChatContext(session, chatUri)); +- await agent.chats.changeModel(chatUri, { id: 'model-x' }, exactChatContext(session, chatUri)); +- await agent.chats.changeAgent(chatUri, { uri: 'agent://x' }, exactChatContext(session, chatUri)); +- await agent.chats.changeAgent(chatUri, undefined, exactChatContext(session, chatUri)); - -- assert.deepStrictEqual({ -- aborted: rec.aborted, -- modelCalls: rec.modelCalls, -- agentCalls: rec.agentCalls, -- }, { -- aborted: 1, -- modelCalls: ['model-x'], -- agentCalls: ['Resolved Agent', undefined], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- aborted: rec.aborted, +- modelCalls: rec.modelCalls, +- agentCalls: rec.agentCalls, +- }, { +- aborted: 1, +- modelCalls: ['model-x'], +- agentCalls: ['Resolved Agent', undefined], +- }); - }); - - test('getMessages returns the addressed chat history', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'conv-history'); -- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); -- installFake(agent, chatUri.toString(), 'chat', session); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'conv-history'); +- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); +- installFake(agent, chatUri.toString(), 'chat', session); - -- const turns = await agent.chats.getMessages(chatUri, exactChatContext(session, chatUri)); +- const turns = await agent.chats.getMessages(chatUri, exactChatContext(session, chatUri)); - -- assert.deepStrictEqual(turns.map(t => t.id), [`turn-${chatUri.toString()}`]); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(turns.map(t => t.id), [`turn-${chatUri.toString()}`]); - }); - - test('disposeChat disposes the addressed chat', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'conv-dispose'); -- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); -- const rec = installFake(agent, chatUri.toString(), 'chat', session); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'conv-dispose'); +- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); +- const rec = installFake(agent, chatUri.toString(), 'chat', session); - -- await agent.chats.disposeChat(chatUri, exactChatContext(session, chatUri)); +- await agent.chats.disposeChat(chatUri, exactChatContext(session, chatUri)); - -- assert.deepStrictEqual({ -- disposed: rec.disposed, -- tracked: hasLiveChat(agent, chatUri), -- deleted: client.deletedSessionIds, -- }, { -- disposed: true, -- tracked: false, -- deleted: ['sdk-' + chatUri.toString()], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- disposed: rec.disposed, +- tracked: hasLiveChat(agent, chatUri), +- deleted: client.deletedSessionIds, +- }, { +- disposed: true, +- tracked: false, +- deleted: ['sdk-' + chatUri.toString()], +- }); - }); - - test('releaseChat releases only the addressed live chat', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'conv-release'); -- const defaultRec = installFake(agent, AgentSession.id(session), 'session', session); -- const peerChat = URI.parse(buildChatUri(session, 'peer-release')); -- const peerRec = installFake(agent, peerChat.toString(), 'chat', session); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'conv-release'); +- const defaultRec = installFake(agent, AgentSession.id(session), 'session', session); +- const peerChat = URI.parse(buildChatUri(session, 'peer-release')); +- const peerRec = installFake(agent, peerChat.toString(), 'chat', session); - -- await agent.chats.releaseChat(peerChat, exactChatContext(session, peerChat)); +- await agent.chats.releaseChat(peerChat, exactChatContext(session, peerChat)); - -- assert.deepStrictEqual({ -- defaultDisposed: defaultRec.disposed, -- peerDisposed: peerRec.disposed, -- defaultLive: hasLiveChat(agent, defaultChatUri(session)), -- peerLive: hasLiveChat(agent, peerChat), -- peerBacking: chatBackings(agent).get(peerChat.toString()), -- }, { -- defaultDisposed: false, -- peerDisposed: true, -- defaultLive: true, -- peerLive: false, -- peerBacking: { sdkSessionId: 'sdk-' + peerChat.toString() }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- defaultDisposed: defaultRec.disposed, +- peerDisposed: peerRec.disposed, +- defaultLive: hasLiveChat(agent, defaultChatUri(session)), +- peerLive: hasLiveChat(agent, peerChat), +- peerBacking: chatBackings(agent).get(peerChat.toString()), +- }, { +- defaultDisposed: false, +- peerDisposed: true, +- defaultLive: true, +- peerLive: false, +- peerBacking: { sdkSessionId: 'sdk-' + peerChat.toString() }, +- }); - }); - - test('disposeChat releases the peer chat\'s own OTel trace context without releasing a still-live sibling\'s scope', async () => { - const otelService = new RecordingReleaseOTelService(); - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]), otelService }); -- try { -- const session = AgentSession.uri('copilotcli', 'conv-peer-otel'); -- const defaultChat = defaultChatUri(session); -- const defaultRec = installFake(agent, AgentSession.id(session), 'session', session); -- const peerChat = URI.parse(buildChatUri(session, 'peer-otel')); -- const peerRec = installFake(agent, peerChat.toString(), 'chat', session); -- -- // A peer chat's own OTel trace context is keyed by its host-chosen -- // persistence resource — its own chat URI, `exactChatContext`'s -- // default `resource` — never by the shared session scope. -- // Disposing it while the default chat still shares the scope must -- // release only that key: the scope is still live, so nothing -- // scope-wide is released or finalized. -- await agent.chats.disposeChat(peerChat, exactChatContext(session, peerChat)); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'conv-peer-otel'); +- const defaultChat = defaultChatUri(session); +- const defaultRec = installFake(agent, AgentSession.id(session), 'session', session); +- const peerChat = URI.parse(buildChatUri(session, 'peer-otel')); +- const peerRec = installFake(agent, peerChat.toString(), 'chat', session); +- +- // A peer chat's own OTel trace context is keyed by its host-chosen +- // persistence resource — its own chat URI, `exactChatContext`'s +- // default `resource` — never by the shared session scope. +- // Disposing it while the default chat still shares the scope must +- // release only that key: the scope is still live, so nothing +- // scope-wide is released or finalized. +- await agent.chats.disposeChat(peerChat, exactChatContext(session, peerChat)); - -- assert.deepStrictEqual({ -- peerDisposed: peerRec.disposed, -- defaultDisposed: defaultRec.disposed, -- peerLive: hasLiveChat(agent, peerChat), -- defaultLive: hasLiveChat(agent, defaultChat), -- released: otelService.released, -- }, { -- peerDisposed: true, -- defaultDisposed: false, -- peerLive: false, -- defaultLive: true, -- released: [peerChat.toString()], -- }); +- assert.deepStrictEqual({ +- peerDisposed: peerRec.disposed, +- defaultDisposed: defaultRec.disposed, +- peerLive: hasLiveChat(agent, peerChat), +- defaultLive: hasLiveChat(agent, defaultChat), +- released: otelService.released, +- }, { +- peerDisposed: true, +- defaultDisposed: false, +- peerLive: false, +- defaultLive: true, +- released: [peerChat.toString()], +- }); - -- // Disposing the last remaining chat — the default, whose own -- // resource coincides with the scope — finalizes the scope too: -- // its key is released once for the chat's own teardown and once -- // more (idempotently) by scope finalization. -- await agent.chats.disposeChat(defaultChat, exactChatContext(session, defaultChat, session)); +- // Disposing the last remaining chat — the default, whose own +- // resource coincides with the scope — finalizes the scope too: +- // its key is released once for the chat's own teardown and once +- // more (idempotently) by scope finalization. +- await agent.chats.disposeChat(defaultChat, exactChatContext(session, defaultChat, session)); - -- assert.deepStrictEqual(otelService.released, [peerChat.toString(), session.toString(), session.toString()]); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual(otelService.released, [peerChat.toString(), session.toString(), session.toString()]); - }); - }); - @@ -76007,152 +77830,143 @@ index f24a97a6296..00000000000 - const peer = URI.parse(buildChatUri(session, 'peer')); - const sharedA: ToolDefinition = { ...toolA, name: 'shared' }; - const sharedB: ToolDefinition = { ...toolB, name: 'shared' }; -- try { -- agent.getOrCreateActiveClient(chat, session, { clientId: 'client-A' }).tools = [sharedA, { name: 'create_session' }]; -- agent.getOrCreateActiveClient(peer, session, { clientId: 'client-A' }); -- agent.getOrCreateActiveClient(chat, session, { clientId: 'client-B' }).tools = [sharedB, toolB]; -- const active = membership(agent, session); -- const firstSnapshot = await active.snapshot(chat.toString()); -- active.setPreferredClient(chat.toString(), 'client-B'); -- active.setPreferredClient(peer.toString(), 'client-B'); +- afterTest(() => disposeAgent(agent)); +- agent.getOrCreateActiveClient(chat, session, { clientId: 'client-A' }).tools = [sharedA, { name: 'create_session' }]; +- agent.getOrCreateActiveClient(peer, session, { clientId: 'client-A' }); +- agent.getOrCreateActiveClient(chat, session, { clientId: 'client-B' }).tools = [sharedB, toolB]; +- const active = membership(agent, session); +- const firstSnapshot = await active.snapshot(chat.toString()); +- active.setPreferredClient(chat.toString(), 'client-B'); +- active.setPreferredClient(peer.toString(), 'client-B'); - -- assert.deepStrictEqual({ -- first: firstSnapshot.tools, -- selected: (await active.snapshot(chat.toString())).tools, -- observed: active.toolsForChat(chat.toString()), -- peer: (await active.snapshot(peer.toString())).tools, -- refresh: await active.getRestartReason(firstSnapshot, chat.toString()), -- unscoped: (await active.snapshot()).tools, -- }, { -- first: [sharedA, { name: 'create_session' }, toolB], -- selected: [sharedB, toolB, { name: 'create_session' }], -- observed: [sharedB, toolB, { name: 'create_session' }], -- peer: [sharedA, { name: 'create_session' }], -- refresh: undefined, -- unscoped: [sharedA, { name: 'create_session' }, toolB], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- first: firstSnapshot.tools, +- selected: (await active.snapshot(chat.toString())).tools, +- observed: active.toolsForChat(chat.toString()), +- peer: (await active.snapshot(peer.toString())).tools, +- refresh: await active.getRestartReason(firstSnapshot, chat.toString()), +- unscoped: (await active.snapshot()).tools, +- }, { +- first: [sharedA, { name: 'create_session' }, toolB], +- selected: [sharedB, toolB, { name: 'create_session' }], +- observed: [sharedB, toolB, { name: 'create_session' }], +- peer: [sharedA, { name: 'create_session' }], +- refresh: undefined, +- unscoped: [sharedA, { name: 'create_session' }, toolB], +- }); - }); - - test('adding a chat to a client\'s membership extends its reach without touching other clients', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'membership-growth'); -- const defaultChat = defaultChatUri(session); -- const peerChat = URI.parse(buildChatUri(session, 'peer-1')); -- -- // First: client A reaches only the session's own chat; client B -- // reaches both, added one exact chat at a time — so the peer chat -- // is a chat the host has published membership for. -- agent.getOrCreateActiveClient(defaultChat, session, { clientId: 'client-A' }).tools = [toolA]; -- agent.getOrCreateActiveClient(defaultChat, session, { clientId: 'client-B' }).tools = [toolB]; -- agent.getOrCreateActiveClient(peerChat, session, { clientId: 'client-B' }); -- const active = membership(agent, session); -- const peerSnapshotBeforeGrowth = await active.snapshot(peerChat.toString()); -- -- const before = { +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'membership-growth'); +- const defaultChat = defaultChatUri(session); +- const peerChat = URI.parse(buildChatUri(session, 'peer-1')); +- +- // First: client A reaches only the session's own chat; client B +- // reaches both, added one exact chat at a time — so the peer chat +- // is a chat the host has published membership for. +- agent.getOrCreateActiveClient(defaultChat, session, { clientId: 'client-A' }).tools = [toolA]; +- agent.getOrCreateActiveClient(defaultChat, session, { clientId: 'client-B' }).tools = [toolB]; +- agent.getOrCreateActiveClient(peerChat, session, { clientId: 'client-B' }); +- const active = membership(agent, session); +- const peerSnapshotBeforeGrowth = await active.snapshot(peerChat.toString()); +- +- const before = { +- chatsA: active.clientChats('client-A'), +- toolsOnDefault: active.toolsForChat(defaultChat.toString()).map(tool => tool.name), +- toolsOnPeer: active.toolsForChat(peerChat.toString()).map(tool => tool.name), +- reachesPeerA: active.contributesTo('client-A', peerChat.toString()), +- reachesDefaultA: active.contributesTo('client-A', defaultChat.toString()), +- }; +- +- // The catalog grew: the host addresses the new peer chat for +- // client A too, incrementally alongside its existing membership. +- agent.getOrCreateActiveClient(peerChat, session, { clientId: 'client-A' }); +- +- assert.deepStrictEqual({ +- before, +- after: { - chatsA: active.clientChats('client-A'), -- toolsOnDefault: active.toolsForChat(defaultChat.toString()).map(tool => tool.name), - toolsOnPeer: active.toolsForChat(peerChat.toString()).map(tool => tool.name), - reachesPeerA: active.contributesTo('client-A', peerChat.toString()), -- reachesDefaultA: active.contributesTo('client-A', defaultChat.toString()), -- }; -- -- // The catalog grew: the host addresses the new peer chat for -- // client A too, incrementally alongside its existing membership. -- agent.getOrCreateActiveClient(peerChat, session, { clientId: 'client-A' }); -- -- assert.deepStrictEqual({ -- before, -- after: { -- chatsA: active.clientChats('client-A'), -- toolsOnPeer: active.toolsForChat(peerChat.toString()).map(tool => tool.name), -- reachesPeerA: active.contributesTo('client-A', peerChat.toString()), -- // The peer chat's live runtime advertised the pre-growth -- // set, so its next interaction must reconcile. -- peerRefreshReason: await active.getRestartReason(peerSnapshotBeforeGrowth, peerChat.toString()), -- defaultRefreshReason: await active.getRestartReason(await active.snapshot(defaultChat.toString()), defaultChat.toString()), -- }, -- }, { -- before: { -- chatsA: [defaultChat.toString()], -- toolsOnDefault: ['tool_a', 'tool_b'], -- toolsOnPeer: ['tool_b'], -- reachesPeerA: false, -- reachesDefaultA: true, -- }, -- after: { -- chatsA: [defaultChat.toString(), peerChat.toString()], -- toolsOnPeer: ['tool_a', 'tool_b'], -- reachesPeerA: true, -- peerRefreshReason: undefined, -- defaultRefreshReason: undefined, -- }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- // The peer chat's live runtime advertised the pre-growth +- // set, so its next interaction must reconcile. +- peerRefreshReason: await active.getRestartReason(peerSnapshotBeforeGrowth, peerChat.toString()), +- defaultRefreshReason: await active.getRestartReason(await active.snapshot(defaultChat.toString()), defaultChat.toString()), +- }, +- }, { +- before: { +- chatsA: [defaultChat.toString()], +- toolsOnDefault: ['tool_a', 'tool_b'], +- toolsOnPeer: ['tool_b'], +- reachesPeerA: false, +- reachesDefaultA: true, +- }, +- after: { +- chatsA: [defaultChat.toString(), peerChat.toString()], +- toolsOnPeer: ['tool_a', 'tool_b'], +- reachesPeerA: true, +- peerRefreshReason: undefined, +- defaultRefreshReason: undefined, +- }, +- }); - }); - - test('removeActiveClient drops membership for exactly the addressed chat, clearing tools/customizations only once no chats remain', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'membership-replacement'); -- const defaultChat = defaultChatUri(session); -- const peerChat = URI.parse(buildChatUri(session, 'peer-1')); -- -- agent.getOrCreateActiveClient(defaultChat, session, { clientId: 'client-A' }).tools = [toolA]; -- agent.getOrCreateActiveClient(peerChat, session, { clientId: 'client-A' }); -- agent.getOrCreateActiveClient(defaultChat, session, { clientId: 'client-B' }).tools = [toolB]; -- agent.getOrCreateActiveClient(peerChat, session, { clientId: 'client-B' }); -- const active = membership(agent, session); -- -- // Removing one of client A's two chats narrows its membership — -- // it still reaches the default chat, so its tool contribution -- // there is untouched. -- agent.removeActiveClient(peerChat, session, 'client-A'); -- const afterNarrowing = { -- chatsA: active.clientChats('client-A'), -- reachesPeer: active.contributesTo('client-A', peerChat.toString()), -- toolsOnPeer: active.toolsForChat(peerChat.toString()).map(tool => tool.name), -- toolsOnDefault: active.toolsForChat(defaultChat.toString()).map(tool => tool.name), -- }; +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'membership-replacement'); +- const defaultChat = defaultChatUri(session); +- const peerChat = URI.parse(buildChatUri(session, 'peer-1')); - -- // Removing a chat the client is no longer (or never was) -- // registered for is a no-op — it must not disturb the client's -- // remaining membership. -- agent.removeActiveClient(peerChat, session, 'client-A'); -- const afterNoOpRemoval = active.clientChats('client-A'); +- agent.getOrCreateActiveClient(defaultChat, session, { clientId: 'client-A' }).tools = [toolA]; +- agent.getOrCreateActiveClient(peerChat, session, { clientId: 'client-A' }); +- agent.getOrCreateActiveClient(defaultChat, session, { clientId: 'client-B' }).tools = [toolB]; +- agent.getOrCreateActiveClient(peerChat, session, { clientId: 'client-B' }); +- const active = membership(agent, session); +- +- // Removing one of client A's two chats narrows its membership — +- // it still reaches the default chat, so its tool contribution +- // there is untouched. +- agent.removeActiveClient(peerChat, session, 'client-A'); +- const afterNarrowing = { +- chatsA: active.clientChats('client-A'), +- reachesPeer: active.contributesTo('client-A', peerChat.toString()), +- toolsOnPeer: active.toolsForChat(peerChat.toString()).map(tool => tool.name), +- toolsOnDefault: active.toolsForChat(defaultChat.toString()).map(tool => tool.name), +- }; - -- // Removing client A's last remaining chat fully drops its tool -- // and customization contributions. -- agent.removeActiveClient(defaultChat, session, 'client-A'); +- // Removing a chat the client is no longer (or never was) +- // registered for is a no-op — it must not disturb the client's +- // remaining membership. +- agent.removeActiveClient(peerChat, session, 'client-A'); +- const afterNoOpRemoval = active.clientChats('client-A'); - -- assert.deepStrictEqual({ -- afterNarrowing, -- afterNoOpRemoval, -- afterRemoval: { -- chatsA: active.clientChats('client-A'), -- toolsOnDefault: active.toolsForChat(defaultChat.toString()).map(tool => tool.name), -- }, -- }, { -- afterNarrowing: { -- chatsA: [defaultChat.toString()], -- reachesPeer: false, -- toolsOnPeer: ['tool_b'], -- toolsOnDefault: ['tool_a', 'tool_b'], -- }, -- afterNoOpRemoval: [defaultChat.toString()], -- afterRemoval: { -- chatsA: [], -- toolsOnDefault: ['tool_b'], -- }, -- }); -- } finally { -- await disposeAgent(agent); -- } +- // Removing client A's last remaining chat fully drops its tool +- // and customization contributions. +- agent.removeActiveClient(defaultChat, session, 'client-A'); +- +- assert.deepStrictEqual({ +- afterNarrowing, +- afterNoOpRemoval, +- afterRemoval: { +- chatsA: active.clientChats('client-A'), +- toolsOnDefault: active.toolsForChat(defaultChat.toString()).map(tool => tool.name), +- }, +- }, { +- afterNarrowing: { +- chatsA: [defaultChat.toString()], +- reachesPeer: false, +- toolsOnPeer: ['tool_b'], +- toolsOnDefault: ['tool_a', 'tool_b'], +- }, +- afterNoOpRemoval: [defaultChat.toString()], +- afterRemoval: { +- chatsA: [], +- toolsOnDefault: ['tool_b'], +- }, +- }); - }); - - test('a chat the host has published no membership for yet keeps every client in scope', async () => { @@ -76161,24 +77975,21 @@ index f24a97a6296..00000000000 - // in that window must still resolve an owning client rather than - // being dropped, so an unpublished chat is in scope for everyone. - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'membership-pre-fanout'); -- const defaultChat = defaultChatUri(session); -- const justCreatedPeer = URI.parse(buildChatUri(session, 'peer-new')); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'membership-pre-fanout'); +- const defaultChat = defaultChatUri(session); +- const justCreatedPeer = URI.parse(buildChatUri(session, 'peer-new')); - -- agent.getOrCreateActiveClient(defaultChat, session, { clientId: 'client-A' }).tools = [toolA]; -- const active = membership(agent, session); +- agent.getOrCreateActiveClient(defaultChat, session, { clientId: 'client-A' }).tools = [toolA]; +- const active = membership(agent, session); - -- assert.deepStrictEqual({ -- reachesUnpublished: active.contributesTo('client-A', justCreatedPeer.toString()), -- toolsOnUnpublished: active.toolsForChat(justCreatedPeer.toString()).map(tool => tool.name), -- }, { -- reachesUnpublished: true, -- toolsOnUnpublished: ['tool_a'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- reachesUnpublished: active.contributesTo('client-A', justCreatedPeer.toString()), +- toolsOnUnpublished: active.toolsForChat(justCreatedPeer.toString()).map(tool => tool.name), +- }, { +- reachesUnpublished: true, +- toolsOnUnpublished: ['tool_a'], +- }); - }); - - test('a live chat stamps a client tool call only with a client the host fanned that chat out to', async () => { @@ -76211,24 +78022,21 @@ index f24a97a6296..00000000000 - snapshot: { discoveredAgents: [], additionalRootInstructions: [], tools: [sharedTool], plugins: [], mcpServers: {} }, - }); - const agentSession = disposables.add(createdSession.session); -- try { -- await agentSession.initializeSession(); -- // The turn's sender is client-B, which does NOT reach this chat. -- agentSession.resetTurnState('turn-1', 'client-B'); +- afterTest(() => disposeAgent(agent)); +- await agentSession.initializeSession(); +- // The turn's sender is client-B, which does NOT reach this chat. +- agentSession.resetTurnState('turn-1', 'client-B'); - -- mockSession.emit({ -- type: 'tool.execution_start', -- data: { toolCallId: 'tool-1', toolName: 'shared', arguments: {} }, -- } as SessionEventPayload<'tool.execution_start'>); +- mockSession.emit({ +- type: 'tool.execution_start', +- data: { toolCallId: 'tool-1', toolName: 'shared', arguments: {} }, +- } as SessionEventPayload<'tool.execution_start'>); - -- const toolStart = actions.find(action => action.type === ActionType.ChatToolCallStart); -- assert.deepStrictEqual(toolStart?.type === ActionType.ChatToolCallStart ? toolStart.contributor : undefined, { -- kind: ToolCallContributorKind.Client, -- clientId: 'client-A', -- }); -- } finally { -- await disposeAgent(agent); -- } +- const toolStart = actions.find(action => action.type === ActionType.ChatToolCallStart); +- assert.deepStrictEqual(toolStart?.type === ActionType.ChatToolCallStart ? toolStart.contributor : undefined, { +- kind: ToolCallContributorKind.Client, +- clientId: 'client-A', +- }); - }); - }); - @@ -76260,83 +78068,77 @@ index f24a97a6296..00000000000 - for (const cache of ['fsPath', 'toString', 'both'] as const) { - test(`unchanged ${kind} do not require a restart after launch populates URI ${cache} caches`, async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'discovered-uri-cache'); -- agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client' }); -- const activeClient = getActiveClient(agent, session); -- const base = await activeClient.snapshot(); -- const root = URI.file('/additional'); -- const customizations = [{ -- type: CustomizationType.Directory, -- id: 'discovered', -- uri: root.toString(), -- name: 'Discovered', -- enabled: true, -- writable: true, -- contents: CustomizationType.Rule, -- children: ['first', 'second'].flatMap((name): (IParsedAgent['customization'] | IParsedRule['customization'])[] => [ -- { type: CustomizationType.Agent, id: `agent:${name}`, uri: URI.joinPath(root, `${name}.agent.md`).toString(), name }, -- { type: CustomizationType.Rule, id: `rule:${name}`, uri: URI.joinPath(root, `${name}.instructions.md`).toString(), name }, -- ]), -- }] satisfies Extract[]; -- const snapshot = (): IActiveClientSnapshot => ({ -- ...base, -- [kind]: mapDiscoveredCustomizations(customizations, URI.file('/primary'), [root])[kind], -- }); -- const applied = snapshot(); -- for (const resource of applied[kind]) { -- if (cache !== 'toString') { -- void resource.uri.fsPath; -- } -- if (cache !== 'fsPath') { -- resource.uri.toString(); -- } +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'discovered-uri-cache'); +- agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client' }); +- const activeClient = getActiveClient(agent, session); +- const base = await activeClient.snapshot(); +- const root = URI.file('/additional'); +- const customizations = [{ +- type: CustomizationType.Directory, +- id: 'discovered', +- uri: root.toString(), +- name: 'Discovered', +- enabled: true, +- writable: true, +- contents: CustomizationType.Rule, +- children: ['first', 'second'].flatMap((name): (IParsedAgent['customization'] | IParsedRule['customization'])[] => [ +- { type: CustomizationType.Agent, id: `agent:${name}`, uri: URI.joinPath(root, `${name}.agent.md`).toString(), name }, +- { type: CustomizationType.Rule, id: `rule:${name}`, uri: URI.joinPath(root, `${name}.instructions.md`).toString(), name }, +- ]), +- }] satisfies Extract[]; +- const snapshot = (): IActiveClientSnapshot => ({ +- ...base, +- [kind]: mapDiscoveredCustomizations(customizations, URI.file('/primary'), [root])[kind], +- }); +- const applied = snapshot(); +- for (const resource of applied[kind]) { +- if (cache !== 'toString') { +- void resource.uri.fsPath; +- } +- if (cache !== 'fsPath') { +- resource.uri.toString(); - } -- const current = snapshot(); -- assert.deepStrictEqual({ -- unchanged: await activeClient.getRestartReason(applied, undefined, current), -- reordered: await activeClient.getRestartReason(applied, undefined, { ...current, [kind]: [...current[kind]].reverse() }), -- uri: await activeClient.getRestartReason(applied, undefined, { ...current, [kind]: current[kind].map(resource => ({ ...resource, uri: resource.uri.with({ query: 'changed' }) })) }), -- metadata: await activeClient.getRestartReason(applied, undefined, { ...current, [kind]: current[kind].map(resource => ({ ...resource, description: 'Changed description' })) }), -- customization: await activeClient.getRestartReason(applied, undefined, { ...current, [kind]: current[kind].map(resource => ({ ...resource, customization: { ...resource.customization, description: 'Changed customization' } })) }), -- }, { -- unchanged: undefined, -- reordered: 'clientPluginsChanged', -- uri: 'clientPluginsChanged', -- metadata: 'clientPluginsChanged', -- customization: 'clientPluginsChanged', -- }); -- } finally { -- await disposeAgent(agent); - } +- const current = snapshot(); +- assert.deepStrictEqual({ +- unchanged: await activeClient.getRestartReason(applied, undefined, current), +- reordered: await activeClient.getRestartReason(applied, undefined, { ...current, [kind]: [...current[kind]].reverse() }), +- uri: await activeClient.getRestartReason(applied, undefined, { ...current, [kind]: current[kind].map(resource => ({ ...resource, uri: resource.uri.with({ query: 'changed' }) })) }), +- metadata: await activeClient.getRestartReason(applied, undefined, { ...current, [kind]: current[kind].map(resource => ({ ...resource, description: 'Changed description' })) }), +- customization: await activeClient.getRestartReason(applied, undefined, { ...current, [kind]: current[kind].map(resource => ({ ...resource, customization: { ...resource.customization, description: 'Changed customization' } })) }), +- }, { +- unchanged: undefined, +- reordered: 'clientPluginsChanged', +- uri: 'clientPluginsChanged', +- metadata: 'clientPluginsChanged', +- customization: 'clientPluginsChanged', +- }); - }); - } - } - - test('changes to discovered agents and additional-root instructions require a restart', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'discovered-customizations-refresh'); -- agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client' }); -- const activeClient = getActiveClient(agent, session); -- const applied = await activeClient.snapshot(); -- const uri = URI.file('/additional/.github/instructions/test.instructions.md'); -- const rule = { uri, name: 'test', customization: { type: CustomizationType.Rule, id: uri.toString(), uri: uri.toString(), name: 'test' } } satisfies IParsedRule; -- const agentUri = URI.file('/workspace/.github/agents/helper.agent.md'); -- const customAgent = { uri: agentUri, name: 'helper', customization: { type: CustomizationType.Agent, id: agentUri.toString(), uri: agentUri.toString(), name: 'helper' } } satisfies IParsedAgent; -- const withRule: IActiveClientSnapshot = { ...applied, additionalRootInstructions: [rule] }; -- const withAgent: IActiveClientSnapshot = { ...applied, discoveredAgents: [customAgent] }; -- assert.deepStrictEqual([ -- await activeClient.getRestartReason(applied, undefined, withRule), -- await activeClient.getRestartReason(withRule, undefined, withRule), -- await activeClient.getRestartReason(withRule, undefined, applied), -- await activeClient.getRestartReason(applied, undefined, withAgent), -- await activeClient.getRestartReason(withAgent, undefined, withAgent), -- await activeClient.getRestartReason(withAgent, undefined, applied), -- ], ['clientPluginsChanged', undefined, 'clientPluginsChanged', 'clientPluginsChanged', undefined, 'clientPluginsChanged']); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'discovered-customizations-refresh'); +- agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client' }); +- const activeClient = getActiveClient(agent, session); +- const applied = await activeClient.snapshot(); +- const uri = URI.file('/additional/.github/instructions/test.instructions.md'); +- const rule = { uri, name: 'test', customization: { type: CustomizationType.Rule, id: uri.toString(), uri: uri.toString(), name: 'test' } } satisfies IParsedRule; +- const agentUri = URI.file('/workspace/.github/agents/helper.agent.md'); +- const customAgent = { uri: agentUri, name: 'helper', customization: { type: CustomizationType.Agent, id: agentUri.toString(), uri: agentUri.toString(), name: 'helper' } } satisfies IParsedAgent; +- const withRule: IActiveClientSnapshot = { ...applied, additionalRootInstructions: [rule] }; +- const withAgent: IActiveClientSnapshot = { ...applied, discoveredAgents: [customAgent] }; +- assert.deepStrictEqual([ +- await activeClient.getRestartReason(applied, undefined, withRule), +- await activeClient.getRestartReason(withRule, undefined, withRule), +- await activeClient.getRestartReason(withRule, undefined, applied), +- await activeClient.getRestartReason(applied, undefined, withAgent), +- await activeClient.getRestartReason(withAgent, undefined, withAgent), +- await activeClient.getRestartReason(withAgent, undefined, applied), +- ], ['clientPluginsChanged', undefined, 'clientPluginsChanged', 'clientPluginsChanged', undefined, 'clientPluginsChanged']); - }); - - test('customization restart diagnostics identify the first changed component without contents', async () => { @@ -76350,189 +78152,171 @@ index f24a97a6296..00000000000 - }; - const { agent, fileService } = createTestAgentContext(disposables, { logService }); - disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); -- try { -- const session = AgentSession.uri('copilotcli', 'customization-diagnostics'); -- agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client' }); -- const activeClient = getActiveClient(agent, session); -- const base = await activeClient.snapshot(); -- const plugin = { format: PluginFormat.Copilot, nonce: 'private-nonce', pluginDir: URI.file('/private/applied'), hooks: [], mcpServers: [], skills: [], agents: [], instructions: [] }; -- const uri = URI.file('/private/resource'); -- const customAgent = { uri, name: 'private-name', description: 'private-content', customization: { type: CustomizationType.Agent, id: 'private-id', uri: uri.toString(), name: 'private-name' } } satisfies IParsedAgent; -- const rule = { ...customAgent, customization: { ...customAgent.customization, type: CustomizationType.Rule } } satisfies IParsedRule; -- const cases: IActiveClientSnapshot[] = [ -- { ...base, plugins: [plugin], discoveredAgents: [customAgent], additionalRootInstructions: [rule] }, -- { ...base, discoveredAgents: [customAgent], additionalRootInstructions: [rule] }, -- { ...base, additionalRootInstructions: [rule] }, -- ]; -- for (const applied of cases) { -- await activeClient.getRestartReason(applied, undefined, base); -- } -- await activeClient.getRestartReason({ ...base, plugins: [plugin] }, undefined, { ...base, plugins: [{ ...plugin, pluginDir: URI.file('/private/relocated') }] }); -- await activeClient.getRestartReason(base, undefined, base); -- assert.deepStrictEqual(messages, [ -- '[Copilot:customization-diagnostics] Session customization changed: component=plugins, appliedCount=1, currentCount=0', -- '[Copilot:customization-diagnostics] Session customization changed: component=discoveredAgents, appliedCount=1, currentCount=0', -- '[Copilot:customization-diagnostics] Session customization changed: component=additionalRootInstructions, appliedCount=1, currentCount=0', -- '[Copilot:customization-diagnostics] Session customization changed: component=evictedRelocatedRoot, appliedCount=1, currentCount=1', -- ]); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'customization-diagnostics'); +- agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client' }); +- const activeClient = getActiveClient(agent, session); +- const base = await activeClient.snapshot(); +- const plugin = { format: PluginFormat.Copilot, nonce: 'private-nonce', pluginDir: URI.file('/private/applied'), hooks: [], mcpServers: [], skills: [], agents: [], instructions: [] }; +- const uri = URI.file('/private/resource'); +- const customAgent = { uri, name: 'private-name', description: 'private-content', customization: { type: CustomizationType.Agent, id: 'private-id', uri: uri.toString(), name: 'private-name' } } satisfies IParsedAgent; +- const rule = { ...customAgent, customization: { ...customAgent.customization, type: CustomizationType.Rule } } satisfies IParsedRule; +- const cases: IActiveClientSnapshot[] = [ +- { ...base, plugins: [plugin], discoveredAgents: [customAgent], additionalRootInstructions: [rule] }, +- { ...base, discoveredAgents: [customAgent], additionalRootInstructions: [rule] }, +- { ...base, additionalRootInstructions: [rule] }, +- ]; +- for (const applied of cases) { +- await activeClient.getRestartReason(applied, undefined, base); +- } +- await activeClient.getRestartReason({ ...base, plugins: [plugin] }, undefined, { ...base, plugins: [{ ...plugin, pluginDir: URI.file('/private/relocated') }] }); +- await activeClient.getRestartReason(base, undefined, base); +- assert.deepStrictEqual(messages, [ +- '[Copilot:customization-diagnostics] Session customization changed: component=plugins, appliedCount=1, currentCount=0', +- '[Copilot:customization-diagnostics] Session customization changed: component=discoveredAgents, appliedCount=1, currentCount=0', +- '[Copilot:customization-diagnostics] Session customization changed: component=additionalRootInstructions, appliedCount=1, currentCount=0', +- '[Copilot:customization-diagnostics] Session customization changed: component=evictedRelocatedRoot, appliedCount=1, currentCount=1', +- ]); - }); - - test('identical client plugin content under a different URI does not require a restart while the applied root exists', async () => { - const { agent, fileService } = createTestAgentContext(disposables); - disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); - await fileService.createFolder(URI.file('/cache/old')); -- try { -- const session = AgentSession.uri('copilotcli', 'relocated-plugin-session'); -- agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client' }); -- const activeClient = getActiveClient(agent, session); -- const snapshot = (root: string, nonce = 'same-content'): IActiveClientSnapshot => ({ -- discoveredAgents: [], -- additionalRootInstructions: [], -- tools: [], -- mcpServers: {}, -- plugins: [{ -- format: PluginFormat.Copilot, -- pluginDir: URI.file(root), -- sourceUri: URI.file(root), -- nonce, -- hooks: [], -- mcpServers: [], -- skills: [{ uri: URI.file(`${root}/skills/a/SKILL.md`), name: 'a', customization: { type: CustomizationType.Skill, id: 'skill:a', uri: 'file:///skill', name: 'a' } }], -- agents: [], -- instructions: [], -- }], -- }); -- const applied = snapshot('/cache/old'); -- const relocated = await activeClient.getRestartReason(applied, undefined, snapshot('/cache/new')); -- const changed = await activeClient.getRestartReason(applied, undefined, snapshot('/cache/new', 'changed-content')); -- await fileService.del(URI.file('/cache/old'), { recursive: true }); -- assert.deepStrictEqual([ -- relocated, -- changed, -- await activeClient.getRestartReason(applied, undefined, snapshot('/cache/new')), -- ], [undefined, 'clientPluginsChanged', 'clientPluginsChanged']); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'relocated-plugin-session'); +- agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client' }); +- const activeClient = getActiveClient(agent, session); +- const snapshot = (root: string, nonce = 'same-content'): IActiveClientSnapshot => ({ +- discoveredAgents: [], +- additionalRootInstructions: [], +- tools: [], +- mcpServers: {}, +- plugins: [{ +- format: PluginFormat.Copilot, +- pluginDir: URI.file(root), +- sourceUri: URI.file(root), +- nonce, +- hooks: [], +- mcpServers: [], +- skills: [{ uri: URI.file(`${root}/skills/a/SKILL.md`), name: 'a', customization: { type: CustomizationType.Skill, id: 'skill:a', uri: 'file:///skill', name: 'a' } }], +- agents: [], +- instructions: [], +- }], +- }); +- const applied = snapshot('/cache/old'); +- const relocated = await activeClient.getRestartReason(applied, undefined, snapshot('/cache/new')); +- const changed = await activeClient.getRestartReason(applied, undefined, snapshot('/cache/new', 'changed-content')); +- await fileService.del(URI.file('/cache/old'), { recursive: true }); +- assert.deepStrictEqual([ +- relocated, +- changed, +- await activeClient.getRestartReason(applied, undefined, snapshot('/cache/new')), +- ], [undefined, 'clientPluginsChanged', 'clientPluginsChanged']); - }); - - test('clientId-only change (reload) does NOT require a restart and updates the live owner', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'reload-session'); -- -- // Window A registers its tools; this is the snapshot the SDK -- // session would be created with. -- agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client-A' }).tools = tools; -- const activeClient = getActiveClient(agent, session); -- const appliedSnapshot = await activeClient.snapshot(); -- assert.strictEqual(activeClient.toolSet.ownerOf('my_tool'), 'client-A'); -- -- // Window A reloads: window B reconnects with a new clientId but -- // the identical tool list. The reload removes A then adds B. -- agent.removeActiveClient(defaultChatUri(session), session, 'client-A'); -- agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client-B' }).tools = [...tools]; -- -- // Root-cause assertions: the cached SDK session must be reused -- // (no restart) AND the live owner must now be window B's, so -- // the next client tool call is stamped with a live owner. -- assert.strictEqual(await activeClient.getRestartReason(appliedSnapshot), undefined); -- assert.strictEqual(activeClient.toolSet.ownerOf('my_tool'), 'client-B'); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'reload-session'); +- +- // Window A registers its tools; this is the snapshot the SDK +- // session would be created with. +- agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client-A' }).tools = tools; +- const activeClient = getActiveClient(agent, session); +- const appliedSnapshot = await activeClient.snapshot(); +- assert.strictEqual(activeClient.toolSet.ownerOf('my_tool'), 'client-A'); +- +- // Window A reloads: window B reconnects with a new clientId but +- // the identical tool list. The reload removes A then adds B. +- agent.removeActiveClient(defaultChatUri(session), session, 'client-A'); +- agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client-B' }).tools = [...tools]; +- +- // Root-cause assertions: the cached SDK session must be reused +- // (no restart) AND the live owner must now be window B's, so +- // the next client tool call is stamped with a live owner. +- assert.strictEqual(await activeClient.getRestartReason(appliedSnapshot), undefined); +- assert.strictEqual(activeClient.toolSet.ownerOf('my_tool'), 'client-B'); - }); - - test('a structural tool change does not require a customization restart', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'tools-change-session'); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'tools-change-session'); - -- agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client-A' }).tools = tools; -- const activeClient = getActiveClient(agent, session); -- const appliedSnapshot = await activeClient.snapshot(); +- agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client-A' }).tools = tools; +- const activeClient = getActiveClient(agent, session); +- const appliedSnapshot = await activeClient.snapshot(); - -- agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client-A' }).tools = [...tools, { name: 'second_tool', description: 'another', inputSchema: { type: 'object', properties: {} } }]; +- agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client-A' }).tools = [...tools, { name: 'second_tool', description: 'another', inputSchema: { type: 'object', properties: {} } }]; - -- assert.strictEqual(await activeClient.getRestartReason(appliedSnapshot), undefined); -- } finally { -- await disposeAgent(agent); -- } +- assert.strictEqual(await activeClient.getRestartReason(appliedSnapshot), undefined); - }); - - test('stops comparing structural contributions after the first change', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'short-circuit-session'); -- agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client' }); -- const activeClient = getActiveClient(agent, session); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'short-circuit-session'); +- agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client' }); +- const activeClient = getActiveClient(agent, session); - -- assert.deepStrictEqual({ -- plugins: await activeClient.getRestartReason({ -- discoveredAgents: [], -- additionalRootInstructions: [], -- tools: [], -- plugins: [{ format: PluginFormat.Copilot, hooks: [], mcpServers: [], skills: [], agents: [], instructions: [] }], -- get mcpServers(): IActiveClientSnapshot['mcpServers'] { -- throw new Error('A plugin change must skip the MCP comparison'); -- }, -- }), -- mcpServers: await activeClient.getRestartReason({ -- discoveredAgents: [], -- additionalRootInstructions: [], -- get tools(): IActiveClientSnapshot['tools'] { -- throw new Error('An MCP change must skip the tool comparison'); -- }, -- plugins: [], -- mcpServers: { server: { type: McpServerType.LOCAL, command: 'test-server' } }, -- }), -- }, { -- plugins: 'clientPluginsChanged', -- mcpServers: 'rootMcpServersChanged', -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- plugins: await activeClient.getRestartReason({ +- discoveredAgents: [], +- additionalRootInstructions: [], +- tools: [], +- plugins: [{ format: PluginFormat.Copilot, hooks: [], mcpServers: [], skills: [], agents: [], instructions: [] }], +- get mcpServers(): IActiveClientSnapshot['mcpServers'] { +- throw new Error('A plugin change must skip the MCP comparison'); +- }, +- }), +- mcpServers: await activeClient.getRestartReason({ +- discoveredAgents: [], +- additionalRootInstructions: [], +- get tools(): IActiveClientSnapshot['tools'] { +- throw new Error('An MCP change must skip the tool comparison'); +- }, +- plugins: [], +- mcpServers: { server: { type: McpServerType.LOCAL, command: 'test-server' } }, +- }), +- }, { +- plugins: 'clientPluginsChanged', +- mcpServers: 'rootMcpServersChanged', +- }); - }); - - test('multiple active clients merge their tools and removal isolates per client', async () => { - const agent = createTestAgent(disposables); -- try { -- const session = AgentSession.uri('copilotcli', 'multi-client-session'); +- afterTest(() => disposeAgent(agent)); +- const session = AgentSession.uri('copilotcli', 'multi-client-session'); - -- // Two clients each contribute their own tool plus a shared one. -- agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client-A' }).tools = [ -- { name: 'shared', description: 'from A', inputSchema: { type: 'object', properties: {} } }, -- { name: 'a_tool', description: 'A only', inputSchema: { type: 'object', properties: {} } }, -- ]; -- agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client-B' }).tools = [ -- { name: 'shared', description: 'from B', inputSchema: { type: 'object', properties: {} } }, -- { name: 'b_tool', description: 'B only', inputSchema: { type: 'object', properties: {} } }, -- ]; -- const activeClient = getActiveClient(agent, session); -- -- // The SDK snapshot merges both clients, deduping the shared name -- // in favor of the first-inserted client (A), and ownership maps -- // each tool to its contributing client. -- const merged = await activeClient.snapshot(); -- assert.deepStrictEqual(merged.tools.map(t => t.name), ['shared', 'a_tool', 'b_tool']); -- assert.strictEqual(activeClient.toolSet.ownerOf('shared'), 'client-A'); -- assert.strictEqual(activeClient.toolSet.ownerOf('a_tool'), 'client-A'); -- assert.strictEqual(activeClient.toolSet.ownerOf('b_tool'), 'client-B'); -- -- // Removing client A keeps B's contribution and hands the shared -- // tool to B (now the sole provider). -- agent.removeActiveClient(defaultChatUri(session), session, 'client-A'); -- const afterRemoval = await activeClient.snapshot(); -- assert.deepStrictEqual(afterRemoval.tools.map(t => t.name), ['shared', 'b_tool']); -- assert.strictEqual(activeClient.toolSet.ownerOf('shared'), 'client-B'); -- assert.strictEqual(activeClient.toolSet.ownerOf('a_tool'), undefined); -- } finally { -- await disposeAgent(agent); -- } +- // Two clients each contribute their own tool plus a shared one. +- agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client-A' }).tools = [ +- { name: 'shared', description: 'from A', inputSchema: { type: 'object', properties: {} } }, +- { name: 'a_tool', description: 'A only', inputSchema: { type: 'object', properties: {} } }, +- ]; +- agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client-B' }).tools = [ +- { name: 'shared', description: 'from B', inputSchema: { type: 'object', properties: {} } }, +- { name: 'b_tool', description: 'B only', inputSchema: { type: 'object', properties: {} } }, +- ]; +- const activeClient = getActiveClient(agent, session); +- +- // The SDK snapshot merges both clients, deduping the shared name +- // in favor of the first-inserted client (A), and ownership maps +- // each tool to its contributing client. +- const merged = await activeClient.snapshot(); +- assert.deepStrictEqual(merged.tools.map(t => t.name), ['shared', 'a_tool', 'b_tool']); +- assert.strictEqual(activeClient.toolSet.ownerOf('shared'), 'client-A'); +- assert.strictEqual(activeClient.toolSet.ownerOf('a_tool'), 'client-A'); +- assert.strictEqual(activeClient.toolSet.ownerOf('b_tool'), 'client-B'); +- +- // Removing client A keeps B's contribution and hands the shared +- // tool to B (now the sole provider). +- agent.removeActiveClient(defaultChatUri(session), session, 'client-A'); +- const afterRemoval = await activeClient.snapshot(); +- assert.deepStrictEqual(afterRemoval.tools.map(t => t.name), ['shared', 'b_tool']); +- assert.strictEqual(activeClient.toolSet.ownerOf('shared'), 'client-B'); +- assert.strictEqual(activeClient.toolSet.ownerOf('a_tool'), undefined); - }); - }); - @@ -76565,7 +78349,7 @@ index f24a97a6296..00000000000 - const launches: { kind: string; agents: string[]; instructions: string[] }[] = []; - const runtimes: MockCopilotSession[] = []; - const recordLaunch = (kind: string, id: string, config: Pick | undefined): CopilotSession => { -- launches.push({ kind, agents: (config?.customAgents ?? []).filter(agent => agent.name === 'helper').map(agent => agent.name), instructions: config?.instructionDirectories ?? [] }); +- launches.push({ kind, agents: (config?.customAgents ?? []).flatMap(agent => agent.displayName === 'helper' ? [agent.displayName] : []), instructions: config?.instructionDirectories ?? [] }); - const runtime = new MockCopilotSession(id); - runtimes.push(runtime); - return runtime as unknown as CopilotSession; @@ -76582,39 +78366,38 @@ index f24a97a6296..00000000000 - rootConfig: { [AgentHostGitHubMcpServerEnabledConfigKey]: false }, - }); - disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new TestDiskFileSystemProvider(logService)))); -- try { -- await fileService.writeFile(agentFile, VSBuffer.fromString('Helper prompt')); -- await fileService.writeFile(instructionsFile, VSBuffer.fromString('Additional instructions')); -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.chats.createChat(chat, context, { deferBacking: true, workingDirectories }); -- const activeClient = agent.getOrCreateActiveClient(chat, context, { clientId: 'client' }); -- const browserTool: ToolDefinition = { name: 'browser_tool', inputSchema: { type: 'object' } }; -- for (const [index, enabled] of [true, false, true].entries()) { -- const turnId = `turn-${index}`; -- activeClient.tools = enabled ? [browserTool] : []; -- await agent.chats.prepareTurn!(chat, turnId, workingDirectories, context); -- await agent.chats.sendMessage(chat, 'Next turn', workingDirectories, undefined, turnId, 'client', undefined, context); -- runtimes.at(-1)!.emit({ -- id: `idle-${index}`, parentId: null, timestamp: new Date().toISOString(), type: 'session.idle', ephemeral: true, data: {}, -- }); -- } -- assert.deepStrictEqual({ -- launches, -- updates: runtimes.flatMap(runtime => runtime.setToolsCalls.map(tools => tools.filter(tool => tool.name === 'browser_tool').map(tool => tool.name))), -- sends: runtimes.map(runtime => runtime.sendCalls), -- disconnects: runtimes.map(runtime => runtime.disconnectCalls), -- refreshes: logService.messages.length, -- }, { -- launches: [{ kind: 'create', agents: ['helper'], instructions: [dirname(instructionsFile.fsPath)] }], -- updates: [[], ['browser_tool']], -- sends: [3], -- disconnects: [0], -- refreshes: 0, -- }); -- } finally { +- afterTest(async () => { - await disposeAgent(agent); - await fs.rm(root, { recursive: true, force: true }); +- }); +- await fileService.writeFile(agentFile, VSBuffer.fromString('Helper prompt')); +- await fileService.writeFile(instructionsFile, VSBuffer.fromString('Additional instructions')); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.chats.createChat(chat, context, { deferBacking: true, workingDirectories }); +- const activeClient = agent.getOrCreateActiveClient(chat, context, { clientId: 'client' }); +- const browserTool: ToolDefinition = { name: 'browser_tool', inputSchema: { type: 'object' } }; +- for (const [index, enabled] of [true, false, true].entries()) { +- const turnId = `turn-${index}`; +- activeClient.tools = enabled ? [browserTool] : []; +- await agent.chats.prepareTurn!(chat, turnId, workingDirectories, context); +- await agent.chats.sendMessage(chat, 'Next turn', workingDirectories, undefined, turnId, 'client', undefined, context); +- runtimes.at(-1)!.emit({ +- id: `idle-${index}`, parentId: null, timestamp: new Date().toISOString(), type: 'session.idle', ephemeral: true, data: {}, +- }); - } +- assert.deepStrictEqual({ +- launches, +- updates: runtimes.flatMap(runtime => runtime.setToolsCalls.map(tools => tools.filter(tool => tool.name === 'browser_tool').map(tool => tool.name))), +- sends: runtimes.map(runtime => runtime.sendCalls), +- disconnects: runtimes.map(runtime => runtime.disconnectCalls), +- refreshes: logService.messages.length, +- }, { +- launches: [{ kind: 'create', agents: ['helper'], instructions: [dirname(instructionsFile.fsPath)] }], +- updates: [[], ['browser_tool']], +- sends: [3], +- disconnects: [0], +- refreshes: 0, +- }); - }).timeout(30_000); - - test('send and resume refresh conflicting client schemas to prefer their sender', async () => { @@ -76649,44 +78432,43 @@ index f24a97a6296..00000000000 - })); - const toolA: ToolDefinition = { name: 'shared', description: 'A', inputSchema: { type: 'object', properties: { fromA: { type: 'string' } } } }; - const toolB: ToolDefinition = { name: 'shared', description: 'B', inputSchema: { type: 'object', properties: { fromB: { type: 'string' } } } }; -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.chats.createChat(chat, context, { deferBacking: true, workingDirectories }); -- agent.getOrCreateActiveClient(chat, context, { clientId: 'client-A' }).tools = [toolA]; -- agent.getOrCreateActiveClient(chat, context, { clientId: 'client-B' }).tools = [toolB]; -- await agent.chats.prepareTurn!(chat, 'turn-1', workingDirectories, context); -- await agent.chats.sendMessage(chat, 'from B', workingDirectories, undefined, 'turn-1', 'client-B', undefined, context); -- runtimes.at(-1)!.emit({ -- id: 'event-from-B', parentId: null, timestamp: new Date().toISOString(), -- type: 'tool.execution_start', data: { toolCallId: 'from-B', toolName: 'shared', arguments: { fromB: 'value' } }, -- }); -- runtimes.at(-1)!.emit({ -- id: 'idle-from-B', parentId: null, timestamp: new Date().toISOString(), type: 'session.idle', ephemeral: true, data: {}, -- }); -- await agent.chats.resumeTurn!(chat, 'turn-2', context, 'client-A'); -- runtimes.at(-1)!.emit({ -- id: 'event-from-A', parentId: null, timestamp: new Date().toISOString(), -- type: 'tool.execution_start', data: { toolCallId: 'from-A', toolName: 'shared', arguments: { fromA: 'value' } }, -- }); -- -- assert.deepStrictEqual({ -- launches, -- updates: runtimes.flatMap(runtime => runtime.setToolsCalls.map(tools => tools.filter(tool => tool.name === 'shared').map(({ name, parameters }) => ({ name, parameters })))), -- disconnects: runtimes.map(runtime => runtime.disconnectCalls), -- owners: actions.flatMap(action => action.type === ActionType.ChatToolCallStart ? [action.contributor] : []), -- }, { -- launches: [{ kind: 'create', tools: [{ name: 'shared', parameters: toolA.inputSchema }] }], -- updates: [[{ name: 'shared', parameters: toolB.inputSchema }], [{ name: 'shared', parameters: toolA.inputSchema }]], -- disconnects: [0], -- owners: [ -- { kind: ToolCallContributorKind.Client, clientId: 'client-B' }, -- { kind: ToolCallContributorKind.Client, clientId: 'client-A' }, -- ], -- }); -- } finally { +- afterTest(async () => { - await disposeAgent(agent); - await fs.rm(workingDirectory, { recursive: true, force: true }); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.chats.createChat(chat, context, { deferBacking: true, workingDirectories }); +- agent.getOrCreateActiveClient(chat, context, { clientId: 'client-A' }).tools = [toolA]; +- agent.getOrCreateActiveClient(chat, context, { clientId: 'client-B' }).tools = [toolB]; +- await agent.chats.prepareTurn!(chat, 'turn-1', workingDirectories, context); +- await agent.chats.sendMessage(chat, 'from B', workingDirectories, undefined, 'turn-1', 'client-B', undefined, context); +- runtimes.at(-1)!.emit({ +- id: 'event-from-B', parentId: null, timestamp: new Date().toISOString(), +- type: 'tool.execution_start', data: { toolCallId: 'from-B', toolName: 'shared', arguments: { fromB: 'value' } }, +- }); +- runtimes.at(-1)!.emit({ +- id: 'idle-from-B', parentId: null, timestamp: new Date().toISOString(), type: 'session.idle', ephemeral: true, data: {}, +- }); +- await agent.chats.resumeTurn!(chat, 'turn-2', context, 'client-A'); +- runtimes.at(-1)!.emit({ +- id: 'event-from-A', parentId: null, timestamp: new Date().toISOString(), +- type: 'tool.execution_start', data: { toolCallId: 'from-A', toolName: 'shared', arguments: { fromA: 'value' } }, +- }); +- +- assert.deepStrictEqual({ +- launches, +- updates: runtimes.flatMap(runtime => runtime.setToolsCalls.map(tools => tools.filter(tool => tool.name === 'shared').map(({ name, parameters }) => ({ name, parameters })))), +- disconnects: runtimes.map(runtime => runtime.disconnectCalls), +- owners: actions.flatMap(action => action.type === ActionType.ChatToolCallStart ? [action.contributor] : []), +- }, { +- launches: [{ kind: 'create', tools: [{ name: 'shared', parameters: toolA.inputSchema }] }], +- updates: [[{ name: 'shared', parameters: toolB.inputSchema }], [{ name: 'shared', parameters: toolA.inputSchema }]], +- disconnects: [0], +- owners: [ +- { kind: ToolCallContributorKind.Client, clientId: 'client-B' }, +- { kind: ToolCallContributorKind.Client, clientId: 'client-A' }, +- ], +- }); - }).timeout(30_000); - - class RefreshLogService extends NullLogService { @@ -76778,32 +78560,29 @@ index f24a97a6296..00000000000 - setDefaultSessionStub(agent, sessionId, replacement); - return replacement as unknown as CopilotAgentSession; - }; -- try { -- await agent.chats.sendMessage(chat, 'equal initialization', undefined); -- const beforeChange = { resumes, destroyed: current.destroyCalls, refreshes: logService.messages.length }; -- desiredInitialization = { systemMessage: { mode: 'append', content: 'Changed prompt' } }; -- await agent.chats.sendMessage(chat, 'changed initialization', undefined); -- await agent.chats.sendMessage(chat, 'equal replacement initialization', undefined); -- assert.deepStrictEqual({ -- beforeChange, -- resumes, -- destroyed: current.destroyCalls, -- oldSends: current.sendCalls, -- newSends: replacement.sendCalls, -- checks, -- initializationRefreshes: logService.messages.map(message => message.includes('reason=clientToolsChanged')), -- }, { -- beforeChange: { resumes: 0, destroyed: 0, refreshes: 0 }, -- resumes: 1, -- destroyed: 1, -- oldSends: ['equal initialization'], -- newSends: ['changed initialization', 'equal replacement initialization'], -- checks: [false, true, false].map(changed => ({ tools, changed })), -- initializationRefreshes: [true], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.chats.sendMessage(chat, 'equal initialization', undefined); +- const beforeChange = { resumes, destroyed: current.destroyCalls, refreshes: logService.messages.length }; +- desiredInitialization = { systemMessage: { mode: 'append', content: 'Changed prompt' } }; +- await agent.chats.sendMessage(chat, 'changed initialization', undefined); +- await agent.chats.sendMessage(chat, 'equal replacement initialization', undefined); +- assert.deepStrictEqual({ +- beforeChange, +- resumes, +- destroyed: current.destroyCalls, +- oldSends: current.sendCalls, +- newSends: replacement.sendCalls, +- checks, +- initializationRefreshes: logService.messages.map(message => message.includes('reason=clientToolsChanged')), +- }, { +- beforeChange: { resumes: 0, destroyed: 0, refreshes: 0 }, +- resumes: 1, +- destroyed: 1, +- oldSends: ['equal initialization'], +- newSends: ['changed initialization', 'equal replacement initialization'], +- checks: [false, true, false].map(changed => ({ tools, changed })), +- initializationRefreshes: [true], +- }); - }); - - for (const activity of ['turn', 'subagent'] as const) { @@ -76821,26 +78600,23 @@ index f24a97a6296..00000000000 - current.appliedSnapshot = { ...current.appliedSnapshot, tools: original }; - setDefaultSessionStub(agent, sessionId, current); - agent.getOrCreateActiveClient(chat, session, { clientId: 'client' }).tools = tools; -- try { -- await agent.chats.sendMessage(chat, 'while busy', undefined); -- const beforeIdle = { updates: [...current.toolUpdates], applied: current.appliedSnapshot.tools }; -- current.hasActiveTurn = false; -- current.hasActiveSubagents = false; -- await agent.chats.sendMessage(chat, 'after idle', undefined); -- assert.deepStrictEqual({ -- beforeIdle, -- updates: current.toolUpdates, -- applied: current.appliedSnapshot.tools, -- destroyed: current.destroyCalls, -- disposed: current.disposeCalls, -- sends: current.sendCalls, -- }, { -- beforeIdle: { updates: [tools], applied: tools }, -- updates: [tools], applied: tools, destroyed: 0, disposed: 0, sends: ['while busy', 'after idle'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.chats.sendMessage(chat, 'while busy', undefined); +- const beforeIdle = { updates: [...current.toolUpdates], applied: current.appliedSnapshot.tools }; +- current.hasActiveTurn = false; +- current.hasActiveSubagents = false; +- await agent.chats.sendMessage(chat, 'after idle', undefined); +- assert.deepStrictEqual({ +- beforeIdle, +- updates: current.toolUpdates, +- applied: current.appliedSnapshot.tools, +- destroyed: current.destroyCalls, +- disposed: current.disposeCalls, +- sends: current.sendCalls, +- }, { +- beforeIdle: { updates: [tools], applied: tools }, +- updates: [tools], applied: tools, destroyed: 0, disposed: 0, sends: ['while busy', 'after idle'], +- }); - }); - } - @@ -76861,22 +78637,19 @@ index f24a97a6296..00000000000 - undefined, - { operation: 'startMcpServer', allowRestart: 'whenIdle' }, - ); -- try { -- await refresh(); -- activeClient.tools = renamed; -- await refresh(); -- activeClient.tools = []; -- await refresh(); -- if (activity === 'turn') { -- current.clientToolsRequireRestart = () => true; -- await assert.rejects(refresh(), /Cannot refresh Copilot session configuration/); -- } -- assert.deepStrictEqual({ updates: current.toolUpdates, applied: current.appliedSnapshot.tools, destroyed: current.destroyCalls, disposed: current.disposeCalls }, { -- updates: [tools, renamed, []], applied: [], destroyed: 0, disposed: 0, -- }); -- } finally { -- await disposeAgent(agent); +- afterTest(() => disposeAgent(agent)); +- await refresh(); +- activeClient.tools = renamed; +- await refresh(); +- activeClient.tools = []; +- await refresh(); +- if (activity === 'turn') { +- current.clientToolsRequireRestart = () => true; +- await assert.rejects(refresh(), /Cannot refresh Copilot session configuration/); - } +- assert.deepStrictEqual({ updates: current.toolUpdates, applied: current.appliedSnapshot.tools, destroyed: current.destroyCalls, disposed: current.disposeCalls }, { +- updates: [tools, renamed, []], applied: [], destroyed: 0, disposed: 0, +- }); - }); - } - @@ -76896,15 +78669,12 @@ index f24a97a6296..00000000000 - setDefaultSessionStub(agent, sessionId, replacement); - return replacement as unknown as CopilotAgentSession; - }; -- try { -- await agent.chats.sendMessage(chat, 'hello', undefined, undefined, 'refresh-turn'); -- assert.deepStrictEqual(telemetryService.events.filter(event => event.eventName === 'agentHost.copilot.runtimeRestart').map(event => { -- const { kind, cause, firstTurn, phase } = event.data as { kind: string; cause: string; firstTurn: boolean; phase: string }; -- return { kind, cause, firstTurn, phase }; -- }), [{ kind: 'sdkSession', cause: 'workingDirectoryChanged', firstTurn: true, phase: 'turnPrepare' }]); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.chats.sendMessage(chat, 'hello', undefined, undefined, 'refresh-turn'); +- assert.deepStrictEqual(telemetryService.events.filter(event => event.eventName === 'agentHost.copilot.runtimeRestart').map(event => { +- const { kind, cause, firstTurn, phase } = event.data as { kind: string; cause: string; firstTurn: boolean; phase: string }; +- return { kind, cause, firstTurn, phase }; +- }), [{ kind: 'sdkSession', cause: 'workingDirectoryChanged', firstTurn: true, phase: 'turnPrepare' }]); - }); - - test('re-applies the uncommitted model selection to the session a turn-preparation refresh replaces it with', async () => { @@ -76929,12 +78699,9 @@ index f24a97a6296..00000000000 - setDefaultSessionStub(agent, sessionId, replacement); - return replacement as unknown as CopilotAgentSession; - }; -- try { -- await agent.chats.sendMessage(chat, 'hello', undefined, undefined, 'refresh-turn'); -- assert.deepStrictEqual(events, [`setModel ${JSON.stringify(['auto', 'high', 'default', 'intelligence', selection])}`, 'send hello']); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.chats.sendMessage(chat, 'hello', undefined, undefined, 'refresh-turn'); +- assert.deepStrictEqual(events, [`setModel ${JSON.stringify(['auto', 'high', 'default', 'intelligence', selection])}`, 'send hello']); - }); - - function createModelRefreshTest() { @@ -76981,31 +78748,28 @@ index f24a97a6296..00000000000 - const { agent, session, chat, previous, replacement, launch, send } = createModelRefreshTest(); - const selection: ModelSelection = { id: 'auto', config: { tier: 'intelligence' } }; - const error = new Error('Model replay rejected'); -- try { -- await agent.chats.changeModel(chat, selection, session); -- previous.requiresRestartAfterWorkingDirectoryChange = true; -- replacement.setModelError = error; -- await assert.rejects(send('blocked'), error); -- const blocked = { sends: [...replacement.sendCalls], resumes: [...replacement.resumeCalls] }; -- replacement.setModelError = undefined; -- if (retry === 'send') { -- await send('retry'); -- } else { -- await agent.chats.resumeTurn!(chat, 'retry-turn', exactChatContext(session, chat, session)); -- } -- await send('after success'); -- assert.deepStrictEqual({ -- blocked, launches: launch.count, calls: replacement.modelCalls, -- sends: replacement.sendCalls, resumes: replacement.resumeCalls, -- }, { -- blocked: { sends: [], resumes: [] }, launches: 1, -- calls: Array.from({ length: 2 }, () => ['auto', undefined, 'default', 'intelligence', selection]), -- sends: retry === 'send' ? ['retry', 'after success'] : ['after success'], -- resumes: retry === 'resume' ? ['retry-turn'] : [], -- }); -- } finally { -- await disposeAgent(agent); +- afterTest(() => disposeAgent(agent)); +- await agent.chats.changeModel(chat, selection, session); +- previous.requiresRestartAfterWorkingDirectoryChange = true; +- replacement.setModelError = error; +- await assert.rejects(send('blocked'), error); +- const blocked = { sends: [...replacement.sendCalls], resumes: [...replacement.resumeCalls] }; +- replacement.setModelError = undefined; +- if (retry === 'send') { +- await send('retry'); +- } else { +- await agent.chats.resumeTurn!(chat, 'retry-turn', exactChatContext(session, chat, session)); - } +- await send('after success'); +- assert.deepStrictEqual({ +- blocked, launches: launch.count, calls: replacement.modelCalls, +- sends: replacement.sendCalls, resumes: replacement.resumeCalls, +- }, { +- blocked: { sends: [], resumes: [] }, launches: 1, +- calls: Array.from({ length: 2 }, () => ['auto', undefined, 'default', 'intelligence', selection]), +- sends: retry === 'send' ? ['retry', 'after success'] : ['after success'], +- resumes: retry === 'resume' ? ['retry-turn'] : [], +- }); - }); - } - @@ -77013,21 +78777,18 @@ index f24a97a6296..00000000000 - const { agent, session, chat, previous, replacement, launch, send } = createModelRefreshTest(); - const selection: ModelSelection = { id: 'auto', config: { tier: 'balance' } }; - const error = new Error('Resume rejected'); -- try { -- await agent.chats.changeModel(chat, selection, session); -- previous.requiresRestartAfterWorkingDirectoryChange = true; -- launch.error = error; -- await assert.rejects(send('blocked'), error); -- launch.error = undefined; -- await send('retry'); -- assert.deepStrictEqual({ -- calls: replacement.modelCalls, sends: replacement.sendCalls, -- }, { -- calls: [['auto', undefined, 'default', 'balance', selection]], sends: ['retry'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.chats.changeModel(chat, selection, session); +- previous.requiresRestartAfterWorkingDirectoryChange = true; +- launch.error = error; +- await assert.rejects(send('blocked'), error); +- launch.error = undefined; +- await send('retry'); +- assert.deepStrictEqual({ +- calls: replacement.modelCalls, sends: replacement.sendCalls, +- }, { +- calls: [['auto', undefined, 'default', 'balance', selection]], sends: ['retry'], +- }); - }); - - test('a newer explicit model selection supersedes a failed replay', async () => { @@ -77035,71 +78796,62 @@ index f24a97a6296..00000000000 - const selection: ModelSelection = { id: 'auto', config: { tier: 'intelligence' } }; - const next: ModelSelection = { id: 'auto', config: { tier: 'balance' } }; - const error = new Error('Model replay rejected'); -- try { -- await agent.chats.changeModel(chat, selection, session); -- previous.requiresRestartAfterWorkingDirectoryChange = true; -- replacement.setModelError = error; -- await assert.rejects(send('blocked'), error); -- replacement.setModelError = undefined; -- await agent.chats.changeModel(chat, next, session); -- await send('new selection'); -- assert.deepStrictEqual({ -- calls: replacement.modelCalls, sends: replacement.sendCalls, -- }, { -- calls: [ -- ['auto', undefined, 'default', 'intelligence', selection], -- ['auto', undefined, 'default', 'balance', next], -- ], -- sends: ['new selection'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.chats.changeModel(chat, selection, session); +- previous.requiresRestartAfterWorkingDirectoryChange = true; +- replacement.setModelError = error; +- await assert.rejects(send('blocked'), error); +- replacement.setModelError = undefined; +- await agent.chats.changeModel(chat, next, session); +- await send('new selection'); +- assert.deepStrictEqual({ +- calls: replacement.modelCalls, sends: replacement.sendCalls, +- }, { +- calls: [ +- ['auto', undefined, 'default', 'intelligence', selection], +- ['auto', undefined, 'default', 'balance', next], +- ], +- sends: ['new selection'], +- }); - }); - - for (const removeOverride of [false, true]) { - test(`refresh resolves current effort and Auto tier overrides (removed=${removeOverride})`, async () => { - const { agent, session, chat, previous, replacement, configurationService } = createModelRefreshTest(); - const selection: ModelSelection = { id: 'auto', config: { tier: 'intelligence', thinkingLevel: 'medium' } }; -- try { -- configurationService.updateRootConfig({ modelCapabilityOverrides: { auto: { reasoningEffort: 'low' } }, autoModeTierOverride: 'efficiency' }); -- await agent.chats.changeModel(chat, selection, session); -- configurationService.updateRootConfig({ -- modelCapabilityOverrides: removeOverride ? {} : { auto: { reasoningEffort: 'high' } }, -- autoModeTierOverride: removeOverride ? '' : 'balance', -- }); -- previous.requiresRestartAfterWorkingDirectoryChange = true; -- await agent.chats.resumeTurn!(chat, 'refresh-turn', exactChatContext(session, chat, session)); -- assert.deepStrictEqual({ -- before: previous.modelCalls, after: replacement.modelCalls, resumes: replacement.resumeCalls, -- }, { -- before: [['auto', 'low', 'default', 'efficiency', selection]], -- after: [['auto', removeOverride ? 'medium' : 'high', 'default', removeOverride ? 'intelligence' : 'balance', selection]], -- resumes: ['refresh-turn'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- configurationService.updateRootConfig({ modelCapabilityOverrides: { auto: { reasoningEffort: 'low' } }, autoModeTierOverride: 'efficiency' }); +- await agent.chats.changeModel(chat, selection, session); +- configurationService.updateRootConfig({ +- modelCapabilityOverrides: removeOverride ? {} : { auto: { reasoningEffort: 'high' } }, +- autoModeTierOverride: removeOverride ? '' : 'balance', +- }); +- previous.requiresRestartAfterWorkingDirectoryChange = true; +- await agent.chats.resumeTurn!(chat, 'refresh-turn', exactChatContext(session, chat, session)); +- assert.deepStrictEqual({ +- before: previous.modelCalls, after: replacement.modelCalls, resumes: replacement.resumeCalls, +- }, { +- before: [['auto', 'low', 'default', 'efficiency', selection]], +- after: [['auto', removeOverride ? 'medium' : 'high', 'default', removeOverride ? 'intelligence' : 'balance', selection]], +- resumes: ['refresh-turn'], +- }); - }); - } - - test('replay leaves inherited Auto options to newly resolved managed defaults', async () => { - const { agent, session, chat, previous, replacement, client, send } = createModelRefreshTest(); - const selection: ModelSelection = { id: 'auto', config: { tier: 'intelligence', [modelConfigurationSourceKey('tier')]: 'managed' } }; -- try { -- await agent.chats.changeModel(chat, selection, session); -- client.managedSettingsResolution = { ...client.managedSettingsResolution, values: { autoTier: 'balance' } }; -- previous.requiresRestartAfterWorkingDirectoryChange = true; -- await send('new managed default'); -- assert.deepStrictEqual({ -- before: previous.modelCalls, after: replacement.modelCalls, -- }, { -- before: [['auto', undefined, 'default', 'intelligence', selection]], -- after: [['auto', undefined, 'default', undefined, selection]], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.chats.changeModel(chat, selection, session); +- client.managedSettingsResolution = { ...client.managedSettingsResolution, values: { autoTier: 'balance' } }; +- previous.requiresRestartAfterWorkingDirectoryChange = true; +- await send('new managed default'); +- assert.deepStrictEqual({ +- before: previous.modelCalls, after: replacement.modelCalls, +- }, { +- before: [['auto', undefined, 'default', 'intelligence', selection]], +- after: [['auto', undefined, 'default', undefined, selection]], +- }); - }); - - test('relaunches a runtime at the next send after its chat changed owner, and only then', async () => { @@ -77121,23 +78873,22 @@ index f24a97a6296..00000000000 - const entry = agent['_findChatByUri'](chat) as unknown as { launchOwner: URI; _configTarget(): { readonly session: string } } | undefined; - return entry ? { launchOwner: entry.launchOwner.toString(), configTarget: entry._configTarget().session } : undefined; - }; -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.chats.createChat(chat, context, { workingDirectories: [URI.file(workingDirectory)], deferBacking: true }); -- await agent.chats.sendMessage(chat, 'first', undefined, undefined, 'turn-1', undefined, undefined, context); -- await agent.chats.sendMessage(chat, 'same owner', undefined, undefined, 'turn-2', undefined, undefined, context); -- const beforeChange = { launches: [...launches], runtime: runtime() }; -- owner.set(later); -- await agent.chats.sendMessage(chat, 'new owner', undefined, undefined, 'turn-3', undefined, undefined, context); -- -- assert.deepStrictEqual({ beforeChange, afterChange: { launches, runtime: runtime() } }, { -- beforeChange: { launches: ['create'], runtime: { launchOwner: session.toString(), configTarget: session.toString() } }, -- afterChange: { launches: ['create', 'resume'], runtime: { launchOwner: later.toString(), configTarget: later.toString() } }, -- }); -- } finally { +- afterTest(async () => { - await disposeAgent(agent); - await fs.rm(workingDirectory, { recursive: true, force: true }); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.chats.createChat(chat, context, { workingDirectories: [URI.file(workingDirectory)], deferBacking: true }); +- await agent.chats.sendMessage(chat, 'first', undefined, undefined, 'turn-1', undefined, undefined, context); +- await agent.chats.sendMessage(chat, 'same owner', undefined, undefined, 'turn-2', undefined, undefined, context); +- const beforeChange = { launches: [...launches], runtime: runtime() }; +- owner.set(later); +- await agent.chats.sendMessage(chat, 'new owner', undefined, undefined, 'turn-3', undefined, undefined, context); +- +- assert.deepStrictEqual({ beforeChange, afterChange: { launches, runtime: runtime() } }, { +- beforeChange: { launches: ['create'], runtime: { launchOwner: session.toString(), configTarget: session.toString() } }, +- afterChange: { launches: ['create', 'resume'], runtime: { launchOwner: later.toString(), configTarget: later.toString() } }, +- }); - }).timeout(30_000); - - test('a peer moved before its first send keeps its reservation and launches from it', async () => { @@ -77160,32 +78911,31 @@ index f24a97a6296..00000000000 - const owner = disposables.add(new TestChatOwnerRef(session)); - // A peer stores at its own URI, whichever session owns it. - const context = { ...exactChatContext(session, peer), owner }; -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.chats.createChat(sessionChat, exactChatContext(session, sessionChat, session), { workingDirectories: [URI.file(workingDirectory)], deferBacking: true }); -- await agent.chats.createChat(peer, context, { workingDirectories: [URI.file(workingDirectory)], deferBacking: true }); -- const reserved = [...provisionals.values()].find(provisional => isEqual(provisional.chat, peer))?.sdkSessionId; -- const beforeMove = reservations(); -- owner.set(later); -- await agent.chats.changeModel!(peer, { id: 'model-after-move' }, context); -- await agent.chats.sendMessage(peer, 'first', undefined, undefined, 'turn-1', undefined, undefined, context); -- -- const entry = agent['_findChatByUri'](peer) as unknown as { launchOwner: URI } | undefined; -- assert.deepStrictEqual({ -- beforeMove, -- launches, -- launchOwner: entry?.launchOwner.toString(), -- afterSend: reservations(), -- }, { -- beforeMove: [{ key: sessionId, chat: sessionChat.toString() }, { key: peer.toString(), chat: peer.toString() }], -- launches: [{ sessionId: reserved, model: 'model-after-move' }], -- launchOwner: later.toString(), -- afterSend: [{ key: sessionId, chat: sessionChat.toString() }], -- }); -- } finally { +- afterTest(async () => { - await disposeAgent(agent); - await fs.rm(workingDirectory, { recursive: true, force: true }); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await agent.chats.createChat(sessionChat, exactChatContext(session, sessionChat, session), { workingDirectories: [URI.file(workingDirectory)], deferBacking: true }); +- await agent.chats.createChat(peer, context, { workingDirectories: [URI.file(workingDirectory)], deferBacking: true }); +- const reserved = [...provisionals.values()].find(provisional => isEqual(provisional.chat, peer))?.sdkSessionId; +- const beforeMove = reservations(); +- owner.set(later); +- await agent.chats.changeModel!(peer, { id: 'model-after-move' }, context); +- await agent.chats.sendMessage(peer, 'first', undefined, undefined, 'turn-1', undefined, undefined, context); +- +- const entry = agent['_findChatByUri'](peer) as unknown as { launchOwner: URI } | undefined; +- assert.deepStrictEqual({ +- beforeMove, +- launches, +- launchOwner: entry?.launchOwner.toString(), +- afterSend: reservations(), +- }, { +- beforeMove: [{ key: sessionId, chat: sessionChat.toString() }, { key: peer.toString(), chat: peer.toString() }], +- launches: [{ sessionId: reserved, model: 'model-after-move' }], +- launchOwner: later.toString(), +- afterSend: [{ key: sessionId, chat: sessionChat.toString() }], +- }); - }).timeout(30_000); - - test('retries pending Connector reconciliation before sending without destroying or resuming the backing', async () => { @@ -77197,14 +78947,11 @@ index f24a97a6296..00000000000 - async refreshConnectors() { this.connectorRefreshes++; this.hasPendingConnectorRefresh = false; }, - }; - setDefaultSessionStub(agent, current.sessionId, current); -- try { -- await agent.chats.sendMessage(defaultChatUri(AgentSession.uri('copilotcli', current.sessionId)), 'hello', undefined); -- assert.deepStrictEqual({ -- refreshes: current.connectorRefreshes, destroyed: current.destroyCalls, disposed: current.disposeCalls, sends: current.sendCalls, -- }, { refreshes: 1, destroyed: 0, disposed: 0, sends: ['hello'] }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.chats.sendMessage(defaultChatUri(AgentSession.uri('copilotcli', current.sessionId)), 'hello', undefined); +- assert.deepStrictEqual({ +- refreshes: current.connectorRefreshes, destroyed: current.destroyCalls, disposed: current.disposeCalls, sends: current.sendCalls, +- }, { refreshes: 1, destroyed: 0, disposed: 0, sends: ['hello'] }); - }); - - test('aborting a send releases a blocked Connector attempt and lets later chat work proceed', async () => { @@ -77379,23 +79126,20 @@ index f24a97a6296..00000000000 - return resumedSession as unknown as CopilotAgentSession; - }; - -- try { -- await agent.chats.sendMessage(chat, 'hello', undefined); +- afterTest(() => disposeAgent(agent)); +- await agent.chats.sendMessage(chat, 'hello', undefined); - -- assert.deepStrictEqual({ -- handoffAtResume, -- pendingHandoffs: internals._canvasHandoffs.size, -- membership, -- resumedSends: resumedSession.sendCalls, -- }, { -- handoffAtResume: canvasHandoff, -- pendingHandoffs: 0, -- membership: [], -- resumedSends: ['hello'], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- handoffAtResume, +- pendingHandoffs: internals._canvasHandoffs.size, +- membership, +- resumedSends: resumedSession.sendCalls, +- }, { +- handoffAtResume: canvasHandoff, +- pendingHandoffs: 0, +- membership: [], +- resumedSends: ['hello'], +- }); - }); - - suite('canvas definition registry stability', () => { @@ -77455,16 +79199,13 @@ index f24a97a6296..00000000000 - throw new Error('resume failed'); - }; - -- try { -- await assert.rejects(() => agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, exactChatContext(session, chat, session)), /resume failed/); +- afterTest(() => disposeAgent(agent)); +- await assert.rejects(() => agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, exactChatContext(session, chat, session)), /resume failed/); - -- assert.deepStrictEqual({ pendingHandoffs: internals._canvasHandoffs.size, membership }, { -- pendingHandoffs: 0, -- membership: [{ resource: chat.toString(), action: { type: ActionType.ChatCanvasesChanged, canvases: undefined } }], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ pendingHandoffs: internals._canvasHandoffs.size, membership }, { +- pendingHandoffs: 0, +- membership: [{ resource: chat.toString(), action: { type: ActionType.ChatCanvasesChanged, canvases: undefined } }], +- }); - }); - - test('commits handed-over canvases only to the replacement that survives a restore retry', async () => { @@ -77494,20 +79235,17 @@ index f24a97a6296..00000000000 - return attempt as unknown as CopilotAgentSession; - }; - -- try { -- await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, exactChatContext(session, chat, session)); +- afterTest(() => disposeAgent(agent)); +- await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, exactChatContext(session, chat, session)); - -- assert.deepStrictEqual({ attempts: attempts.map(attempt => ({ ...attempt })), pendingHandoffs: internals._canvasHandoffs.size, membership }, { -- attempts: [ -- { receivedHandoff: true, adoptions: 0, sends: [] }, -- { receivedHandoff: true, adoptions: 1, sends: ['hello'] }, -- ], -- pendingHandoffs: 0, -- membership: [], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ attempts: attempts.map(attempt => ({ ...attempt })), pendingHandoffs: internals._canvasHandoffs.size, membership }, { +- attempts: [ +- { receivedHandoff: true, adoptions: 0, sends: [] }, +- { receivedHandoff: true, adoptions: 1, sends: ['hello'] }, +- ], +- pendingHandoffs: 0, +- membership: [], +- }); - }); - - test('coalesces root and structural divergence into one same-conversation resume before send', async () => { @@ -77553,28 +79291,25 @@ index f24a97a6296..00000000000 - return resumedSession as unknown as CopilotAgentSession; - }; - -- try { -- await agent.chats.sendMessage(defaultChatUri(session), 'hello', [primary, newSecondary], undefined, 'refresh-turn'); +- afterTest(() => disposeAgent(agent)); +- await agent.chats.sendMessage(defaultChatUri(session), 'hello', [primary, newSecondary], undefined, 'refresh-turn'); - -- assert.deepStrictEqual({ -- previousDestroyCalls: previousSession.destroyCalls, -- previousDisposeCalls: previousSession.disposeCalls, -- resumeCalls: resumeCalls.map(call => ({ -- sessionId: call.sessionId, -- workingDirectories: call.workingDirectories?.map(directory => directory.toString()), -- })), -- resumedSends: resumedSession.sendCalls, -- logs: logService.messages, -- }, { -- previousDestroyCalls: 1, -- previousDisposeCalls: 1, -- resumeCalls: [{ sessionId, workingDirectories: [primary.toString(), newSecondary.toString()] }], -- resumedSends: ['hello'], -- logs: [`[Copilot:${sessionId}] Session configuration changed, refreshing session: operation=sendMessage, sdkSessionId=${sessionId}, chat=${defaultChatUri(session).toString()}, turnId=refresh-turn, reason=workingDirectoryChanged, clients=[client]`], -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- previousDestroyCalls: previousSession.destroyCalls, +- previousDisposeCalls: previousSession.disposeCalls, +- resumeCalls: resumeCalls.map(call => ({ +- sessionId: call.sessionId, +- workingDirectories: call.workingDirectories?.map(directory => directory.toString()), +- })), +- resumedSends: resumedSession.sendCalls, +- logs: logService.messages, +- }, { +- previousDestroyCalls: 1, +- previousDisposeCalls: 1, +- resumeCalls: [{ sessionId, workingDirectories: [primary.toString(), newSecondary.toString()] }], +- resumedSends: ['hello'], +- logs: [`[Copilot:${sessionId}] Session configuration changed, refreshing session: operation=sendMessage, sdkSessionId=${sessionId}, chat=${defaultChatUri(session).toString()}, turnId=refresh-turn, reason=workingDirectoryChanged, clients=[client]`], +- }); - }); - - test('does not refresh equivalent reordered additional roots', async () => { @@ -77599,22 +79334,19 @@ index f24a97a6296..00000000000 - throw new Error('Identical roots must not resume'); - }; - -- try { -- await agent.chats.sendMessage(defaultChatUri(session), 'hello', [primary, secondaryA, secondaryB]); -- assert.deepStrictEqual({ -- destroyCalls: currentSession.destroyCalls, -- resumeCalls, -- sends: currentSession.sendCalls, -- logs: logService.messages, -- }, { -- destroyCalls: 0, -- resumeCalls: 0, -- sends: ['hello'], -- logs: [], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.chats.sendMessage(defaultChatUri(session), 'hello', [primary, secondaryA, secondaryB]); +- assert.deepStrictEqual({ +- destroyCalls: currentSession.destroyCalls, +- resumeCalls, +- sends: currentSession.sendCalls, +- logs: logService.messages, +- }, { +- destroyCalls: 0, +- resumeCalls: 0, +- sends: ['hello'], +- logs: [], +- }); - }); - - test('skips structural comparison when a working-directory change refreshes a failed turn', async () => { @@ -77644,19 +79376,16 @@ index f24a97a6296..00000000000 - return resumedSession as unknown as CopilotAgentSession; - }; - -- try { -- assert.ok(agent.chats.resumeTurn); -- await agent.chats.resumeTurn(chat, 'failed-turn', exactChatContext(session, chat, session)); -- assert.deepStrictEqual({ -- resumeCalls: resumedSession.resumeCalls, -- logs: logService.messages, -- }, { -- resumeCalls: ['failed-turn'], -- logs: [`[Copilot:config-refresh-session] Session configuration changed, refreshing session: operation=resumeTurn, sdkSessionId=config-refresh-session, chat=${chat.toString()}, turnId=failed-turn, reason=workingDirectoryChanged, clients=[(none)]`], -- }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- assert.ok(agent.chats.resumeTurn); +- await agent.chats.resumeTurn(chat, 'failed-turn', exactChatContext(session, chat, session)); +- assert.deepStrictEqual({ +- resumeCalls: resumedSession.resumeCalls, +- logs: logService.messages, +- }, { +- resumeCalls: ['failed-turn'], +- logs: [`[Copilot:config-refresh-session] Session configuration changed, refreshing session: operation=resumeTurn, sdkSessionId=config-refresh-session, chat=${chat.toString()}, turnId=failed-turn, reason=workingDirectoryChanged, clients=[(none)]`], +- }); - }); - - test('leaves a failed root refresh retryable on the next send', async () => { @@ -77689,25 +79418,22 @@ index f24a97a6296..00000000000 - setDefaultSessionStub(agent, sessionId, previousSession); - agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client' }); - -- try { -- const chat = defaultChatUri(session); -- await assert.rejects(() => agent.chats.sendMessage(chat, 'first', roots, undefined, undefined, undefined, exactChatContext(session, chat, session)), /permission configure failed/); -- await agent.chats.sendMessage(chat, 'retry', roots, undefined, undefined, undefined, exactChatContext(session, chat, session)); +- afterTest(() => disposeAgent(agent)); +- const chat = defaultChatUri(session); +- await assert.rejects(() => agent.chats.sendMessage(chat, 'first', roots, undefined, undefined, undefined, exactChatContext(session, chat, session)), /permission configure failed/); +- await agent.chats.sendMessage(chat, 'retry', roots, undefined, undefined, undefined, exactChatContext(session, chat, session)); - -- assert.deepStrictEqual({ -- resumeCalls, -- previousDestroyCalls: previousSession.destroyCalls, -- resumedSends: resumedSession.sendCalls, -- providerChatErrors, -- }, { -- resumeCalls: 2, -- previousDestroyCalls: 1, -- resumedSends: ['retry'], -- providerChatErrors: 0, -- }); -- } finally { -- await disposeAgent(agent); -- } +- assert.deepStrictEqual({ +- resumeCalls, +- previousDestroyCalls: previousSession.destroyCalls, +- resumedSends: resumedSession.sendCalls, +- providerChatErrors, +- }, { +- resumeCalls: 2, +- previousDestroyCalls: 1, +- resumedSends: ['retry'], +- providerChatErrors: 0, +- }); - }); - - test('cold resume applies and persists the complete send snapshot including root removal', async () => { @@ -77735,33 +79461,32 @@ index f24a97a6296..00000000000 - return mockSession as unknown as CopilotSession; - }; - const { agent } = createTestAgentContext(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService }); -- try { -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- const chat = defaultChatUri(session); -- const recovered = await agent.recoverLegacyChat(chat, exactChatContext(session, chat, session)); -- await agent.materializeChat(chat, exactChatContext(session, chat, session), recovered.providerData); -- await agent.chats.sendMessage(chat, 'hello', [primary], undefined, undefined, undefined, exactChatContext(session, chat, session)); -- const persistedDbRef = sessionDataService.openDatabase(session); -- let persistedWorkingDirectories: string | undefined; -- try { -- persistedWorkingDirectories = await persistedDbRef.object.getMetadata('copilot.workingDirectories'); -- } finally { -- persistedDbRef.dispose(); -- } -- -- assert.deepStrictEqual({ -- resumeCalls, -- resumeAdditionalDirectories, -- persistedWorkingDirectories: persistedWorkingDirectories ? JSON.parse(persistedWorkingDirectories) : undefined, -- }, { -- resumeCalls: [sessionId], -- resumeAdditionalDirectories: [[]], -- persistedWorkingDirectories: [primary.toString()], -- }); -- } finally { +- afterTest(async () => { - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); +- }); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); +- const chat = defaultChatUri(session); +- const recovered = await agent.recoverLegacyChat(chat, exactChatContext(session, chat, session)); +- await agent.materializeChat(chat, exactChatContext(session, chat, session), recovered.providerData); +- await agent.chats.sendMessage(chat, 'hello', [primary], undefined, undefined, undefined, exactChatContext(session, chat, session)); +- const persistedDbRef = sessionDataService.openDatabase(session); +- let persistedWorkingDirectories: string | undefined; +- try { +- persistedWorkingDirectories = await persistedDbRef.object.getMetadata('copilot.workingDirectories'); +- } finally { +- persistedDbRef.dispose(); - } +- +- assert.deepStrictEqual({ +- resumeCalls, +- resumeAdditionalDirectories, +- persistedWorkingDirectories: persistedWorkingDirectories ? JSON.parse(persistedWorkingDirectories) : undefined, +- }, { +- resumeCalls: [sessionId], +- resumeAdditionalDirectories: [[]], +- persistedWorkingDirectories: [primary.toString()], +- }); - }); - }); - @@ -77817,13 +79542,10 @@ index f24a97a6296..00000000000 - doResumeCalls++; - return makeFakeSession(); - }; -- try { -- await internals._resumeSession('s1', sessionBackedChat('s1')); -- await internals._resumeSession('s1', sessionBackedChat('s1')); -- assert.strictEqual(doResumeCalls, 2); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await internals._resumeSession('s1', sessionBackedChat('s1')); +- await internals._resumeSession('s1', sessionBackedChat('s1')); +- assert.strictEqual(doResumeCalls, 2); - }); - - test('clears inflight entry on rejection so the next call retries', async () => { @@ -77837,13 +79559,10 @@ index f24a97a6296..00000000000 - } - return makeFakeSession(); - }; -- try { -- await assert.rejects(() => internals._resumeSession('s1', sessionBackedChat('s1')), /first failed/); -- await internals._resumeSession('s1', sessionBackedChat('s1')); -- assert.strictEqual(attempt, 2); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await assert.rejects(() => internals._resumeSession('s1', sessionBackedChat('s1')), /first failed/); +- await internals._resumeSession('s1', sessionBackedChat('s1')); +- assert.strictEqual(attempt, 2); - }); - - test('does not dedupe across different sessionIds', async () => { @@ -77854,15 +79573,12 @@ index f24a97a6296..00000000000 - ids.push(id); - return makeFakeSession(); - }; -- try { -- await Promise.all([ -- internals._resumeSession('s1', sessionBackedChat('s1')), -- internals._resumeSession('s2', sessionBackedChat('s2')), -- ]); -- assert.deepStrictEqual([...ids].sort(), ['s1', 's2']); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await Promise.all([ +- internals._resumeSession('s1', sessionBackedChat('s1')), +- internals._resumeSession('s2', sessionBackedChat('s2')), +- ]); +- assert.deepStrictEqual([...ids].sort(), ['s1', 's2']); - }); - - test('post-init shutdown race: disposes the session and throws CancellationError instead of registering on a disposed chat-entry map', async () => { @@ -77907,17 +79623,14 @@ index f24a97a6296..00000000000 - sessionId: 'stale-session', - dispose: () => { disposed++; }, - } as unknown as CopilotAgentSession; -- try { -- await agent.listChatsToMigrate(); -- assert.doesNotThrow(() => internals._throwIfClientReplaced(currentClient as unknown as CopilotClient, staleSession)); -- assert.throws( -- () => internals._throwIfClientReplaced(new TestCopilotClient([]) as unknown as CopilotClient, staleSession), -- (error: unknown) => isCancellationError(error), -- ); -- assert.strictEqual(disposed, 1); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.listChatsToMigrate(); +- assert.doesNotThrow(() => internals._throwIfClientReplaced(currentClient as unknown as CopilotClient, staleSession)); +- assert.throws( +- () => internals._throwIfClientReplaced(new TestCopilotClient([]) as unknown as CopilotClient, staleSession), +- (error: unknown) => isCancellationError(error), +- ); +- assert.strictEqual(disposed, 1); - }); - - for (const route of ['default', 'addressed'] as const) { @@ -78036,16 +79749,13 @@ index f24a97a6296..00000000000 - }; - const failure = new Error('session history is unreadable'); - let attempts = 0; -- try { -- await assert.rejects(internals._retryRestoreAfterClientReplacement(async acquireClient => { -- await acquireClient(); -- attempts++; -- throw failure; -- }), error => error === failure); -- assert.strictEqual(attempts, 1); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await assert.rejects(internals._retryRestoreAfterClientReplacement(async acquireClient => { +- await acquireClient(); +- attempts++; +- throw failure; +- }), error => error === failure); +- assert.strictEqual(attempts, 1); - }); - - test('bounds restore retries to five attempts when the SDK client keeps being replaced', async () => { @@ -78057,17 +79767,14 @@ index f24a97a6296..00000000000 - }; - const failure = new Error('Client is not connected. Call start() first.'); - let attempts = 0; -- try { -- await assert.rejects(internals._retryRestoreAfterClientReplacement(async acquireClient => { -- await acquireClient(); -- attempts++; -- await internals._requestClientRestart('restore regression test'); -- throw failure; -- }), error => error === failure); -- assert.strictEqual(attempts, 5); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await assert.rejects(internals._retryRestoreAfterClientReplacement(async acquireClient => { +- await acquireClient(); +- attempts++; +- await internals._requestClientRestart('restore regression test'); +- throw failure; +- }), error => error === failure); +- assert.strictEqual(attempts, 5); - }); - - test('does not acquire a client or retry when restore preparation fails', async () => { @@ -78078,15 +79785,12 @@ index f24a97a6296..00000000000 - }; - const failure = new Error('stored metadata is unreadable'); - let attempts = 0; -- try { -- await assert.rejects(internals._retryRestoreAfterClientReplacement(async () => { -- attempts++; -- throw failure; -- }), error => error === failure); -- assert.deepStrictEqual({ attempts, starts: client.startCallCount }, { attempts: 1, starts: 0 }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await assert.rejects(internals._retryRestoreAfterClientReplacement(async () => { +- attempts++; +- throw failure; +- }), error => error === failure); +- assert.deepStrictEqual({ attempts, starts: client.startCallCount }, { attempts: 1, starts: 0 }); - }); - - test('retries a restore result invalidated before it reaches the caller', async () => { @@ -78097,20 +79801,17 @@ index f24a97a6296..00000000000 - _requestClientRestart(reason: string): Promise; - }; - let attempts = 0; -- try { -- const result = await internals._retryRestoreAfterClientReplacement(async acquireClient => { -- await acquireClient(); -- attempts++; -- if (attempts < 5) { -- await internals._requestClientRestart('restore regression test'); -- return 'stale'; -- } -- return 'current'; -- }); -- assert.deepStrictEqual({ result, attempts }, { result: 'current', attempts: 5 }); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const result = await internals._retryRestoreAfterClientReplacement(async acquireClient => { +- await acquireClient(); +- attempts++; +- if (attempts < 5) { +- await internals._requestClientRestart('restore regression test'); +- return 'stale'; +- } +- return 'current'; +- }); +- assert.deepStrictEqual({ result, attempts }, { result: 'current', attempts: 5 }); - }); - - test('does not retry a restore after shutdown', async () => { @@ -78121,17 +79822,14 @@ index f24a97a6296..00000000000 - }; - const failure = new CancellationError(); - let attempts = 0; -- try { -- await assert.rejects(internals._retryRestoreAfterClientReplacement(async acquireClient => { -- await acquireClient(); -- attempts++; -- await agent.shutdown(); -- throw failure; -- }), error => error === failure); -- assert.strictEqual(attempts, 1); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await assert.rejects(internals._retryRestoreAfterClientReplacement(async acquireClient => { +- await acquireClient(); +- attempts++; +- await agent.shutdown(); +- throw failure; +- }), error => error === failure); +- assert.strictEqual(attempts, 1); - }); - - test('shutdown during resume cancels the in-flight resume', async () => { @@ -78151,21 +79849,20 @@ index f24a97a6296..00000000000 - sessionDataService: disposables.add(new TestSessionDataService()), - }); - const internals = agent as unknown as { _resumeSession: (id: string, chat: URI) => Promise }; -- try { -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- const resumePromise = internals._resumeSession('s1', sessionBackedChat('s1')); -- await resumeStarted.p; -- assert.strictEqual(resumeCalled, true); -- -- const shutdown = agent.shutdown(); -- deferredSession.complete(new MockCopilotSession() as unknown as CopilotSession); -- -- await shutdown; -- await assert.rejects(resumePromise, (error: unknown) => isCancellationError(error)); -- } finally { +- afterTest(async () => { - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); +- const resumePromise = internals._resumeSession('s1', sessionBackedChat('s1')); +- await resumeStarted.p; +- assert.strictEqual(resumeCalled, true); +- +- const shutdown = agent.shutdown(); +- deferredSession.complete(new MockCopilotSession() as unknown as CopilotSession); +- +- await shutdown; +- await assert.rejects(resumePromise, (error: unknown) => isCancellationError(error)); - }); - }); - @@ -78203,16 +79900,15 @@ index f24a97a6296..00000000000 - return new MockCopilotSession(id) as unknown as CopilotSession; - }; - const agent = createTestAgent(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService, userHome }); -- try { -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- await agent.materializeChat(chat, exactChatContext(session, chat, session), JSON.stringify({ sdkSessionId })); -- await (agent as unknown as AgentInternals)._resumeSession(sdkSessionId, chat); -- -- assert.strictEqual(systemMessage?.content?.includes(AGENT_HOST_WORKSPACELESS_INSTRUCTIONS), workspaceless); -- } finally { +- afterTest(async () => { - await disposeAgent(agent); - await fs.rm(userHome.fsPath, { recursive: true, force: true }); -- } +- }); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); +- await agent.materializeChat(chat, exactChatContext(session, chat, session), JSON.stringify({ sdkSessionId })); +- await (agent as unknown as AgentInternals)._resumeSession(sdkSessionId, chat); +- +- assert.strictEqual(systemMessage?.content?.includes(AGENT_HOST_WORKSPACELESS_INSTRUCTIONS), workspaceless); - }).timeout(30_000); - } - @@ -78231,23 +79927,22 @@ index f24a97a6296..00000000000 - disposables.add(fileService.registerProvider(Schemas.file, provider)); - await fileService.writeFile(URI.file(promptOverrideFile), VSBuffer.fromString('systemPrompt: |-\n You are an evaluation agent.\n')); - const internals = agent as unknown as AgentInternals; -- try { -- configurationService.updateRootConfig({ -- [CopilotCliConfigKey.ModelCapabilityOverrides]: { -- '*': { promptOverrideFile }, -- }, -- }); -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- await internals._resumeSession('s1', sessionBackedChat('s1')); -- -- assert.deepStrictEqual(capturedSystemMessage, { -- mode: 'replace', -- content: 'You are an evaluation agent.', -- }); -- } finally { +- afterTest(async () => { - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- configurationService.updateRootConfig({ +- [CopilotCliConfigKey.ModelCapabilityOverrides]: { +- '*': { promptOverrideFile }, +- }, +- }); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); +- await internals._resumeSession('s1', sessionBackedChat('s1')); +- +- assert.deepStrictEqual(capturedSystemMessage, { +- mode: 'replace', +- content: 'You are an evaluation agent.', +- }); - }); - - test('does not restore a persisted custom agent that is absent from the current plugin snapshot', async () => { @@ -78270,19 +79965,17 @@ index f24a97a6296..00000000000 - }; - const agent = createTestAgent(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService }); - const internals = agent as unknown as AgentInternals; -- try { -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- await internals._resumeSession('s1', sessionBackedChat('s1')); -- assert.deepStrictEqual(resumeAgents, [undefined]); -- } finally { +- afterTest(async () => { - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); +- await internals._resumeSession('s1', sessionBackedChat('s1')); +- assert.deepStrictEqual(resumeAgents, [undefined]); - }); - - test('retries resume without a custom agent when the SDK reports the stored agent is missing', async () => { -- const fileService = disposables.add(new FileService(new NullLogService())); -- disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); +- const fileService = createInMemoryFileService(disposables, Schemas.file); - - const repo = URI.file('/repo'); - const dataAgent = URI.joinPath(repo, '.github', 'agents', 'data.md'); @@ -78309,7 +80002,7 @@ index f24a97a6296..00000000000 - client.resumeSession = async (_sessionId, options) => { - resumeAgents.push(options?.agent); - if (resumeAgents.length === 1) { -- throw new TestSdkError(`Request session.resume failed with message: Custom agent 'Data' not found`, -32603); +- throw new TestSdkError(`Request session.resume failed with message: Custom agent '${options?.agent}' not found`, -32603); - } - return new MockCopilotSession() as unknown as CopilotSession; - }; @@ -78319,13 +80012,10 @@ index f24a97a6296..00000000000 - - const agent = createTestAgent(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService, fileService }); - const internals = agent as unknown as AgentInternals; -- try { -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- await internals._resumeSession('s1', sessionBackedChat('s1')); -- assert.deepStrictEqual(resumeAgents, ['Data', undefined]); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); +- await internals._resumeSession('s1', sessionBackedChat('s1')); +- assert.deepStrictEqual(resumeAgents, [getSdkCustomAgentName(dataAgent), undefined]); - }); - - test('resume passes persisted additional roots to the SDK by default', async () => { @@ -78349,15 +80039,14 @@ index f24a97a6296..00000000000 - }; - const { agent } = createTestAgentContext(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService }); - const internals = agent as unknown as AgentInternals; -- try { -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- await internals._resumeSession('s1', sessionBackedChat('s1')); -- assert.deepStrictEqual(capturedAdditional, [URI.file(secondary).fsPath]); -- } finally { +- afterTest(async () => { - await fs.rm(workingDirectory, { recursive: true, force: true }); - await fs.rm(secondary, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); +- await internals._resumeSession('s1', sessionBackedChat('s1')); +- assert.deepStrictEqual(capturedAdditional, [URI.file(secondary).fsPath]); - }); - }); - @@ -78511,9 +80200,182 @@ index f24a97a6296..00000000000 - - suite('standalone client customizations', () => { - +- test('restores a legacy singleton selection when other bundle filenames collide and updates persisted provider data', async () => { +- const fileService = createInMemoryFileService(disposables, Schemas.file); +- const root = URI.file('/cache/bundle/current'); +- const source = URI.parse('client-customizations:/bundle'); +- const paths = ['agents/solo.agent.md', 'agents/first/reviewer.agent.md', 'agents/second/reviewer.agent.md']; +- const names = ['Solo', 'Reviewer', 'Reviewer']; +- const uris = paths.map(path => URI.joinPath(root, path)); +- const saved = URI.file('/cache/bundle/previous/agents/solo.agent.md').toString(); +- await fileService.writeFile(URI.joinPath(root, '.plugin', 'plugin.json'), VSBuffer.fromString(JSON.stringify({ name: 'VS Code Synced Data', agents: paths }))); +- await Promise.all(uris.map((uri, index) => fileService.writeFile(uri, VSBuffer.fromString(`---\nname: ${names[index]}\n---\nPrompt ${index}`)))); +- class PluginManager extends TestAgentPluginManager { +- override async syncCustomizations(_clientId: string, customizations: ClientPluginCustomization[]): Promise { +- return customizations.map(customization => ({ +- customization: { ...customization, load: { kind: CustomizationLoadStatus.Loaded } }, pluginDir: root, +- })); +- } +- } +- const client = new TestCopilotClient([]); +- const creates: SessionConfig[] = []; +- const resumes: ResumeSessionConfig[] = []; +- client.createSession = async config => { creates.push(config); return new MockCopilotSession() as unknown as CopilotSession; }; +- client.resumeSession = async (_id, config) => { resumes.push(config); return new MockCopilotSession() as unknown as CopilotSession; }; +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const options = { copilotClient: client, fileService, sessionDataService, pluginManager: new PluginManager() }; +- const first = createTestAgentContext(disposables, options).agent; +- const session = AgentSession.uri('copilotcli', 'legacy-flat-bundle'); +- const chat = defaultChatUri(session); +- const context = exactChatContext(session, chat, session); +- const workspace = URI.file('/workspace'); +- const ref: ClientPluginCustomization = { type: CustomizationType.Plugin, id: source.toString(), uri: source.toString(), name: 'VS Code Synced Data', nonce: 'current' }; +- let materializedData: string | undefined; +- disposables.add(first.onDidChangeChatData(event => { materializedData = event.providerData ?? materializedData; })); +- let firstDisposed = false; +- let restored: CopilotAgent | undefined; +- try { +- await first.authenticate('https://api.github.com', 'token'); +- await provisionSession(first, { +- session, workingDirectories: [workspace], agent: { uri: saved }, +- activeClient: { clientId: 'client', tools: [], customizations: [ref] }, +- }); +- await first.chats.changeAgent(chat, { uri: saved }, context); +- await first.chats.sendMessage(chat, 'hello', [workspace], undefined, 'first', undefined, context); +- const initiallyPersisted = chatBackings(first).get(chat.toString())?.agent?.uri; +- assert.ok(materializedData); +- const legacyData = JSON.stringify({ ...JSON.parse(materializedData), agent: { uri: saved } }); +- await disposeAgent(first); +- firstDisposed = true; +- restored = createTestAgentContext(disposables, options).agent; +- await restored.authenticate('https://api.github.com', 'token'); +- restored.getOrCreateActiveClient(chat, session, { clientId: 'client' }).customizations = [ref]; +- await restored.materializeChat(chat, context, legacyData); +- await restored.chats.sendMessage(chat, 'followup', [workspace], undefined, 'second', undefined, context); +- const expectedName = getSdkCustomAgentName(URI.joinPath(source, paths[0])); +- assert.deepStrictEqual({ +- create: creates.map(config => config.agent), resume: resumes.map(config => config.agent), +- initiallyPersisted, restored: chatBackings(restored).get(chat.toString())?.agent?.uri, +- }, { +- create: [expectedName], resume: [expectedName], +- initiallyPersisted: uris[0].toString(), restored: uris[0].toString(), +- }); +- } finally { +- if (restored) { +- await disposeAgent(restored); +- } +- if (!firstDisposed) { +- await disposeAgent(first); +- } +- } +- }); +- +- test('registers and switches between same-named agents inside one client bundle', async () => { +- const fileService = createInMemoryFileService(disposables, Schemas.file); +- const root = URI.file('/synced/extension-bundle'); +- const paths = ['first', 'second'].map(name => `agents/${name}/reviewer.agent.md`); +- const uris = paths.map(path => URI.joinPath(root, path)); +- await fileService.writeFile(URI.joinPath(root, '.plugin', 'plugin.json'), VSBuffer.fromString(JSON.stringify({ name: 'VS Code Synced Data', agents: paths }))); +- await Promise.all(uris.map((uri, index) => fileService.writeFile(uri, VSBuffer.fromString(`---\nname: Friendly Reviewer\n---\nPrompt ${index}`)))); +- class PluginManager extends TestAgentPluginManager { +- override async syncCustomizations(_clientId: string, customizations: ClientPluginCustomization[]): Promise { +- return customizations.map(customization => ({ +- customization: { ...customization, load: { kind: CustomizationLoadStatus.Loaded } }, +- pluginDir: URI.parse(customization.uri), +- })); +- } +- } +- const client = new TestCopilotClient([]); +- const launches: SessionConfig[] = []; +- const sdkSession = new MockCopilotSession(); +- client.createSession = async config => { +- launches.push(config); +- return sdkSession as unknown as CopilotSession; +- }; +- const { agent } = createTestAgentContext(disposables, { +- copilotClient: client, fileService, pluginManager: new PluginManager(), +- sessionDataService: disposables.add(new TestSessionDataService()), +- }); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'bundled-agent-collision'); +- const chat = defaultChatUri(session); +- const workspace = URI.file('/workspace'); +- await provisionSession(agent, { +- session, workingDirectories: [workspace], agent: { uri: uris[0].toString() }, +- activeClient: { +- clientId: 'client-1', tools: [], +- customizations: [{ type: CustomizationType.Plugin, id: root.toString(), uri: root.toString(), name: 'VS Code Synced Data' }], +- }, +- }); +- const context = exactChatContext(session, chat, session); +- await agent.chats.sendMessage(chat, 'hello', [workspace], undefined, 'turn-1', undefined, context); +- await agent.chats.changeAgent(chat, { uri: uris[1].toString() }, context); +- const customizations = await getDefaultChatCustomizations(agent, session); +- assert.deepStrictEqual({ +- published: customizations.flatMap(customization => customization.type === CustomizationType.Plugin +- ? customization.children?.filter(child => child.type === CustomizationType.Agent && child.name === 'Friendly Reviewer').map(child => child.uri) ?? [] : []), +- launches: launches.map(config => ({ +- selected: config.agent, +- agents: config.customAgents?.filter(agent => agent.displayName === 'Friendly Reviewer').map(agent => ({ name: agent.name, prompt: agent.prompt })), +- })), +- switches: sdkSession.agentSelectionCalls, +- }, { +- published: uris.map(uri => uri.toString()), +- launches: [{ +- selected: getSdkCustomAgentName(uris[0]), +- agents: uris.map((uri, index) => ({ name: getSdkCustomAgentName(uri), prompt: `Prompt ${index}` })), +- }], +- switches: [getSdkCustomAgentName(uris[1])], +- }); +- }); +- +- test('registers and switches between forwarded definitions with identical display names', async () => { +- const fileService = createInMemoryFileService(disposables, Schemas.file); +- const roots = ['first', 'second'].map(source => URI.file(`/plugins/${source}`)); +- const uris = roots.map(root => URI.joinPath(root, 'agents', 'shared.agent.md')); +- for (const [index, root] of roots.entries()) { +- await fileService.writeFile(URI.joinPath(root, '.plugin', 'plugin.json'), VSBuffer.fromString(`{"name":"source-${index}"}`)); +- await fileService.writeFile(uris[index], VSBuffer.fromString(`---\nname: Friendly Reviewer\n---\nPrompt ${index}`)); +- } +- const client = new TestCopilotClient([]); +- const launches: SessionConfig[] = []; +- const sdkSession = new MockCopilotSession(); +- client.createSession = async config => { +- launches.push(config); +- return sdkSession as unknown as CopilotSession; +- }; +- const { agent } = createTestAgentContext(disposables, { +- copilotClient: client, fileService, +- sessionDataService: disposables.add(new TestSessionDataService()), +- rootConfig: { [AgentHostConfigKey.Customizations]: roots.map((uri, index) => ({ uri: uri.toString(), displayName: `source-${index}` })) }, +- }); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'forwarded-agent-collision'); +- const chat = defaultChatUri(session); +- const workspace = URI.file('/workspace'); +- await provisionSession(agent, { session, workingDirectories: [workspace], agent: { uri: uris[0].toString() } }); +- const context = exactChatContext(session, chat, session); +- await agent.chats.sendMessage(chat, 'hello', [workspace], undefined, 'turn-1', undefined, context); +- await agent.chats.changeAgent(chat, { uri: uris[1].toString() }, context); +- assert.deepStrictEqual({ +- launches: launches.map(config => ({ +- selected: config.agent, +- agents: config.customAgents?.filter(agent => agent.displayName === 'Friendly Reviewer').map(agent => ({ name: agent.name, prompt: agent.prompt })), +- })), +- switches: sdkSession.agentSelectionCalls, +- }, { +- launches: [{ +- selected: getSdkCustomAgentName(uris[0]), +- agents: uris.map((uri, index) => ({ name: getSdkCustomAgentName(uri), prompt: `Prompt ${index}` })), +- }], +- switches: [getSdkCustomAgentName(uris[1])], +- }); +- }); +- - test('passes a standalone client bundle through non-plugin SDK options and reserves built-in agent names', async () => { -- const fileService = disposables.add(new FileService(new NullLogService())); -- disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); +- const fileService = createInMemoryFileService(disposables, Schemas.file); - const pluginDir = URI.file('/plugins/plugin-a'); - const standaloneDir = URI.file('/synced/standalone'); - await fileService.writeFile(URI.joinPath(pluginDir, '.plugin', 'plugin.json'), VSBuffer.fromString(JSON.stringify({ name: 'Plugin A' }))); @@ -78580,7 +80442,11 @@ index f24a97a6296..00000000000 - }, { - pluginDirectories: [pluginDir.fsPath], - skillDirectories: [URI.joinPath(standaloneDir, 'skills', 'my-skill').fsPath], -- customAgents: ['plugin-agent', 'my-agent', ...COPILOT_BUILTIN_AGENT_NAMES], +- customAgents: [ +- getSdkCustomAgentName(URI.joinPath(pluginDir, 'agents', 'plugin-agent.agent.md')), +- getSdkCustomAgentName(URI.joinPath(standaloneDir, 'agents', 'my-agent.agent.md')), +- ...COPILOT_BUILTIN_AGENT_NAMES, +- ], - builtinAgents: buildCopilotBuiltinAgents([]), - mcpServers: ['my-server'], - }); @@ -78610,108 +80476,113 @@ index f24a97a6296..00000000000 - return { sessionId: AgentSession.id(sessionUri), sessionUri, workingDirectory, model: undefined, agent, project: undefined }; - } - +- test('resolves native plugin agents by SDK identity without registering their definitions', async () => { +- const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); +- afterTest(() => disposeAgent(agent)); +- const uri = URI.file('/sdk/plugins/reviewer/agents/review.agent.md').toString(); +- const snapshot: IActiveClientSnapshot = { +- ...emptySnapshot, +- nativeAgents: [{ type: CustomizationType.Agent, uri, id: 'reviewer@shop:review', name: 'Review' }], +- }; +- const internals = agent as unknown as AgentInternals; +- const selection = { uri }; +- assert.deepStrictEqual({ +- selected: internals._resolveAgentName(snapshot, selection), +- materialized: await internals._resolveAgentWhenMaterializing(provisional(repo, selection), snapshot, worktree), +- registered: getCopilotCustomAgents(snapshot), +- }, { +- selected: 'reviewer@shop:review', +- materialized: { agent: selection, name: 'reviewer@shop:review' }, +- registered: [], +- }); +- }); +- - test('_resolveAgentName resolves all built-ins without a plugin or worktree translation', async () => { - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); -- try { -- const internals = agent as unknown as AgentInternals; -- const resolved = []; -- for (const builtin of COPILOT_BUILTIN_AGENTS) { -- const selection: AgentSelection = { uri: getCopilotBuiltinAgentUri(builtin).toString() }; -- resolved.push({ -- name: internals._resolveAgentName(emptySnapshot, selection), -- materialized: await internals._resolveAgentWhenMaterializing(provisional(repo, selection), emptySnapshot, worktree), -- }); -- } -- const missingUri = URI.joinPath(URI.parse(buildCopilotBuiltinAgentsContainer().uri), 'missing.agent.md').toString(); -- assert.deepStrictEqual({ -- resolved, -- missingBuiltin: internals._resolveAgentName(emptySnapshot, { uri: missingUri }), -- }, { -- resolved: COPILOT_BUILTIN_AGENTS.map(builtin => ({ -- name: builtin.name, -- materialized: { agent: { uri: getCopilotBuiltinAgentUri(builtin).toString() }, name: builtin.name }, -- })), -- missingBuiltin: undefined, +- afterTest(() => disposeAgent(agent)); +- const internals = agent as unknown as AgentInternals; +- const resolved = []; +- for (const builtin of COPILOT_BUILTIN_AGENTS) { +- const selection: AgentSelection = { uri: getCopilotBuiltinAgentUri(builtin).toString() }; +- resolved.push({ +- name: internals._resolveAgentName(emptySnapshot, selection), +- materialized: await internals._resolveAgentWhenMaterializing(provisional(repo, selection), emptySnapshot, worktree), - }); -- } finally { -- await disposeAgent(agent); - } +- const missingUri = URI.joinPath(URI.parse(buildCopilotBuiltinAgentsContainer().uri), 'missing.agent.md').toString(); +- assert.deepStrictEqual({ +- resolved, +- missingBuiltin: internals._resolveAgentName(emptySnapshot, { uri: missingUri }), +- }, { +- resolved: COPILOT_BUILTIN_AGENTS.map(builtin => ({ +- name: builtin.name, +- materialized: { agent: { uri: getCopilotBuiltinAgentUri(builtin).toString() }, name: builtin.name }, +- })), +- missingBuiltin: undefined, +- }); - }); - - test('_getAlternativeAgentForWorktree rewrites a repo agent path onto the worktree', async () => { - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); -- try { -- const internals = agent as unknown as AgentInternals; -- assert.deepStrictEqual( -- internals._getAlternativeAgentForWorktree(provisional(repo, { uri: repoAgentUri }), worktree), -- { uri: worktreeAgentUri }, -- ); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const internals = agent as unknown as AgentInternals; +- assert.deepStrictEqual( +- internals._getAlternativeAgentForWorktree(provisional(repo, { uri: repoAgentUri }), worktree), +- { uri: worktreeAgentUri }, +- ); - }); - - test('_getAlternativeAgentForWorktree returns undefined when there is nothing to translate', async () => { - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); -- try { -- const internals = agent as unknown as AgentInternals; -- const outsideRepoAgent: AgentSelection = { uri: URI.file('/home/me/.copilot/agents/agent.md').toString() }; -- assert.deepStrictEqual( -- { -- noAgent: internals._getAlternativeAgentForWorktree(provisional(repo, undefined), worktree), -- folderIsolation: internals._getAlternativeAgentForWorktree(provisional(repo, { uri: repoAgentUri }), undefined), -- sameWorkingDirectory: internals._getAlternativeAgentForWorktree(provisional(repo, { uri: repoAgentUri }), repo), -- agentOutsideRepo: internals._getAlternativeAgentForWorktree(provisional(repo, outsideRepoAgent), worktree), -- }, -- { -- noAgent: undefined, -- folderIsolation: undefined, -- sameWorkingDirectory: undefined, -- agentOutsideRepo: undefined, -- }, -- ); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const internals = agent as unknown as AgentInternals; +- const outsideRepoAgent: AgentSelection = { uri: URI.file('/home/me/.copilot/agents/agent.md').toString() }; +- assert.deepStrictEqual( +- { +- noAgent: internals._getAlternativeAgentForWorktree(provisional(repo, undefined), worktree), +- folderIsolation: internals._getAlternativeAgentForWorktree(provisional(repo, { uri: repoAgentUri }), undefined), +- sameWorkingDirectory: internals._getAlternativeAgentForWorktree(provisional(repo, { uri: repoAgentUri }), repo), +- agentOutsideRepo: internals._getAlternativeAgentForWorktree(provisional(repo, outsideRepoAgent), worktree), +- }, +- { +- noAgent: undefined, +- folderIsolation: undefined, +- sameWorkingDirectory: undefined, +- agentOutsideRepo: undefined, +- }, +- ); - }); - - test('_resolveAgentWhenMaterializing keeps the original agent for folder isolation (no worktree)', async () => { - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); -- try { -- const internals = agent as unknown as AgentInternals; -- internals._resolveAgentName = (_snapshot, selection) => selection.uri === repoAgentUri ? 'Repo Agent' : undefined; -- // Folder isolation: the resolved working directory equals the -- // user-picked folder, so there is no worktree copy to translate to -- // and the originally selected agent is kept as-is. -- assert.deepStrictEqual( -- await internals._resolveAgentWhenMaterializing(provisional(repo, { uri: repoAgentUri }), emptySnapshot, repo), -- { agent: { uri: repoAgentUri }, name: 'Repo Agent' }, -- ); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const internals = agent as unknown as AgentInternals; +- internals._resolveAgentName = (_snapshot, selection) => selection.uri === repoAgentUri ? 'Repo Agent' : undefined; +- // Folder isolation: the resolved working directory equals the +- // user-picked folder, so there is no worktree copy to translate to +- // and the originally selected agent is kept as-is. +- assert.deepStrictEqual( +- await internals._resolveAgentWhenMaterializing(provisional(repo, { uri: repoAgentUri }), emptySnapshot, repo), +- { agent: { uri: repoAgentUri }, name: 'Repo Agent' }, +- ); - }); - - test('_resolveAgentWhenMaterializing returns undefined when no agent is selected or neither resolves', async () => { - const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); -- try { -- const internals = agent as unknown as AgentInternals; -- internals._resolveAgentName = () => undefined; -- assert.deepStrictEqual( -- { -- noAgent: await internals._resolveAgentWhenMaterializing(provisional(repo, undefined), emptySnapshot, worktree), -- neitherResolves: await internals._resolveAgentWhenMaterializing(provisional(repo, { uri: repoAgentUri }), emptySnapshot, worktree), -- }, -- { noAgent: undefined, neitherResolves: undefined }, -- ); -- } finally { -- await disposeAgent(agent); -- } +- afterTest(() => disposeAgent(agent)); +- const internals = agent as unknown as AgentInternals; +- internals._resolveAgentName = () => undefined; +- assert.deepStrictEqual( +- { +- noAgent: await internals._resolveAgentWhenMaterializing(provisional(repo, undefined), emptySnapshot, worktree), +- neitherResolves: await internals._resolveAgentWhenMaterializing(provisional(repo, { uri: repoAgentUri }), emptySnapshot, worktree), +- }, +- { noAgent: undefined, neitherResolves: undefined }, +- ); - }); - - test('materialization rewrites a repo agent to its worktree copy and persists it (no resolution stubbing)', async () => { -- const fileService = disposables.add(new FileService(new NullLogService())); -- disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); +- const fileService = createInMemoryFileService(disposables, Schemas.file); - - const repoFolder = URI.file('/repo'); - const worktreeFolder = URI.file('/repo.worktrees/agents-x'); @@ -78755,27 +80626,24 @@ index f24a97a6296..00000000000 - return originalCreateAgentSession.call(agent, launchPlan, customizationDirectory, activeClient, identity); - }; - -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const result = await provisionSession(agent, { -- session: AgentSession.uri('copilotcli', 'agent-translate'), -- workingDirectories: [repoFolder], -- agent: { uri: repoAgentFile.toString() }, -- }); -- assert.strictEqual(result.provisional, true); -- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', worktreeFolder, undefined, undefined, undefined, exactChatContext(result.session, defaultChatUri(result.session), result.session)); +- afterTest(() => disposeAgent(agent)); +- await agent.authenticate('https://api.github.com', 'token'); +- const result = await provisionSession(agent, { +- session: AgentSession.uri('copilotcli', 'agent-translate'), +- workingDirectories: [repoFolder], +- agent: { uri: repoAgentFile.toString() }, +- }); +- assert.strictEqual(result.provisional, true); +- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', worktreeFolder, undefined, undefined, undefined, exactChatContext(result.session, defaultChatUri(result.session), result.session)); - -- // `_readSessionMetadata` reads back the exact agent field the -- // resume path consumes, so asserting it stands in for restore. -- const stored = await internals._readSessionMetadata({ storage: result.session, owner: result.session }); -- assert.deepStrictEqual( -- { storedAgent: stored.agent, launchAgentName }, -- { storedAgent: { uri: worktreeAgentFile.toString() }, launchAgentName: 'My Agent' }, -- 'the repo agent must be rewritten to its worktree copy, both for the SDK launch and the persisted metadata the restore path reads', -- ); -- } finally { -- await disposeAgent(agent); -- } +- // `_readSessionMetadata` reads back the exact agent field the +- // resume path consumes, so asserting it stands in for restore. +- const stored = await internals._readSessionMetadata({ storage: result.session, owner: result.session }); +- assert.deepStrictEqual( +- { storedAgent: stored.agent, launchAgentName }, +- { storedAgent: { uri: worktreeAgentFile.toString() }, launchAgentName: getSdkCustomAgentName(worktreeAgentFile) }, +- 'the repo agent must be rewritten to its worktree copy, both for the SDK launch and the persisted metadata the restore path reads', +- ); - }); - - }); @@ -78799,6 +80667,83 @@ index f24a97a6296..00000000000 - await fs.writeFile(join(dir, 'vscode.requests.metadata.json'), JSON.stringify(details), 'utf8'); - } - +- test('adopts sessions the extension recorded only in its shared or per-install bulk metadata', async () => { +- const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`)); +- const settingsHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-settings-`)); +- const workspace = await fs.mkdtemp(`${os.tmpdir()}/adopt-workspace-`); +- const copilotHome = getCopilotHomePath(userHome.fsPath, {}); +- const ids = ['bulk-shared', 'bulk-per-install', 'bulk-other-app']; +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const client = new TestCopilotClient(ids.map(id => sdkSession(id, workspace))); +- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome, appSettingsHome: settingsHome }); +- afterTest(async () => { +- await fs.rm(userHome.fsPath, { recursive: true, force: true }); +- await fs.rm(settingsHome.fsPath, { recursive: true, force: true }); +- await fs.rm(workspace, { recursive: true, force: true }); +- await disposeAgent(agent); +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- for (const id of ids) { +- await fs.mkdir(join(copilotHome, 'session-state', id), { recursive: true }); +- } +- const workspaceFolder = { folderPath: workspace, timestamp: 1 }; +- await fs.writeFile(join(copilotHome, 'vscode.session.metadata.cache.json'), JSON.stringify({ +- 'bulk-shared': { origin: 'vscode', workspaceFolder, customTitle: 'From the shared file' }, +- 'bulk-other-app': { origin: 'other', workspaceFolder }, +- })); +- const perInstallFile = join(settingsHome.fsPath, 'globalStorage', 'github.copilot-chat', 'copilotcli', 'copilotcli.session.metadata.json'); +- await fs.mkdir(dirname(perInstallFile), { recursive: true }); +- await fs.writeFile(perInstallFile, JSON.stringify({ 'bulk-per-install': { origin: 'vscode', workspaceFolder } })); +- +- const results: Record = {}; +- for (const id of ids) { +- const { adopted, reason, listVisible } = await ensureDefaultChatAdopted(agent, AgentSession.uri('copilotcli', id)); +- results[id] = { adopted, reason, title: listVisible?.title }; +- } +- +- assert.deepStrictEqual(results, { +- 'bulk-shared': { adopted: true, reason: 'adopted', title: 'From the shared file' }, +- 'bulk-per-install': { adopted: true, reason: 'adopted', title: 'SDK bulk-per-install' }, +- 'bulk-other-app': { adopted: false, reason: 'notLegacyChat', title: undefined }, +- }); +- }); +- +- test('adopts a session the bulk metadata restored even when reporting the fallback throws', async () => { +- const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`)); +- const workspace = await fs.mkdtemp(`${os.tmpdir()}/adopt-workspace-`); +- const copilotHome = getCopilotHomePath(userHome.fsPath, {}); +- const ids = ['bulk-first', 'bulk-second']; +- const telemetryService = new class extends NullTelemetryServiceShape { +- override publicLog2(name?: string): void { +- if (name === 'agentHost.legacyCopilotCliMarkerFallback') { +- throw new Error('telemetry failed'); +- } +- } +- }; +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const client = new TestCopilotClient(ids.map(id => sdkSession(id, workspace))); +- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome, telemetryService }); +- afterTest(async () => { +- await fs.rm(userHome.fsPath, { recursive: true, force: true }); +- await fs.rm(workspace, { recursive: true, force: true }); +- await disposeAgent(agent); +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- const workspaceFolder = { folderPath: workspace, timestamp: 1 }; +- for (const id of ids) { +- await fs.mkdir(join(copilotHome, 'session-state', id), { recursive: true }); +- } +- await fs.writeFile(join(copilotHome, 'vscode.session.metadata.cache.json'), JSON.stringify(Object.fromEntries(ids.map(id => [id, { origin: 'vscode', workspaceFolder }])))); +- +- // The second adoption reuses the fallback's settled result. +- const adopted: boolean[] = []; +- for (const id of ids) { +- adopted.push((await ensureDefaultChatAdopted(agent, AgentSession.uri('copilotcli', id))).adopted); +- } +- +- assert.deepStrictEqual(adopted, [true, true]); +- }); +- - test('keeps a deleted worktree as the working directory so resume can recreate it', async () => { - // Parity with native worktree sessions: the checkout is recreated from the - // recorded branch rather than the session being re-rooted at the repository. @@ -78811,39 +80756,97 @@ index f24a97a6296..00000000000 - // The SDK still reports the deleted checkout, exactly as it does on disk. - const client = new TestCopilotClient([sdkSession(sessionId, worktreePath)]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await writeExtensionHostMarker(userHome, sessionId, { -- origin: 'vscode', -- worktreeProperties: { worktreePath, repositoryPath: repositoryRoot, branchName: 'feature/x', baseBranchName: 'main' }, -- }); +- afterTest(async () => { +- await fs.rm(userHome.fsPath, { recursive: true, force: true }); +- await fs.rm(repositoryRoot, { recursive: true, force: true }); +- await disposeAgent(agent); +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await writeExtensionHostMarker(userHome, sessionId, { +- origin: 'vscode', +- worktreeProperties: { worktreePath, repositoryPath: repositoryRoot, branchName: 'feature/x', baseBranchName: 'main' }, +- }); - -- const adopted = await ensureDefaultChatAdopted(agent, session); +- const adopted = await ensureDefaultChatAdopted(agent, session); - -- const db = await sessionDataService.tryOpenDatabase(session); -- const persistedCwd = await db?.object.getMetadata('copilot.workingDirectory'); -- db?.dispose(); +- const db = await sessionDataService.tryOpenDatabase(session); +- const persistedCwd = await db?.object.getMetadata('copilot.workingDirectory'); +- db?.dispose(); - -- assert.deepStrictEqual( -- { -- adopted: adopted.adopted, -- worktree: adopted.worktree && { -- branchName: adopted.worktree.branchName, -- baseBranch: adopted.worktree.baseBranch, -- worktreePath: adopted.worktree.worktreePath.fsPath, -- repositoryRoot: adopted.worktree.repositoryRoot.fsPath, -- }, -- persistedCwd, -- }, -- { -- adopted: true, -- worktree: { branchName: 'feature/x', baseBranch: 'main', worktreePath: URI.file(worktreePath).fsPath, repositoryRoot: URI.file(repositoryRoot).fsPath }, -- persistedCwd: URI.file(worktreePath).toString(), +- assert.deepStrictEqual( +- { +- adopted: adopted.adopted, +- worktree: adopted.worktree && { +- branchName: adopted.worktree.branchName, +- baseBranch: adopted.worktree.baseBranch, +- worktreePath: adopted.worktree.worktreePath.fsPath, +- repositoryRoot: adopted.worktree.repositoryRoot.fsPath, - }, -- ); +- persistedCwd, +- }, +- { +- adopted: true, +- worktree: { branchName: 'feature/x', baseBranch: 'main', worktreePath: URI.file(worktreePath).fsPath, repositoryRoot: URI.file(repositoryRoot).fsPath }, +- persistedCwd: URI.file(worktreePath).toString(), +- }, +- ); +- }); +- +- test('reads the recorded worktree of an un-adopted extension-host session only, for deletion to remove it', async () => { +- const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`)); +- const repositoryRoot = await fs.mkdtemp(`${os.tmpdir()}/adopt-repo-`); +- const worktreesRoot = join(repositoryRoot, '..', `delete.worktrees-${Date.now()}`); +- // A checkout that exists without a recorded base branch: adoption leaves it to the probe, deletion must not. +- const worktreePath = join(worktreesRoot, 'feature-d'); +- const sharedWorktreePath = join(worktreesRoot, 'feature-shared'); +- const adoptedWorktreePath = join(worktreesRoot, 'feature-adopted'); +- await fs.mkdir(worktreePath, { recursive: true }); +- await fs.mkdir(adoptedWorktreePath, { recursive: true }); +- const worktreeProperties = (path: string) => ({ worktreePath: path, repositoryPath: repositoryRoot, branchName: 'feature/d' }); +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const client = new TestCopilotClient([sdkSession('legacy-delete-adopted', adoptedWorktreePath)]); +- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- await writeExtensionHostMarker(userHome, 'legacy-delete-worktree', { origin: 'vscode', worktreeProperties: worktreeProperties(worktreePath) }); +- // The extension host's delete did not count archived siblings. +- await writeExtensionHostMarker(userHome, 'legacy-delete-archived-sibling', { origin: 'vscode', archived: true, worktreeProperties: worktreeProperties(worktreePath) }); +- await writeExtensionHostMarker(userHome, 'legacy-delete-shared', { origin: 'vscode', worktreeProperties: worktreeProperties(sharedWorktreePath) }); +- // A sibling discovery has not registered yet, working in a folder inside the shared checkout. +- await writeExtensionHostMarker(userHome, 'legacy-delete-shared-sibling', { origin: 'vscode', workspaceFolder: { folderPath: join(sharedWorktreePath, 'src') } }); +- await writeExtensionHostMarker(userHome, 'legacy-delete-folder', { origin: 'vscode', repositoryProperties: { repositoryPath: repositoryRoot } }); +- await writeExtensionHostMarker(userHome, 'legacy-delete-other-host', { origin: 'other', worktreeProperties: worktreeProperties(join(worktreesRoot, 'feature-other')) }); +- await writeExtensionHostMarker(userHome, 'legacy-delete-adopted', { origin: 'vscode', worktreeProperties: worktreeProperties(adoptedWorktreePath) }); +- const adoptedSession = AgentSession.uri('copilotcli', 'legacy-delete-adopted'); +- const adopted = (await ensureDefaultChatAdopted(agent, adoptedSession)).adopted; +- +- const read = async (sessionId: string) => { +- const worktree = await agent.readLegacyChatWorktree(AgentSession.uri('copilotcli', sessionId)); +- return worktree && { branchName: worktree.branchName, baseBranch: worktree.baseBranch, worktreePath: worktree.worktreePath.fsPath, repositoryRoot: worktree.repositoryRoot.fsPath }; +- }; +- const sharedWhileSiblingExists = await read('legacy-delete-shared'); +- await fs.rm(join(getCopilotHomePath(userHome.fsPath, {}), 'session-state', 'legacy-delete-shared-sibling'), { recursive: true, force: true }); +- assert.deepStrictEqual({ +- unadopted: await read('legacy-delete-worktree'), +- sharedWhileSiblingExists, +- sharedAfterSiblingDeleted: await read('legacy-delete-shared'), +- folder: await read('legacy-delete-folder'), +- otherHost: await read('legacy-delete-other-host'), +- adopted, +- afterAdoption: await read('legacy-delete-adopted'), +- }, { +- unadopted: { branchName: 'feature/d', baseBranch: undefined, worktreePath: URI.file(worktreePath).fsPath, repositoryRoot: URI.file(repositoryRoot).fsPath }, +- sharedWhileSiblingExists: undefined, +- sharedAfterSiblingDeleted: { branchName: 'feature/d', baseBranch: undefined, worktreePath: URI.file(sharedWorktreePath).fsPath, repositoryRoot: URI.file(repositoryRoot).fsPath }, +- folder: undefined, +- otherHost: undefined, +- adopted: true, +- afterAdoption: undefined, +- }); - } finally { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(repositoryRoot, { recursive: true, force: true }); +- await fs.rm(worktreesRoot, { recursive: true, force: true }); - await disposeAgent(agent); - } - }); @@ -78863,36 +80866,35 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession(sessionId, worktreePath)]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await writeExtensionHostMarker(userHome, sessionId, { -- origin: 'vscode', -- worktreeProperties: { worktreePath, repositoryPath: repositoryRoot, branchName: 'feature/z', baseBranchName: 'main' }, -- }); -- -- const adopted = await ensureDefaultChatAdopted(agent, session); -- -- assert.deepStrictEqual( -- { -- adopted: adopted.adopted, -- worktree: adopted.worktree && { -- branchName: adopted.worktree.branchName, -- baseBranch: adopted.worktree.baseBranch, -- worktreePath: adopted.worktree.worktreePath.fsPath, -- repositoryRoot: adopted.worktree.repositoryRoot.fsPath, -- }, -- }, -- { -- adopted: true, -- worktree: { branchName: 'feature/z', baseBranch: 'main', worktreePath: URI.file(worktreePath).fsPath, repositoryRoot: URI.file(repositoryRoot).fsPath }, -- }, -- ); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(repositoryRoot, { recursive: true, force: true }); - await fs.rm(join(worktreePath, '..'), { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await writeExtensionHostMarker(userHome, sessionId, { +- origin: 'vscode', +- worktreeProperties: { worktreePath, repositoryPath: repositoryRoot, branchName: 'feature/z', baseBranchName: 'main' }, +- }); +- +- const adopted = await ensureDefaultChatAdopted(agent, session); +- +- assert.deepStrictEqual( +- { +- adopted: adopted.adopted, +- worktree: adopted.worktree && { +- branchName: adopted.worktree.branchName, +- baseBranch: adopted.worktree.baseBranch, +- worktreePath: adopted.worktree.worktreePath.fsPath, +- repositoryRoot: adopted.worktree.repositoryRoot.fsPath, +- }, +- }, +- { +- adopted: true, +- worktree: { branchName: 'feature/z', baseBranch: 'main', worktreePath: URI.file(worktreePath).fsPath, repositoryRoot: URI.file(repositoryRoot).fsPath }, +- }, +- ); - }); - - test('leaves an existing worktree checkout without a recorded base branch to the probe-based bridge', async () => { @@ -78908,25 +80910,24 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession(sessionId, worktreePath)]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await writeExtensionHostMarker(userHome, sessionId, { -- origin: 'vscode', -- worktreeProperties: { worktreePath, repositoryPath: repositoryRoot, branchName: 'feature/w' }, -- }); -- -- const adopted = await ensureDefaultChatAdopted(agent, session); -- -- assert.deepStrictEqual( -- { adopted: adopted.adopted, worktree: adopted.worktree }, -- { adopted: true, worktree: undefined }, -- ); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(repositoryRoot, { recursive: true, force: true }); - await fs.rm(join(worktreePath, '..'), { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await writeExtensionHostMarker(userHome, sessionId, { +- origin: 'vscode', +- worktreeProperties: { worktreePath, repositoryPath: repositoryRoot, branchName: 'feature/w' }, +- }); +- +- const adopted = await ensureDefaultChatAdopted(agent, session); +- +- assert.deepStrictEqual( +- { adopted: adopted.adopted, worktree: adopted.worktree }, +- { adopted: true, worktree: undefined }, +- ); - }); - - test('adopts a deleted worktree with the local repository as its project, not the remote', async () => { @@ -78943,25 +80944,24 @@ index f24a97a6296..00000000000 - // No git root resolves for a checkout that is gone, so the project would - // otherwise come from `context.repository`. - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await writeExtensionHostMarker(userHome, sessionId, { -- origin: 'vscode', -- worktreeProperties: { worktreePath, repositoryPath: repositoryRoot, branchName: 'feature/y', baseBranchName: 'main' }, -- }); -- -- await ensureDefaultChatAdopted(agent, session); -- -- const db = await sessionDataService.tryOpenDatabase(session); -- const projectUri = await db?.object.getMetadata('copilot.project.uri'); -- db?.dispose(); -- -- assert.strictEqual(projectUri, URI.file(repositoryRoot).toString()); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(repositoryRoot, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await writeExtensionHostMarker(userHome, sessionId, { +- origin: 'vscode', +- worktreeProperties: { worktreePath, repositoryPath: repositoryRoot, branchName: 'feature/y', baseBranchName: 'main' }, +- }); +- +- await ensureDefaultChatAdopted(agent, session); +- +- const db = await sessionDataService.tryOpenDatabase(session); +- const projectUri = await db?.object.getMetadata('copilot.project.uri'); +- db?.dispose(); +- +- assert.strictEqual(projectUri, URI.file(repositoryRoot).toString()); - }); - - test('backfills the adopted-legacy marker for a session migrated by an older build', async () => { @@ -78975,33 +80975,32 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await writeExtensionHostMarker(userHome, sessionId, { origin: 'vscode', customTitle: 'Legacy title' }); -- // Metadata an older build wrote: adopted, but without the provenance marker. -- const seed = sessionDataService.openDatabase(session); -- await seed.object.setMetadata('copilot.workingDirectory', URI.file(workingDirectory).toString()); -- await seed.object.setMetadata('customTitle', 'Legacy title'); -- seed.dispose(); -- -- const adopted = await ensureDefaultChatAdopted(agent, session); -- -- const db = await sessionDataService.tryOpenDatabase(session); -- const marker = await db?.object.getMetadata('agentHost.ehcliAdopted'); -- const title = await db?.object.getMetadata('customTitle'); -- const titleSource = await db?.object.getMetadata('customTitleSource'); -- const isRead = await db?.object.getMetadata(AH_META_IS_READ_DB_KEY); -- db?.dispose(); -- -- assert.deepStrictEqual( -- { reason: adopted.reason, marker, title, titleSource, isRead }, -- { reason: 'alreadyNative', marker: 'true', title: 'Legacy title', titleSource: 'user', isRead: 'true' }, -- ); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await writeExtensionHostMarker(userHome, sessionId, { origin: 'vscode', customTitle: 'Legacy title' }); +- // Metadata an older build wrote: adopted, but without the provenance marker. +- const seed = sessionDataService.openDatabase(session); +- await seed.object.setMetadata('copilot.workingDirectory', URI.file(workingDirectory).toString()); +- await seed.object.setMetadata('customTitle', 'Legacy title'); +- seed.dispose(); +- +- const adopted = await ensureDefaultChatAdopted(agent, session); +- +- const db = await sessionDataService.tryOpenDatabase(session); +- const marker = await db?.object.getMetadata('agentHost.ehcliAdopted'); +- const title = await db?.object.getMetadata('customTitle'); +- const titleSource = await db?.object.getMetadata('customTitleSource'); +- const isRead = await db?.object.getMetadata(AH_META_IS_READ_DB_KEY); +- db?.dispose(); +- +- assert.deepStrictEqual( +- { reason: adopted.reason, marker, title, titleSource, isRead }, +- { reason: 'alreadyNative', marker: 'true', title: 'Legacy title', titleSource: 'user', isRead: 'true' }, +- ); - }); - - test('persists the last migrated turn id from the request sidecar on adoption', async () => { @@ -79014,26 +81013,25 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await writeExtensionHostMarker(userHome, sessionId); -- await writeExtensionHostRequestDetails(userHome, sessionId, [ -- { copilotRequestId: 'turn-1', creditsUsed: 1 }, -- { copilotRequestId: 'turn-2', creditsUsed: 2 }, -- ]); -- -- await ensureDefaultChatAdopted(agent, session); -- -- const db = await sessionDataService.tryOpenDatabase(session); -- const lastTurn = await db?.object.getMetadata('agentHost.ehcliLastMigratedTurn'); -- db?.dispose(); -- -- assert.strictEqual(lastTurn, 'turn-2'); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await writeExtensionHostMarker(userHome, sessionId); +- await writeExtensionHostRequestDetails(userHome, sessionId, [ +- { copilotRequestId: 'turn-1', creditsUsed: 1 }, +- { copilotRequestId: 'turn-2', creditsUsed: 2 }, +- ]); +- +- await ensureDefaultChatAdopted(agent, session); +- +- const db = await sessionDataService.tryOpenDatabase(session); +- const lastTurn = await db?.object.getMetadata('agentHost.ehcliLastMigratedTurn'); +- db?.dispose(); +- +- assert.strictEqual(lastTurn, 'turn-2'); - }); - - test('backfills the base branch and last migrated turn for a session migrated by an older build', async () => { @@ -79048,36 +81046,35 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await writeExtensionHostMarker(userHome, sessionId, { -- origin: 'vscode', -- worktreeProperties: { worktreePath: workingDirectory, repositoryPath: repositoryRoot, branchName: 'feature/x', baseBranchName: 'main' }, -- }); -- await writeExtensionHostRequestDetails(userHome, sessionId, [{ copilotRequestId: 'turn-9', creditsUsed: 1 }]); -- // Metadata the older build wrote: adopted with a working directory, but no base branch or boundary. -- const seed = sessionDataService.openDatabase(session); -- await seed.object.setMetadata('copilot.workingDirectory', URI.file(workingDirectory).toString()); -- await seed.object.setMetadata('agentHost.ehcliAdopted', 'true'); -- seed.dispose(); -- -- const adopted = await ensureDefaultChatAdopted(agent, session); -- -- const db = await sessionDataService.tryOpenDatabase(session); -- const baseBranch = await db?.object.getMetadata('agentHost.diffBaseBranch'); -- const lastTurn = await db?.object.getMetadata('agentHost.ehcliLastMigratedTurn'); -- db?.dispose(); -- -- assert.deepStrictEqual( -- { reason: adopted.reason, baseBranch, lastTurn }, -- { reason: 'alreadyNative', baseBranch: 'main', lastTurn: 'turn-9' }, -- ); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(repositoryRoot, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await writeExtensionHostMarker(userHome, sessionId, { +- origin: 'vscode', +- worktreeProperties: { worktreePath: workingDirectory, repositoryPath: repositoryRoot, branchName: 'feature/x', baseBranchName: 'main' }, +- }); +- await writeExtensionHostRequestDetails(userHome, sessionId, [{ copilotRequestId: 'turn-9', creditsUsed: 1 }]); +- // Metadata the older build wrote: adopted with a working directory, but no base branch or boundary. +- const seed = sessionDataService.openDatabase(session); +- await seed.object.setMetadata('copilot.workingDirectory', URI.file(workingDirectory).toString()); +- await seed.object.setMetadata('agentHost.ehcliAdopted', 'true'); +- seed.dispose(); +- +- const adopted = await ensureDefaultChatAdopted(agent, session); +- +- const db = await sessionDataService.tryOpenDatabase(session); +- const baseBranch = await db?.object.getMetadata('agentHost.diffBaseBranch'); +- const lastTurn = await db?.object.getMetadata('agentHost.ehcliLastMigratedTurn'); +- db?.dispose(); +- +- assert.deepStrictEqual( +- { reason: adopted.reason, baseBranch, lastTurn }, +- { reason: 'alreadyNative', baseBranch: 'main', lastTurn: 'turn-9' }, +- ); - }); - - test('does not backfill the adopted-legacy marker onto a native session', async () => { @@ -79089,24 +81086,23 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const seed = sessionDataService.openDatabase(session); -- await seed.object.setMetadata('copilot.workingDirectory', URI.file(workingDirectory).toString()); -- seed.dispose(); -- -- await ensureDefaultChatAdopted(agent, session); -- -- const db = await sessionDataService.tryOpenDatabase(session); -- const marker = await db?.object.getMetadata('agentHost.ehcliAdopted'); -- db?.dispose(); -- -- assert.strictEqual(marker, undefined); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- const seed = sessionDataService.openDatabase(session); +- await seed.object.setMetadata('copilot.workingDirectory', URI.file(workingDirectory).toString()); +- seed.dispose(); +- +- await ensureDefaultChatAdopted(agent, session); +- +- const db = await sessionDataService.tryOpenDatabase(session); +- const marker = await db?.object.getMetadata('agentHost.ehcliAdopted'); +- db?.dispose(); +- +- assert.strictEqual(marker, undefined); - }); - - test('sees an archive toggled in the extension host after the marker was cached', async () => { @@ -79119,26 +81115,25 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await writeExtensionHostMarker(userHome, sessionId, { origin: 'vscode', archived: false }); -- // Populate the marker cache, as discovery does when it classifies the chat. -- await (agent as unknown as { _isExtensionHostCliSession(id: string): Promise })._isExtensionHostCliSession(sessionId); -- // The user archives it in the extension host list afterwards. -- await writeExtensionHostMarker(userHome, sessionId, { origin: 'vscode', archived: true }); -- -- await ensureDefaultChatAdopted(agent, session); -- -- const db = await sessionDataService.tryOpenDatabase(session); -- const archived = await db?.object.getMetadata('isArchived'); -- db?.dispose(); -- -- assert.strictEqual(archived, 'true'); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await writeExtensionHostMarker(userHome, sessionId, { origin: 'vscode', archived: false }); +- // Populate the marker cache, as discovery does when it classifies the chat. +- await (agent as unknown as { _isExtensionHostCliSession(id: string): Promise })._isExtensionHostCliSession(sessionId); +- // The user archives it in the extension host list afterwards. +- await writeExtensionHostMarker(userHome, sessionId, { origin: 'vscode', archived: true }); +- +- await ensureDefaultChatAdopted(agent, session); +- +- const db = await sessionDataService.tryOpenDatabase(session); +- const archived = await db?.object.getMetadata('isArchived'); +- db?.dispose(); +- +- assert.strictEqual(archived, 'true'); - }); - - test('declines adoption when the archived state can no longer be read', async () => { @@ -79152,28 +81147,27 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await writeExtensionHostMarker(userHome, sessionId); -- // Classify it as legacy while the marker is readable, then corrupt it. -- await (agent as unknown as { _isExtensionHostCliSession(id: string): Promise })._isExtensionHostCliSession(sessionId); -- await fs.writeFile(join(getCopilotHomePath(userHome.fsPath, {}), 'session-state', sessionId, 'vscode.metadata.json'), '{ not json', 'utf8'); -- -- const adopted = await ensureDefaultChatAdopted(agent, session); -- -- const db = await sessionDataService.tryOpenDatabase(session); -- const persistedCwd = await db?.object.getMetadata('copilot.workingDirectory'); -- db?.dispose(); -- -- assert.deepStrictEqual( -- { adopted, persistedCwd }, -- { adopted: { adopted: false, eligible: true, reason: 'markerUnavailable', diagnostics: { markerStatus: 'invalid', provenance: 'unknown', markerFromCache: false } }, persistedCwd: undefined }, -- ); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await writeExtensionHostMarker(userHome, sessionId); +- // Classify it as legacy while the marker is readable, then corrupt it. +- await (agent as unknown as { _isExtensionHostCliSession(id: string): Promise })._isExtensionHostCliSession(sessionId); +- await fs.writeFile(join(getCopilotHomePath(userHome.fsPath, {}), 'session-state', sessionId, 'vscode.metadata.json'), '{ not json', 'utf8'); +- +- const adopted = await ensureDefaultChatAdopted(agent, session); +- +- const db = await sessionDataService.tryOpenDatabase(session); +- const persistedCwd = await db?.object.getMetadata('copilot.workingDirectory'); +- db?.dispose(); +- +- assert.deepStrictEqual( +- { adopted, persistedCwd }, +- { adopted: { adopted: false, eligible: true, reason: 'markerUnavailable', diagnostics: { markerStatus: 'invalid', provenance: 'unknown', markerFromCache: false } }, persistedCwd: undefined }, +- ); - }); - - test('reports no recorded worktree when the checkout still exists', async () => { @@ -79185,21 +81179,20 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await writeExtensionHostMarker(userHome, sessionId, { -- origin: 'vscode', -- worktreeProperties: { worktreePath: workingDirectory, repositoryPath: workingDirectory, branchName: 'feature/y' }, -- }); -- -- const adopted = await ensureDefaultChatAdopted(agent, session); -- -- assert.deepStrictEqual({ adopted: adopted.adopted, worktree: adopted.worktree }, { adopted: true, worktree: undefined }); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await writeExtensionHostMarker(userHome, sessionId, { +- origin: 'vscode', +- worktreeProperties: { worktreePath: workingDirectory, repositoryPath: workingDirectory, branchName: 'feature/y' }, +- }); +- +- const adopted = await ensureDefaultChatAdopted(agent, session); +- +- assert.deepStrictEqual({ adopted: adopted.adopted, worktree: adopted.worktree }, { adopted: true, worktree: undefined }); - }); - - test('adopts a legacy extension-host session in place and seeds folder isolation', async () => { @@ -79210,28 +81203,27 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await writeExtensionHostMarker(userHome, sessionId); -- -- const first = await ensureDefaultChatAdopted(agent, session); -- // A second call is a no-op: the first adopt persisted a working -- // directory, which now reads as an already-native session. -- const second = await ensureDefaultChatAdopted(agent, session); -- -- const db = await sessionDataService.tryOpenDatabase(session); -- const configValues = await db?.object.getMetadata('configValues'); -- db?.dispose(); -- -- assert.deepStrictEqual( -- { first, second, configValues }, -- { first: { adopted: true, eligible: true, reason: 'adopted', listVisible: { title: 'SDK legacy-adopt', titleSource: 'auto', isRead: true } }, second: { adopted: false, eligible: false, native: true, reason: 'alreadyNative' }, configValues: JSON.stringify({ [SessionConfigKey.Isolation]: 'folder' }) }, -- ); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await writeExtensionHostMarker(userHome, sessionId); +- +- const first = await ensureDefaultChatAdopted(agent, session); +- // A second call is a no-op: the first adopt persisted a working +- // directory, which now reads as an already-native session. +- const second = await ensureDefaultChatAdopted(agent, session); +- +- const db = await sessionDataService.tryOpenDatabase(session); +- const configValues = await db?.object.getMetadata('configValues'); +- db?.dispose(); +- +- assert.deepStrictEqual( +- { first, second, configValues }, +- { first: { adopted: true, eligible: true, reason: 'adopted', listVisible: { title: 'SDK legacy-adopt', titleSource: 'auto', isRead: true } }, second: { adopted: false, eligible: false, native: true, reason: 'alreadyNative' }, configValues: JSON.stringify({ [SessionConfigKey.Isolation]: 'folder' }) }, +- ); - }); - - test('adopts a session whose recorded working directory no longer exists into the workspaceless scratch directory', async () => { @@ -79246,62 +81238,61 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession(sessionId, deletedWorkingDirectory)]); - const { agent, fileService } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- (agent as TestableCopilotAgent).registerFakeSession(sessionId, { send: async () => { }, getMessages: async () => [], dispose: () => { } }); -- await writeExtensionHostMarker(userHome, sessionId, { -- workspacePath: deletedWorkingDirectory, -- worktreeProperties: { repositoryPath: deletedWorkingDirectory }, -- }); -- -- const adopted = await ensureDefaultChatAdopted(agent, session); -- const metadataAfterAdoption = await agent.getChatMetadata(defaultChatUri(session), exactChatContext(session, defaultChatUri(session), session)); -- assert.ok(metadataAfterAdoption); -- const host = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, TEST_PRODUCT_SERVICE, new TestAgentHostGitService())); -- registerTestAgentProvider(host, agent); -- await (host as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry.register(session, { -- provider: agent.id, startTime: metadataAfterAdoption.startTime, modifiedTime: metadataAfterAdoption.modifiedTime, source: 'restore', -- }, { checkTombstone: true }); -- await host.restoreSession(session); -- const restoredWorkingDirectories = getTestAgentStateManager(host).getSessionSummary(session.toString())?.workingDirectories; -- -- const db = await sessionDataService.tryOpenDatabase(session); -- const persistedCwd = await db?.object.getMetadata('copilot.workingDirectory'); -- const persistedWorkspaceless = await db?.object.getMetadata(AH_META_WORKSPACELESS_DB_KEY); -- const persistedSessionWorkingDirectories = await db?.object.getMetadata(SESSION_WORKING_DIRECTORIES_KEY); -- const persistedProjectResolved = await db?.object.getMetadata('copilot.project.resolved'); -- const persistedProjectUri = await db?.object.getMetadata('copilot.project.uri'); -- db?.dispose(); -- const expectedScratchDir = workspacelessScratchDir(userHome, sessionId); -- -- assert.deepStrictEqual( -- { -- adopted, -- persistedCwd, -- persistedWorkspaceless, -- persistedSessionWorkingDirectories, -- persistedProjectResolved, -- persistedProjectUri, -- metadataProject: metadataAfterAdoption?.project, -- restoredWorkingDirectories, -- scratchExists: (await fs.stat(expectedScratchDir.fsPath)).isDirectory(), -- }, -- { -- adopted: { adopted: true, eligible: true, reason: 'adopted', listVisible: { title: 'SDK legacy-missing-cwd', titleSource: 'auto', isRead: true } }, -- persistedCwd: expectedScratchDir.toString(), -- persistedWorkspaceless: 'true', -- persistedSessionWorkingDirectories: '[]', -- persistedProjectResolved: 'true', -- persistedProjectUri: undefined, -- metadataProject: undefined, -- restoredWorkingDirectories: [], -- scratchExists: true, -- }, -- ); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- (agent as TestableCopilotAgent).registerFakeSession(sessionId, { send: async () => { }, getMessages: async () => [], dispose: () => { } }); +- await writeExtensionHostMarker(userHome, sessionId, { +- workspacePath: deletedWorkingDirectory, +- worktreeProperties: { repositoryPath: deletedWorkingDirectory }, +- }); +- +- const adopted = await ensureDefaultChatAdopted(agent, session); +- const metadataAfterAdoption = await agent.getChatMetadata(defaultChatUri(session), exactChatContext(session, defaultChatUri(session), session)); +- assert.ok(metadataAfterAdoption); +- const host = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, TEST_PRODUCT_SERVICE, new TestAgentHostGitService())); +- registerTestAgentProvider(host, agent); +- await (host as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry.register(session, { +- provider: agent.id, startTime: metadataAfterAdoption.startTime, modifiedTime: metadataAfterAdoption.modifiedTime, source: 'restore', +- }, { checkTombstone: true }); +- await host.restoreSession(session); +- const restoredWorkingDirectories = getTestAgentStateManager(host).getSessionSummary(session.toString())?.workingDirectories; +- +- const db = await sessionDataService.tryOpenDatabase(session); +- const persistedCwd = await db?.object.getMetadata('copilot.workingDirectory'); +- const persistedWorkspaceless = await db?.object.getMetadata(AH_META_WORKSPACELESS_DB_KEY); +- const persistedSessionWorkingDirectories = await db?.object.getMetadata(SESSION_WORKING_DIRECTORIES_KEY); +- const persistedProjectResolved = await db?.object.getMetadata('copilot.project.resolved'); +- const persistedProjectUri = await db?.object.getMetadata('copilot.project.uri'); +- db?.dispose(); +- const expectedScratchDir = workspacelessScratchDir(userHome, sessionId); +- +- assert.deepStrictEqual( +- { +- adopted, +- persistedCwd, +- persistedWorkspaceless, +- persistedSessionWorkingDirectories, +- persistedProjectResolved, +- persistedProjectUri, +- metadataProject: metadataAfterAdoption?.project, +- restoredWorkingDirectories, +- scratchExists: (await fs.stat(expectedScratchDir.fsPath)).isDirectory(), +- }, +- { +- adopted: { adopted: true, eligible: true, reason: 'adopted', listVisible: { title: 'SDK legacy-missing-cwd', titleSource: 'auto', isRead: true } }, +- persistedCwd: expectedScratchDir.toString(), +- persistedWorkspaceless: 'true', +- persistedSessionWorkingDirectories: '[]', +- persistedProjectResolved: 'true', +- persistedProjectUri: undefined, +- metadataProject: undefined, +- restoredWorkingDirectories: [], +- scratchExists: true, +- }, +- ); - }); - - test('propagates transient SDK metadata failures during adoption instead of falling back to scratch storage', async () => { @@ -79320,29 +81311,28 @@ index f24a97a6296..00000000000 - return origGetSessionMetadata(id); - }; - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- // Marker contains only `origin: 'vscode'` (no workspacePath). -- await writeExtensionHostMarker(userHome, sessionId); -- -- await assert.rejects(() => ensureDefaultChatAdopted(agent, session), /SDK connection temporarily unavailable/); -- -- // Once the transient SDK failure clears, a subsequent adopt uses the real working directory. -- failMetadata = false; -- const adopted = await ensureDefaultChatAdopted(agent, session); -- const db = await sessionDataService.tryOpenDatabase(session); -- const persistedCwd = await db?.object.getMetadata('copilot.workingDirectory'); -- db?.dispose(); -- -- assert.deepStrictEqual( -- { adopted: adopted.adopted, persistedCwd }, -- { adopted: true, persistedCwd: URI.file(workingDirectory).toString() }, -- ); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- // Marker contains only `origin: 'vscode'` (no workspacePath). +- await writeExtensionHostMarker(userHome, sessionId); +- +- await assert.rejects(() => ensureDefaultChatAdopted(agent, session), /SDK connection temporarily unavailable/); +- +- // Once the transient SDK failure clears, a subsequent adopt uses the real working directory. +- failMetadata = false; +- const adopted = await ensureDefaultChatAdopted(agent, session); +- const db = await sessionDataService.tryOpenDatabase(session); +- const persistedCwd = await db?.object.getMetadata('copilot.workingDirectory'); +- db?.dispose(); +- +- assert.deepStrictEqual( +- { adopted: adopted.adopted, persistedCwd }, +- { adopted: true, persistedCwd: URI.file(workingDirectory).toString() }, +- ); - }); - - test('does not adopt into scratch storage when a recorded working directory is transiently inaccessible', async () => { @@ -79388,24 +81378,23 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession(sessionId, deletedWorkingDirectory)]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await writeExtensionHostMarker(userHome, sessionId); -- // Block mkdir(workspacelessChatsRoot) by placing a regular file at `~/.copilot/chats`. -- const chatsRoot = workspacelessChatsRoot(userHome); -- await fs.mkdir(join(userHome.fsPath, '.copilot'), { recursive: true }); -- await fs.writeFile(chatsRoot.fsPath, 'not-a-directory', 'utf8'); -- -- await assert.rejects(() => ensureDefaultChatAdopted(agent, session)); -- -- const db = await sessionDataService.tryOpenDatabase(session); -- const persistedCwd = await db?.object.getMetadata('copilot.workingDirectory'); -- db?.dispose(); -- assert.strictEqual(persistedCwd, undefined); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await writeExtensionHostMarker(userHome, sessionId); +- // Block mkdir(workspacelessChatsRoot) by placing a regular file at `~/.copilot/chats`. +- const chatsRoot = workspacelessChatsRoot(userHome); +- await fs.mkdir(join(userHome.fsPath, '.copilot'), { recursive: true }); +- await fs.writeFile(chatsRoot.fsPath, 'not-a-directory', 'utf8'); +- +- await assert.rejects(() => ensureDefaultChatAdopted(agent, session)); +- +- const db = await sessionDataService.tryOpenDatabase(session); +- const persistedCwd = await db?.object.getMetadata('copilot.workingDirectory'); +- db?.dispose(); +- assert.strictEqual(persistedCwd, undefined); - }); - - test('carries over the legacy archived state on adoption', async () => { @@ -79418,25 +81407,24 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await writeExtensionHostMarker(userHome, sessionId, { origin: 'vscode', archived: true }); -- -- const adopted = await ensureDefaultChatAdopted(agent, session); -- -- const db = await sessionDataService.tryOpenDatabase(session); -- const archived = await db?.object.getMetadata(AH_META_IS_ARCHIVED_DB_KEY); -- db?.dispose(); -- -- assert.deepStrictEqual( -- { adopted, archived }, -- { adopted: { adopted: true, eligible: true, reason: 'adopted', listVisible: { title: 'SDK legacy-archived', titleSource: 'auto', isRead: true } }, archived: 'true' }, -- ); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await writeExtensionHostMarker(userHome, sessionId, { origin: 'vscode', archived: true }); +- +- const adopted = await ensureDefaultChatAdopted(agent, session); +- +- const db = await sessionDataService.tryOpenDatabase(session); +- const archived = await db?.object.getMetadata(AH_META_IS_ARCHIVED_DB_KEY); +- db?.dispose(); +- +- assert.deepStrictEqual( +- { adopted, archived }, +- { adopted: { adopted: true, eligible: true, reason: 'adopted', listVisible: { title: 'SDK legacy-archived', titleSource: 'auto', isRead: true } }, archived: 'true' }, +- ); - }); - - test('carries over the legacy per-request credits on adoption', async () => { @@ -79447,39 +81435,38 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await writeExtensionHostMarker(userHome, sessionId); -- await writeExtensionHostRequestDetails(userHome, sessionId, [ -- { vscodeRequestId: 'vsc-1', copilotRequestId: 'evt-1', responseModelId: 'gpt-5.4', creditsUsed: 1.5 }, -- // Zero credits is real consumption and keeps its response model. -- { vscodeRequestId: 'vsc-2', copilotRequestId: 'evt-2', responseModelId: 'gpt-5.4-mini', creditsUsed: 0 }, -- // No credits recorded and no SDK id: both are skipped. -- { vscodeRequestId: 'vsc-3', copilotRequestId: 'evt-3' }, -- { vscodeRequestId: 'vsc-4', creditsUsed: 4 }, -- ]); -- -- const adopted = await ensureDefaultChatAdopted(agent, session); -- -- const db = await sessionDataService.tryOpenDatabase(session); -- const usages = [...(await db?.object.getTurnUsages() ?? new Map()).entries()]; -- db?.dispose(); -- -- assert.deepStrictEqual( -- { adopted, usages }, -- { -- adopted: { adopted: true, eligible: true, reason: 'adopted', listVisible: { title: 'SDK legacy-credits', titleSource: 'auto', isRead: true } }, -- usages: [ -- ['evt-1', JSON.stringify({ model: 'gpt-5.4', _meta: { copilotUsage: { totalNanoAiu: 1_500_000_000 } } })], -- ['evt-2', JSON.stringify({ model: 'gpt-5.4-mini', _meta: { copilotUsage: { totalNanoAiu: 0 } } })], -- ], -- }, -- ); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await writeExtensionHostMarker(userHome, sessionId); +- await writeExtensionHostRequestDetails(userHome, sessionId, [ +- { vscodeRequestId: 'vsc-1', copilotRequestId: 'evt-1', responseModelId: 'gpt-5.4', creditsUsed: 1.5 }, +- // Zero credits is real consumption and keeps its response model. +- { vscodeRequestId: 'vsc-2', copilotRequestId: 'evt-2', responseModelId: 'gpt-5.4-mini', creditsUsed: 0 }, +- // No credits recorded and no SDK id: both are skipped. +- { vscodeRequestId: 'vsc-3', copilotRequestId: 'evt-3' }, +- { vscodeRequestId: 'vsc-4', creditsUsed: 4 }, +- ]); +- +- const adopted = await ensureDefaultChatAdopted(agent, session); +- +- const db = await sessionDataService.tryOpenDatabase(session); +- const usages = [...(await db?.object.getTurnUsages() ?? new Map()).entries()]; +- db?.dispose(); +- +- assert.deepStrictEqual( +- { adopted, usages }, +- { +- adopted: { adopted: true, eligible: true, reason: 'adopted', listVisible: { title: 'SDK legacy-credits', titleSource: 'auto', isRead: true } }, +- usages: [ +- ['evt-1', JSON.stringify({ model: 'gpt-5.4', _meta: { copilotUsage: { totalNanoAiu: 1_500_000_000 } } })], +- ['evt-2', JSON.stringify({ model: 'gpt-5.4-mini', _meta: { copilotUsage: { totalNanoAiu: 0 } } })], +- ], +- }, +- ); - }); - - test('carries over the legacy custom title on adoption', async () => { @@ -79491,37 +81478,36 @@ index f24a97a6296..00000000000 - // No SDK `name`: the staged custom title is the adopted title (it beats the summary). - const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await writeExtensionHostMarker(userHome, sessionId, { origin: 'vscode', customTitle: 'My Legacy Session' }); +- afterTest(async () => { +- await fs.rm(userHome.fsPath, { recursive: true, force: true }); +- await fs.rm(workingDirectory, { recursive: true, force: true }); +- await disposeAgent(agent); +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await writeExtensionHostMarker(userHome, sessionId, { origin: 'vscode', customTitle: 'My Legacy Session' }); - -- const adopted = await ensureDefaultChatAdopted(agent, session); -- const retried = await ensureDefaultChatAdopted(agent, session); +- const adopted = await ensureDefaultChatAdopted(agent, session); +- const retried = await ensureDefaultChatAdopted(agent, session); - -- const db = await sessionDataService.tryOpenDatabase(session); -- const customTitle = await db?.object.getMetadata('customTitle'); -- const isRead = await db?.object.getMetadata(AH_META_IS_READ_DB_KEY); -- db?.dispose(); +- const db = await sessionDataService.tryOpenDatabase(session); +- const customTitle = await db?.object.getMetadata('customTitle'); +- const isRead = await db?.object.getMetadata(AH_META_IS_READ_DB_KEY); +- db?.dispose(); - -- assert.deepStrictEqual( -- { adopted, retried, customTitle, isRead }, -- { -- adopted: { -- adopted: true, -- eligible: true, -- reason: 'adopted', -- listVisible: { title: 'My Legacy Session', titleSource: 'user', isRead: true }, -- }, -- retried: { adopted: false, eligible: false, native: true, reason: 'alreadyNative' }, -- customTitle: 'My Legacy Session', -- isRead: 'true', +- assert.deepStrictEqual( +- { adopted, retried, customTitle, isRead }, +- { +- adopted: { +- adopted: true, +- eligible: true, +- reason: 'adopted', +- listVisible: { title: 'My Legacy Session', titleSource: 'user', isRead: true }, - }, -- ); -- } finally { -- await fs.rm(userHome.fsPath, { recursive: true, force: true }); -- await fs.rm(workingDirectory, { recursive: true, force: true }); -- await disposeAgent(agent); -- } +- retried: { adopted: false, eligible: false, native: true, reason: 'alreadyNative' }, +- customTitle: 'My Legacy Session', +- isRead: 'true', +- }, +- ); - }); - - test('prefers the legacy session name over the staged custom title on adoption', async () => { @@ -79533,25 +81519,24 @@ index f24a97a6296..00000000000 - // name must win over both the staged custom title and the summary. - const client = new TestCopilotClient([{ ...sdkSession(sessionId, workingDirectory), name: 'Telemetry analysis for Agents window' }]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await writeExtensionHostMarker(userHome, sessionId, { origin: 'vscode', customTitle: 'Staged VS Code Title' }); -- -- const adopted = await ensureDefaultChatAdopted(agent, session); -- -- const db = await sessionDataService.tryOpenDatabase(session); -- const title = await db?.object.getMetadata('customTitle'); -- db?.dispose(); -- -- assert.deepStrictEqual( -- { adopted, title }, -- { adopted: { adopted: true, eligible: true, reason: 'adopted', listVisible: { title: 'Telemetry analysis for Agents window', titleSource: 'auto', isRead: true } }, title: 'Telemetry analysis for Agents window' }, -- ); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await writeExtensionHostMarker(userHome, sessionId, { origin: 'vscode', customTitle: 'Staged VS Code Title' }); +- +- const adopted = await ensureDefaultChatAdopted(agent, session); +- +- const db = await sessionDataService.tryOpenDatabase(session); +- const title = await db?.object.getMetadata('customTitle'); +- db?.dispose(); +- +- assert.deepStrictEqual( +- { adopted, title }, +- { adopted: { adopted: true, eligible: true, reason: 'adopted', listVisible: { title: 'Telemetry analysis for Agents window', titleSource: 'auto', isRead: true } }, title: 'Telemetry analysis for Agents window' }, +- ); - }); - - test('falls back to the SDK summary as the adopted title when there is no custom title or name', async () => { @@ -79563,25 +81548,24 @@ index f24a97a6296..00000000000 - // summary is the last title source when there is no custom title or name. - const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await writeExtensionHostMarker(userHome, sessionId); -- -- const adopted = await ensureDefaultChatAdopted(agent, session); -- -- const db = await sessionDataService.tryOpenDatabase(session); -- const title = await db?.object.getMetadata('customTitle'); -- db?.dispose(); -- -- assert.deepStrictEqual( -- { adopted, title }, -- { adopted: { adopted: true, eligible: true, reason: 'adopted', listVisible: { title: `SDK ${sessionId}`, titleSource: 'auto', isRead: true } }, title: `SDK ${sessionId}` }, -- ); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await writeExtensionHostMarker(userHome, sessionId); +- +- const adopted = await ensureDefaultChatAdopted(agent, session); +- +- const db = await sessionDataService.tryOpenDatabase(session); +- const title = await db?.object.getMetadata('customTitle'); +- db?.dispose(); +- +- assert.deepStrictEqual( +- { adopted, title }, +- { adopted: { adopted: true, eligible: true, reason: 'adopted', listVisible: { title: `SDK ${sessionId}`, titleSource: 'auto', isRead: true } }, title: `SDK ${sessionId}` }, +- ); - }); - - test('marks the adopted session read so it is not surfaced as unread on open', async () => { @@ -79592,25 +81576,24 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await writeExtensionHostMarker(userHome, sessionId); -- -- const adopted = await ensureDefaultChatAdopted(agent, session); -- -- const db = await sessionDataService.tryOpenDatabase(session); -- const isRead = await db?.object.getMetadata(AH_META_IS_READ_DB_KEY); -- db?.dispose(); -- -- assert.deepStrictEqual( -- { adopted, isRead }, -- { adopted: { adopted: true, eligible: true, reason: 'adopted', listVisible: { title: 'SDK legacy-read', titleSource: 'auto', isRead: true } }, isRead: 'true' }, -- ); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await writeExtensionHostMarker(userHome, sessionId); +- +- const adopted = await ensureDefaultChatAdopted(agent, session); +- +- const db = await sessionDataService.tryOpenDatabase(session); +- const isRead = await db?.object.getMetadata(AH_META_IS_READ_DB_KEY); +- db?.dispose(); +- +- assert.deepStrictEqual( +- { adopted, isRead }, +- { adopted: { adopted: true, eligible: true, reason: 'adopted', listVisible: { title: 'SDK legacy-read', titleSource: 'auto', isRead: true } }, isRead: 'true' }, +- ); - }); - - test('does not adopt a Copilot SDK session without the extension-host marker', async () => { @@ -79620,20 +81603,19 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession(sessionId, '/workspace')]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- -- // No `vscode.metadata.json` marker -> not an adoptable EH CLI session. -- const adopted = await ensureDefaultChatAdopted(agent, session); -- -- assert.deepStrictEqual( -- { adopted, getSessionMetadataCalls: client.getSessionMetadataCalls, openedDatabases: sessionDataService.openedSessions }, -- { adopted: { adopted: false, eligible: false, reason: 'notLegacyChat', diagnostics: { ...uninspectedStorage, sessionStateRootStatus: 'missing', markerOrigin: 'unavailable', markerStatus: 'missing', provenance: 'unknown', markerFromCache: false } }, getSessionMetadataCalls: [], openedDatabases: [] }, -- ); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- +- // No `vscode.metadata.json` marker -> not an adoptable EH CLI session. +- const adopted = await ensureDefaultChatAdopted(agent, session); +- +- assert.deepStrictEqual( +- { adopted, getSessionMetadataCalls: client.getSessionMetadataCalls, openedDatabases: sessionDataService.openedSessions }, +- { adopted: { adopted: false, eligible: false, reason: 'notLegacyChat', diagnostics: { ...uninspectedStorage, sessionStateRootStatus: 'missing', markerOrigin: 'unavailable', markerStatus: 'missing', provenance: 'unknown', markerFromCache: false } }, getSessionMetadataCalls: [], openedDatabases: [] }, +- ); - }); - - test('does not adopt a session whose marker originates from another Copilot host', async () => { @@ -79643,27 +81625,26 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession(sessionId, '/workspace')]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); +- afterTest(async () => { +- await fs.rm(userHome.fsPath, { recursive: true, force: true }); +- await disposeAgent(agent); +- }); +- await agent.authenticate('https://api.github.com', 'token'); - -- // The GitHub Copilot app writes the same marker with `origin: 'other'`. -- await writeExtensionHostMarker(userHome, sessionId, { origin: 'other' }); -- // Credits are migrated for legacy VS Code sessions only: a sidecar -- // belonging to another Copilot host must never be read or applied. -- await writeExtensionHostRequestDetails(userHome, sessionId, [ -- { vscodeRequestId: 'vsc-1', copilotRequestId: 'evt-1', creditsUsed: 9 }, -- ]); +- // The GitHub Copilot app writes the same marker with `origin: 'other'`. +- await writeExtensionHostMarker(userHome, sessionId, { origin: 'other' }); +- // Credits are migrated for legacy VS Code sessions only: a sidecar +- // belonging to another Copilot host must never be read or applied. +- await writeExtensionHostRequestDetails(userHome, sessionId, [ +- { vscodeRequestId: 'vsc-1', copilotRequestId: 'evt-1', creditsUsed: 9 }, +- ]); - -- const adopted = await ensureDefaultChatAdopted(agent, session); +- const adopted = await ensureDefaultChatAdopted(agent, session); - -- assert.deepStrictEqual( -- { adopted, getSessionMetadataCalls: client.getSessionMetadataCalls, openedDatabases: sessionDataService.openedSessions }, -- { adopted: { adopted: false, eligible: false, reason: 'notLegacyChat', diagnostics: { ...uninspectedStorage, markerOrigin: 'other', markerStatus: 'valid', provenance: 'external', markerFromCache: false } }, getSessionMetadataCalls: [], openedDatabases: [] }, -- ); -- } finally { -- await fs.rm(userHome.fsPath, { recursive: true, force: true }); -- await disposeAgent(agent); -- } +- assert.deepStrictEqual( +- { adopted, getSessionMetadataCalls: client.getSessionMetadataCalls, openedDatabases: sessionDataService.openedSessions }, +- { adopted: { adopted: false, eligible: false, reason: 'notLegacyChat', diagnostics: { ...uninspectedStorage, markerOrigin: 'other', markerStatus: 'valid', provenance: 'external', markerFromCache: false } }, getSessionMetadataCalls: [], openedDatabases: [] }, +- ); - }); - - for (const { name, raw, markerStatus, errorCode, markerOrigin } of [ @@ -79681,35 +81662,34 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession(sessionId, '/workspace')]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const directory = join(getCopilotHomePath(userHome.fsPath, {}), 'session-state', sessionId); -- const markerPath = join(directory, 'vscode.metadata.json'); -- await fs.mkdir(directory, { recursive: true }); -- if (raw === undefined) { -- await fs.mkdir(markerPath); -- } else { -- await fs.writeFile(markerPath, raw, 'utf8'); -- } -- const result = await ensureDefaultChatAdopted(agent, session); -- assert.deepStrictEqual({ -- adopted: result.adopted, eligible: result.eligible, reason: result.reason, -- markerStatus: result.diagnostics?.markerStatus, provenance: result.diagnostics?.provenance, -- markerOrigin: result.diagnostics?.markerOrigin, -- markerFromCache: result.diagnostics?.markerFromCache, errorCode: result.diagnostics?.errorCode, -- hasErrorMessage: !!result.diagnostics?.errorMessage, -- containsMarkerContents: JSON.stringify(result.diagnostics).includes('private-marker-content'), -- metadataCalls: client.getSessionMetadataCalls, openedDatabases: sessionDataService.openedSessions, -- }, { -- adopted: false, eligible: false, reason: 'notLegacyChat', -- markerStatus, markerOrigin, provenance: 'unknown', markerFromCache: false, errorCode, -- hasErrorMessage: errorCode !== undefined, containsMarkerContents: false, -- metadataCalls: [], openedDatabases: [], -- }); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await disposeAgent(agent); +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- const directory = join(getCopilotHomePath(userHome.fsPath, {}), 'session-state', sessionId); +- const markerPath = join(directory, 'vscode.metadata.json'); +- await fs.mkdir(directory, { recursive: true }); +- if (raw === undefined) { +- await fs.mkdir(markerPath); +- } else { +- await fs.writeFile(markerPath, raw, 'utf8'); - } +- const result = await ensureDefaultChatAdopted(agent, session); +- assert.deepStrictEqual({ +- adopted: result.adopted, eligible: result.eligible, reason: result.reason, +- markerStatus: result.diagnostics?.markerStatus, provenance: result.diagnostics?.provenance, +- markerOrigin: result.diagnostics?.markerOrigin, +- markerFromCache: result.diagnostics?.markerFromCache, errorCode: result.diagnostics?.errorCode, +- hasErrorMessage: !!result.diagnostics?.errorMessage, +- containsMarkerContents: JSON.stringify(result.diagnostics).includes('private-marker-content'), +- metadataCalls: client.getSessionMetadataCalls, openedDatabases: sessionDataService.openedSessions, +- }, { +- adopted: false, eligible: false, reason: 'notLegacyChat', +- markerStatus, markerOrigin, provenance: 'unknown', markerFromCache: false, errorCode, +- hasErrorMessage: errorCode !== undefined, containsMarkerContents: false, +- metadataCalls: [], openedDatabases: [], +- }); - }); - } - @@ -79719,21 +81699,20 @@ index f24a97a6296..00000000000 - const session = AgentSession.uri('copilotcli', sessionId); - const client = new TestCopilotClient([sdkSession(sessionId, '/workspace')]); - const agent = createTestAgent(disposables, { copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const missing = await ensureDefaultChatAdopted(agent, session); -- await writeExtensionHostMarker(userHome, sessionId, { origin: 'other' }); -- const fresh = await ensureDefaultChatAdopted(agent, session); -- const cached = await ensureDefaultChatAdopted(agent, session); -- assert.deepStrictEqual([missing.diagnostics, fresh.diagnostics, cached.diagnostics], [ -- { ...uninspectedStorage, sessionStateRootStatus: 'missing', markerOrigin: 'unavailable', markerStatus: 'missing', provenance: 'unknown', markerFromCache: false }, -- { ...uninspectedStorage, markerOrigin: 'other', markerStatus: 'valid', provenance: 'external', markerFromCache: false }, -- { ...uninspectedStorage, markerOrigin: 'other', markerStatus: 'valid', provenance: 'external', markerFromCache: true }, -- ]); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- const missing = await ensureDefaultChatAdopted(agent, session); +- await writeExtensionHostMarker(userHome, sessionId, { origin: 'other' }); +- const fresh = await ensureDefaultChatAdopted(agent, session); +- const cached = await ensureDefaultChatAdopted(agent, session); +- assert.deepStrictEqual([missing.diagnostics, fresh.diagnostics, cached.diagnostics], [ +- { ...uninspectedStorage, sessionStateRootStatus: 'missing', markerOrigin: 'unavailable', markerStatus: 'missing', provenance: 'unknown', markerFromCache: false }, +- { ...uninspectedStorage, markerOrigin: 'other', markerStatus: 'valid', provenance: 'external', markerFromCache: false }, +- { ...uninspectedStorage, markerOrigin: 'other', markerStatus: 'valid', provenance: 'external', markerFromCache: true }, +- ]); - }); - - for (const layout of ['rootMissing', 'sessionMissing', 'eventsMissing', 'eventsPresent', 'rootIsFile', 'sessionIsFile', 'eventsIsDirectory'] as const) { @@ -79744,49 +81723,48 @@ index f24a97a6296..00000000000 - const client = new TestCopilotClient([sdkSession(sessionId, '/workspace')]); - const copilotHome = join(userHome.fsPath, 'custom-home'); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome, copilotHome }); -- try { -- await fs.mkdir(copilotHome, { recursive: true }); -- const root = join(copilotHome, 'session-state'); -- if (layout === 'rootIsFile') { -- await fs.writeFile(root, ''); -- } else if (layout !== 'rootMissing') { -- await fs.mkdir(root); -- } -- const directory = join(root, sessionId); -- if (layout === 'sessionIsFile') { -- await fs.writeFile(directory, ''); -- } else if (layout === 'eventsMissing' || layout === 'eventsPresent' || layout === 'eventsIsDirectory') { -- await fs.mkdir(directory); -- } -- if (layout === 'eventsPresent') { -- await fs.writeFile(join(directory, 'events.jsonl'), 'private event contents'); -- } else if (layout === 'eventsIsDirectory') { -- await fs.mkdir(join(directory, 'events.jsonl')); -- } -- await agent.authenticate('https://api.github.com', 'token'); -- const result = await ensureDefaultChatAdopted(agent, AgentSession.uri('copilotcli', sessionId)); -- const pathError = layout === 'rootIsFile' || layout === 'sessionIsFile'; -- assert.deepStrictEqual({ -- adopted: result.adopted, eligible: result.eligible, -- provenance: result.diagnostics?.provenance, -- home: result.diagnostics?.copilotHomeSource, -- root: result.diagnostics?.sessionStateRootStatus, -- directory: result.diagnostics?.sessionDirectoryStatus, -- events: result.diagnostics?.eventsFileStatus, -- workspaceMetadata: result.diagnostics?.workspaceMetadataStatus, lastKnownClient: result.diagnostics?.lastKnownClient, -- metadataCalls: client.getSessionMetadataCalls, databases: sessionDataService.openedSessions, -- }, { -- adopted: false, eligible: false, provenance: 'unknown', home: 'environment', -- root: layout === 'rootIsFile' ? 'file' : layout === 'rootMissing' ? 'missing' : 'directory', -- directory: layout === 'rootIsFile' || layout === 'rootMissing' ? 'notChecked' : layout === 'sessionIsFile' ? 'file' : layout === 'sessionMissing' ? 'missing' : 'directory', -- events: pathError || layout === 'rootMissing' || layout === 'sessionMissing' ? 'notChecked' : layout === 'eventsPresent' ? 'file' : layout === 'eventsIsDirectory' ? 'directory' : 'missing', -- workspaceMetadata: pathError && os.platform() !== 'win32' ? 'readError' : 'missing', lastKnownClient: 'unavailable', -- metadataCalls: [], databases: [], -- }); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await disposeAgent(agent); +- }); +- await fs.mkdir(copilotHome, { recursive: true }); +- const root = join(copilotHome, 'session-state'); +- if (layout === 'rootIsFile') { +- await fs.writeFile(root, ''); +- } else if (layout !== 'rootMissing') { +- await fs.mkdir(root); - } +- const directory = join(root, sessionId); +- if (layout === 'sessionIsFile') { +- await fs.writeFile(directory, ''); +- } else if (layout === 'eventsMissing' || layout === 'eventsPresent' || layout === 'eventsIsDirectory') { +- await fs.mkdir(directory); +- } +- if (layout === 'eventsPresent') { +- await fs.writeFile(join(directory, 'events.jsonl'), 'private event contents'); +- } else if (layout === 'eventsIsDirectory') { +- await fs.mkdir(join(directory, 'events.jsonl')); +- } +- await agent.authenticate('https://api.github.com', 'token'); +- const result = await ensureDefaultChatAdopted(agent, AgentSession.uri('copilotcli', sessionId)); +- const pathError = layout === 'rootIsFile' || layout === 'sessionIsFile'; +- assert.deepStrictEqual({ +- adopted: result.adopted, eligible: result.eligible, +- provenance: result.diagnostics?.provenance, +- home: result.diagnostics?.copilotHomeSource, +- root: result.diagnostics?.sessionStateRootStatus, +- directory: result.diagnostics?.sessionDirectoryStatus, +- events: result.diagnostics?.eventsFileStatus, +- workspaceMetadata: result.diagnostics?.workspaceMetadataStatus, lastKnownClient: result.diagnostics?.lastKnownClient, +- metadataCalls: client.getSessionMetadataCalls, databases: sessionDataService.openedSessions, +- }, { +- adopted: false, eligible: false, provenance: 'unknown', home: 'environment', +- root: layout === 'rootIsFile' ? 'file' : layout === 'rootMissing' ? 'missing' : 'directory', +- directory: layout === 'rootIsFile' || layout === 'rootMissing' ? 'notChecked' : layout === 'sessionIsFile' ? 'file' : layout === 'sessionMissing' ? 'missing' : 'directory', +- events: pathError || layout === 'rootMissing' || layout === 'sessionMissing' ? 'notChecked' : layout === 'eventsPresent' ? 'file' : layout === 'eventsIsDirectory' ? 'directory' : 'missing', +- workspaceMetadata: pathError && os.platform() !== 'win32' ? 'readError' : 'missing', lastKnownClient: 'unavailable', +- metadataCalls: [], databases: [], +- }); - }); - } - @@ -79813,30 +81791,29 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- const directory = join(getCopilotHomePath(userHome.fsPath, {}), 'session-state', sessionId); -- await fs.mkdir(directory, { recursive: true }); -- const metadataPath = join(directory, 'workspace.yaml'); -- if (raw === undefined) { -- await fs.mkdir(metadataPath); -- } else { -- await fs.writeFile(metadataPath, raw, 'utf8'); -- } -- const result = await ensureDefaultChatAdopted(agent, AgentSession.uri('copilotcli', sessionId)); -- assert.deepStrictEqual({ -- adopted: result.adopted, eligible: result.eligible, reason: result.reason, -- provenance: result.diagnostics?.provenance, -- status: result.diagnostics?.workspaceMetadataStatus, clientName: result.diagnostics?.lastKnownClient, -- containsPrivateContent: JSON.stringify(result.diagnostics).includes('private-'), -- metadataCalls: client.getSessionMetadataCalls, openedDatabases: sessionDataService.openedSessions, -- }, { -- adopted: false, eligible: false, reason: 'notLegacyChat', provenance: 'unknown', -- status, clientName, containsPrivateContent: false, metadataCalls: [], openedDatabases: [], -- }); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await disposeAgent(agent); +- }); +- const directory = join(getCopilotHomePath(userHome.fsPath, {}), 'session-state', sessionId); +- await fs.mkdir(directory, { recursive: true }); +- const metadataPath = join(directory, 'workspace.yaml'); +- if (raw === undefined) { +- await fs.mkdir(metadataPath); +- } else { +- await fs.writeFile(metadataPath, raw, 'utf8'); - } +- const result = await ensureDefaultChatAdopted(agent, AgentSession.uri('copilotcli', sessionId)); +- assert.deepStrictEqual({ +- adopted: result.adopted, eligible: result.eligible, reason: result.reason, +- provenance: result.diagnostics?.provenance, +- status: result.diagnostics?.workspaceMetadataStatus, clientName: result.diagnostics?.lastKnownClient, +- containsPrivateContent: JSON.stringify(result.diagnostics).includes('private-'), +- metadataCalls: client.getSessionMetadataCalls, openedDatabases: sessionDataService.openedSessions, +- }, { +- adopted: false, eligible: false, reason: 'notLegacyChat', provenance: 'unknown', +- status, clientName, containsPrivateContent: false, metadataCalls: [], openedDatabases: [], +- }); - }); - } - @@ -79899,25 +81876,24 @@ index f24a97a6296..00000000000 - const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-client-symlink-`)); - const sessionId = 'client-symlink'; - const agent = createTestAgent(disposables, { userHome }); -- try { -- const directory = join(getCopilotHomePath(userHome.fsPath, {}), 'session-state', sessionId); -- await fs.mkdir(directory, { recursive: true }); -- await fs.writeFile(join(directory, 'workspace.yaml'), 'client_name: vscode'); -- const replacement = join(directory, 'replacement.yaml'); -- await fs.writeFile(replacement, 'client_name: github/cli'); -- let opened = false; -- const result = await agent['_readLegacyClientMetadata'](sessionId, async (path, flags, mode) => { -- await fs.unlink(path); -- await fs.symlink(replacement, path); -- const file = await fs.open(path, flags, mode); -- opened = true; -- return file; -- }); -- assert.deepStrictEqual({ result, opened }, { result: { workspaceMetadataStatus: 'readError' }, opened: false }); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- const directory = join(getCopilotHomePath(userHome.fsPath, {}), 'session-state', sessionId); +- await fs.mkdir(directory, { recursive: true }); +- await fs.writeFile(join(directory, 'workspace.yaml'), 'client_name: vscode'); +- const replacement = join(directory, 'replacement.yaml'); +- await fs.writeFile(replacement, 'client_name: github/cli'); +- let opened = false; +- const result = await agent['_readLegacyClientMetadata'](sessionId, async (path, flags, mode) => { +- await fs.unlink(path); +- await fs.symlink(replacement, path); +- const file = await fs.open(path, flags, mode); +- opened = true; +- return file; +- }); +- assert.deepStrictEqual({ result, opened }, { result: { workspaceMetadataStatus: 'readError' }, opened: false }); - }); - } - @@ -79926,47 +81902,45 @@ index f24a97a6296..00000000000 - const sessionId = 'client-retry'; - const copilotHome = join(userHome.fsPath, 'custom-home'); - const agent = createTestAgent(disposables, { userHome, copilotHome }); -- try { -- await writeExtensionHostMarker(userHome, sessionId, { origin: 'other' }, copilotHome); -- const path = join(copilotHome, 'session-state', sessionId, 'workspace.yaml'); -- const results: IAgentChatAdoptionResult[] = []; -- for (const client of ['vscode', 'github/cli']) { -- await fs.writeFile(path, `client_name: ${client}`); -- results.push(await ensureDefaultChatAdopted(agent, AgentSession.uri('copilotcli', sessionId))); -- } -- assert.deepStrictEqual(results.map(result => ({ -- reason: result.reason, provenance: result.diagnostics?.provenance, -- client: result.diagnostics?.lastKnownClient, cachedMarker: result.diagnostics?.markerFromCache, -- })), [ -- { reason: 'notLegacyChat', provenance: 'external', client: 'vscode', cachedMarker: false }, -- { reason: 'notLegacyChat', provenance: 'external', client: 'github-cli', cachedMarker: true }, -- ]); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await disposeAgent(agent); +- }); +- await writeExtensionHostMarker(userHome, sessionId, { origin: 'other' }, copilotHome); +- const path = join(copilotHome, 'session-state', sessionId, 'workspace.yaml'); +- const results: IAgentChatAdoptionResult[] = []; +- for (const client of ['vscode', 'github/cli']) { +- await fs.writeFile(path, `client_name: ${client}`); +- results.push(await ensureDefaultChatAdopted(agent, AgentSession.uri('copilotcli', sessionId))); - } +- assert.deepStrictEqual(results.map(result => ({ +- reason: result.reason, provenance: result.diagnostics?.provenance, +- client: result.diagnostics?.lastKnownClient, cachedMarker: result.diagnostics?.markerFromCache, +- })), [ +- { reason: 'notLegacyChat', provenance: 'external', client: 'vscode', cachedMarker: false }, +- { reason: 'notLegacyChat', provenance: 'external', client: 'github-cli', cachedMarker: true }, +- ]); - }); - - test('does not follow path separators for last-client evidence', async () => { - const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-client-path-`)); - const sessionId = 'nested/session'; - const agent = createTestAgent(disposables, { userHome }); -- try { -- const directory = join(getCopilotHomePath(userHome.fsPath, {}), 'session-state', sessionId); -- await fs.mkdir(directory, { recursive: true }); -- await fs.writeFile(join(directory, 'workspace.yaml'), 'client_name: github/cli'); -- const result = await ensureDefaultChatAdopted(agent, AgentSession.uri('copilotcli', sessionId)); -- assert.deepStrictEqual({ -- reason: result.reason, identifier: result.diagnostics?.sessionIdStatus, -- status: result.diagnostics?.workspaceMetadataStatus, client: result.diagnostics?.lastKnownClient, -- }, { -- reason: 'notLegacyChat', identifier: 'pathSeparators', -- status: 'notChecked', client: 'unavailable', -- }); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- const directory = join(getCopilotHomePath(userHome.fsPath, {}), 'session-state', sessionId); +- await fs.mkdir(directory, { recursive: true }); +- await fs.writeFile(join(directory, 'workspace.yaml'), 'client_name: github/cli'); +- const result = await ensureDefaultChatAdopted(agent, AgentSession.uri('copilotcli', sessionId)); +- assert.deepStrictEqual({ +- reason: result.reason, identifier: result.diagnostics?.sessionIdStatus, +- status: result.diagnostics?.workspaceMetadataStatus, client: result.diagnostics?.lastKnownClient, +- }, { +- reason: 'notLegacyChat', identifier: 'pathSeparators', +- status: 'notChecked', client: 'unavailable', +- }); - }); - - test('reports malformed identifier shape without emitting the inspected path or throwing', async () => { @@ -79974,21 +81948,20 @@ index f24a97a6296..00000000000 - const sessionId = 'private-session\0suffix'; - const client = new TestCopilotClient([]); - const agent = createTestAgent(disposables, { copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const result = await ensureDefaultChatAdopted(agent, AgentSession.uri('copilotcli', sessionId)); -- assert.deepStrictEqual(result, { -- adopted: false, eligible: false, reason: 'notLegacyChat', -- diagnostics: { -- ...uninspectedStorage, workspaceMetadataStatus: 'notChecked', sessionIdStatus: 'controlCharacters', markerOrigin: 'unavailable', -- markerStatus: 'readError', provenance: 'unknown', markerFromCache: false, -- errorCode: 'ERR_INVALID_ARG_VALUE', errorMessage: 'Invalid session marker path', -- }, -- }); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- const result = await ensureDefaultChatAdopted(agent, AgentSession.uri('copilotcli', sessionId)); +- assert.deepStrictEqual(result, { +- adopted: false, eligible: false, reason: 'notLegacyChat', +- diagnostics: { +- ...uninspectedStorage, workspaceMetadataStatus: 'notChecked', sessionIdStatus: 'controlCharacters', markerOrigin: 'unavailable', +- markerStatus: 'readError', provenance: 'unknown', markerFromCache: false, +- errorCode: 'ERR_INVALID_ARG_VALUE', errorMessage: 'Invalid session marker path', +- }, +- }); - }); - - test('adopts an origin-less legacy marker carrying VS Code repository properties', async () => { @@ -79999,21 +81972,20 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- // Older VS Code markers predate the `origin` field but carry VS -- // Code-specific properties; the EH `getSessionOrigin` heuristic -- // treats these as `vscode`. -- await writeExtensionHostMarker(userHome, sessionId, { repositoryProperties: { repositoryPath: workingDirectory } }); -- -- const adopted = await ensureDefaultChatAdopted(agent, session); -- -- assert.deepStrictEqual(adopted, { adopted: true, eligible: true, reason: 'adopted', listVisible: { title: 'SDK legacy-originless', titleSource: 'auto', isRead: true } }); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await fs.rm(workingDirectory, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- // Older VS Code markers predate the `origin` field but carry VS +- // Code-specific properties; the EH `getSessionOrigin` heuristic +- // treats these as `vscode`. +- await writeExtensionHostMarker(userHome, sessionId, { repositoryProperties: { repositoryPath: workingDirectory } }); +- +- const adopted = await ensureDefaultChatAdopted(agent, session); +- +- assert.deepStrictEqual(adopted, { adopted: true, eligible: true, reason: 'adopted', listVisible: { title: 'SDK legacy-originless', titleSource: 'auto', isRead: true } }); - }); - - test('does not adopt an origin-less legacy marker without VS Code properties', async () => { @@ -80023,23 +81995,22 @@ index f24a97a6296..00000000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession(sessionId, '/workspace')]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- // A bare marker with neither `origin` nor VS Code-specific -- // properties is ambiguous; mirror the EH heuristic and treat it as -- // non-VS Code. -- await writeExtensionHostMarker(userHome, sessionId, { modified: 123, created: 123 }); -- -- const adopted = await ensureDefaultChatAdopted(agent, session); -- -- assert.deepStrictEqual( -- { adopted, getSessionMetadataCalls: client.getSessionMetadataCalls, openedDatabases: sessionDataService.openedSessions }, -- { adopted: { adopted: false, eligible: false, reason: 'notLegacyChat', diagnostics: { ...uninspectedStorage, markerOrigin: 'missing', markerStatus: 'valid', provenance: 'unknown', markerFromCache: false } }, getSessionMetadataCalls: [], openedDatabases: [] }, -- ); -- } finally { +- afterTest(async () => { - await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await disposeAgent(agent); -- } +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- // A bare marker with neither `origin` nor VS Code-specific +- // properties is ambiguous; mirror the EH heuristic and treat it as +- // non-VS Code. +- await writeExtensionHostMarker(userHome, sessionId, { modified: 123, created: 123 }); +- +- const adopted = await ensureDefaultChatAdopted(agent, session); +- +- assert.deepStrictEqual( +- { adopted, getSessionMetadataCalls: client.getSessionMetadataCalls, openedDatabases: sessionDataService.openedSessions }, +- { adopted: { adopted: false, eligible: false, reason: 'notLegacyChat', diagnostics: { ...uninspectedStorage, markerOrigin: 'missing', markerStatus: 'valid', provenance: 'unknown', markerFromCache: false } }, getSessionMetadataCalls: [], openedDatabases: [] }, +- ); - }); - - test('does not re-adopt a session that already has stored working-directory metadata', async () => { @@ -80053,39 +82024,38 @@ index f24a97a6296..00000000000 - seed.dispose(); - const client = new TestCopilotClient([sdkSession(sessionId, '/workspace')]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- await writeExtensionHostMarker(userHome, sessionId); // even with a marker present -- // An already-migrated session's usage must never be rewritten from -- // the legacy sidecar: live agent-host usage is authoritative. -- await writeExtensionHostRequestDetails(userHome, sessionId, [ -- { vscodeRequestId: 'vsc-1', copilotRequestId: 'evt-1', creditsUsed: 9 }, -- ]); +- afterTest(async () => { +- await fs.rm(userHome.fsPath, { recursive: true, force: true }); +- await disposeAgent(agent); +- }); +- await agent.authenticate('https://api.github.com', 'token'); +- await writeExtensionHostMarker(userHome, sessionId); // even with a marker present +- // An already-migrated session's usage must never be rewritten from +- // the legacy sidecar: live agent-host usage is authoritative. +- await writeExtensionHostRequestDetails(userHome, sessionId, [ +- { vscodeRequestId: 'vsc-1', copilotRequestId: 'evt-1', creditsUsed: 9 }, +- ]); - -- const adopted = await ensureDefaultChatAdopted(agent, session); +- const adopted = await ensureDefaultChatAdopted(agent, session); - -- const db = await sessionDataService.tryOpenDatabase(session); -- const usages = [...(await db?.object.getTurnUsages() ?? new Map()).entries()]; -- db?.dispose(); +- const db = await sessionDataService.tryOpenDatabase(session); +- const usages = [...(await db?.object.getTurnUsages() ?? new Map()).entries()]; +- db?.dispose(); - -- assert.deepStrictEqual( -- { adopted, getSessionMetadataCalls: client.getSessionMetadataCalls, usages }, -- { adopted: { adopted: false, eligible: false, native: true, reason: 'alreadyNative' }, getSessionMetadataCalls: [], usages: [] }, -- ); -- } finally { -- await fs.rm(userHome.fsPath, { recursive: true, force: true }); -- await disposeAgent(agent); -- } +- assert.deepStrictEqual( +- { adopted, getSessionMetadataCalls: client.getSessionMetadataCalls, usages }, +- { adopted: { adopted: false, eligible: false, native: true, reason: 'alreadyNative' }, getSessionMetadataCalls: [], usages: [] }, +- ); - }); - - }); -}); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts deleted file mode 100644 -index 3d590951e18..00000000000 +index 1b9f1d49cfe..00000000000 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ /dev/null -@@ -1,33331 +0,0 @@ +@@ -1,34824 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -80095,7 +82065,7 @@ index 3d590951e18..00000000000 -import type { CopilotClient, CopilotSession, CurrentToolMetadata, PermissionHandler, PermissionMode, PermissionRequest, PermissionRequestResult, SessionEvent, SessionEventHandler, SessionEventPayload, SessionEventType, Tool, ToolResultObject, TypedSessionEventHandler } from '@github/copilot-sdk'; -import type { CCAModel } from '@vscode/copilot-api'; -import assert from 'assert'; --import { stub } from 'sinon'; +-import { spy, stub } from 'sinon'; -import { createHash } from 'crypto'; -import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'fs'; -import { tmpdir } from 'os'; @@ -80106,7 +82076,9 @@ index 3d590951e18..00000000000 -import { isCancellationError } from '../../../../base/common/errors.js'; -import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; --import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +-import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +-import { Schemas } from '../../../../base/common/network.js'; +-import { StopWatch } from '../../../../base/common/stopwatch.js'; -import { join, sep } from '../../../../base/common/path.js'; -import { hasKey } from '../../../../base/common/types.js'; -import { URI } from '../../../../base/common/uri.js'; @@ -80151,11 +82123,13 @@ index 3d590951e18..00000000000 -import { IDiffComputeService } from '../../common/diffComputeService.js'; -import { ISessionDataService, type ISessionDatabase } from '../../common/sessionDataService.js'; -import { buildNonPtyShellTerminalUri } from '../../common/nonPtyShellTerminalUri.js'; +-import { subagentChatUri } from '../../common/agentHostChatMinting.js'; -import { buildMcpResourceUri, buildToolResultResourceUri } from '../../common/mcpResourceUri.js'; -import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; -import { ActionType, isChatAction, type ChatDeltaAction, type ChatErrorAction, type ChatInputRequestedAction, type ChatResponsePartAction, type ChatToolCallCompleteAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallStartAction, type ChatTurnCompleteAction, type ChatUsageAction, type SessionAction, type StateAction } from '../../common/state/sessionActions.js'; +-import { chatToolCallComplete, chatToolCallStart, chatTurnComplete, chatTurnStarted } from '../common/chatActionBuilders.js'; -import { TestChatOwnerRef } from './agentChatContextTestUtils.js'; --import { MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, createChatState, createSessionState, getInlineToolInput, mergeSessionWithDefaultChat, readSessionPromptCacheState, readUsageInfoMeta, SessionStatus, withSessionPromptCacheState, type ToolResultContent, type ToolResultTerminalContent, type Turn, type UsageInfoMeta } from '../../common/state/sessionState.js'; +-import { MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, createChatState, createSessionState, getInlineToolInput, mergeSessionWithDefaultChat, readSessionPromptCacheState, readUsageInfoMeta, SessionStatus, withSessionPromptCacheState, type ToolResultContent, type ToolResultTerminalContent, type Turn, type UsageInfoMeta, type MessageAttachment } from '../../common/state/sessionState.js'; -import { chatReducer, sessionReducer } from '../../common/state/sessionReducers.js'; -import { BackgroundWorkKind, TerminalClaimKind, type BackgroundWork, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; -import { canStopBackgroundWork, toStoppableBackgroundWorkMeta } from '../../common/meta/agentHostBackgroundWorkStopMeta.js'; @@ -80182,7 +82156,7 @@ index 3d590951e18..00000000000 -import { IEditSurvivalReporterFactory } from '../../node/shared/editSurvivalReporter.js'; -import { IAgentEditAttribution, IAgentEditAttributionService, NullAgentEditAttributionService } from '../../common/fileEditAttribution.js'; -import { buildSandboxConfigForSdk, type SandboxConfig } from '../../node/copilot/sandboxConfigForSdk.js'; --import { ActiveClientToolSet } from '../../node/activeClientState.js'; +-import { ActiveClientToolSet, structuralToolsEqual } from '../../node/activeClientState.js'; -import { type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotClientToolConfiguration, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from '../../node/copilot/copilotSessionLauncher.js'; -import type { ICopilotConnectorServers } from '../../node/copilot/copilotConnectors.js'; -import type { ILocalModelProvider } from '../../node/copilot/localModelBridge/localModelProvider.js'; @@ -80197,6 +82171,7 @@ index 3d590951e18..00000000000 -import { AgentHostTurnTracker } from '../../node/agentHostTurnTracker.js'; -import { MockAgent } from './mockAgent.js'; -import { IAgentHostCustomizationEnablementService, type CustomizationEnablementResolution, type ICustomizationEnablementTarget } from '../../node/agentHostCustomizationEnablementService.js'; +-import { readCustomizationDiscoveryMeta, withCustomizationDiscoveryMeta } from '../../common/meta/customizationDiscoveryMeta.js'; -import { AgentHostPromptCache, IAgentHostPromptCache } from '../../node/agentHostPromptCache.js'; -import { AgentHostStorageService, IAgentHostStorageService } from '../../node/agentHostStorageService.js'; -import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; @@ -80209,6 +82184,7 @@ index 3d590951e18..00000000000 -import { setAdvertisedAutoModeTiers } from '../../common/autoModeTiers.js'; -import { TestExperimentTriggerTelemetryService } from '../../../telemetry/test/common/experimentTriggerTestUtils.js'; -import { SEMANTIC_SEARCH_TOOL_NAME } from '../../common/semanticSearchConstants.js'; +-import { withAgentToolOrigin } from '../../common/meta/agentToolDefinitionMeta.js'; -import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../common/toolSearchConstants.js'; -import { AgentHostSandboxConfigKey, AgentHostSandboxKey } from '../../common/sandboxConfigSchema.js'; -import { SAVE_SANDBOX_PATH } from '../../common/sandboxPathGrant.js'; @@ -81351,7 +83327,7 @@ index 3d590951e18..00000000000 - readonly toolTitle: string; - readonly readOnly?: boolean; - readonly permissionRecommendation?: Extract['permissionRecommendation']; --}); +-} | Extract); - -function toPermissionRequest(request: TestPermissionRequest): PermissionRequest { - switch (request.kind) { @@ -81374,6 +83350,8 @@ index 3d590951e18..00000000000 - return { toolDescription: '', toolName: '', ...request }; - case 'mcp': - return { readOnly: false, ...request }; +- case 'hook': +- return request; - } -} - @@ -81398,7 +83376,7 @@ index 3d590951e18..00000000000 -type TestCopilotSessionRuntime = Omit & { - handlePermissionRequest(request: TestPermissionRequest, invocation?: Parameters[1]): Promise; - handleAttributedPermissionRequest(request: TestPermissionRequest, invocation?: Parameters[1]): ReturnType; -- createClientSdkTools(toolSearchActive?: boolean, hostToolNames?: ReadonlySet): ReturnType; +- createClientSdkTools(toolSearchActive?: boolean, hostToolNames?: ReadonlySet, withheldToolNames?: ReadonlySet): ReturnType; -}; - -interface ITestSessionConfiguration { @@ -81446,6 +83424,7 @@ index 3d590951e18..00000000000 - disabledRootMcpServers?: readonly string[]; - controlPlaneRpcTimeoutMs?: number; - connectorRefreshTimeoutMs?: number; +- connectorOwnershipLookupTimeoutMs?: number; - onConnectorRefreshRetryable?: () => void; - sessionCustomizations?: () => readonly Customization[]; - resolveCustomizationEnablement?: (target: ICustomizationEnablementTarget) => CustomizationEnablementResolution; @@ -81526,7 +83505,7 @@ index 3d590951e18..00000000000 - onDidReportModelConfiguration: Event; - chatConfigUpdates: ReadonlyArray<{ chat: string; patch: Record }>; - rootConfigUpdates: ReadonlyArray>; -- sandboxResults: Array; +- sandboxResults: Array; - dispatchSessionAction: (action: StateAction) => void; - storageService: IAgentHostStorageService; -} & ITestSessionConfiguration> { @@ -81689,7 +83668,7 @@ index 3d590951e18..00000000000 - const modelConfigurationReported = new DeferredPromise(); - const modelConfigurationReports = disposables.add(new Emitter()); - const chatConfigUpdates: Array<{ chat: string; patch: Record }> = []; -- const sandboxResults: Array = []; +- const sandboxResults: Array = []; - let sandboxEnabled: boolean | undefined; - const configValues = options?.configValues ?? {}; - const rootValues: Record = { ...(options?.rootValues ?? {}) }; @@ -81722,6 +83701,9 @@ index 3d590951e18..00000000000 - getChatOwnConfigValues: target => target.chat === chatChannelUri.toString() ? { ...options?.chatConfigValues } : {}, - updateChatConfig: (target, patch) => { chatConfigUpdates.push({ chat: target.chat, patch }); }, - seedChatConfig: () => ({ values: {}, dispose: () => { } }), +- completeChatConfigs: () => { }, +- getChatPersistedConfigValues: () => undefined, +- getCompleteChatConfigValues: (_session: string, own: Readonly>) => ({ ...own }), - getEffectiveWorkingDirectories: () => undefined, - // Own values only, like the real service; `inheritedConfigValues` is - // visible through `getEffectiveValue` alone so tests can prove a @@ -81734,6 +83716,7 @@ index 3d590951e18..00000000000 - getChatSandboxEnabled: () => sandboxEnabled, - getChatAttachmentScopes: target => [target.session, ...options?.attachmentScopes ?? []], - setChatSandboxEnabled: (_target, enabled, error) => { sandboxEnabled = enabled; sandboxResults.push(error?.message ?? enabled); }, +- reportChatSandboxConflict: (_target, message) => { sandboxResults.push(message); }, - rejectChatSandboxChange: (_target, _values, _origin, message) => { sandboxResults.push(message); }, - updateSessionConfig: (session, patch) => { - if (options?.trackModelConfigurationUpdates || !Object.hasOwn(patch, agentModelConfigurationReportKey)) { @@ -81801,6 +83784,7 @@ index 3d590951e18..00000000000 - _serviceBrand: undefined, - onDidChange: customizationEnablementEmitter.event, - initializeSession: session => options?.initializeEnablementSession?.(session) ?? Promise.resolve(), +- adoptMcpServerOwnership: () => { }, - getWorkingDirectoryState: () => ({ kind: 'workspaceless' }), - resolve: (_session: string, target: ICustomizationEnablementTarget) => { - if (options?.resolveCustomizationEnablement) { @@ -81877,6 +83861,7 @@ index 3d590951e18..00000000000 - realpath: options?.realpath, - controlPlaneRpcTimeoutMs: options?.controlPlaneRpcTimeoutMs, - connectorRefreshTimeoutMs: options?.connectorRefreshTimeoutMs, +- connectorOwnershipLookupTimeoutMs: options?.connectorOwnershipLookupTimeoutMs, - onConnectorRefreshRetryable: options?.onConnectorRefreshRetryable, - canvasHandoff: options?.canvasHandoff, - subagentTaskCompletionDelay: options?.subagentTaskCompletionDelay ?? 0, @@ -81899,7 +83884,7 @@ index 3d590951e18..00000000000 - return result.kind === 'attributed' ? result.result : result; - }, - handleAttributedPermissionRequest: (request, invocation) => sdkRuntime.handlePermissionRequest(toPermissionRequest(request), invocation), -- createClientSdkTools: (toolSearchActive, hostToolNames) => sdkRuntime.createClientSdkTools(toolSearchActive ?? false, hostToolNames), +- createClientSdkTools: (toolSearchActive, hostToolNames, withheldToolNames) => sdkRuntime.createClientSdkTools(toolSearchActive ?? false, hostToolNames, withheldToolNames), - }; - - return { @@ -81997,7 +83982,7 @@ index 3d590951e18..00000000000 - const disposables = new DisposableStore(); - - teardown(() => disposables.clear()); -- ensureNoDisposablesAreLeakedInTestSuite(); +- const store = ensureNoDisposablesAreLeakedInTestSuite(); - - suite('image generation preference', () => { - for (const resume of [false, true]) { @@ -83719,32 +85704,29 @@ index 3d590951e18..00000000000 - const outputDirectory = URI.file(join(testRoot, 'output')); - const retryDirectory = join(testRoot, 'retry'); - mkdirSync(retryDirectory); -- try { -- mockSession.collectLogsResults.push({ -- kind: 'directory', -- path: retryDirectory, -- entries: [], -- skippedEntries: [{ bundlePath: 'events.jsonl', path: join(testRoot, 'events.jsonl'), reason: 'not found' }], -- }, { -- kind: 'directory', -- path: outputDirectory.fsPath, -- entries: [{ bundlePath: 'events.jsonl', source: 'events', sizeBytes: 42 }], -- }); +- store.add(toDisposable(() => rmSync(testRoot, { recursive: true, force: true }))); +- mockSession.collectLogsResults.push({ +- kind: 'directory', +- path: retryDirectory, +- entries: [], +- skippedEntries: [{ bundlePath: 'events.jsonl', path: join(testRoot, 'events.jsonl'), reason: 'not found' }], +- }, { +- kind: 'directory', +- path: outputDirectory.fsPath, +- entries: [{ bundlePath: 'events.jsonl', source: 'events', sizeBytes: 42 }], +- }); - -- const included = await session.collectDebugLogs(outputDirectory, true); +- const included = await session.collectDebugLogs(outputDirectory, true); - -- assert.deepStrictEqual({ -- included, -- callCount: mockSession.collectLogsCalls.length, -- retryDirectoryExists: existsSync(retryDirectory), -- }, { -- included: true, -- callCount: 2, -- retryDirectoryExists: false, -- }); -- } finally { -- rmSync(testRoot, { recursive: true, force: true }); -- } +- assert.deepStrictEqual({ +- included, +- callCount: mockSession.collectLogsCalls.length, +- retryDirectoryExists: existsSync(retryDirectory), +- }, { +- included: true, +- callCount: 2, +- retryDirectoryExists: false, +- }); - }); - - test('does not retry a permanently skipped SDK event log', async () => { @@ -85662,6 +87644,59 @@ index 3d590951e18..00000000000 - }); - } - +- test('restores saved shell output for parent and subagent chats with one batched lookup per history read', async () => { +- const database = new TestSessionDatabase(); +- await database.createTurn('stored-turn'); +- await database.storeTerminalOutput('stored-turn', 'parent-saved', VSBuffer.fromString('parent output').buffer); +- await database.storeTerminalOutput('stored-turn', 'child-saved', VSBuffer.fromString('child output').buffer); +- const batchedLookups = spy(database, 'getTerminalOutputSizes'); +- const singleLookups = spy(database, 'getTerminalOutputSize'); +- disposables.add({ dispose: () => { batchedLookups.restore(); singleLookups.restore(); } }); +- const unsaved = Array.from({ length: 500 }, (_, index) => `unsaved-${index}`); +- const { session } = await createAgentSession(disposables, { +- resume: true, +- sessionDatabase: database, +- configureMockSession: mock => { +- mock.messages = toSessionEvents([ +- { type: 'user.message', id: 'request', data: { content: 'run it' } }, +- { type: 'tool.execution_start', data: { toolCallId: 'parent-saved', toolName: 'bash', arguments: { command: 'build' } } }, +- { type: 'tool.execution_complete', data: { toolCallId: 'parent-saved', success: true, result: { content: 'Saved output was temporary' } } }, +- ...unsaved.flatMap(toolCallId => [ +- { type: 'tool.execution_start', data: { toolCallId, toolName: 'bash', arguments: { command: 'echo' } } }, +- { type: 'tool.execution_complete', data: { toolCallId, success: true, result: { content: 'inline output' } } }, +- ]), +- { type: 'tool.execution_start', data: { toolCallId: 'task', toolName: 'task', arguments: { description: 'Explore', agent_type: 'explore' } } }, +- { type: 'subagent.started', agentId: 'child', data: { toolCallId: 'task', agentName: 'explore', agentDisplayName: 'Explorer', agentDescription: 'Explore' } }, +- { type: 'tool.execution_start', agentId: 'child', data: { toolCallId: 'child-saved', toolName: 'bash', arguments: { command: 'test' } } }, +- { type: 'tool.execution_complete', agentId: 'child', data: { toolCallId: 'child-saved', success: true, result: { content: 'Saved output was temporary' } } }, +- { type: 'tool.execution_complete', data: { toolCallId: 'task', success: true } }, +- ]); +- }, +- }); +- const terminalResources = (turns: readonly Turn[]) => turns.flatMap(turn => turn.responseParts).flatMap(part => +- part.kind === ResponsePartKind.ToolCall && part.toolCall.status === ToolCallStatus.Completed +- ? (part.toolCall.content ?? []).flatMap(content => content.type === ToolResultContentType.Terminal ? [content.resource] : []) +- : []); +- +- const parent = terminalResources(await session.getMessages()); +- const child = terminalResources(await session.getSubagentMessages('task')); +- +- const childChat = URI.parse(subagentChatUri('shaped', session.ownerSessionUri.toString(), session.chatChannelUri.toString(), 'task')); +- assert.deepStrictEqual({ +- parent, +- child, +- batchedLookupSizes: batchedLookups.args.map(([toolCallIds]) => toolCallIds.length), +- childLookedUp: batchedLookups.lastCall.args[0].includes('child-saved'), +- singleLookups: singleLookups.callCount, +- }, { +- parent: [buildNonPtyShellTerminalUri(session.resourceUri, session.ownerSessionUri, session.chatChannelUri, 'parent-saved')], +- child: [buildNonPtyShellTerminalUri(session.resourceUri, session.ownerSessionUri, childChat, 'child-saved')], +- batchedLookupSizes: [unsaved.length + 1, unsaved.length + 2], +- childLookedUp: true, +- singleLookups: 0, +- }); +- }); +- - for (const fail of [false, true]) { - test(`history reconstruction owns its database reference through session disposal (failure: ${fail})`, async () => { - const fileService = disposables.add(new FileService(new NullLogService())); @@ -85696,7 +87731,7 @@ index 3d590951e18..00000000000 - }); - disposables.add({ dispose: () => resources.restore() }); - if (fail) { -- const output = stub(database, 'getTerminalOutputSize').rejects(failure); +- const output = stub(database, 'getTerminalOutputSizes').rejects(failure); - disposables.add({ dispose: () => output.restore() }); - } - loading = session.getMessages(); @@ -86389,6 +88424,23 @@ index 3d590951e18..00000000000 - ]); - }); - +- test('records the turn that owned each compaction attempt for forwarded compaction telemetry', async () => { +- const { session, mockSession } = await createAgentSession(disposables); +- session.resetTurnState('compaction-turn'); +- mockSession.fire('session.compaction_start', { compactionAttemptId: 'attempt-in-turn' } as SessionEventPayload<'session.compaction_start'>['data']); +- session.discardActiveTurn(); +- // Completion is recorded under the turn that was active when the attempt started. +- mockSession.fire('session.compaction_complete', { success: true, compactionAttemptId: 'attempt-in-turn' } as SessionEventPayload<'session.compaction_complete'>['data']); +- mockSession.fire('session.compaction_start', { compactionAttemptId: 'attempt-between-turns' } as SessionEventPayload<'session.compaction_start'>['data']); +- mockSession.fire('session.compaction_complete', { success: false, compactionAttemptId: 'attempt-without-start' } as SessionEventPayload<'session.compaction_complete'>['data']); +- +- assert.deepStrictEqual(await Promise.all(['attempt-in-turn', 'attempt-between-turns', 'attempt-without-start'].map(id => session.compactionTurnCorrelation.resolve(id))), [ +- { turnId: 'compaction-turn', outcome: 'mappingAvailable' }, +- { turnId: undefined, outcome: 'noOwningTurn' }, +- { turnId: undefined, outcome: 'noOwningTurn' }, +- ]); +- }); +- - test('model-call diagnostics preserve SDK timings and never infer the active turn', async () => { - const { session, mockSession, signals } = await createAgentSession(disposables); - session.modelCallTurnCorrelation.record('old-call', 'old-turn'); @@ -87731,6 +89783,192 @@ index 3d590951e18..00000000000 - }); - }); - +- suite('MCP show command', () => { +- for (const source of [undefined, 'user', 'workspace', 'builtin', 'managed'] as const) { +- test(`shows ${source} server details without invoking the runtime command or starting servers`, async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables, { +- rootValues: { [AgentHostMcpToolRoutingEnabledConfigKey]: false }, +- copilotHome: '/config', +- getUserMcpServers: async () => ({ +- 'dbt-mcp': { command: './user-mcp', args: ['--user'] }, +- }), +- clientSnapshot: { +- discoveredAgents: [], additionalRootInstructions: [], tools: [], plugins: [], +- mcpServers: { 'dbt-mcp': { type: McpServerType.LOCAL, command: './workspace-mcp', args: ['--workspace'] } }, +- }, +- configureMockSession: mock => { +- mock.commandListResult = { commands: [{ name: 'mcp', kind: 'builtin', description: 'Manage MCP servers', allowDuringAgentExecution: false }] }; +- mock.mcpListResult = { servers: [{ name: 'dbt-mcp', source, status: 'connected' }] }; +- mock.mcpListToolsResult = { tools: [{ name: 'query' }] }; +- }, +- }); +- await session.send('/mcp show dbt-mcp', undefined, 'turn-mcp-show'); +- const actions = getActions(signals); +- const output = actions.filter(a => a.type === ActionType.ChatResponsePart) +- .map(a => a.part.kind === ResponsePartKind.Markdown ? a.part.content : '').join(''); +- assert.deepStrictEqual({ +- invocations: mockSession.commandInvokeCalls, +- sends: mockSession.sendRequests, +- materializingLists: mockSession.mcpMaterializingListCalls, +- starts: mockSession.mcpStartServerCalls, +- tools: mockSession.mcpListToolsCalls, +- completed: actions.filter(a => a.type === ActionType.ChatTurnComplete).map(a => a.turnId), +- showsDetails: output.startsWith('# MCP server: `dbt-mcp`\n'), +- showsCorrectConfiguration: source === 'builtin' || source === 'managed' +- ? output.includes('The runtime does not expose this server\'s launch configuration') +- : output.includes(`"command": "./${source ?? 'user'}-mcp"`), +- showsSourceFile: (source !== 'user' && source !== undefined) || output.includes('file:///config/mcp-config.json'), +- showsSource: output.includes(`- Source: \`${source ?? 'user'}\``), +- showsTools: output.includes('- `query`'), +- }, { +- invocations: [], sends: [], materializingLists: 0, starts: [], +- tools: [{ serverName: 'dbt-mcp' }], completed: ['turn-mcp-show'], +- showsDetails: true, showsCorrectConfiguration: true, showsSourceFile: true, showsSource: true, showsTools: true, +- }); +- }); +- } +- +- for (const configuredEnabled of [true, false]) { +- test(`shows runtime enablement after toggling configured enablement ${configuredEnabled}`, async () => { +- const status = configuredEnabled ? 'disabled' : 'connected'; +- const { session, mockSession, signals } = await createAgentSession(disposables, { +- rootValues: { [AgentHostMcpToolRoutingEnabledConfigKey]: false }, +- configureMockSession: mock => { +- mock.commandListResult = { commands: [{ name: 'mcp', kind: 'builtin', description: 'Manage MCP servers', allowDuringAgentExecution: false }] }; +- mock.mcpConfiguredListResult = { servers: [{ name: 'toggled', enabled: configuredEnabled, live: { status } }] }; +- mock.mcpListToolsResult = { tools: [{ name: 'query' }] }; +- }, +- }); +- await session.send('/mcp show toggled', undefined, 'turn-mcp-toggled'); +- const output = getActions(signals).filter(a => a.type === ActionType.ChatResponsePart) +- .map(a => a.part.kind === ResponsePartKind.Markdown ? a.part.content : '').join(''); +- assert.deepStrictEqual({ +- invocations: mockSession.commandInvokeCalls, +- tools: mockSession.mcpListToolsCalls, +- showsLiveEnabled: output.includes(`- Enabled: ${configuredEnabled ? 'No' : 'Yes'}`), +- showsTools: output.includes('- `query`'), +- }, { +- invocations: [], +- tools: configuredEnabled ? [] : [{ serverName: 'toggled' }], +- showsLiveEnabled: true, +- showsTools: !configuredEnabled, +- }); +- }); +- } +- +- test('preserves inventory details and logs a failed user configuration lookup', async () => { +- const logService = new CapturingLogService(); +- const error = new Error('Configuration unavailable'); +- let failConfiguration = false; +- const { session, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { +- logService, +- copilotHome: '/config', +- rootValues: { [AgentHostMcpToolRoutingEnabledConfigKey]: false }, +- getUserMcpServers: async () => { +- if (failConfiguration) { +- throw error; +- } +- return { user: { command: './user-mcp' } }; +- }, +- configureMockSession: mock => { +- mock.commandListResult = { commands: [{ name: 'mcp', kind: 'builtin', description: 'Manage MCP servers', allowDuringAgentExecution: false }] }; +- mock.mcpListResult = { servers: [{ name: 'user', source: 'user', status: 'connected' }] }; +- mock.mcpListToolsResult = { tools: [{ name: 'query' }] }; +- }, +- }); +- await waitForSignal(signal => isAction(signal, ActionType.SessionCustomizationUpdated)); +- failConfiguration = true; +- await session.send('/mcp show user', undefined, 'turn-mcp-user-error'); +- assert.deepStrictEqual({ +- invocations: mockSession.commandInvokeCalls, +- tools: mockSession.mcpListToolsCalls, +- warnings: logService.warnings.filter(warning => warning.args.includes(error)), +- responses: getActions(signals).filter(a => a.type === ActionType.ChatResponsePart) +- .map(a => a.part.kind === ResponsePartKind.Markdown ? a.part.content : ''), +- completed: getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete).map(a => a.turnId), +- }, { +- invocations: [], tools: [{ serverName: 'user' }], +- warnings: [{ message: '[Copilot:test-session-1] Failed to read launch configuration for MCP server \'user\'', args: [error] }], +- responses: [[ +- '# MCP server: `user`', '', +- '- Status: `connected`', '- Enabled: Yes', '- Source: `user`', +- '- Configuration file: `file:///config/mcp-config.json`', '', +- '## Launch configuration', '', +- 'Could not read the launch configuration.', '', +- '```text', 'Configuration unavailable', '```', '', +- '## Available tools', '', +- '- `query`', +- ].join('\n')], +- completed: ['turn-mcp-user-error'], +- }); +- }); +- +- test('uses a known plugin declaration and its original source file', async () => { +- const pluginUri = URI.file('/plugins/tools'); +- const sourceUri = URI.file('/plugins/tools/.mcp.json'); +- const snapshot = createPluginSnapshot(pluginUri); +- const { session, mockSession, signals } = await createAgentSession(disposables, { +- rootValues: { [AgentHostMcpToolRoutingEnabledConfigKey]: false }, +- clientSnapshot: { +- ...snapshot, +- plugins: [{ +- ...snapshot.plugins[0], sourceUri: pluginUri, disabledMcpServers: ['plugin-server'], +- mcpServers: [{ +- name: 'plugin-server', uri: sourceUri, sdkRegistration: 'pluginDiscovery', +- configuration: { type: McpServerType.LOCAL, command: './plugin-mcp', cwd: '/plugin-cwd' }, +- customization: { type: CustomizationType.McpServer, id: sourceUri.toString(), uri: sourceUri.toString(), name: 'plugin-server', state: { kind: McpServerStatus.Stopped } }, +- }], +- }], +- }, +- configureMockSession: mock => { +- mock.commandListResult = { commands: [{ name: 'mcp', kind: 'builtin', description: 'Manage MCP servers', allowDuringAgentExecution: false }] }; +- mock.mcpConfiguredListResult = { servers: [{ name: 'plugin-server', enabled: false, live: { status: 'disabled' }, source: 'plugin', sourcePlugin: 'tools' }] }; +- }, +- }); +- await session.send('/mcp show plugin-server', undefined, 'turn-mcp-plugin'); +- const output = getActions(signals).filter(a => a.type === ActionType.ChatResponsePart) +- .map(a => a.part.kind === ResponsePartKind.Markdown ? a.part.content : '').join(''); +- assert.deepStrictEqual({ +- invocations: mockSession.commandInvokeCalls, +- tools: mockSession.mcpListToolsCalls, +- hasPlugin: output.includes('- Plugin: `tools`'), +- hasSource: output.includes(`- Configuration file: \`${sourceUri.toString()}\``), +- hasCommand: output.includes('"command": "./plugin-mcp"'), +- hasCwd: output.includes('"cwd": "/plugin-cwd"'), +- }, { invocations: [], tools: [], hasPlugin: true, hasSource: true, hasCommand: true, hasCwd: true }); +- }); +- +- test('shows disabled servers without requesting tools and reports unknown servers', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables, { +- configureMockSession: mock => { +- mock.commandListResult = { commands: [{ name: 'mcp', kind: 'builtin', description: 'Manage MCP servers', allowDuringAgentExecution: false }] }; +- mock.mcpConfiguredListResult = { servers: [{ name: 'disabled', enabled: false, live: { status: 'disabled' } }] }; +- }, +- }); +- await session.send('/mcp show disabled', undefined, 'turn-mcp-disabled'); +- await session.send('/mcp show missing', undefined, 'turn-mcp-missing'); +- assert.deepStrictEqual({ +- invocations: mockSession.commandInvokeCalls, +- tools: mockSession.mcpListToolsCalls, +- responses: getActions(signals).filter(a => a.type === ActionType.ChatResponsePart) +- .map(a => a.part.kind === ResponsePartKind.Markdown ? a.part.content : ''), +- }, { +- invocations: [], tools: [], +- responses: [ +- [ +- '# MCP server: `disabled`', '', +- '- Status: `disabled`', '- Enabled: No', '', +- '## Launch configuration', '', +- 'The runtime does not expose this server\'s launch configuration to the agent host.', '', +- '## Available tools', '', +- 'Tools are not loaded for this server.', +- ].join('\n'), +- 'MCP server "missing" is not configured in this session.', +- ], +- }); +- }); +- }); +- - suite('Chronicle commands', () => { - async function createChronicleSession() { - const context = await createAgentSession(disposables); @@ -89673,7 +91911,7 @@ index 3d590951e18..00000000000 - }); - - for (const phase of ['commentary', 'final_answer']) { -- test(`marks only an empty final answer as a section boundary without claiming the markdown scope (${phase})`, async () => { +- test(`retains empty final-answer notices without reusing reasoning across markdown (${phase})`, async () => { - const { session, mockSession, signals } = await createAgentSession(disposables); - session.resetTurnState('turn-parent'); - mockSession.fire('assistant.turn_start', { turnId: 'sdk-parent' }); @@ -89694,11 +91932,12 @@ index 3d590951e18..00000000000 - } - return [{ kind: part.kind, content: part.kind === ResponsePartKind.Markdown || part.kind === ResponsePartKind.Reasoning ? part.content : undefined }]; - } -- return action.type === ActionType.ChatDelta || action.type === ActionType.ChatReasoning ? [{ kind: action.type, content: action.content }] : []; +- return action.type === ActionType.ChatDelta || (action.type === ActionType.ChatReasoning && action.content) ? [{ kind: action.type, content: action.content }] : []; - }); - - assert.deepStrictEqual({ - parts, +- timingUpdates: getActions(signals).filter(action => action.type === ActionType.ChatReasoning && action.duration !== undefined).length, - parentCompleted: getActions(signals).some(action => action.type === ActionType.ChatTurnComplete), - childCompleted: signals.some(signal => signal.kind === 'subagent_completed'), - }, { @@ -89712,8 +91951,9 @@ index 3d590951e18..00000000000 - : [ - { kind: ResponsePartKind.Reasoning, content: 'Assessing' }, - { kind: ResponsePartKind.Markdown, content: 'Later text' }, -- { kind: ActionType.ChatReasoning, content: 'Later reasoning' }, +- { kind: ResponsePartKind.Reasoning, content: 'Later reasoning' }, - ], +- timingUpdates: 1, - parentCompleted: false, - childCompleted: false, - }); @@ -91826,6 +94066,38 @@ index 3d590951e18..00000000000 - assert.deepStrictEqual(actual, cases.map(({ toolCallId, expected }) => `${toolCallId}=${expected}`)); - }); - +- test('hook terminal confirmations show the command and reason without becoming shell permissions', async () => { +- const { session, runtime, mockSession, waitForSignal } = await createAgentSession(disposables); +- const toolCallId = 'tc-hook-shell'; +- mockSession.fire('tool.execution_start', { +- toolCallId, toolName: 'bash', arguments: { command: 'echo old-command' }, +- }); +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'hook', +- toolCallId, +- toolName: 'bash', +- toolArgs: { command: 'echo review-me' }, +- hookMessage: 'Review this command before running it.', +- }); +- const signal = await waitForSignal(s => s.kind === 'pending_confirmation'); +- assert.ok(signal.kind === 'pending_confirmation'); +- assert.deepStrictEqual({ +- title: signal.state.confirmationTitle, +- message: signal.state.invocationMessage, +- input: getInlineToolInput(signal.state.toolInput), +- permissionKind: signal.permissionKind, +- toolKind: readToolCallMeta(signal.state).toolKind, +- }, { +- title: 'Run in terminal?', +- message: 'Review this command before running it.', +- input: JSON.stringify({ command: 'echo review-me' }, null, 2), +- permissionKind: 'hook', +- toolKind: 'terminal', +- }); +- session.respondToPermissionRequest(toolCallId, false); +- assert.strictEqual((await resultPromise).kind, 'reject'); +- }); +- - test('custom terminal permissions are normalized for shell auto-approval', async () => { - const cases = [ - { toolCallId: 'tc-custom-bash', toolName: 'bash', command: 'git status' }, @@ -93183,7 +95455,7 @@ index 3d590951e18..00000000000 - } - configuration.setConfigValue(SessionConfigKey.AutoApprove, 'default'); - configuration.fireSessionConfigChange({ [SessionConfigKey.AutoApprove]: 'default' }); -- await runtime.applyLaunchSandbox(sdkSession as unknown as CopilotSession); +- await runtime.applyLaunchSandbox(sdkSession as unknown as CopilotSession, false); - launchUpdates = [...sdkSession.sandboxConfigUpdates]; - }, - configureMockSession: value => { sdkSession = value; }, @@ -93238,7 +95510,7 @@ index 3d590951e18..00000000000 - }; - }, - duringLaunch: async (runtime, configuration) => { -- const launch = runtime.applyLaunchSandbox(sdkSession as unknown as CopilotSession); +- const launch = runtime.applyLaunchSandbox(sdkSession as unknown as CopilotSession, false); - await applying.p; - try { - if (source === 'root') { @@ -93451,7 +95723,7 @@ index 3d590951e18..00000000000 - } - - for (const activeTurn of [false, true]) { -- test(`reports explicit sandbox conflicts with active turn ${activeTurn}`, async () => { +- test(`reports explicit and subsequent background sandbox conflicts with active turn ${activeTurn}`, async () => { - const logService = new CapturingLogService(); - const { session, mockSession, setConfigValue, fireSessionConfigChange, sandboxResults } = await createAgentSession(disposables, { logService }); - setConfigValue(SessionConfigKey.SandboxEnabled, 'on'); @@ -93478,7 +95750,7 @@ index 3d590951e18..00000000000 - errors: logService.errors, - }, { - hadActiveTurn: activeTurn, -- sandboxResults: [true, 'Managed sandbox conflict'], +- sandboxResults: [true, 'Managed sandbox conflict', 'Managed sandbox conflict'], - abortCalls: activeTurn ? 1 : 0, - sendCount: 1, - rejectedUpdates: [{ enabled: false }, { enabled: false }], @@ -93491,7 +95763,7 @@ index 3d590951e18..00000000000 - - mockSession.sandboxConfigUpdateError = undefined; - await session.send('retry', undefined, 'retry-turn'); -- assert.deepStrictEqual(sandboxResults, [true, 'Managed sandbox conflict', false]); +- assert.deepStrictEqual(sandboxResults, [true, 'Managed sandbox conflict', 'Managed sandbox conflict', false]); - }); - } - @@ -93886,11 +96158,69 @@ index 3d590951e18..00000000000 - }); - - for (const platform of ['linux', 'darwin', 'win32'] as const) { +- for (const resume of [false, true]) { +- test(`sandbox launch and first turn: grants read-only access to applied plugin resources on ${platform} (resume=${resume})`, async () => { +- const pluginDir = URI.file('/extensions/fixture extension/plugin'); +- const resourceDir = URI.file('/plugins/projected'); +- const snapshot = createPluginSnapshot(pluginDir, resourceDir, URI.from({ scheme: Schemas.vscodeRemote, authority: 'other-host', path: '/plugins/remote' })); +- const { session, runtime, mockSession } = await createAgentSession(disposables, { +- platform, +- resume, +- clientSnapshot: { +- ...snapshot, +- plugins: snapshot.plugins.map((plugin, index) => index === 1 ? { ...plugin, pluginDir: undefined, resourceDir } : plugin), +- }, +- rootValues: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On } }, +- }); +- const launchConfig = runtime.getLaunchSandboxConfig(resume); +- await session.send('hello', undefined, 'turn-1'); +- +- const native = (path: string) => platform === 'win32' ? path.replace(/\//g, '\\') : path; +- assert.deepStrictEqual({ +- launchFilesystem: launchConfig?.userPolicy?.filesystem, +- firstTurnConfig: mockSession.sandboxConfigUpdates.at(-1), +- }, { +- launchFilesystem: { readonlyPaths: [TEST_SESSION_ATTACHMENTS_DIR, pluginDir.fsPath, resourceDir.fsPath].map(native) }, +- firstTurnConfig: launchConfig, +- }); +- }); +- } +- +- test(`per-request sandbox: preserves denies and write grants for applied plugin directories on ${platform}`, async () => { +- const deniedPlugin = URI.file('/plugins/denied'); +- const writablePlugin = URI.file('/plugins/writable'); +- const readonlyPlugin = URI.file('/plugins/readonly'); +- const { session, mockSession } = await createAgentSession(disposables, { +- platform, +- clientSnapshot: createPluginSnapshot(deniedPlugin, writablePlugin, readonlyPlugin, readonlyPlugin), +- rootValues: { +- [AgentHostSandboxConfigKey.Sandbox]: { +- [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On, +- [AgentHostSandboxKey.UserConfiguredPaths]: { +- deniedPaths: [deniedPlugin.fsPath], +- readwritePaths: [writablePlugin.fsPath], +- readonlyPaths: [readonlyPlugin.fsPath], +- }, +- }, +- }, +- }); +- await session.send('hello', undefined, 'turn-1'); +- +- const native = (path: string) => platform === 'win32' ? path.replace(/\//g, '\\') : path; +- assert.deepStrictEqual((mockSession.sandboxConfigUpdates.at(-1) as SandboxConfig).userPolicy?.filesystem, { +- deniedPaths: [native(deniedPlugin.fsPath)], +- readwritePaths: [native(writablePlugin.fsPath)], +- readonlyPaths: [readonlyPlugin.fsPath, TEST_SESSION_ATTACHMENTS_DIR].map(native), +- }); +- }); +- - test(`per-request sandbox: skips extra read grants under managed readonly paths on ${platform}`, async () => { - const sandboxPolicy: { enabled: boolean; readonlyPaths?: string[] } = { enabled: true }; +- const pluginDir = URI.file('/plugins/active'); - const { session, mockSession } = await createAgentSession(disposables, { - platform, - sandboxPolicy, +- clientSnapshot: createPluginSnapshot(pluginDir), - rootValues: { - [AgentHostSandboxConfigKey.Sandbox]: { - [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On, @@ -93906,11 +96236,12 @@ index 3d590951e18..00000000000 - paths.push(applied.userPolicy?.filesystem?.readonlyPaths); - } - const attachmentsPath = platform === 'win32' ? TEST_SESSION_ATTACHMENTS_DIR.replace(/\//g, '\\') : TEST_SESSION_ATTACHMENTS_DIR; +- const pluginPath = platform === 'win32' ? pluginDir.fsPath.replace(/\//g, '\\') : pluginDir.fsPath; - assert.deepStrictEqual(paths, [ -- ['local-reference', attachmentsPath], +- ['local-reference', attachmentsPath, pluginPath], - ['managed-reference'], - undefined, -- ['local-reference', attachmentsPath], +- ['local-reference', attachmentsPath, pluginPath], - ]); - }); - @@ -93965,7 +96296,7 @@ index 3d590951e18..00000000000 - rootValues: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On } }, - }); - -- await runtime.applyLaunchSandbox(mockSession as unknown as CopilotSession); +- await runtime.applyLaunchSandbox(mockSession as unknown as CopilotSession, false); - const launchConfig = mockSession.sandboxConfigUpdates.at(-1) as SandboxConfig; - const updatesBeforeTurn = mockSession.sandboxConfigUpdates.length; - await session.send('hello', undefined, 'turn-1'); @@ -93993,7 +96324,7 @@ index 3d590951e18..00000000000 - rootValues: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On } }, - }); - -- await runtime.applyLaunchSandbox(mockSession as unknown as CopilotSession); +- await runtime.applyLaunchSandbox(mockSession as unknown as CopilotSession, false); - const updatesBeforeTurn = mockSession.sandboxConfigUpdates.length; - await session.send('hello', undefined, 'turn-1'); - @@ -94009,7 +96340,7 @@ index 3d590951e18..00000000000 - attachmentScopes, - rootValues: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On } }, - }); -- await runtime.applyLaunchSandbox(mockSession as unknown as CopilotSession); +- await runtime.applyLaunchSandbox(mockSession as unknown as CopilotSession, false); - attachmentScopes.push(resource.toString()); - await session.send('hello', undefined, 'turn-1'); - @@ -94020,6 +96351,118 @@ index 3d590951e18..00000000000 - ]]); - }); - +- test(`sandbox grants attached files and folders read-only before the turn or steering that attaches them on ${platform}`, async () => { +- const { session, mockSession } = await createAgentSession(disposables, { +- platform, +- workingDirectory: URI.file('/workspace'), +- rootValues: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On } }, +- }); +- // The session's platform decides the path syntax, not the OS running the test. +- const native = (uri: URI) => platform === 'win32' ? uri.path.replace(/\//g, '\\') : uri.path; +- const resource = (uri: URI, displayKind: string): MessageAttachment => ({ type: MessageAttachmentKind.Resource, uri: uri.toString(), label: uri.path.split('/').pop() ?? '', displayKind }); +- const readonlyPaths = () => (mockSession.sandboxConfigUpdates.at(-1) as SandboxConfig | undefined)?.userPolicy?.filesystem?.readonlyPaths; +- const desktopFile = URI.file('/home/u/Desktop/mockup.txt'); +- const folder = URI.file('/home/u/notes'); +- const steeringFile = URI.file('/home/u/Downloads/spec.md'); +- +- await session.send('summarize', [resource(desktopFile, 'file'), resource(folder, 'directory'), resource(URI.file('/workspace/src/a.ts'), 'file')], 'turn-1'); +- const afterFirstTurn = readonlyPaths(); +- const updatesAfterFirstTurn = mockSession.sandboxConfigUpdates.length; +- await session.send('again', [resource(desktopFile, 'file')], 'turn-2'); +- const updatesAfterRepeat = mockSession.sandboxConfigUpdates.length; +- session.resetTurnState('turn-3'); +- await session.sendSteering({ id: 'steer', message: { text: 'also this', origin: { kind: MessageKind.User }, attachments: [resource(steeringFile, 'file')] } }); +- +- const attachmentsDir = platform === 'win32' ? TEST_SESSION_ATTACHMENTS_DIR.replace(/\//g, '\\') : TEST_SESSION_ATTACHMENTS_DIR; +- assert.deepStrictEqual({ +- afterFirstTurn, +- repeatedAttachmentUpdates: updatesAfterRepeat - updatesAfterFirstTurn, +- afterSteering: readonlyPaths(), +- steeringUpdates: mockSession.sandboxConfigUpdates.length - updatesAfterRepeat, +- steeringRequest: (mockSession.sendRequests.at(-1) as { prompt?: string } | undefined)?.prompt, +- }, { +- afterFirstTurn: [attachmentsDir, native(desktopFile), native(folder)], +- repeatedAttachmentUpdates: 0, +- afterSteering: [attachmentsDir, native(desktopFile), native(folder), native(steeringFile)], +- steeringUpdates: 1, +- steeringRequest: 'also this', +- }); +- }); +- +- test(`sandbox holds a steering message until the grant for its attachment is applied, even when another message recorded it first, on ${platform}`, async () => { +- const { session, mockSession } = await createAgentSession(disposables, { +- platform, +- workingDirectory: URI.file('/workspace'), +- rootValues: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On } }, +- }); +- const desktopFile = URI.file('/home/u/Desktop/mockup.txt'); +- const attachments: MessageAttachment[] = [{ type: MessageAttachmentKind.Resource, uri: desktopFile.toString(), label: 'mockup.txt', displayKind: 'file' }]; +- const steering = (id: string) => ({ id, message: { text: id, origin: { kind: MessageKind.User as const }, attachments } }); +- session.resetTurnState('turn-1'); +- const updatesBefore = mockSession.sandboxConfigUpdates.length; +- const release = new DeferredPromise(); +- mockSession.sandboxConfigUpdateGate = release.p; +- +- const first = session.sendSteering(steering('first')); +- const second = session.sendSteering(steering('second')); +- await timeout(10); +- const sentWhileUpdatePending = mockSession.sendRequests.length; +- release.complete(); +- await Promise.all([first, second]); +- +- assert.deepStrictEqual({ +- sentWhileUpdatePending, +- updates: mockSession.sandboxConfigUpdates.length - updatesBefore, +- sent: mockSession.sendRequests.map(request => (request as { prompt?: string }).prompt), +- }, { sentWhileUpdatePending: 0, updates: 1, sent: ['first', 'second'] }); +- }); +- +- test(`sandbox does not grant an attached folder containing the working directory and tells the model why on ${platform}`, async () => { +- const { session, mockSession } = await createAgentSession(disposables, { +- platform, +- workingDirectory: URI.file('/home/u/demo'), +- rootValues: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On } }, +- }); +- const parent = URI.file('/home/u'); +- const nativePath = platform === 'win32' ? parent.path.replace(/\//g, '\\') : parent.path; +- +- await session.send('summarize', [{ type: MessageAttachmentKind.Resource, uri: parent.toString(), label: 'u', displayKind: 'directory' }], 'turn-1'); +- const additionalContext = session.handleUserPromptSubmitted()?.additionalContext ?? ''; +- +- assert.deepStrictEqual({ +- readonlyPaths: (mockSession.sandboxConfigUpdates.at(-1) as SandboxConfig).userPolicy?.filesystem?.readonlyPaths, +- namesFolder: additionalContext.includes(`- ${nativePath}`), +- explainsWritable: additionalContext.includes('contain the session\'s working directory or another writable folder'), +- }, { +- readonlyPaths: [platform === 'win32' ? TEST_SESSION_ATTACHMENTS_DIR.replace(/\//g, '\\') : TEST_SESSION_ATTACHMENTS_DIR], +- namesFolder: true, +- explainsWritable: true, +- }); +- }); +- +- test(`sandbox keeps attachments outside a managed read-only list unreadable and tells the model why on ${platform}`, async () => { +- const { session, mockSession } = await createAgentSession(disposables, { +- platform, +- sandboxPolicy: { enabled: true, readonlyPaths: ['/managed'] }, +- rootValues: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On } }, +- }); +- const desktopFile = URI.file('/home/u/Desktop/mockup.txt'); +- const nativePath = platform === 'win32' ? desktopFile.path.replace(/\//g, '\\') : desktopFile.path; +- +- await session.send('summarize', [{ type: MessageAttachmentKind.Resource, uri: desktopFile.toString(), label: 'mockup.txt', displayKind: 'file' }], 'turn-1'); +- const additionalContext = session.handleUserPromptSubmitted()?.additionalContext ?? ''; +- +- assert.deepStrictEqual({ +- readonlyPaths: (mockSession.sandboxConfigUpdates.at(-1) as SandboxConfig).userPolicy?.filesystem?.readonlyPaths, +- namesBlockedPath: additionalContext.includes(`- ${nativePath}`), +- discouragesPolicyChange: additionalContext.includes('Do not ask the user to change the sandbox policy'), +- }, { +- readonlyPaths: [platform === 'win32' ? '\\managed' : '/managed'], +- namesBlockedPath: true, +- discouragesPolicyChange: true, +- }); +- }); +- - test(`per-request sandbox: sends managed outbound denial and keeps local bypass false on ${platform}`, async () => { - const sandboxPolicy = { enabled: true, allowBypass: true, allowOutbound: false }; - const { session, mockSession } = await createAgentSession(disposables, { @@ -96350,7 +98793,7 @@ index 3d590951e18..00000000000 - ]); - }); - -- test('completion notifications split parent reasoning without splitting running subagent reasoning', async () => { +- test('completion notifications do not split contiguous parent or child reasoning', async () => { - const { session, mockSession, signals } = await createAgentSession(disposables); - session.resetTurnState('turn-active'); - mockSession.fire('subagent.started', { @@ -96381,21 +98824,23 @@ index 3d590951e18..00000000000 - } - return { kind: action.part.kind, scope: signal.parentToolCallId ?? 'parent', content: action.part.content }; - } -- return action.type === ActionType.ChatReasoning +- return action.type === ActionType.ChatReasoning && action.content - ? { kind: 'delta', scope: signal.parentToolCallId ?? 'parent', content: action.content } - : undefined; - }).filter(update => update !== undefined); - -- assert.deepStrictEqual({ updates, parentReasoningParts: reasoningIds.size }, { +- const timingScopes = signals.flatMap(signal => signal.kind === 'action' && signal.action.type === ActionType.ChatReasoning && signal.action.duration !== undefined ? [signal.parentToolCallId ?? 'parent'] : []); +- assert.deepStrictEqual({ updates, parentReasoningParts: reasoningIds.size, timingScopes }, { - updates: [ - { kind: ResponsePartKind.Reasoning, scope: 'parent', content: 'Before notification' }, - { kind: ResponsePartKind.Reasoning, scope: 'task-running', content: 'Child reasoning' }, - { kind: ResponsePartKind.SystemNotification, scope: 'parent', content: 'Background agent `Completed reviewer` is complete' }, -- { kind: ResponsePartKind.Reasoning, scope: 'parent', content: 'After notification' }, +- { kind: 'delta', scope: 'parent', content: 'After notification' }, - { kind: 'delta', scope: 'parent', content: ' continued' }, - { kind: 'delta', scope: 'task-running', content: ' still running' }, - ], -- parentReasoningParts: 2, +- parentReasoningParts: 1, +- timingScopes: [], - }); - }); - @@ -96837,10 +99282,7 @@ index 3d590951e18..00000000000 - executions: mockSession.samplingExecutions, - responses: mockSession.samplingResponses, - }, { -- start: { -- type: ActionType.ChatToolCallStart, turnId: 'turn-sampling', -- toolCallId: ready.toolCallId, toolName: 'mcp_sampling', displayName: 'MCP Sampling', -- }, +- start: chatToolCallStart('turn-sampling', ready.toolCallId, 'mcp_sampling', 'MCP Sampling'), - ready: { - type: ActionType.ChatToolCallReady, turnId: 'turn-sampling', toolCallId: '', - invocationMessage: 'MCP server \'test-server\' wants to send a 32-character long message to the language model.', @@ -97186,10 +99628,7 @@ index 3d590951e18..00000000000 - }, { - executions: [], - responses: [], -- completions: [{ -- type: ActionType.ChatToolCallComplete, turnId: 'turn-sampling', toolCallId: ready.toolCallId, -- result: { success: false, pastTenseMessage: 'Sampling for test-server did not complete' }, -- }], +- completions: [chatToolCallComplete('turn-sampling', ready.toolCallId, { success: false, pastTenseMessage: 'Sampling for test-server did not complete' })], - acceptsLateApproval: false, - }); - }); @@ -97796,6 +100235,49 @@ index 3d590951e18..00000000000 - }))); - }); - +- test('MCP tool completion preserves result meta alongside host UI metadata on success and error', async () => { +- const owner = AgentSession.uri('ahp-session', 'test-session-1'); +- const { session, mockSession, signals } = await createAgentSession(disposables, { +- sessionUri: owner, +- configureMockSession: m => { +- m.mcpListResult = { servers: [{ name: 'docs', status: 'connected' }] }; +- }, +- }); +- session.resetTurnState('turn-mcp-result-meta'); +- mockSession.fire('session.mcp_server_status_changed', { serverName: 'docs', status: 'connected' }); +- const ui = { resourceUri: 'ui://docs', channel: buildMcpChannel(URI.parse(buildDefaultChatUri(owner)), 'docs', 'copilotcli') }; +- const resultMetas = [{ ui: { private: [null, false, 0] }, toolKind: 'server-owned' }, {}, undefined]; +- for (const success of [true, false]) { +- for (const [index, mcpResultMeta] of resultMetas.entries()) { +- const toolCallId = `tc-mcp-result-meta-${success}-${index}`; +- mockSession.fire('tool.execution_start', { +- toolCallId, +- toolName: 'docs-open_app', +- mcpServerName: 'docs', +- mcpToolName: 'open_app', +- toolDescription: { name: 'open_app', _meta: { ui: { resourceUri: ui.resourceUri } } }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- mockSession.fire('tool.execution_complete', { +- toolCallId, +- success, +- result: { content: 'App result', mcpResultMeta }, +- ...(success ? {} : { error: { message: 'App failed' } }), +- } as SessionEventPayload<'tool.execution_complete'>['data']); +- } +- } +- assert.deepStrictEqual(getActions(signals).flatMap(action => action.type === ActionType.ChatToolCallComplete +- ? [{ success: action.result.success, resultMetadata: action.result.resultMetadata, meta: action._meta }] : []), +- [true, false].flatMap(success => resultMetas.map(mcpResultMeta => ({ +- success, +- resultMetadata: mcpResultMeta, +- meta: { +- mcpServerName: 'docs', +- mcpToolName: 'open_app', +- ui, +- }, +- })))); +- }); +- - test('MCP resources are stored before tool and turn completion', async () => { - const writeStarted = new DeferredPromise(); - const writeGate = new DeferredPromise(); @@ -99801,7 +102283,7 @@ index 3d590951e18..00000000000 - message: signal.kind === 'subagent_resumed' && signal.message ? signal.message : { text: 'Seed', origin: { kind: MessageKind.Tool } }, - }); - } else if (signal.kind === 'subagent_completed' && signal.toolCallId === 'task' && chat.activeTurn) { -- chat = chatReducer(chat, { type: ActionType.ChatTurnComplete, turnId: chat.activeTurn.id, duration: 0 }); +- chat = chatReducer(chat, chatTurnComplete(chat.activeTurn.id, 0)); - } else if (signal.kind === 'action' && signal.parentToolCallId === 'task' && chat.activeTurn && isChatAction(signal.action) && hasKey(signal.action, { turnId: true })) { - chat = chatReducer(chat, { ...signal.action, turnId: chat.activeTurn.id }); - } @@ -103311,12 +105793,81 @@ index 3d590951e18..00000000000 - }); - }); - +- test('publishes and persists reasoning timing before the assistant answer', async () => { +- const database = new TestSessionDatabase(); +- const { session, mockSession, signals } = await createAgentSession(disposables, { sessionDatabase: database }); +- const elapsed = stub(StopWatch.prototype, 'elapsed').returns(45_000); +- store.add(toDisposable(() => elapsed.restore())); +- session.resetTurnState('host-turn'); +- mockSession.fire('assistant.reasoning_delta', { reasoningId: 'sdk-reasoning', deltaContent: 'First' }); +- mockSession.fire('assistant.reasoning_delta', { reasoningId: 'sdk-reasoning', deltaContent: ' thought' }); +- mockSession.fire('assistant.message', { messageId: 'sdk-message', content: 'Done', reasoningText: 'First thought' }, { id: 'durable-response' }); +- const actions = signals.flatMap(signal => signal.kind === 'action' ? [signal.action] : []); +- const reasoning = actions.flatMap(action => action.type === ActionType.ChatResponsePart && action.part.kind === ResponsePartKind.Reasoning ? [action.part] : [])[0]; +- const finalTiming = actions.flatMap(action => action.type === ActionType.ChatReasoning && action.duration !== undefined ? [action] : [])[0]; +- const timingIndex = actions.indexOf(finalTiming); +- const answerIndex = actions.findIndex(action => action.type === ActionType.ChatResponsePart && action.part.kind === ResponsePartKind.Markdown); +- const recorded = await database.getAssistantResponseParts(); +- const recordedReasoning = recorded.get('durable-response')?.find(part => part.kind === ResponsePartKind.Reasoning); +- +- assert.deepStrictEqual({ +- duration: finalTiming?.duration, +- partIdMatches: finalTiming?.partId === reasoning?.id, +- publishedBeforeAnswer: timingIndex >= 0 && timingIndex < answerIndex, +- recorded: [...recorded.keys()], +- recordedDuration: recordedReasoning?.duration, +- startMatches: recordedReasoning?.startedAt === reasoning?.startedAt, +- }, { +- duration: 45_000, +- partIdMatches: true, +- publishedBeforeAnswer: true, +- recorded: ['durable-response'], +- recordedDuration: 45_000, +- startMatches: true, +- }); +- }); +- +- test('SDK reasoning and message completion do not end contiguous thinking output', async () => { +- const database = new TestSessionDatabase(); +- const { session, mockSession, signals } = await createAgentSession(disposables, { sessionDatabase: database }); +- const elapsed = stub(StopWatch.prototype, 'elapsed').returns(45_000); +- store.add(toDisposable(() => elapsed.restore())); +- session.resetTurnState('host-turn'); +- mockSession.fire('assistant.reasoning_delta', { reasoningId: 'sdk-first', deltaContent: 'First' }); +- mockSession.fire('assistant.reasoning', { reasoningId: 'sdk-first', content: 'First' }); +- mockSession.fire('assistant.message', { messageId: 'message-first', content: '', reasoningText: 'First' }, { id: 'durable-first' }); +- mockSession.fire('assistant.reasoning_delta', { reasoningId: 'sdk-second', deltaContent: 'Second' }); +- mockSession.fire('assistant.reasoning', { reasoningId: 'sdk-second', content: 'Second' }); +- mockSession.fire('assistant.message', { messageId: 'message-second', content: '', reasoningText: 'Second' }, { id: 'durable-second' }); +- const beforeBoundary = signals.filter(signal => signal.kind === 'action' && signal.action.type === ActionType.ChatReasoning && signal.action.duration !== undefined).length; +- mockSession.fire('assistant.message_delta', { messageId: 'answer', deltaContent: 'Done' }); +- const parts = signals.flatMap(signal => signal.kind === 'action' && signal.action.type === ActionType.ChatResponsePart && signal.action.part.kind === ResponsePartKind.Reasoning ? [signal.action.part] : []); +- const durations = signals.flatMap(signal => signal.kind === 'action' && signal.action.type === ActionType.ChatReasoning && signal.action.duration !== undefined ? [signal.action.duration] : []); +- const recorded = await database.getAssistantResponseParts(); +- +- assert.deepStrictEqual({ +- beforeBoundary, +- reasoningParts: parts.length, +- durations, +- recorded: [...recorded].map(([responseId, value]) => ({ responseId, runs: value.flatMap(part => part.kind === ResponsePartKind.Reasoning ? [{ sameRun: part.id === parts[0].id, duration: part.duration }] : []) })), +- }, { +- beforeBoundary: 0, +- reasoningParts: 1, +- durations: [45_000], +- recorded: [ +- { responseId: 'durable-first', runs: [{ sameRun: true, duration: 45_000 }] }, +- { responseId: 'durable-second', runs: [{ sameRun: true, duration: 45_000 }] }, +- ], +- }); +- }); +- - test('reasoning delta after tool_start starts a new reasoning response part', async () => { - const { session, mockSession, signals } = await createAgentSession(disposables); - session.resetTurnState('turn-1'); - - // First reasoning delta — allocates a fresh reasoning response part. - mockSession.fire('assistant.reasoning_delta', { +- reasoningId: 'reasoning-before-tool', - deltaContent: 'thinking step 1', - } as SessionEventPayload<'assistant.reasoning_delta'>['data']); - @@ -103337,6 +105888,7 @@ index 3d590951e18..00000000000 - // renderer / state-tree would merge it into the pre-tool-call - // block and the visual ordering would be wrong on restore. - mockSession.fire('assistant.reasoning_delta', { +- reasoningId: 'reasoning-after-tool', - deltaContent: 'thinking step 2', - } as SessionEventPayload<'assistant.reasoning_delta'>['data']); - @@ -103461,13 +106013,7 @@ index 3d590951e18..00000000000 - assert.deepStrictEqual(skillActions, [ - { - parentToolCallId: 'tc-subagent', -- action: { -- type: ActionType.ChatToolCallStart, -- turnId: 'turn-1', -- toolCallId: 'synth-skill-skill-event', -- toolName: 'skill', -- displayName: 'Read Skill', -- }, +- action: chatToolCallStart('turn-1', 'synth-skill-skill-event', 'skill', 'Read Skill'), - }, - { - parentToolCallId: 'tc-subagent', @@ -105089,7 +107635,7 @@ index 3d590951e18..00000000000 - return toolSet; - }; - -- function clientToolConfiguration(toolSearchActive = false, hostToolNames: ReadonlySet = new Set()): ICopilotClientToolConfiguration { +- function clientToolConfiguration(toolSearchActive = false, hostToolNames: ReadonlySet = new Set(), withheldToolNames: ReadonlySet = new Set()): ICopilotClientToolConfiguration { - const sessionConfig: ICopilotClientToolConfiguration['sessionConfig'] = { - toolSearch: { enabled: toolSearchActive }, - excludedTools: [], @@ -105097,7 +107643,7 @@ index 3d590951e18..00000000000 - customAgents: [], - }; - return { -- toolSearchActive, hostToolNames, sessionConfig, +- toolSearchActive, hostToolNames, withheldToolNames, sessionConfig, - resolve: (_definitions, tools = []) => ({ sessionConfig, tools: [...tools] }), - replaceTools: async (sdk, tools) => sdk.setTools(tools), - }; @@ -105141,7 +107687,7 @@ index 3d590951e18..00000000000 - }); - }); - -- test('client tool replacements route offered tools before SDK acceptance, commit definitions only after success, and remain retryable', async () => { +- test('client tool replacements commit definitions and routing only after SDK success and remain retryable', async () => { - const activeClientToolSet = activeClientToolSetWith('client'); - const { session, runtime, mockSession, signals, onProgress } = await createAgentSession(disposables, { - clientSnapshot: snapshot, activeClientToolSet, @@ -105155,22 +107701,27 @@ index 3d590951e18..00000000000 - inputSchema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] }, - }, { name: 'host_tool' }]; - activeClientToolSet.set('client', definitions); +- const gate = new DeferredPromise(); +- mockSession.setToolsGate = gate.p; +- const executed: string[] = []; - disposables.add(onProgress(signal => { - if (signal.kind === 'action' && signal.action.type === ActionType.ChatToolCallReady && signal.action.contributor?.kind === ToolCallContributorKind.Client) { +- executed.push(signal.action.toolCallId); - session.handleClientToolCallComplete(signal.action.toolCallId, { - success: true, pastTenseMessage: 'Replacement completed', -- content: [{ type: ToolResultContentType.Text, text: signal.action.toolCallId }], +- content: [{ type: ToolResultContentType.Text, text: 'replacement result' }], - }); - } - })); -- const gate = new DeferredPromise(); -- mockSession.setToolsGate = gate.p; - const updating = session.updateClientTools(definitions); - const before = session.appliedSnapshot.tools; +- mockSession.fire('assistant.tool_call_delta', { toolCallId: 'not-yet-applied', toolName: 'replacement', inputDelta: '{"query":"test"}' }); - mockSession.fire('tool.execution_start', { toolCallId: 'not-yet-applied', toolName: 'replacement', arguments: { query: 'test' } }); -- const beforeAcceptance = await invokeClientToolHandler(mockSession.setToolsCalls[0][0], 'not-yet-applied', { query: 'test' }); +- const result = invokeClientToolHandler(mockSession.setToolsCalls[0][0], 'not-yet-applied', { query: 'test' }); +- const beforeAcceptance = { actions: getActions(signals), executed: [...executed] }; - gate.complete(); - await updating; +- const completed = await result; - mockSession.fire('tool.execution_start', { toolCallId: 'updated', toolName: 'replacement', arguments: { query: 'test' } }); - const afterSuccess = session.appliedSnapshot.tools; - mockSession.setToolsError = new Error('replacement rejected'); @@ -105184,20 +107735,26 @@ index 3d590951e18..00000000000 - const client = { kind: ToolCallContributorKind.Client, clientId: 'client' }; - assert.deepStrictEqual({ - before, -- beforeAcceptance: [beforeAcceptance.resultType, beforeAcceptance.textResultForLlm], +- beforeAcceptance, - afterSuccess, - afterFailure, - afterRemoval: session.appliedSnapshot.tools, - updates: mockSession.setToolsCalls.map(tools => tools.map(({ name, description, parameters }) => ({ name, description, parameters }))), -- contributors: getActions(signals).flatMap(action => action.type === ActionType.ChatToolCallStart ? [[action.toolCallId, action.contributor]] : []), +- contributors: getActions(signals).flatMap(action => action.type === ActionType.ChatToolCallStart ? [action.contributor] : []), +- readyContributors: getActions(signals).flatMap(action => action.type === ActionType.ChatToolCallReady ? [action.contributor] : []), +- executed, +- result: { resultType: completed.resultType, textResultForLlm: completed.textResultForLlm }, - }, { - before: snapshot.tools, -- beforeAcceptance: ['success', 'not-yet-applied'], +- beforeAcceptance: { actions: [], executed: [] }, - afterSuccess: definitions, - afterFailure: definitions, - afterRemoval: [], - updates: [[{ name: 'replacement', description: 'Updated description', parameters: definitions[0].inputSchema }], [], []], -- contributors: [['not-yet-applied', client], ['updated', client], ['after-rejection', client]], +- contributors: [client, client, client], +- readyContributors: [client, client, client], +- executed: ['not-yet-applied', 'updated', 'after-rejection'], +- result: { resultType: 'success', textResultForLlm: 'replacement result' }, - }); - }); - @@ -105215,42 +107772,304 @@ index 3d590951e18..00000000000 - mockSession.setToolsGate = gate.p; - const updating = session.updateClientTools(definitions); - mockSession.fire('tool.execution_start', { toolCallId: 'replacement', toolName: 'my_tool', arguments: { query: 'new' } }); +- const beforeAcceptance = getActions(signals); - gate.complete(); - await updating; - -- assert.deepStrictEqual( -- getActions(signals).flatMap(action => action.type === ActionType.ChatToolCallStart ? [[action.toolCallId, action.contributor]] : []), -- [['replacement', { kind: ToolCallContributorKind.Client, clientId: 'new-client' }]], -- ); +- assert.deepStrictEqual({ +- beforeAcceptance, +- contributors: getActions(signals).flatMap(action => action.type === ActionType.ChatToolCallStart ? [[action.toolCallId, action.contributor]] : []), +- }, { +- beforeAcceptance: [], +- contributors: [['replacement', { kind: ToolCallContributorKind.Client, clientId: 'new-client' }]], +- }); - }); - -- for (const activity of ['turn', 'subagent'] as const) { -- test(`client tool removal and rename apply during an active ${activity} without aborting or disconnecting`, async () => { -- const { session, runtime, mockSession } = await createAgentSession(disposables, { clientSnapshot: snapshot }); +- test('same-name client tool replacement holds new calls while existing and unchanged handlers finish', async () => { +- const activeClientToolSet = activeClientToolSetWith('old-client'); +- const { session, runtime, mockSession, signals, onProgress } = await createAgentSession(disposables, { +- clientSnapshot: snapshot, activeClientToolSet, +- configValues: { [SessionConfigKey.AutoApprove]: 'autoApprove' }, +- }); +- await session.syncPermissionMode('turn-start'); +- const [originalTool] = runtime.createClientSdkTools(); +- runtime.configureClientTools!(clientToolConfiguration()); +- mockSession.fire('tool.execution_start', { toolCallId: 'original', toolName: 'my_tool', arguments: {} }); +- const originalResult = invokeClientToolHandler(originalTool, 'original'); +- const unchanged = { name: 'unchanged' }; +- const additions = [...snapshot.tools, unchanged]; +- activeClientToolSet.set('old-client', additions); +- await session.updateClientTools(additions); +- const unchangedTool = mockSession.setToolsCalls[0].find(tool => tool.name === 'unchanged')!; +- const definitions: ToolDefinition[] = [{ +- ...snapshot.tools[0], +- inputSchema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] }, +- }, unchanged]; +- activeClientToolSet.set('old-client', [unchanged]); +- activeClientToolSet.set('new-client', definitions); +- const executed: string[] = []; +- disposables.add(onProgress(signal => { +- if (signal.kind === 'action' && signal.action.type === ActionType.ChatToolCallReady) { +- executed.push(signal.action.toolCallId); +- session.handleClientToolCallComplete(signal.action.toolCallId, { +- success: true, pastTenseMessage: 'Completed', +- content: [{ type: ToolResultContentType.Text, text: signal.action.toolCallId }], +- }); +- } +- })); +- const gate = new DeferredPromise(); +- mockSession.setToolsGate = gate.p; +- const updating = session.updateClientTools(definitions); +- mockSession.fire('assistant.tool_call_delta', { toolCallId: 'replacement', toolName: 'my_tool', inputDelta: '{"query":"new"}' }); +- mockSession.fire('tool.execution_start', { toolCallId: 'replacement', toolName: 'my_tool', arguments: { query: 'new' } }); +- const replacementTool = mockSession.setToolsCalls[1].find(tool => tool.name === 'my_tool')!; +- const replacementResult = invokeClientToolHandler(replacementTool, 'replacement', { query: 'new' }); +- mockSession.fire('tool.execution_start', { toolCallId: 'unchanged-call', toolName: 'unchanged', arguments: {} }); +- const unchangedResult = await invokeClientToolHandler(unchangedTool, 'unchanged-call'); +- session.handleClientToolCallComplete('original', { +- success: true, pastTenseMessage: 'Original completed', +- content: [{ type: ToolResultContentType.Text, text: 'original' }], +- }); +- const completedOriginal = await originalResult; +- const beforeAcceptance = [...executed]; +- gate.complete(); +- await updating; +- const completedReplacement = await replacementResult; +- assert.deepStrictEqual({ +- beforeAcceptance, +- executed, +- contributors: getActions(signals).flatMap(action => action.type === ActionType.ChatToolCallStart ? [[action.toolCallId, action.contributor]] : []), +- results: [completedOriginal, unchangedResult, completedReplacement].map(result => [result.resultType, result.textResultForLlm]), +- }, { +- beforeAcceptance: ['unchanged-call'], +- executed: ['unchanged-call', 'replacement'], +- contributors: [ +- ['original', { kind: ToolCallContributorKind.Client, clientId: 'old-client' }], +- ['unchanged-call', { kind: ToolCallContributorKind.Client, clientId: 'old-client' }], +- ['replacement', { kind: ToolCallContributorKind.Client, clientId: 'new-client' }], +- ], +- results: [['success', 'original'], ['success', 'unchanged-call'], ['success', 'replacement']], +- }); +- }); +- +- for (const toolName of ['added', 'my_tool']) { +- test(`rejected client tool replacement fails held ${toolName} calls and remains retryable`, async () => { +- const activeClientToolSet = activeClientToolSetWith('client'); +- const { session, runtime, mockSession, signals } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientToolSet }); - runtime.configureClientTools!(clientToolConfiguration()); -- await assert.rejects(session.updateClientTools([], CancellationToken.Cancelled), isCancellationError); -- const busy = stub(session, 'hasActiveSubagents').get(() => activity === 'subagent'); +- const definitions: ToolDefinition[] = [{ name: toolName, inputSchema: { type: 'object', properties: { query: { type: 'string' } } } }]; +- activeClientToolSet.set('client', definitions); +- const gate = new DeferredPromise(); +- mockSession.setToolsGate = gate.p; +- mockSession.setToolsError = new Error('replacement rejected'); +- const updating = assert.rejects(session.updateClientTools(definitions), /replacement rejected/); +- const tool = mockSession.setToolsCalls[0][0]; +- mockSession.fire('tool.execution_start', { toolCallId: 'rejected', toolName, arguments: { query: 'test' } }); +- const result = invokeClientToolHandler(tool, 'rejected', { query: 'test' }); +- const permission = runtime.handlePermissionRequest({ kind: 'custom-tool', toolCallId: 'rejected', toolName }); +- gate.complete(); +- await updating; +- const completed = await result; +- const lateHandler = await invokeClientToolHandler(tool, 'late-rejected', { query: 'test' }); +- const afterRejection = session.appliedSnapshot.tools; +- mockSession.setToolsError = undefined; +- await session.updateClientTools(definitions); +- assert.deepStrictEqual({ +- afterRejection, +- applied: session.appliedSnapshot.tools, +- results: [completed.resultType, lateHandler.resultType], +- permission: (await permission).kind, +- lifecycle: getActions(signals) +- .filter(action => action.type === ActionType.ChatToolCallStart || action.type === ActionType.ChatToolCallReady || action.type === ActionType.ChatToolCallComplete) +- .map(action => ({ +- type: action.type, +- contributor: action.type === ActionType.ChatToolCallStart || action.type === ActionType.ChatToolCallReady ? action.contributor : undefined, +- success: action.type === ActionType.ChatToolCallComplete ? action.result.success : undefined, +- })), +- updates: mockSession.setToolsCalls.map(tools => tools.map(tool => tool.name)), +- }, { +- afterRejection: snapshot.tools, +- applied: definitions, +- results: ['failure', 'failure'], +- permission: 'reject', +- lifecycle: [ +- { type: ActionType.ChatToolCallStart, contributor: undefined, success: undefined }, +- { type: ActionType.ChatToolCallReady, contributor: undefined, success: undefined }, +- { type: ActionType.ChatToolCallComplete, contributor: undefined, success: false }, +- ], +- updates: [[toolName], [toolName]], +- }); +- }); +- } +- +- test('client tool replacement holds permissions arriving before the start until accepted routing is ready', async () => { +- const activeClientToolSet = activeClientToolSetWith('client'); +- const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientToolSet }); +- runtime.configureClientTools!(clientToolConfiguration()); +- const definitions = [{ name: 'added' }]; +- activeClientToolSet.set('client', definitions); +- const gate = new DeferredPromise(); +- mockSession.setToolsGate = gate.p; +- const updating = session.updateClientTools(definitions); +- const permission = runtime.handlePermissionRequest({ kind: 'custom-tool', toolCallId: 'held-permission', toolName: 'added' }); +- mockSession.fire('tool.execution_start', { toolCallId: 'held-permission', toolName: 'added', arguments: {} }); +- const beforeAcceptance = [...signals]; +- gate.complete(); +- await updating; +- const confirmation = await waitForSignal(signal => signal.kind === 'pending_confirmation'); +- session.respondToPermissionRequest('held-permission', false); +- assert.deepStrictEqual({ +- beforeAcceptance, +- contributor: confirmation.kind === 'pending_confirmation' ? confirmation.state.contributor : undefined, +- starts: getActions(signals).flatMap(action => action.type === ActionType.ChatToolCallStart ? [action.contributor] : []), +- permission: (await permission).kind, +- }, { +- beforeAcceptance: [], +- contributor: { kind: ToolCallContributorKind.Client, clientId: 'client' }, +- starts: [{ kind: ToolCallContributorKind.Client, clientId: 'client' }], +- permission: 'reject', +- }); +- }); +- +- for (const ending of ['abort', 'dispose', 'discard', 'next turn'] as const) { +- test(`held client tool replacement callbacks settle on ${ending} without late starts`, async () => { +- const activeClientToolSet = activeClientToolSetWith('client'); +- const { session, runtime, mockSession, signals } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientToolSet }); +- session.resetTurnState('original-turn'); +- runtime.configureClientTools!(clientToolConfiguration()); +- const definitions = [{ name: 'added' }]; +- activeClientToolSet.set('client', definitions); +- const gate = new DeferredPromise(); +- mockSession.setToolsGate = gate.p; +- const updating = session.updateClientTools(definitions); +- mockSession.fire('tool.execution_start', { toolCallId: 'cancelled', toolName: 'added', arguments: {} }); +- const result = invokeClientToolHandler(mockSession.setToolsCalls[0][0], 'cancelled'); +- const permission = runtime.handlePermissionRequest({ kind: 'custom-tool', toolCallId: 'cancelled', toolName: 'added' }); - try { -- if (activity === 'turn') { -- session.resetTurnState('busy-turn'); +- if (ending === 'abort') { +- await session.abort(); +- } else if (ending === 'dispose') { +- session.dispose(); +- } else if (ending === 'discard') { +- session.discardActiveTurn(); +- } else { +- session.resetTurnState('next-turn'); - } -- await session.updateClientTools([{ name: 'renamed' }]); -- await session.updateClientTools([]); +- const completed = await result; +- const denied = await permission; +- gate.complete(); +- await updating; - assert.deepStrictEqual({ +- result: completed.resultType, +- permission: denied.kind, +- lifecycle: getActions(signals).filter(action => +- action.type === ActionType.ChatToolCallStart || action.type === ActionType.ChatToolCallReady || action.type === ActionType.ChatToolCallComplete), - applied: session.appliedSnapshot.tools, -- updates: mockSession.setToolsCalls.map(tools => tools.map(tool => tool.name)), -- activeTurn: session.hasActiveTurn, -- activeSubagents: session.hasActiveSubagents, -- aborts: mockSession.abortCalls, -- disconnects: mockSession.disconnectCalls, -- }, { -- applied: [], updates: [['renamed'], []], -- activeTurn: activity === 'turn', activeSubagents: activity === 'subagent', -- aborts: 0, disconnects: 0, -- }); +- }, { result: 'failure', permission: 'reject', lifecycle: [], applied: definitions }); - } finally { -- busy.restore(); +- gate.complete(); +- await updating; +- } +- }); +- } +- +- test('a terminal event for a held client tool replacement settles its handler before SDK acceptance', async () => { +- const activeClientToolSet = activeClientToolSetWith('client'); +- const { session, runtime, mockSession, signals } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientToolSet }); +- runtime.configureClientTools!(clientToolConfiguration()); +- const definitions = [{ name: 'added' }]; +- activeClientToolSet.set('client', definitions); +- const gate = new DeferredPromise(); +- mockSession.setToolsGate = gate.p; +- const updating = session.updateClientTools(definitions); +- mockSession.fire('tool.execution_start', { toolCallId: 'ended', toolName: 'added', arguments: {} }); +- const result = invokeClientToolHandler(mockSession.setToolsCalls[0][0], 'ended'); +- mockSession.fire('tool.execution_complete', { toolCallId: 'ended', success: false, error: { message: 'Runtime stopped the call' } }); +- const completed = await result; +- const beforeAcceptance = getActions(signals); +- gate.complete(); +- await updating; +- assert.deepStrictEqual({ +- result: { resultType: completed.resultType, textResultForLlm: completed.textResultForLlm }, +- unchanged: getActions(signals), +- contributors: beforeAcceptance.flatMap(action => action.type === ActionType.ChatToolCallReady ? [action.contributor] : []), +- completions: beforeAcceptance.flatMap(action => action.type === ActionType.ChatToolCallComplete ? [action.result.success] : []), +- }, { +- result: { resultType: 'failure', textResultForLlm: 'Runtime stopped the call' }, +- unchanged: beforeAcceptance, +- contributors: [undefined], +- completions: [false], +- }); +- }); +- +- for (const toolName of [SEMANTIC_SEARCH_TOOL_NAME, CLIENT_TOOL_SEARCH_REFERENCE_NAME]) { +- test(`client tool replacement releases ${toolName} handlers without a permission callback`, async () => { +- const activeClientToolSet = activeClientToolSetWith('client'); +- const { session, runtime, mockSession, signals, onProgress } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientToolSet }); +- runtime.createClientSdkTools(true); +- runtime.configureClientTools!(clientToolConfiguration(true)); +- const definitions = [...snapshot.tools, { name: toolName }]; +- activeClientToolSet.set('client', definitions); +- const gate = new DeferredPromise(); +- mockSession.setToolsGate = gate.p; +- disposables.add(onProgress(signal => { +- if (signal.kind === 'action' && signal.action.type === ActionType.ChatToolCallReady && signal.action.toolCallId === 'held-search') { +- session.handleClientToolCallComplete('held-search', { +- success: true, pastTenseMessage: 'Searched', +- content: [{ type: ToolResultContentType.Text, text: '[]' }], +- }); +- } +- })); +- const updating = session.updateClientTools(definitions); +- const sdkName = toolName === CLIENT_TOOL_SEARCH_REFERENCE_NAME ? RUNTIME_TOOL_SEARCH_TOOL_NAME : toolName; +- const tool = mockSession.setToolsCalls[0].find(tool => tool.name === sdkName)!; +- mockSession.fire('assistant.tool_call_delta', { toolCallId: 'held-search', toolName: sdkName, inputDelta: '{"query":"test"}' }); +- mockSession.fire('tool.execution_start', { toolCallId: 'held-search', toolName: sdkName, arguments: { query: 'test' } }); +- const result = invokeClientToolHandler(tool, 'held-search', { query: 'test' }); +- const beforeAcceptance = getActions(signals); +- gate.complete(); +- await updating; +- const completed = await result; +- assert.deepStrictEqual({ +- beforeAcceptance, +- result: completed.resultType, +- lifecycle: getActions(signals).flatMap(action => +- action.type === ActionType.ChatToolCallStart || action.type === ActionType.ChatToolCallReady ? [[action.type, action.contributor]] : []), +- }, { +- beforeAcceptance: [], +- result: 'success', +- lifecycle: [ +- [ActionType.ChatToolCallStart, { kind: ToolCallContributorKind.Client, clientId: 'client' }], +- [ActionType.ChatToolCallReady, { kind: ToolCallContributorKind.Client, clientId: 'client' }], +- ], +- }); +- }); +- } +- +- for (const activity of ['turn', 'subagent'] as const) { +- test(`client tool removal and rename apply during an active ${activity} without aborting or disconnecting`, async () => { +- const { session, runtime, mockSession } = await createAgentSession(disposables, { clientSnapshot: snapshot }); +- runtime.configureClientTools!(clientToolConfiguration()); +- await assert.rejects(session.updateClientTools([], CancellationToken.Cancelled), isCancellationError); +- const busy = stub(session, 'hasActiveSubagents').get(() => activity === 'subagent'); +- store.add(toDisposable(() => busy.restore())); +- if (activity === 'turn') { +- session.resetTurnState('busy-turn'); - } +- await session.updateClientTools([{ name: 'renamed' }]); +- await session.updateClientTools([]); +- assert.deepStrictEqual({ +- applied: session.appliedSnapshot.tools, +- updates: mockSession.setToolsCalls.map(tools => tools.map(tool => tool.name)), +- activeTurn: session.hasActiveTurn, +- activeSubagents: session.hasActiveSubagents, +- aborts: mockSession.abortCalls, +- disconnects: mockSession.disconnectCalls, +- }, { +- applied: [], updates: [['renamed'], []], +- activeTurn: activity === 'turn', activeSubagents: activity === 'subagent', +- aborts: 0, disconnects: 0, +- }); - }); - } - @@ -105261,49 +108080,46 @@ index 3d590951e18..00000000000 - const [originalTool] = runtime.createClientSdkTools(); - runtime.configureClientTools!(clientToolConfiguration()); - const busy = stub(session, 'hasActiveSubagents').get(() => activity === 'subagent'); -- try { -- if (activity === 'turn') { -- session.resetTurnState('running-turn'); -- } -- mockSession.fire('tool.execution_start', { toolCallId: 'original', toolName: 'my_tool', arguments: {} }); -- const originalResult = invokeClientToolHandler(originalTool, 'original'); -- const added = { name: 'added' }; -- const additions = [...snapshot.tools, added]; -- const replacements: ToolDefinition[] = [{ -- ...snapshot.tools[0], description: 'Updated description', -- inputSchema: { type: 'object', properties: { query: { type: 'string' } } }, -- }, added]; -- activeClientToolSet.set('new-client', replacements); -- await session.updateClientTools(additions); -- mockSession.setToolsError = new Error('replacement rejected'); -- await assert.rejects(session.updateClientTools(replacements), /replacement rejected/); -- const afterFailure = session.appliedSnapshot.tools; -- mockSession.setToolsError = undefined; -- await session.updateClientTools(replacements); -- mockSession.fire('tool.execution_start', { toolCallId: 'replacement', toolName: 'my_tool', arguments: { query: 'new' } }); -- const replacementTool = mockSession.setToolsCalls.at(-1)!.find(tool => tool.name === 'my_tool')!; -- const replacementResult = invokeClientToolHandler(replacementTool, 'replacement', { query: 'new' }); -- session.handleClientToolCallComplete('original', { success: true, pastTenseMessage: 'Original completed', content: [{ type: ToolResultContentType.Text, text: 'original result' }] }); -- session.handleClientToolCallComplete('replacement', { success: true, pastTenseMessage: 'Replacement completed', content: [{ type: ToolResultContentType.Text, text: 'replacement result' }] }); +- store.add(toDisposable(() => busy.restore())); +- if (activity === 'turn') { +- session.resetTurnState('running-turn'); +- } +- mockSession.fire('tool.execution_start', { toolCallId: 'original', toolName: 'my_tool', arguments: {} }); +- const originalResult = invokeClientToolHandler(originalTool, 'original'); +- const added = { name: 'added' }; +- const additions = [...snapshot.tools, added]; +- const replacements: ToolDefinition[] = [{ +- ...snapshot.tools[0], description: 'Updated description', +- inputSchema: { type: 'object', properties: { query: { type: 'string' } } }, +- }, added]; +- activeClientToolSet.set('new-client', replacements); +- await session.updateClientTools(additions); +- mockSession.setToolsError = new Error('replacement rejected'); +- await assert.rejects(session.updateClientTools(replacements), /replacement rejected/); +- const afterFailure = session.appliedSnapshot.tools; +- mockSession.setToolsError = undefined; +- await session.updateClientTools(replacements); +- mockSession.fire('tool.execution_start', { toolCallId: 'replacement', toolName: 'my_tool', arguments: { query: 'new' } }); +- const replacementTool = mockSession.setToolsCalls.at(-1)!.find(tool => tool.name === 'my_tool')!; +- const replacementResult = invokeClientToolHandler(replacementTool, 'replacement', { query: 'new' }); +- session.handleClientToolCallComplete('original', { success: true, pastTenseMessage: 'Original completed', content: [{ type: ToolResultContentType.Text, text: 'original result' }] }); +- session.handleClientToolCallComplete('replacement', { success: true, pastTenseMessage: 'Replacement completed', content: [{ type: ToolResultContentType.Text, text: 'replacement result' }] }); - -- assert.deepStrictEqual({ -- afterFailure, -- applied: session.appliedSnapshot.tools, -- updates: mockSession.setToolsCalls.map(tools => tools.map(tool => tool.name)), -- contributors: getActions(signals).flatMap(action => action.type === ActionType.ChatToolCallStart ? [action.contributor] : []), -- results: (await Promise.all([originalResult, replacementResult])).map(({ resultType, textResultForLlm }) => ({ resultType, textResultForLlm })), -- aborts: mockSession.abortCalls, -- disconnects: mockSession.disconnectCalls, -- }, { -- afterFailure: additions, applied: replacements, -- updates: [['my_tool', 'added'], ['my_tool', 'added'], ['my_tool', 'added']], -- contributors: [{ kind: ToolCallContributorKind.Client, clientId: 'old-client' }, { kind: ToolCallContributorKind.Client, clientId: 'new-client' }], -- results: [{ resultType: 'success', textResultForLlm: 'original result' }, { resultType: 'success', textResultForLlm: 'replacement result' }], -- aborts: 0, disconnects: 0, -- }); -- } finally { -- busy.restore(); -- } +- assert.deepStrictEqual({ +- afterFailure, +- applied: session.appliedSnapshot.tools, +- updates: mockSession.setToolsCalls.map(tools => tools.map(tool => tool.name)), +- contributors: getActions(signals).flatMap(action => action.type === ActionType.ChatToolCallStart ? [action.contributor] : []), +- results: (await Promise.all([originalResult, replacementResult])).map(({ resultType, textResultForLlm }) => ({ resultType, textResultForLlm })), +- aborts: mockSession.abortCalls, +- disconnects: mockSession.disconnectCalls, +- }, { +- afterFailure: additions, applied: replacements, +- updates: [['my_tool', 'added'], ['my_tool', 'added'], ['my_tool', 'added']], +- contributors: [{ kind: ToolCallContributorKind.Client, clientId: 'old-client' }, { kind: ToolCallContributorKind.Client, clientId: 'new-client' }], +- results: [{ resultType: 'success', textResultForLlm: 'original result' }, { resultType: 'success', textResultForLlm: 'replacement result' }], +- aborts: 0, disconnects: 0, +- }); - }); - } - @@ -105356,12 +108172,26 @@ index 3d590951e18..00000000000 - await session.updateClientTools([...definitions, { name: SEMANTIC_SEARCH_TOOL_NAME }]); - assert.deepStrictEqual(mockSession.setToolsCalls.map(tools => tools.map(({ name, defer, overridesBuiltInTool, skipPermission }) => ({ name, defer, overridesBuiltInTool, skipPermission }))), [[ - { name: RUNTIME_TOOL_SEARCH_TOOL_NAME, defer: 'never', overridesBuiltInTool: true, skipPermission: true }, -- { name: 'runTests', defer: 'never', overridesBuiltInTool: undefined, skipPermission: undefined }, +- { name: 'runTests', defer: 'auto', overridesBuiltInTool: undefined, skipPermission: undefined }, - { name: 'other', defer: 'auto', overridesBuiltInTool: undefined, skipPermission: undefined }, - { name: SEMANTIC_SEARCH_TOOL_NAME, defer: 'auto', overridesBuiltInTool: true, skipPermission: true }, - ]]); - }); - +- test('managed permissions withhold semantic search at launch and across live client tool replacements, but not MCP App tools', async () => { +- const definitions: ToolDefinition[] = [{ name: 'other' }, { name: SEMANTIC_SEARCH_TOOL_NAME }]; +- const { session, runtime, mockSession } = await createAgentSession(disposables, { clientSnapshot: { ...snapshot, tools: definitions } }); +- const withheld = new Set([SEMANTIC_SEARCH_TOOL_NAME]); +- const atLaunch = runtime.createClientSdkTools(false, undefined, withheld).map(tool => tool.name); +- runtime.configureClientTools!(clientToolConfiguration(false, new Set(), withheld)); +- await session.updateClientTools([...definitions, { name: 'added' }]); +- await session.updateClientTools([{ name: 'other' }, withAgentToolOrigin({ name: SEMANTIC_SEARCH_TOOL_NAME }, 'mcpApp')]); +- assert.deepStrictEqual({ atLaunch, replacements: mockSession.setToolsCalls.map(tools => tools.map(tool => tool.name)) }, { +- atLaunch: ['other'], +- replacements: [['other', 'added'], ['other', SEMANTIC_SEARCH_TOOL_NAME]], +- }); +- }); +- - test('declares the native input contract without attributing client or MCP tools', async () => { - const clientSnapshot: IActiveClientSnapshot = { - ...snapshot, @@ -105398,6 +108228,96 @@ index 3d590951e18..00000000000 - assert.strictEqual((await result).kind, 'reject'); - }); - +- test('app origin changes structural tool identity without comparing unrelated metadata', () => { +- const definition = snapshot.tools[0]; +- const app = withAgentToolOrigin(definition, 'mcpApp'); +- assert.deepStrictEqual([ +- structuralToolsEqual([definition], [app]), +- structuralToolsEqual([app], [definition]), +- structuralToolsEqual([app], [{ ...app, _meta: { ...app._meta, unrelated: true } }]), +- structuralToolsEqual([definition], [{ ...definition, _meta: { 'vscode.chat.toolOrigin': 'future' } }]), +- ], [false, false, true, true]); +- }); +- +- for (const toolSearchActive of [false, true]) { +- test(`app tools keep trusted names without SDK privileges with tool search=${toolSearchActive}`, async () => { +- const names = [SEMANTIC_SEARCH_TOOL_NAME, CLIENT_TOOL_SEARCH_REFERENCE_NAME, 'runTests']; +- const { runtime } = await createAgentSession(disposables, { +- clientSnapshot: { ...snapshot, tools: names.map(name => withAgentToolOrigin({ name }, 'mcpApp')) }, +- }); +- assert.deepStrictEqual( +- runtime.createClientSdkTools(toolSearchActive).map(({ name, skipPermission, overridesBuiltInTool, defer }) => ({ name, skipPermission, overridesBuiltInTool, defer })), +- names.map(name => ({ name, skipPermission: undefined, overridesBuiltInTool: undefined, defer: toolSearchActive ? 'auto' : undefined })), +- ); +- }); +- } +- +- for (const name of [SEMANTIC_SEARCH_TOOL_NAME, CLIENT_TOOL_SEARCH_REFERENCE_NAME, 'list_agents', 'read_agent', 'write_agent']) { +- test(`app ${name} waits for permission and matches only its originating client`, async () => { +- const definition: ToolDefinition = { name, description: 'Same definition', inputSchema: { type: 'object' } }; +- const app = withAgentToolOrigin(definition, 'mcpApp'); +- const activeClientToolSet = new ActiveClientToolSet(); +- activeClientToolSet.set('base-client', [definition]); +- activeClientToolSet.set('app-client', [app]); +- const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { +- clientSnapshot: { ...snapshot, tools: [app] }, activeClientToolSet, +- }); +- runtime.createClientSdkTools(true); +- mockSession.fire('tool.execution_start', { toolCallId: 'app-call', toolName: name, arguments: {} }); +- const beforePermission = getActions(signals).map(action => ({ +- type: action.type, +- contributor: action.type === ActionType.ChatToolCallStart ? action.contributor : undefined, +- })); +- const permission = runtime.handlePermissionRequest({ kind: 'custom-tool', toolName: name, toolCallId: 'app-call', args: {} }); +- const confirmation = await waitForSignal(signal => signal.kind === 'pending_confirmation'); +- session.respondToPermissionRequest('app-call', false); +- assert.deepStrictEqual({ +- beforePermission, +- confirmation: confirmation.kind === 'pending_confirmation' ? confirmation.state.contributor : undefined, +- result: (await permission).kind, +- }, { +- beforePermission: [{ type: ActionType.ChatToolCallStart, contributor: { kind: ToolCallContributorKind.Client, clientId: 'app-client' } }], +- confirmation: { kind: ToolCallContributorKind.Client, clientId: 'app-client' }, +- result: 'reject', +- }); +- }); +- } +- +- test('origin-only client tool replacement waits for SDK acceptance and changes policy and owner', async () => { +- const definition: ToolDefinition = { name: SEMANTIC_SEARCH_TOOL_NAME, description: 'Same definition', inputSchema: { type: 'object' } }; +- const app = withAgentToolOrigin(definition, 'mcpApp'); +- const activeClientToolSet = new ActiveClientToolSet(); +- activeClientToolSet.set('base-client', [definition]); +- const { session, runtime, mockSession, signals } = await createAgentSession(disposables, { +- clientSnapshot: { ...snapshot, tools: [definition] }, activeClientToolSet, +- }); +- runtime.createClientSdkTools(); +- runtime.configureClientTools!(clientToolConfiguration()); +- activeClientToolSet.set('app-client', [app]); +- const gate = new DeferredPromise(); +- mockSession.setToolsGate = gate.p; +- const replacing = session.updateClientTools([app]); +- mockSession.fire('tool.execution_start', { toolCallId: 'replaced-app', toolName: definition.name, arguments: {} }); +- const beforeAcceptance = getActions(signals); +- gate.complete(); +- await replacing; +- await session.updateClientTools([definition]); +- assert.deepStrictEqual({ +- beforeAcceptance, +- contributors: getActions(signals).flatMap(action => action.type === ActionType.ChatToolCallStart ? [action.contributor] : []), +- ready: getActions(signals).filter(action => action.type === ActionType.ChatToolCallReady), +- policies: mockSession.setToolsCalls.map(tools => tools.map(({ name, skipPermission, overridesBuiltInTool }) => ({ name, skipPermission, overridesBuiltInTool }))), +- }, { +- beforeAcceptance: [], +- contributors: [{ kind: ToolCallContributorKind.Client, clientId: 'app-client' }], +- ready: [], +- policies: [ +- [{ name: definition.name, skipPermission: undefined, overridesBuiltInTool: undefined }], +- [{ name: definition.name, skipPermission: true, overridesBuiltInTool: true }], +- ], +- }); +- }); +- - test('semantic search overrides the built-in tool and is deferred', async () => { - const semanticSearchSnapshot: IActiveClientSnapshot = { - discoveredAgents: [], @@ -106060,6 +108980,262 @@ index 3d590951e18..00000000000 - mcpServers: {}, - }; - +- const workIqConnectors: ICopilotConnectorServers = { +- servers: new Map([['connector-workiq', { connectorName: 'workiqmcp', displayName: 'Work IQ' }]]), +- authorizationRequired: true, +- }; +- +- async function createConnectorRoutingSession(options: { routingEnabled?: boolean; serverEnabled?: boolean; reconcile?: Promise } = {}) { +- const activeClientToolSet = new ActiveClientToolSet(); +- activeClientToolSet.set('tool-search-client', globalMcpRoutingSnapshot.tools); +- const created = await createAgentSession(disposables, { +- clientSnapshot: globalMcpRoutingSnapshot, +- activeClientToolSet, +- modelId: 'claude-opus-4.8', +- rootValues: { +- [CopilotCliConfigKey.ToolSearchEnabled]: true, +- [AgentHostMcpToolRoutingEnabledConfigKey]: options.routingEnabled !== false, +- [AgentHostMcpConnectorsEnabledConfigKey]: true, +- }, +- ...(options.serverEnabled === false ? { +- resolveCustomizationEnablement: () => ({ +- kind: 'resolved' as const, +- enablement: [{ kind: CustomizationEnablementKind.Session, enabled: false }], +- enabled: false, +- workingDirectory: { kind: 'workspaceless' as const }, +- }), +- } : {}), +- configureMockSession: mock => { +- mock.connectorStatus = { +- ...mock.connectorStatus, +- catalog: { revision: 1, refreshedAtMs: 1, connectors: [{ name: 'workiqmcp', displayName: 'Work IQ', status: 'connected', runtimeServerIds: ['connector-workiq'] }] }, +- authorizationRequirement: { accountId: 'session-account', scope: 'write_plugin_gateway_connections' }, +- runtimeServers: [{ connectorName: 'workiqmcp', runtimeServerId: 'connector-workiq', status: 'needs_auth' }], +- }; +- mock.mcpListResult = { servers: [{ name: 'connector-workiq', status: options.serverEnabled === false ? 'disabled' : 'needs-auth', source: 'managed', url: 'https://connectors.example.com/workiq/mcp' }] }; +- mock.mcpListToolsResult = { tools: [{ name: 'current_search', description: 'Search current Work IQ documents' }] }; +- }, +- duringLaunch: runtime => { +- runtime.createClientSdkTools(true); +- runtime.configureClientTools!(clientToolConfiguration(true)); +- runtime.adoptLaunchConnectorReconcile(options.reconcile ?? Promise.resolve(workIqConnectors)); +- }, +- }); +- if (!options.reconcile) { +- await created.session.refreshConnectors(); +- for (const action of getActions(created.signals)) { +- if (action.type === ActionType.SessionMetaChanged) { +- created.dispatchSessionAction(action); +- } +- } +- } +- return created; +- } +- +- test('MCP routing registers searchable deferred Connector proxies after asynchronous launch discovery', async () => { +- const reconcile = new DeferredPromise(); +- const { session, mockSession, signals, waitForSignal } = await createConnectorRoutingSession({ reconcile: reconcile.p }); +- assert.strictEqual(mockSession.setToolsCalls.length, 0); +- await reconcile.complete(workIqConnectors); +- await session.refreshConnectors(); +- const [search, proxy] = mockSession.setToolsCalls.at(-1)!; +- session.resetTurnState('connector-tool-search'); +- mockSession.fire('tool.execution_start', { toolCallId: 'find-workiq', toolName: search.name, arguments: { query: 'Work IQ Connector' } }); +- const resultPromise = invokeClientToolHandler(search, 'find-workiq', { query: 'Work IQ Connector' }, [{ +- name: proxy.name, description: proxy.description!, deferLoading: true, +- }]); +- const readySignal = await waitForSignal(signal => isAction(signal, ActionType.ChatToolCallReady)); +- assert.ok(isAction(readySignal, ActionType.ChatToolCallReady)); +- session.handleClientToolCallComplete('find-workiq', { +- success: true, +- pastTenseMessage: 'Searched tools', +- content: [{ type: ToolResultContentType.Text, text: JSON.stringify([proxy.name]) }], +- }); +- const result = await resultPromise; +- assert.deepStrictEqual({ +- nameIsRecognizable: proxy.name.startsWith('mcp_route_work_iq_connector_'), +- descriptionNamesConnector: proxy.description?.includes('"Work IQ (Connector)"'), +- defer: proxy.defer, +- candidates: readToolCallMeta(readySignal.action as ChatToolCallReadyAction).toolSearchCandidates, +- references: result.toolReferences, +- routedServers: getActions(signals).flatMap(action => action.type === ActionType.SessionMetaChanged ? [...readMcpRoutingServers(action, session.chatChannelUri.toString())] : []), +- authPrompts: getActions(signals).filter(action => action.type === ActionType.ChatToolCallAuthRequired), +- starts: mockSession.mcpStartServerCalls, +- discovery: mockSession.mcpListToolsCalls, +- }, { +- nameIsRecognizable: true, +- descriptionNamesConnector: true, +- defer: 'auto', +- candidates: [{ name: proxy.name, description: proxy.description }], +- references: [proxy.name], +- routedServers: ['connector-workiq'], +- authPrompts: [], +- starts: [], +- discovery: [], +- }); +- }); +- +- for (const timing of ['statusOnly', 'statusWhileStarting', 'sdkCallback'] as const) { +- test(`MCP routing requests Connector authorization only on selection (${timing})`, async () => { +- const { session, runtime, mockSession, signals, waitForSignal } = await createConnectorRoutingSession(); +- let authentication: ReturnType | undefined; +- if (timing === 'sdkCallback') { +- authentication = runtime.handleMcpAuthRequest({ +- requestId: 'connector-auth', +- serverName: 'connector-workiq', +- serverUrl: 'https://connectors.example.com/workiq/mcp', +- reason: 'upscope', +- wwwAuthenticateParams: { scope: 'write:plugin_gateway_connections' }, +- }, { sessionId: 'test-session-1' }); +- await timeout(0); +- } +- if (timing === 'statusWhileStarting') { +- mockSession.mcpListResult = { servers: [{ name: 'connector-workiq', status: 'pending', source: 'managed' }] }; +- mockSession.fire('session.mcp_server_status_changed', { serverName: 'connector-workiq', status: 'pending' }); +- } +- const [, proxy] = mockSession.setToolsCalls.at(-1)!; +- session.resetTurnState('connector-route'); +- mockSession.fire('tool.execution_start', { toolCallId: 'connect-workiq', toolName: proxy.name, arguments: {} }); +- const activation = invokeClientToolHandler(proxy, 'connect-workiq'); +- if (timing === 'statusWhileStarting') { +- await timeout(0); +- mockSession.mcpListResult = { servers: [{ name: 'connector-workiq', status: 'needs-auth', source: 'managed', url: 'https://connectors.example.com/workiq/mcp' }] }; +- mockSession.fire('session.mcp_server_status_changed', { serverName: 'connector-workiq', status: 'needs-auth' }); +- } +- await waitForSignal(signal => isAction(signal, ActionType.ChatToolCallAuthRequired)); +- const acceptsMcpToken = await session.resolveMcpAuthentication({ +- resource: 'https://connectors.example.com/workiq/mcp', scopes: ['write:plugin_gateway_connections'], token: 'not-a-connector-credential', +- }); +- session.handleClientToolCallComplete('connect-workiq', { +- success: false, pastTenseMessage: 'Cancelled authentication', error: { message: 'Authentication cancelled', code: 'cancelled' }, +- }); +- const result = await activation; +- await authentication; +- const start = getActions(signals).find(action => action.type === ActionType.ChatToolCallStart); +- const auth = getActions(signals).filter(action => action.type === ActionType.ChatToolCallAuthRequired); +- assert.deepStrictEqual({ +- displayName: start?.type === ActionType.ChatToolCallStart ? start.displayName : undefined, +- auth: auth.map(action => action.type === ActionType.ChatToolCallAuthRequired ? { toolCallId: action.toolCallId, scopes: action.auth.requiredScopes } : undefined), +- acceptsMcpToken, +- resultType: result.resultType, +- starts: mockSession.mcpStartServerCalls, +- discovery: mockSession.mcpListToolsCalls, +- }, { +- displayName: 'Connect to Work IQ (Connector)', +- auth: [{ toolCallId: 'connect-workiq', scopes: ['write:plugin_gateway_connections'] }], +- acceptsMcpToken: false, +- resultType: 'failure', +- starts: [], +- discovery: [], +- }); +- }); +- } +- +- test('MCP routing orders client tool replacement after asynchronous Connector registration', async () => { +- const reconcile = new DeferredPromise(); +- const { session, mockSession } = await createConnectorRoutingSession({ reconcile: reconcile.p }); +- const update = session.updateClientTools([...globalMcpRoutingSnapshot.tools, { name: 'added' }]); +- await reconcile.complete(workIqConnectors); +- await update; +- const proxyName = mockSession.setToolsCalls[0][1].name; +- assert.deepStrictEqual(mockSession.setToolsCalls.map(tools => tools.map(tool => tool.name)), [ +- [RUNTIME_TOOL_SEARCH_TOOL_NAME, proxyName], +- [RUNTIME_TOOL_SEARCH_TOOL_NAME, 'added', proxyName], +- ]); +- }); +- +- test('MCP routing retries failed Connector tool registration without advertising an unaccepted proxy', async () => { +- const reconcile = new DeferredPromise(); +- const { session, mockSession, signals } = await createConnectorRoutingSession({ reconcile: reconcile.p }); +- mockSession.setToolsError = new Error('SDK rejected the replacement tools'); +- await reconcile.complete(workIqConnectors); +- await session.refreshConnectors(); +- const advertisedAfterFailure = getActions(signals).filter(action => action.type === ActionType.SessionMetaChanged); +- mockSession.setToolsError = undefined; +- session.markConnectorConfigurationChanged(); +- await session.refreshConnectors(); +- const proxyName = mockSession.setToolsCalls.at(-1)![1].name; +- assert.deepStrictEqual({ +- advertisedAfterFailure, +- replacements: mockSession.setToolsCalls.map(tools => tools.map(tool => tool.name)), +- advertisedAfterRetry: getActions(signals).flatMap(action => action.type === ActionType.SessionMetaChanged ? [...readMcpRoutingServers(action, session.chatChannelUri.toString())] : []), +- }, { +- advertisedAfterFailure: [], +- replacements: [[RUNTIME_TOOL_SEARCH_TOOL_NAME, proxyName], [RUNTIME_TOOL_SEARCH_TOOL_NAME, proxyName]], +- advertisedAfterRetry: ['connector-workiq'], +- }); +- }); +- +- test('MCP routing discovers current Connector tools after account authorization without sharing account metadata in the cache', async () => { +- const { session, mockSession, waitForSignal, storageService } = await createConnectorRoutingSession(); +- const [, proxy] = mockSession.setToolsCalls.at(-1)!; +- session.resetTurnState('connector-authorize'); +- mockSession.fire('tool.execution_start', { toolCallId: 'authorize-workiq', toolName: proxy.name, arguments: {} }); +- const activation = invokeClientToolHandler(proxy, 'authorize-workiq'); +- await waitForSignal(signal => isAction(signal, ActionType.ChatToolCallAuthRequired)); +- session.markConnectorConfigurationChanged('workiqmcp'); +- assert.strictEqual((await activation).resultType, 'failure'); +- mockSession.fire('tool.execution_complete', { toolCallId: 'authorize-workiq', success: false }); +- mockSession.fire('session.idle', {}); +- mockSession.connectorStatus = { ...mockSession.connectorStatus, authorizationRequirement: undefined }; +- mockSession.mcpListResult = { servers: [{ name: 'connector-workiq', status: 'connected', toolsLoaded: true, source: 'managed' }] }; +- await session.refreshConnectors(); +- session.resetTurnState('connector-retry'); +- const result = await invokeClientToolHandler(proxy, 'retry-workiq'); +- assert.deepStrictEqual({ +- resultType: result.resultType, +- liveTools: result.textResultForLlm, +- queriedConnector: mockSession.mcpListToolsCalls.some(call => call.serverName === 'connector-workiq'), +- cache: storageService.get('copilotMcpToolRoutingCache'), +- }, { +- resultType: 'success', +- liveTools: 'The Work IQ (Connector) MCP server is connected. Search tools again to use one of its current tools: ["current_search"]', +- queriedConnector: true, +- cache: undefined, +- }); +- }); +- +- test('MCP routing removes Connector proxies on catalog refresh and rejects stale activation', async () => { +- const { session, mockSession, signals } = await createConnectorRoutingSession(); +- const [, proxy] = mockSession.setToolsCalls.at(-1)!; +- mockSession.connectorStatus = { ...mockSession.connectorStatus, runtimeServers: [] }; +- mockSession.mcpListResult = { servers: [] }; +- session.markConnectorConfigurationChanged(); +- await session.refreshConnectors(); +- session.resetTurnState('removed-connector'); +- const result = await invokeClientToolHandler(proxy, 'stale-connector'); +- assert.deepStrictEqual({ +- tools: mockSession.setToolsCalls.at(-1)!.map(tool => tool.name), +- routedServers: getActions(signals).flatMap(action => action.type === ActionType.SessionMetaChanged ? [[...readMcpRoutingServers(action, session.chatChannelUri.toString())]] : []).at(-1), +- resultType: result.resultType, +- starts: mockSession.mcpStartServerCalls, +- }, { +- tools: [RUNTIME_TOOL_SEARCH_TOOL_NAME], +- routedServers: [], +- resultType: 'failure', +- starts: [], +- }); +- }); +- +- test('MCP routing does not activate disabled Connectors or register their proxies when routing is off', async () => { +- const disabled = await createConnectorRoutingSession({ serverEnabled: false }); +- const [, proxy] = disabled.mockSession.setToolsCalls.at(-1)!; +- disabled.session.resetTurnState('disabled-connector'); +- const result = await invokeClientToolHandler(proxy, 'disabled-workiq'); +- const routingOff = await createConnectorRoutingSession({ routingEnabled: false }); +- assert.deepStrictEqual({ +- disabledResult: result.resultType, +- starts: disabled.mockSession.mcpStartServerCalls, +- routingOffReplacements: routingOff.mockSession.setToolsCalls, +- routingOffTools: routingOff.runtime.createClientSdkTools(true).map(tool => tool.name), +- }, { +- disabledResult: 'failure', +- starts: [], +- routingOffReplacements: [], +- routingOffTools: [RUNTIME_TOOL_SEARCH_TOOL_NAME], +- }); +- }); +- - for (const resume of [false, true]) { - test(`MCP routing includes uncached SDK user servers before ${resume ? 'resuming' : 'creating'} the SDK session`, async () => { - const configuration = { type: 'http' as const, url: 'https://mcp.atlassian.example/mcp', tools: ['*'] }; @@ -107635,10 +110811,7 @@ index 3d590951e18..00000000000 - const { session, runtime, mockSession, waitForSignal } = await createAgentSession(disposables, { - serverToolHost, clientSnapshot, activeClientToolSet, resume, - }); -- stateManager.dispatchServerAction(buildDefaultChatUri(sessionUri), { -- type: ActionType.ChatTurnStarted, turnId: 'artifact-tools', startedAt: new Date(0).toISOString(), -- message: { text: 'Manage the recorded artifacts', origin: { kind: MessageKind.User } }, -- }); +- stateManager.dispatchServerAction(buildDefaultChatUri(sessionUri), chatTurnStarted('artifact-tools', 'Manage the recorded artifacts', new Date(0).toISOString())); - const [search] = runtime.createClientSdkTools(true); - const tools = runtime.createServerSdkTools(); - const definitions = serverToolHost.getDefinitionsForSession(sessionUri); @@ -108642,6 +111815,81 @@ index 3d590951e18..00000000000 - }); - }); - +- for (const defaultEnabled of [false, true]) { +- test(`restores SDK-native MCP inheritance after a host override with default ${defaultEnabled}`, async () => { +- const name = 'native'; +- const id = 'mcp-top-level:copilotcli:test-session-1:native'; +- let decision: boolean | undefined; +- const { session, mockSession } = await createAgentSession(disposables, { +- sessionCustomizations: () => [{ +- type: CustomizationType.McpServer, id, uri: 'agent-discovery://copilot/mcp#native', name, +- state: { kind: McpServerStatus.Stopped }, +- _meta: withCustomizationDiscoveryMeta(undefined, { source: 'plugin', defaultEnabled }), +- }], +- resolveCustomizationEnablement: target => { +- const enabled = decision ?? target.defaultEnabled; +- return { +- kind: 'resolved', workingDirectory: { kind: 'workspaceless' }, +- enabled: enabled ?? true, +- enablement: enabled === undefined ? [] : [{ kind: CustomizationEnablementKind.Global, enabled }], +- }; +- }, +- configureMockSession: mock => { +- mock.mcpListResult = { servers: [{ name, status: defaultEnabled ? 'connected' : 'disabled' }] }; +- }, +- }); +- await session.send('leave native defaults alone'); +- const inherited = { enable: [...mockSession.mcpEnableCalls], disable: [...mockSession.mcpDisableCalls] }; +- decision = !defaultEnabled; +- await session.send('apply host override'); +- decision = undefined; +- await session.send('restore inheritance'); +- await session.send('leave restored inheritance alone'); +- assert.deepStrictEqual({ +- inherited, enable: mockSession.mcpEnableCalls, disable: mockSession.mcpDisableCalls, +- }, { +- inherited: { enable: [], disable: [] }, enable: [{ serverName: name }], disable: [{ serverName: name }], +- }); +- }); +- +- test(`observes SDK-native MCP default ${defaultEnabled} changing without creating host intent`, async () => { +- const name = 'native'; +- const id = 'mcp-top-level:copilotcli:test-session-1:native'; +- const { session, mockSession, signals } = await createAgentSession(disposables, { +- sessionCustomizations: () => [{ +- type: CustomizationType.McpServer, id, uri: 'agent-discovery://copilot/mcp#native', name, +- state: { kind: McpServerStatus.Stopped }, +- _meta: withCustomizationDiscoveryMeta(undefined, { source: 'plugin', defaultEnabled }), +- }], +- resolveCustomizationEnablement: target => ({ +- kind: 'resolved', workingDirectory: { kind: 'workspaceless' }, +- enabled: target.defaultEnabled ?? true, +- enablement: target.defaultEnabled === undefined ? [] : [{ kind: CustomizationEnablementKind.Global, enabled: target.defaultEnabled }], +- }), +- configureMockSession: mock => { +- mock.mcpListResult = { servers: [{ name, status: defaultEnabled ? 'connected' : 'disabled' }] }; +- }, +- }); +- await timeout(0); +- const status = defaultEnabled ? 'disabled' : 'pending'; +- mockSession.mcpListResult = { servers: [{ name, status }] }; +- mockSession.fire('session.mcp_server_status_changed', { serverName: name, status }); +- await timeout(0); +- await session.send('preserve the external SDK configuration'); +- const server = session.topLevelMcpCustomizations().find(server => server.name === name); +- assert.deepStrictEqual({ +- defaultEnabled: server && readCustomizationDiscoveryMeta(server)?.defaultEnabled, +- enablement: server?.enablement, +- enable: mockSession.mcpEnableCalls, disable: mockSession.mcpDisableCalls, +- toggles: getActions(signals).filter(action => action.type === ActionType.SessionCustomizationToggled), +- }, { +- defaultEnabled: !defaultEnabled, +- enablement: [{ kind: CustomizationEnablementKind.Global, enabled: !defaultEnabled }], +- enable: [], disable: [], toggles: [], +- }); +- }); +- } +- - test('does not force-enable a newly discovered configured-disabled MCP server or infer intent from startup', async () => { - const serverName = 'context7'; - let retained: readonly Customization[] = []; @@ -110979,6 +114227,138 @@ index 3d590951e18..00000000000 - }); - }); - +- suite('Connector ownership of an MCP authentication request', () => { +- const connectorScopeChallenge = 'read:user user:email write:plugin_gateway_connections repo'; +- +- async function requestAuthentication(server: Awaited>['servers'][number], options: { +- readonly runtimeConnector?: string; +- readonly getStatusError?: Error; +- readonly getStatus?: () => Promise>>; +- readonly connectorsEnabled?: boolean; +- readonly connectorOwnershipLookupTimeoutMs?: number; +- readonly beforeRequest?: (session: CopilotAgentSession) => void; +- } = {}) { +- const resource = server.url ?? 'https://mcp.test/'; +- let getStatusCalls = 0; +- const { session, mockSession, runtime } = await createAgentSession(disposables, { +- rootValues: { [AgentHostMcpConnectorsEnabledConfigKey]: options.connectorsEnabled ?? true }, +- connectorOwnershipLookupTimeoutMs: options.connectorOwnershipLookupTimeoutMs, +- configureMockSession: mock => { +- mock.mcpListResult = { servers: [server] }; +- if (options.runtimeConnector) { +- mock.connectorStatus = { +- ...mock.connectorStatus, +- catalog: { revision: 1, refreshedAtMs: 1, connectors: [{ name: options.runtimeConnector, displayName: 'Work Mail', status: 'connected', runtimeServerIds: [server.name] }] }, +- runtimeServers: [{ connectorName: options.runtimeConnector, runtimeServerId: server.name, status: 'needs_auth' }], +- }; +- } +- }, +- }); +- if (options.getStatusError) { +- const error = options.getStatusError; +- mockSession.rpc.connectors.getStatus = async () => { throw error; }; +- } else if (options.getStatus) { +- mockSession.rpc.connectors.getStatus = options.getStatus; +- } +- await timeout(0); +- const getStatus = mockSession.rpc.connectors.getStatus; +- mockSession.rpc.connectors.getStatus = () => { getStatusCalls++; return getStatus(); }; +- options.beforeRequest?.(session); +- let settled = false; +- const auth = runtime.handleMcpAuthRequest({ +- requestId: 'insufficient-scope', +- serverName: server.name, +- serverUrl: resource, +- reason: 'upscope', +- wwwAuthenticateParams: { scope: connectorScopeChallenge, error: 'insufficient_scope' }, +- }, { sessionId: 'test-session-1' }); +- void auth.then(() => { settled = true; }); +- await timeout(0); +- await timeout(0); +- return { session, resource, auth, settled: () => settled, getStatusCalls: () => getStatusCalls }; +- } +- +- for (const server of [ +- { name: 'github-mcp-server', status: 'connected', source: 'builtin', url: 'https://api.githubcopilot.com/mcp/' }, +- // A server configured through the session's MCP configuration reports no source. +- { name: 'ordinary', status: 'connected', url: 'https://ordinary.test/mcp' }, +- { name: 'github-copilot-connector-94d26095770df60673dd', status: 'needs-auth', source: 'managed', url: 'https://api.github.com/copilot-connectors/api/v1/connectors/mail/mcp' }, +- ] as const) { +- const runtimeConnector = server.source === 'managed' ? 'mail' : undefined; +- test(`classifies ${server.name} by the runtime's Connector state, not by its challenge`, async () => { +- const { session, resource, auth, settled } = await requestAuthentication(server, { runtimeConnector }); +- const [published] = session.topLevelMcpCustomizations(); +- const resolved = await session.resolveMcpAuthentication({ resource, scopes: connectorScopeChallenge.split(' '), token: 'step-up-token' }); +- await timeout(0); +- const settledByToken = settled(); +- session.cancelMcpServerAuthentication(published.id); +- +- assert.deepStrictEqual({ +- state: published.state.kind, +- connectorName: readMcpServerConnectorName(published), +- displayName: readMcpServerDisplayName(published), +- resolved, +- settledByToken, +- result: await auth, +- }, runtimeConnector ? { +- state: McpServerStatus.AuthRequired, connectorName: 'mail', displayName: 'Work Mail', +- resolved: false, settledByToken: false, result: { kind: 'cancelled' }, +- } : { +- state: McpServerStatus.AuthRequired, connectorName: undefined, displayName: undefined, +- resolved: true, settledByToken: true, result: { kind: 'token', accessToken: 'step-up-token' }, +- }); +- }); +- } +- +- test('treats a server as not Connector-owned when the runtime cannot report Connector state', async () => { +- const server = { name: 'ordinary', status: 'connected', url: 'https://ordinary.test/mcp' } as const; +- const { session, resource, auth } = await requestAuthentication(server, { getStatusError: new Error('connectors unavailable') }); +- const resolved = await session.resolveMcpAuthentication({ resource, scopes: connectorScopeChallenge.split(' '), token: 'mcp-token' }); +- assert.deepStrictEqual({ resolved, result: await auth }, { resolved: true, result: { kind: 'token', accessToken: 'mcp-token' } }); +- }); +- +- test('does not ask the runtime for Connector ownership when Copilot Connectors are disabled', async () => { +- const server = { name: 'github-mcp-server', status: 'connected', source: 'builtin', url: 'https://api.githubcopilot.com/mcp/' } as const; +- const { session, resource, auth, getStatusCalls } = await requestAuthentication(server, { connectorsEnabled: false }); +- const resolved = await session.resolveMcpAuthentication({ resource, scopes: connectorScopeChallenge.split(' '), token: 'mcp-token' }); +- assert.deepStrictEqual({ resolved, getStatusCalls: getStatusCalls(), result: await auth }, { +- resolved: true, getStatusCalls: 0, result: { kind: 'token', accessToken: 'mcp-token' }, +- }); +- }); +- +- test('does not keep a token waiting on a Connector status read that never answers', async () => { +- const server = { name: 'ordinary', status: 'connected', url: 'https://ordinary.test/mcp' } as const; +- const neverAnswers = () => new Promise(() => { }); +- const timedOut = await requestAuthentication(server, { getStatus: neverAnswers, connectorOwnershipLookupTimeoutMs: 1 }); +- const timedOutResolved = await timedOut.session.resolveMcpAuthentication({ resource: timedOut.resource, scopes: connectorScopeChallenge.split(' '), token: 'mcp-token' }); +- +- const cancelled = await requestAuthentication(server, { getStatus: neverAnswers }); +- const [published] = cancelled.session.topLevelMcpCustomizations(); +- const cancelledResolution = cancelled.session.resolveMcpAuthentication({ resource: cancelled.resource, scopes: connectorScopeChallenge.split(' '), token: 'mcp-token' }); +- await timeout(0); +- cancelled.session.cancelMcpServerAuthentication(published.id); +- +- assert.deepStrictEqual({ +- timedOut: { resolved: timedOutResolved, result: await timedOut.auth }, +- cancelled: { resolved: await cancelledResolution, result: await cancelled.auth }, +- }, { +- timedOut: { resolved: true, result: { kind: 'token', accessToken: 'mcp-token' } }, +- cancelled: { resolved: false, result: { kind: 'cancelled' } }, +- }); +- }); +- +- test('releases a Connector request identified from runtime state when the Connector is refreshed during a turn', async () => { +- const server = { name: 'github-copilot-connector-94d26095770df60673dd', status: 'needs-auth', source: 'managed', url: 'https://api.github.com/copilot-connectors/api/v1/connectors/mail/mcp' } as const; +- const { session, auth, settled } = await requestAuthentication(server, { +- runtimeConnector: 'mail', +- beforeRequest: session => session.resetTurnState('active-turn'), +- }); +- const beforeRefresh = settled(); +- session.markConnectorConfigurationChanged('mail'); +- assert.deepStrictEqual({ beforeRefresh, result: await auth }, { beforeRefresh: false, result: { kind: 'cancelled' } }); +- }); +- }); +- - test('targeted MCP authentication isolates servers sharing a protected resource and scopes', async () => { - const { session, runtime, waitForSignal } = await createAgentSession(disposables); - const resource = 'https://mcp.slack.com'; @@ -111624,6 +115004,87 @@ index 3d590951e18..00000000000 - }); - }); - +- test('MCP tool call started while authentication is pending transitions to auth required', async () => { +- const { session, mockSession, runtime, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-auth'); +- const authPromise = runtime.handleMcpAuthRequest({ +- requestId: 'auth-tool', +- serverName: 'github', +- serverUrl: 'https://api.githubcopilot.com/mcp/', +- reason: 'upscope', +- resourceMetadata: JSON.stringify({ +- resource: 'https://api.githubcopilot.com/mcp/', +- resource_name: 'GitHub MCP Server', +- authorization_servers: ['https://github.com/login/oauth'], +- scopes_supported: ['repo', 'notifications'], +- }), +- wwwAuthenticateParams: { scope: 'repo notifications', error: 'insufficient_scope' }, +- }, { sessionId: 'test-session-1' }); +- await timeout(0); +- +- for (const serverName of ['github', 'slack']) { +- mockSession.fire('tool.execution_start', { +- toolCallId: `tool-${serverName}`, +- toolName: 'mcp_tool', +- mcpServerName: serverName, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- } +- +- const beforeResolve = reduceTurnSignals(signals, 'turn-auth').activeTurn?.responseParts +- .filter(part => part.kind === ResponsePartKind.ToolCall) +- .map(part => ({ +- toolCallId: part.toolCall.toolCallId, +- status: part.toolCall.status, +- ...(part.toolCall.status === ToolCallStatus.AuthRequired ? { auth: part.toolCall.auth } : {}), +- })); +- const resolved = await session.resolveMcpAuthentication({ +- resource: 'https://api.githubcopilot.com/mcp/', +- scopes: ['repo', 'notifications'], +- token: 'token', +- }); +- const authResult = await authPromise; +- const resolvedPart = reduceTurnSignals(signals, 'turn-auth').activeTurn?.responseParts +- .find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === 'tool-github'); +- const afterResolve = getActions(signals) +- .filter(action => action.type === ActionType.ChatToolCallAuthResolved) +- .map(action => action.type === ActionType.ChatToolCallAuthResolved ? action : undefined); +- +- assert.deepStrictEqual({ +- beforeResolve, +- resolved, +- authResult, +- afterResolveStatus: resolvedPart?.kind === ResponsePartKind.ToolCall ? resolvedPart.toolCall.status : undefined, +- afterResolve, +- }, { +- beforeResolve: [{ +- toolCallId: 'tool-github', +- status: ToolCallStatus.AuthRequired, +- auth: { +- reason: McpAuthRequiredReason.InsufficientScope, +- resource: { +- resource: 'https://api.githubcopilot.com/mcp/', +- resource_name: 'GitHub MCP Server', +- authorization_servers: ['https://github.com/login/oauth'], +- scopes_supported: ['repo', 'notifications'], +- }, +- requiredScopes: ['repo', 'notifications'], +- description: 'insufficient_scope', +- }, +- }, { +- toolCallId: 'tool-slack', +- status: ToolCallStatus.Running, +- }], +- resolved: true, +- authResult: { kind: 'token', accessToken: 'token' }, +- afterResolveStatus: ToolCallStatus.Running, +- afterResolve: [{ +- type: ActionType.ChatToolCallAuthResolved, +- turnId: 'turn-auth', +- toolCallId: 'tool-github', +- }], +- }); +- }); +- - test('client tool completion cancels a pending MCP authentication for that tool call', async () => { - const { session, mockSession, runtime } = await createAgentSession(disposables); - session.resetTurnState('turn-auth-cancel'); @@ -112643,7 +116104,7 @@ index 3d590951e18..00000000000 - - suite('restricted telemetry', () => { - test('uses the originating chat rather than the persistence resource for compatibility telemetry', async () => { -- const sessionUri = AgentSession.uri('copilot', 'test-session-1'); +- const sessionUri = AgentSession.uri('copilot', 'host-session'); - const peer = URI.parse(buildChatUri(sessionUri, 'peer')); - const cases = [ - { resource: sessionUri, chat: URI.parse(buildDefaultChatUri(sessionUri)) }, @@ -112673,19 +116134,21 @@ index 3d590951e18..00000000000 - mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); - await telemetrySent.p; - -- const correlation = { conversationId: AgentSession.id(resource), chatSessionId: getTelemetryChatSessionId(chat) }; +- const correlation = { conversationId: AgentSession.id(resource), chatSessionId: getTelemetryChatSessionId(chat), sdkSessionId: undefined }; +- const sdkCorrelation = { ...correlation, sdkSessionId: mockSession.sessionId }; - assert.deepStrictEqual(telemetryService.events.map(event => ({ - eventName: event.eventName, - destination: event.destination, - conversationId: event.properties?.conversationId, - chatSessionId: event.properties?.chatSessionId, +- sdkSessionId: event.properties?.sdkSessionId, - })).sort((a, b) => `${a.eventName}:${a.destination}`.localeCompare(`${b.eventName}:${b.destination}`)), [ - { eventName: 'automode.routerDecisionRestricted', destination: 'enhanced', ...correlation }, -- { eventName: 'conversation.messageText', destination: 'enhanced', ...correlation }, -- { eventName: 'conversation.messageText', destination: 'enhanced', ...correlation }, -- { eventName: 'conversation.messageText', destination: 'internal', ...correlation }, -- { eventName: 'conversation.messageText', destination: 'internal', ...correlation }, -- { eventName: 'request.options.tools', destination: 'enhanced', ...correlation }, +- { eventName: 'conversation.messageText', destination: 'enhanced', ...sdkCorrelation }, +- { eventName: 'conversation.messageText', destination: 'enhanced', ...sdkCorrelation }, +- { eventName: 'conversation.messageText', destination: 'internal', ...sdkCorrelation }, +- { eventName: 'conversation.messageText', destination: 'internal', ...sdkCorrelation }, +- { eventName: 'request.options.tools', destination: 'enhanced', ...sdkCorrelation }, - { eventName: 'toolCallDetailsExternal', destination: 'enhanced', ...correlation, conversationId: AgentSession.id(sessionUri) }, - { eventName: 'toolCallDetailsInternal', destination: 'internal', ...correlation, conversationId: AgentSession.id(sessionUri) }, - ]); @@ -113419,19 +116882,19 @@ index 3d590951e18..00000000000 -}); diff --git a/src/vs/platform/agentHost/test/node/copilotApprovalPolicy.test.ts b/src/vs/platform/agentHost/test/node/copilotApprovalPolicy.test.ts deleted file mode 100644 -index 5c15406cb27..00000000000 +index d628c53ce96..00000000000 --- a/src/vs/platform/agentHost/test/node/copilotApprovalPolicy.test.ts +++ /dev/null -@@ -1,177 +0,0 @@ +@@ -1,206 +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 assert from 'assert'; --import type { PermissionMode, SessionEventPayload } from '@github/copilot-sdk'; +-import type { JsonValue, PermissionMode, SessionEventPayload } from '@github/copilot-sdk'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; --import { CopilotSessionApprovalPolicy, fromCopilotPermissionMode, getCopilotApprovalConfig, getCopilotApprovalPolicy } from '../../node/copilot/copilotApprovalPolicy.js'; +-import { CopilotSessionApprovalPolicy, fromCopilotPermissionMode, getCopilotApprovalConfig, getCopilotApprovalPolicy, isManagedWorkspaceRetrievalWithheld } from '../../node/copilot/copilotApprovalPolicy.js'; -import { getAvailableSessionApprovalValues, getSessionApprovalProperty } from '../../common/sessionConfigProperties.js'; - -type Resolved = SessionEventPayload<'session.managed_settings_resolved'>['data']; @@ -113524,6 +116987,35 @@ index 5c15406cb27..00000000000 - assert.deepStrictEqual(getCopilotApprovalPolicy({ ...baseline, failClosed: true, settings: { permissions: { disableBypassPermissionsMode: 'enable' } } }, true).available, ['default']); - }); - +- test('enterprise managed permission rules withhold workspace retrieval that the SDK cannot match against read rules', () => { +- const withPermissions = (permissions: { [key: string]: JsonValue }): Resolved => ({ ...baseline, serverManaged: true, settings: { permissions } }); +- assert.deepStrictEqual({ +- none: isManagedWorkspaceRetrievalWithheld(baseline), +- modesOnly: isManagedWorkspaceRetrievalWithheld(withPermissions({ disableBypassPermissionsMode: 'disable', defaultMode: 'assisted' })), +- domainBoundaryOnly: isManagedWorkspaceRetrievalWithheld(withPermissions({ limitTo: ['Domain(example.com)'] })), +- emptyRuleLists: isManagedWorkspaceRetrievalWithheld(withPermissions({ deny: [], ask: [], allow: [] })), +- readDenied: isManagedWorkspaceRetrievalWithheld(withPermissions({ deny: ['Read(/denied/**)'] })), +- shellAsk: isManagedWorkspaceRetrievalWithheld(withPermissions({ ask: ['Shell'] })), +- allow: isManagedWorkspaceRetrievalWithheld(withPermissions({ allow: ['Read(/src/**)'] })), +- allowIntersected: isManagedWorkspaceRetrievalWithheld({ ...baseline, permissionsAllowIntersected: true }), +- contextRule: isManagedWorkspaceRetrievalWithheld({ ...baseline, permissionsContext: { failClosed: false, permissions: { deny: ['Read(/denied/**)'] } } }), +- failClosed: isManagedWorkspaceRetrievalWithheld({ ...baseline, failClosed: true }), +- contextFailClosed: isManagedWorkspaceRetrievalWithheld({ ...baseline, permissionsContext: { failClosed: true } }), +- }, { +- none: false, +- modesOnly: false, +- domainBoundaryOnly: false, +- emptyRuleLists: false, +- readDenied: true, +- shellAsk: true, +- allow: true, +- allowIntersected: true, +- contextRule: true, +- failClosed: true, +- contextFailClosed: true, +- }); +- }); +- - test('launch snapshots do not apply until selection and runtime observations remain immediate', () => { - const policy = new CopilotSessionApprovalPolicy(); - const inputs = { requested: 'autoApprove' as const, legacyRestricted: false, globalAutoApprove: false }; @@ -113823,10 +117315,10 @@ index 7d3e79be104..a07e5c894ad 100644 diff --git a/src/vs/platform/agentHost/test/node/copilotCustomizationInstallations.test.ts b/src/vs/platform/agentHost/test/node/copilotCustomizationInstallations.test.ts deleted file mode 100644 -index bec5543c907..00000000000 +index cac32a29543..00000000000 --- a/src/vs/platform/agentHost/test/node/copilotCustomizationInstallations.test.ts +++ /dev/null -@@ -1,1855 +0,0 @@ +@@ -1,2056 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -113834,10 +117326,14 @@ index bec5543c907..00000000000 - -import type { CopilotClient, InstallationConfirmationRequest, McpInstallationReview } from '@github/copilot-sdk'; -import assert from 'assert'; +-import { createHash } from 'crypto'; +-import { bufferToStream, VSBuffer } from '../../../../base/common/buffer.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; +-import { stableStringify } from '../../../../base/common/objects.js'; -import { mock } from '../../../../base/test/common/mock.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; +-import { IRequestService } from '../../../request/common/request.js'; -import { CopilotCustomizationInstallations } from '../../node/copilot/customizations/copilotCustomizationInstallations.js'; - -type CatalogSearchSucceeded = Extract>, { readonly kind: 'succeeded' }>; @@ -115216,7 +118712,6 @@ index bec5543c907..00000000000 - protocolVersion: 3, - requiredCapabilities: [ - 'catalog-search-credential-required', -- 'catalog-search-session-bound', - 'catalog-selection', - 'mcp-install-planning', - 'multiple-transport-choice', @@ -115375,6 +118870,204 @@ index bec5543c907..00000000000 - }); - }); - +- function createLocalMcpCardFixture() { +- const document = { +- _meta: {}, +- server: { +- name: 'microsoft/markitdown', +- description: 'Convert documents to Markdown.', +- version: '1.0.0', +- packages: [{ +- registryType: 'pypi', +- identifier: 'markitdown-mcp', +- version: '0.0.1a4', +- transport: { type: 'stdio' }, +- packageArguments: [{ type: 'positional', value: '--verbose' }], +- environmentVariables: [{ name: 'MODE', value: 'stdio' }], +- }], +- }, +- }; +- const plan: McpInstallPlan = { +- planHandle: 'local-plan', +- planHandleExpiresAt: new Date(Date.now() + 60_000).toISOString(), +- identity: { canonicalName: document.server.name, serverName: document.server.name, version: document.server.version }, +- provenance: { +- authority: 'api.mcp.github.com', +- validatedAt: new Date().toISOString(), +- cardDigest: { algorithm: 'sha256-rfc8785', value: createHash('sha256').update(stableStringify(document)).digest('hex') }, +- mediaType: 'application/mcp-server+json', +- }, +- transportChoices: [{ +- choiceId: 'package:0', +- transport: 'stdio', +- installMethod: 'package', +- packageType: 'pypi', +- packageIdentifier: 'markitdown-mcp', +- requiredValues: [], +- secretPlaceholders: [], +- }], +- target: { scope: 'user', configKey: document.server.name }, +- policy: { decision: 'allowed', source: 'none' }, +- configurationChanges: [{ +- operation: 'add', +- scope: 'user', +- configKey: document.server.name, +- changedFields: ['args', 'command', 'env', 'tools', 'type'], +- secretReferences: [], +- }], +- reloadRequired: true, +- requiresInteractiveConfiguration: false, +- }; +- const added: Parameters[0][] = []; +- const requests: { url: string | undefined; followRedirects: number | undefined }[] = []; +- const response = { statusCode: 200, text: JSON.stringify(document) }; +- const client = { +- rpc: { +- catalog: new class extends mock() { }(), +- skills: new class extends mock() { }(), +- mcp: new class extends mock() { +- override readonly planInstall: CopilotClient['rpc']['mcp']['planInstall'] = async () => ({ +- kind: 'planned', plan, negotiated: { runtimeProtocolVersion: 3, grantedCapabilities: [] }, +- }); +- override readonly config = new class extends mock() { +- override readonly add: CopilotClient['rpc']['mcp']['config']['add'] = async request => { added.push(request); }; +- }(); +- }(), +- }, +- }; +- const requestService = new class extends mock() { +- override readonly request: IRequestService['request'] = async options => { +- requests.push({ url: options.url, followRedirects: options.followRedirects }); +- return { +- res: { statusCode: response.statusCode, headers: {} }, +- stream: bufferToStream(VSBuffer.fromString(response.text)), +- }; +- }; +- }(); +- const policySessions = new TestPolicySessions(); +- const service = store.add(new CopilotCustomizationInstallations(async () => client, policySessions.create, requestService)); +- const request = { +- mediaType: 'application/mcp-server+json', +- identifier: 'default:microsoft/markitdown', +- displayName: 'Markitdown', +- description: document.server.description, +- installation: { +- kind: 'mcp' as const, +- source: { +- kind: 'url' as const, +- mediaType: 'application/mcp-server+json' as const, +- url: 'https://api.mcp.github.com/v0.1/servers/microsoft%2Fmarkitdown/versions/latest', +- }, +- }, +- }; +- return { service, request, document, plan, added, requests, response }; +- } +- +- test('parses the SDK-validated local MCP card in the host and writes only after review', async () => { +- const { service, request, added, requests } = createLocalMcpCardFixture(); +- const review = await service.prepare(request); +- assert.deepStrictEqual(added, []); +- await service.apply(review.operationId); +- await assert.rejects(service.apply(review.operationId), /review expired/); +- assert.deepStrictEqual({ review, added, requests }, { +- review: { +- operationId: review.operationId, action: 'install', kind: 'mcp', displayName: 'Markitdown', +- serverName: 'microsoft/markitdown', target: 'microsoft/markitdown', +- command: 'uvx', args: ['markitdown-mcp@0.0.1a4', '--verbose'], env: { MODE: 'stdio' }, +- configurationFields: ['args', 'command', 'env', 'tools', 'type'], +- }, +- added: [{ +- name: 'microsoft/markitdown', +- config: { type: 'stdio', command: 'uvx', args: ['markitdown-mcp@0.0.1a4', '--verbose'], env: { MODE: 'stdio' }, tools: ['*'] }, +- }], +- requests: [{ url: request.installation.source.url, followRedirects: 0 }], +- }); +- }); +- +- test('refuses changed local cards, redirects, invalid JSON, and mismatched planned fields without writing', async () => { +- for (const scenario of ['digest', 'redirect', 'json', 'fields', 'identity', 'update'] as const) { +- const { service, request, plan, added, response } = createLocalMcpCardFixture(); +- switch (scenario) { +- case 'digest': response.text = response.text.replace('Convert documents', 'Changed documents'); break; +- case 'redirect': response.statusCode = 302; break; +- case 'json': response.text = '{'; break; +- case 'fields': plan.configurationChanges[0].changedFields = ['args', 'command', 'tools', 'type']; break; +- case 'identity': plan.identity.canonicalName = 'another/server'; break; +- case 'update': plan.configurationChanges[0].operation = 'update'; break; +- } +- await assert.rejects(service.prepare(request), /changed|HTTP 302|invalid JSON|does not match|cannot preserve/, scenario); +- assert.deepStrictEqual(added, [], scenario); +- } +- }); +- +- test('uses an input-free local package when the recommended remote card requires values', async () => { +- const { service, request, plan, added } = createLocalMcpCardFixture(); +- plan.transportChoices.push({ +- choiceId: 'remote:0', +- transport: 'streamable-http', +- installMethod: 'remote', +- endpoint: 'https://example.com/mcp', +- requiredValues: [{ kind: 'scalar', key: 'region', category: 'header', valueType: 'string', required: true, isRepeated: false }], +- secretPlaceholders: [], +- }); +- plan.recommendedTransportChoiceId = 'remote:0'; +- const review = await service.prepare(request); +- await service.apply(review.operationId); +- assert.deepStrictEqual(added, [{ +- name: 'microsoft/markitdown', +- config: { type: 'stdio', command: 'uvx', args: ['markitdown-mcp@0.0.1a4', '--verbose'], env: { MODE: 'stdio' }, tools: ['*'] }, +- }]); +- }); +- +- test('refuses blocked local plans and package inputs before fetching a card', async () => { +- for (const scenario of ['policy', 'secret', 'input'] as const) { +- const { service, request, plan, added, requests } = createLocalMcpCardFixture(); +- if (scenario === 'policy') { +- plan.policy = { decision: 'blocked', source: 'enterprise-allowlist', reason: 'Blocked by test policy' }; +- } else { +- const choice = plan.transportChoices[0]; +- if (scenario === 'secret') { +- choice.secretPlaceholders.push({ key: 'token', placeholder: '${secret:token}', title: 'Token' }); +- } else { +- choice.requiredValues.push({ kind: 'scalar', key: 'mode', category: 'environment-variable', valueType: 'string', required: true, isRepeated: false }); +- } +- } +- await assert.rejects(service.prepare(request), /Blocked by test policy|additional values/); +- assert.deepStrictEqual({ added, requests }, { added: [], requests: [] }); +- } +- }); +- +- test('bounds local MCP card reads at five mebibytes', async () => { +- for (const excess of [0, 1]) { +- const { service, request, added, response } = createLocalMcpCardFixture(); +- response.text = response.text.padEnd(5 * 1024 * 1024 + excess, ' '); +- if (excess) { +- await assert.rejects(service.prepare(request), /too large/); +- } else { +- await service.prepare(request); +- } +- assert.deepStrictEqual(added, []); +- } +- }); +- +- test('refuses local parser inputs even when the SDK advertises an input-free choice', async () => { +- const { service, request, document, plan, added, response } = createLocalMcpCardFixture(); +- const inputDocument = { +- ...document, +- server: { +- ...document.server, +- packages: [{ +- ...document.server.packages[0], +- environmentVariables: [{ name: 'TOKEN', isRequired: true, isSecret: true }], +- }], +- }, +- }; +- response.text = JSON.stringify(inputDocument); +- plan.provenance.cardDigest.value = createHash('sha256').update(stableStringify(inputDocument)).digest('hex'); +- await assert.rejects(service.prepare(request), /additional configuration/); +- assert.deepStrictEqual(added, []); +- }); +- - test('uses receipt inventory and confirms only the reviewed pending Skill operation', async () => { - const expiresAt = new Date(Date.now() + 60_000).toISOString(); - const catalogue = { @@ -115993,10 +119686,10 @@ index b000d3fd293..00000000000 -}); diff --git a/src/vs/platform/agentHost/test/node/copilotManagedSettings.test.ts b/src/vs/platform/agentHost/test/node/copilotManagedSettings.test.ts deleted file mode 100644 -index 9374ead2f76..00000000000 +index 6b6fa025d49..00000000000 --- a/src/vs/platform/agentHost/test/node/copilotManagedSettings.test.ts +++ /dev/null -@@ -1,297 +0,0 @@ +@@ -1,295 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -116008,6 +119701,7 @@ index 9374ead2f76..00000000000 -import { CancellationTokenSource, type CancellationToken } from '../../../../base/common/cancellation.js'; -import { Emitter } from '../../../../base/common/event.js'; -import { isCancellationError } from '../../../../base/common/errors.js'; +-import { toDisposable } from '../../../../base/common/lifecycle.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { NullLogService } from '../../../log/common/log.js'; -import { AgentPolicyError } from '../../common/agent.js'; @@ -116196,18 +119890,15 @@ index 9374ead2f76..00000000000 - const cancellation: CancellationToken = { isCancellationRequested: false, onCancellationRequested: emitter.event }; - const listeners: boolean[] = []; - const clock = useFakeTimers(); -- try { -- for (let attempt = 0; attempt < 3; attempt++) { -- const rejected = assert.rejects(resolveCopilotManagedSettings(rpc, 'token', 30_000, undefined, cancellation), AgentPolicyError); -- listeners.push(emitter.hasListeners()); -- await clock.tickAsync(30_000); -- await rejected; -- listeners.push(emitter.hasListeners()); -- } -- assert.deepStrictEqual(listeners, [true, false, true, false, true, false]); -- } finally { -- clock.restore(); +- disposables.add(toDisposable(() => clock.restore())); +- for (let attempt = 0; attempt < 3; attempt++) { +- const rejected = assert.rejects(resolveCopilotManagedSettings(rpc, 'token', 30_000, undefined, cancellation), AgentPolicyError); +- listeners.push(emitter.hasListeners()); +- await clock.tickAsync(30_000); +- await rejected; +- listeners.push(emitter.hasListeners()); - } +- assert.deepStrictEqual(listeners, [true, false, true, false, true, false]); - }); - - test('runtime cache fallback is preserved and strict refresh rejection is not bypassed', async () => { @@ -116374,10 +120065,10 @@ index 03bbab04705..00000000000 -}); diff --git a/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts b/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts deleted file mode 100644 -index 2a070ea454c..00000000000 +index 9e0d6ab5bae..00000000000 --- a/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts +++ /dev/null -@@ -1,1149 +0,0 @@ +@@ -1,1283 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -116387,17 +120078,16 @@ index 2a070ea454c..00000000000 -import { writeFileSync, unlinkSync } from 'fs'; -import { fileURLToPath } from 'url'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; --import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { Schemas } from '../../../../base/common/network.js'; -import { URI } from '../../../../base/common/uri.js'; -import { VSBuffer } from '../../../../base/common/buffer.js'; +-import { toDisposable } from '../../../../base/common/lifecycle.js'; -import { FileService } from '../../../files/common/fileService.js'; --import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; --import { NullLogService } from '../../../log/common/log.js'; +-import { createInMemoryFileService } from '../../../files/test/common/inMemoryFileService.js'; -import { IMcpServerConfiguration, McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; -import { toCopilotMcpServerConfiguration } from '../../../mcp/common/mcpCopilotConfiguration.js'; --import { mapCopilotCustomAgentTools, toSdkInstructionDirectories, toSdkMcpServers, toSdkCustomAgents, toSdkSessionCustomAgents, toSdkSkillDirectories, findRelocatedAgentName, parsedPluginsEqual, toSdkHooks, type IPluginAgentsForSdk } from '../../node/copilot/copilotPluginConverters.js'; --import { PluginFormat, type IMcpServerDefinition, type INamedPluginResource, type IParsedHookGroup, type IParsedPlugin, type IParsedSkill } from '../../../agentPlugins/common/pluginParsers.js'; +-import { getSdkCustomAgentName, mapCopilotCustomAgentTools, toSdkInstructionDirectories, toSdkMcpServers, toSdkCustomAgents, toSdkSessionCustomAgents, toSdkSkillDirectories, findRelocatedAgentName, parsedPluginsEqual, toSdkHooks, type IPluginAgentsForSdk } from '../../node/copilot/copilotPluginConverters.js'; +-import { PluginFormat, toParsedAgent, type IMcpServerDefinition, type INamedPluginResource, type IParsedHookGroup, type IParsedPlugin, type IParsedSkill } from '../../../agentPlugins/common/pluginParsers.js'; -import { CustomizationType, McpServerStatus, type HookCustomization, type McpServerCustomization, type SkillCustomization } from '../../common/state/protocol/state.js'; - -function stubMcpCustomization(name = 'test'): McpServerCustomization { @@ -116412,16 +120102,13 @@ index 2a070ea454c..00000000000 - -suite('copilotPluginConverters', () => { - -- const disposables = new DisposableStore(); +- const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - let fileService: FileService; - - setup(() => { -- fileService = disposables.add(new FileService(new NullLogService())); -- disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); +- fileService = createInMemoryFileService(disposables, Schemas.inMemory); - }); - -- teardown(() => disposables.clear()); -- ensureNoDisposablesAreLeakedInTestSuite(); - - // ---- toSdkMcpServers ------------------------------------------------ - @@ -116605,6 +120292,51 @@ index 2a070ea454c..00000000000 - - suite('toSdkCustomAgents', () => { - +- test('keeps authored names visible in single-line model descriptions', async () => { +- const uri = URI.from({ scheme: Schemas.inMemory, path: '/agents/reviewer.md' }); +- await fileService.writeFile(uri, VSBuffer.fromString('---\nname: Reviewer\ndescription: |\n Reviews code.\n Reports bugs.\n---\nPrompt')); +- const [agent] = await toSdkCustomAgents([{ uri, name: 'reviewer' }], fileService); +- assert.deepStrictEqual({ name: agent.name, displayName: agent.displayName, description: agent.description }, { +- name: getSdkCustomAgentName(uri), displayName: 'Reviewer', description: 'Reviewer: Reviews code. Reports bugs.', +- }); +- }); +- +- test('uses a logical source identity supplied by the snapshot rather than the cache path', async () => { +- const uri = URI.from({ scheme: Schemas.inMemory, path: '/cache/revision/agent.md' }); +- await fileService.writeFile(uri, VSBuffer.fromString('---\nname: Friendly Reviewer\n---\nReview.')); +- const sdkName = getSdkCustomAgentName(URI.parse('client-plugin:/source/agent.md')); +- assert.deepStrictEqual(await toSdkCustomAgents([{ uri, name: 'Friendly Reviewer', sdkName }], fileService), [{ +- name: sdkName, displayName: 'Friendly Reviewer', description: 'Friendly Reviewer', tools: null, prompt: 'Review.', +- }]); +- }); +- +- test('keeps duplicate authored names distinct and their SDK identities independent of file contents', async () => { +- const uris = ['first', 'second'].map(source => URI.from({ scheme: Schemas.inMemory, path: `/${source}/shared.agent.md` })); +- for (const [index, uri] of uris.entries()) { +- await fileService.writeFile(uri, VSBuffer.fromString(`---\nname: Friendly Reviewer\n---\nPrompt ${index}`)); +- } +- const agents = uris.map(uri => ({ uri, name: 'Friendly Reviewer' })); +- const original = await toSdkCustomAgents(agents, fileService); +- await fileService.writeFile(uris[0], VSBuffer.fromString('---\nname: Renamed Reviewer\n---\nUpdated prompt')); +- const updated = await toSdkCustomAgents(agents, fileService); +- +- assert.deepStrictEqual({ +- uniqueIds: new Set(original.map(agent => agent.name)).size, +- opaqueIds: original.every(agent => /^vscode-agent-[0-9a-f]{40}$/.test(agent.name)), +- displayNames: original.map(agent => agent.displayName), +- prompts: original.map(agent => agent.prompt), +- stableIds: updated.map(agent => agent.name), +- updatedLabel: updated[0].displayName, +- }, { +- uniqueIds: 2, +- opaqueIds: true, +- displayNames: ['Friendly Reviewer', 'Friendly Reviewer'], +- prompts: ['Prompt 0', 'Prompt 1'], +- stableIds: original.map(agent => agent.name), +- updatedLabel: 'Renamed Reviewer', +- }); +- }); +- - test('reads agent files without frontmatter and creates configs', async () => { - const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/helper.md' }); - await fileService.writeFile(agentUri, VSBuffer.fromString('You are a helpful assistant')); @@ -116613,7 +120345,9 @@ index 2a070ea454c..00000000000 - const result = await toSdkCustomAgents(agents, fileService); - - assert.deepStrictEqual(result, [{ -- name: 'helper', +- name: getSdkCustomAgentName(agentUri), +- displayName: 'helper', +- description: 'helper', - tools: null, - prompt: 'You are a helpful assistant', - }]); @@ -116636,8 +120370,9 @@ index 2a070ea454c..00000000000 - { uri: URI.from({ scheme: Schemas.inMemory, path: '/agents/bad.md' }), name: 'bad' }, - ]; - const result = await toSdkCustomAgents(agents, fileService); -- assert.strictEqual(result.length, 1); -- assert.strictEqual(result[0].name, 'good'); +- assert.deepStrictEqual(result.map(agent => ({ name: agent.name, displayName: agent.displayName })), [{ +- name: getSdkCustomAgentName(goodUri), displayName: 'good', +- }]); - }); - - test('parses YAML frontmatter for name, description, tools, and body', async () => { @@ -116658,8 +120393,9 @@ index 2a070ea454c..00000000000 - const result = await toSdkCustomAgents(agents, fileService); - - assert.deepStrictEqual(result, [{ -- name: 'code-reviewer', -- description: 'Reviews code for quality issues', +- name: getSdkCustomAgentName(agentUri), +- displayName: 'code-reviewer', +- description: 'code-reviewer: Reviews code for quality issues', - tools: ['read_file', 'grep_search'], - prompt: 'You are a meticulous code reviewer.\n', - }]); @@ -116741,13 +120477,17 @@ index 2a070ea454c..00000000000 - - assert.deepStrictEqual(result, [ - { -- name: 'scalar-model', +- name: getSdkCustomAgentName(scalarUri), +- displayName: 'scalar-model', +- description: 'scalar-model', - model: 'claude-haiku-4.5', - tools: null, - prompt: 'Scalar body.', - }, - { -- name: 'fallback-models', +- name: getSdkCustomAgentName(fallbackUri), +- displayName: 'fallback-models', +- description: 'fallback-models', - model: 'gpt-5.6-sol', - tools: null, - prompt: 'Fallback body.', @@ -116790,7 +120530,9 @@ index 2a070ea454c..00000000000 - - assert.deepStrictEqual(result, [ - { -- name: 'first', +- name: getSdkCustomAgentName(firstAgentUri), +- displayName: 'first', +- description: 'first', - tools: null, - hooks: { - SessionStart: [{ type: 'command', command: 'echo first' }], @@ -116799,7 +120541,9 @@ index 2a070ea454c..00000000000 - prompt: 'First agent.', - }, - { -- name: 'second', +- name: getSdkCustomAgentName(secondAgentUri), +- displayName: 'second', +- description: 'second', - tools: null, - hooks: { - PreToolUse: [{ type: 'command', command: 'deny second' }], @@ -116836,7 +120580,9 @@ index 2a070ea454c..00000000000 - const result = await toSdkCustomAgents([{ uri: agentUri, name: 'mixed' }], fileService); - - assert.deepStrictEqual(result, [{ -- name: 'mixed', +- name: getSdkCustomAgentName(agentUri), +- displayName: 'mixed', +- description: 'mixed', - tools: null, - hooks: { - SessionStart: [{ type: 'command', command: 'echo started' }], @@ -116866,8 +120612,10 @@ index 2a070ea454c..00000000000 - - const result = await toSdkCustomAgents(agents, fileService); - -- assert.deepStrictEqual(result, reasoningEfforts.map(reasoningEffort => ({ -- name: reasoningEffort, +- assert.deepStrictEqual(result, reasoningEfforts.map((reasoningEffort, index) => ({ +- name: getSdkCustomAgentName(agents[index].uri), +- displayName: reasoningEffort, +- description: reasoningEffort, - reasoningEffort, - tools: null, - prompt: 'Body.', @@ -116897,8 +120645,8 @@ index 2a070ea454c..00000000000 - ], fileService); - - assert.deepStrictEqual(result, [ -- { name: 'missing-effort', tools: null, prompt: 'Body.' }, -- { name: 'unsupported-effort', tools: null, prompt: 'Body.' }, +- { name: getSdkCustomAgentName(missingUri), displayName: 'missing-effort', description: 'missing-effort', tools: null, prompt: 'Body.' }, +- { name: getSdkCustomAgentName(unsupportedUri), displayName: 'unsupported-effort', description: 'unsupported-effort', tools: null, prompt: 'Body.' }, - ]); - }); - @@ -116919,7 +120667,9 @@ index 2a070ea454c..00000000000 - const result = await toSdkCustomAgents(agents, fileService); - - assert.deepStrictEqual(result, [{ -- name: 'skilled', +- name: getSdkCustomAgentName(agentUri), +- displayName: 'skilled', +- description: 'skilled', - tools: null, - skills: ['baking-cake', 'cooking-pasta'], - infer: true, @@ -116953,14 +120703,18 @@ index 2a070ea454c..00000000000 - - assert.deepStrictEqual(result, [ - { -- name: 'no-invoke', +- name: getSdkCustomAgentName(disabledUri), +- displayName: 'no-invoke', +- description: 'no-invoke', - tools: null, - infer: false, - disableModelInvocation: true, - prompt: 'Body.', - }, - { -- name: 'legacy-no-invoke', +- name: getSdkCustomAgentName(legacyUri), +- displayName: 'legacy-no-invoke', +- description: 'legacy-no-invoke', - tools: null, - infer: false, - disableModelInvocation: true, @@ -116999,7 +120753,9 @@ index 2a070ea454c..00000000000 - const result = await toSdkCustomAgents(agents, fileService); - - assert.deepStrictEqual(result, [{ -- name: 'free-for-all', +- name: getSdkCustomAgentName(agentUri), +- displayName: 'free-for-all', +- description: 'free-for-all', - tools: null, - prompt: 'Body.', - }]); @@ -117018,14 +120774,15 @@ index 2a070ea454c..00000000000 - const result = await toSdkCustomAgents(agents, fileService); - - assert.deepStrictEqual(result, [{ -- name: 'resource-name', -- description: 'Helper without an explicit name', +- name: getSdkCustomAgentName(agentUri), +- displayName: 'resource-name', +- description: 'resource-name: Helper without an explicit name', - tools: null, - prompt: 'Body only.', - }]); - }); - -- test('trims whitespace from frontmatter name to match parsed agent name', async () => { +- test('trims the authored display name without changing the URI-derived SDK identity', async () => { - const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/padded.md' }); - await fileService.writeFile(agentUri, VSBuffer.fromString([ - '---', @@ -117037,7 +120794,9 @@ index 2a070ea454c..00000000000 - const agents: INamedPluginResource[] = [{ uri: agentUri, name: 'padded' }]; - const result = await toSdkCustomAgents(agents, fileService); - -- assert.strictEqual(result[0].name, 'Inbox'); +- assert.deepStrictEqual({ name: result[0].name, displayName: result[0].displayName }, { +- name: getSdkCustomAgentName(agentUri), displayName: 'Inbox', +- }); - }); - }); - @@ -117052,7 +120811,7 @@ index 2a070ea454c..00000000000 - const plugins: IPluginAgentsForSdk[] = [{ agents: [{ uri: agentUri, name: 'helper' }] }]; - const result = await toSdkSessionCustomAgents(plugins, fileService); - -- assert.deepStrictEqual(result, [{ name: 'helper', tools: null, prompt: 'Loose agent' }]); +- assert.deepStrictEqual(result, [{ name: getSdkCustomAgentName(agentUri), displayName: 'helper', description: 'helper', tools: null, prompt: 'Loose agent' }]); - }); - - test('includes plugin agents and preserves their hooks', async () => { @@ -117081,12 +120840,14 @@ index 2a070ea454c..00000000000 - - assert.deepStrictEqual(result, [ - { -- name: 'parent', +- name: getSdkCustomAgentName(parentUri), +- displayName: 'parent', +- description: 'parent', - tools: null, - hooks: { PreToolUse: [{ type: 'command', command: 'deny parent' }] }, - prompt: 'Parent agent', - }, -- { name: 'sibling', tools: null, prompt: 'Sibling agent' }, +- { name: getSdkCustomAgentName(siblingUri), displayName: 'sibling', description: 'sibling', tools: null, prompt: 'Sibling agent' }, - ]); - }); - }); @@ -117182,34 +120943,80 @@ index 2a070ea454c..00000000000 - return { command, cleanup: () => { try { unlinkSync(filePath); } catch { /* ignore */ } } }; - } - +- test('onUserPromptTransformed returns the replacement prompt', async () => { +- const expected = { modifiedTransformedPrompt: 'Host context with plugin guidance' }; +- const { command, cleanup } = echoJsonCmd(expected); +- disposables.add(toDisposable(() => cleanup())); +- const hooks = toSdkHooks([makeHookGroup('UserPromptTransformed', command)]); +- const input = { prompt: 'Original', transformedPrompt: 'Host context', timestamp: new Date(0), workingDirectory: '/', sessionId: 'test' }; +- assert.deepStrictEqual(await hooks.onUserPromptTransformed!(input, { sessionId: 'test' }), expected); +- }); +- +- test('onUserPromptTransformed composes commands without changing the submitted prompt or retaining previous input', async () => { +- const dir = fileURLToPath(new URL('.', import.meta.url)); +- const filePath = `${dir}/vscode-test-prompt-transform-${Date.now()}.js`; +- writeFileSync(filePath, ` +- let stdin = ''; +- for await (const chunk of process.stdin) { stdin += chunk; } +- const input = JSON.parse(stdin); +- process.stdout.write(JSON.stringify({ modifiedTransformedPrompt: input.transformedPrompt + '/' + input.prompt })); +- `); +- disposables.add(toDisposable(() => unlinkSync(filePath))); +- const group = makeHookGroup('UserPromptTransformed', `node ${filePath}`); +- const hooks = toSdkHooks([group, group]); +- const input = { prompt: 'Original', transformedPrompt: 'Host context', timestamp: new Date(0), workingDirectory: '/', sessionId: 'test' }; +- const first = await hooks.onUserPromptTransformed!(input, { sessionId: 'test' }); +- const next = await hooks.onUserPromptTransformed!({ ...input, transformedPrompt: 'New context' }, { sessionId: 'test' }); +- assert.deepStrictEqual({ first, next, original: input.transformedPrompt }, { +- first: { modifiedTransformedPrompt: 'Host context/Original/Original' }, +- next: { modifiedTransformedPrompt: 'New context/Original/Original' }, +- original: 'Host context', +- }); +- }); +- +- test('onUserPromptTransformed preserves an empty replacement across a failed command', async () => { +- const { command, cleanup } = echoJsonCmd({ modifiedTransformedPrompt: '' }); +- disposables.add(toDisposable(() => cleanup())); +- const hooks = toSdkHooks([ +- makeHookGroup('UserPromptTransformed', command), +- makeHookGroup('UserPromptTransformed', 'exit 1'), +- ]); +- const input = { prompt: 'Original', transformedPrompt: 'Host context', timestamp: new Date(0), workingDirectory: '/', sessionId: 'test' }; +- assert.deepStrictEqual(await hooks.onUserPromptTransformed!(input, { sessionId: 'test' }), { modifiedTransformedPrompt: '' }); +- }); +- +- for (const output of [{}, { modifiedTransformedPrompt: 42 }]) { +- test(`onUserPromptTransformed ignores output without a string replacement: ${JSON.stringify(output)}`, async () => { +- const { command, cleanup } = echoJsonCmd(output); +- disposables.add(toDisposable(() => cleanup())); +- const hooks = toSdkHooks([makeHookGroup('UserPromptTransformed', command)]); +- const input = { prompt: 'Original', transformedPrompt: 'Host context', timestamp: new Date(0), workingDirectory: '/', sessionId: 'test' }; +- assert.strictEqual(await hooks.onUserPromptTransformed!(input, { sessionId: 'test' }), undefined); +- }); +- } +- - test('onPostToolUse returns parsed JSON output as hook result', async () => { - const expectedOutput = { additionalContext: 'Before presenting the plan, run review-plan skill' }; - const { command, cleanup } = echoJsonCmd(expectedOutput); -- try { -- const hookGroup = makeHookGroup('PostToolUse', command); -- const hooks = toSdkHooks([hookGroup]); -- const toolResult = { textResultForLlm: 'ok', resultType: 'success' as const }; -- const result = await hooks.onPostToolUse!({ toolName: 'memory', toolArgs: {}, toolResult, timestamp: new Date(0), workingDirectory: '/', sessionId: 'test' }, { sessionId: 'test' }); -- assert.deepStrictEqual(result, expectedOutput); -- } finally { -- cleanup(); -- } +- disposables.add(toDisposable(() => cleanup())); +- const hookGroup = makeHookGroup('PostToolUse', command); +- const hooks = toSdkHooks([hookGroup]); +- const toolResult = { textResultForLlm: 'ok', resultType: 'success' as const }; +- const result = await hooks.onPostToolUse!({ toolName: 'memory', toolArgs: {}, toolResult, timestamp: new Date(0), workingDirectory: '/', sessionId: 'test' }, { sessionId: 'test' }); +- assert.deepStrictEqual(result, expectedOutput); - }); - - test('onPostToolUse returns output of a hook that exits without reading its input', async () => { - const expectedOutput = { additionalContext: 'stdin was ignored' }; - const { command, cleanup } = echoJsonCmd(expectedOutput); -- try { -- const hookGroup = makeHookGroup('PostToolUse', command); -- const hooks = toSdkHooks([hookGroup]); -- // Larger than any OS pipe buffer, so the stdin write is still pending -- // when the hook exits and fails with EPIPE (EOF on Windows). -- const toolResult = { textResultForLlm: 'x'.repeat(4 * 1024 * 1024), resultType: 'success' as const }; -- const result = await hooks.onPostToolUse!({ toolName: 'view', toolArgs: {}, toolResult, timestamp: new Date(0), workingDirectory: '/', sessionId: 'test' }, { sessionId: 'test' }); -- assert.deepStrictEqual(result, expectedOutput); -- } finally { -- cleanup(); -- } +- disposables.add(toDisposable(() => cleanup())); +- const hookGroup = makeHookGroup('PostToolUse', command); +- const hooks = toSdkHooks([hookGroup]); +- // Larger than any OS pipe buffer, so the stdin write is still pending +- // when the hook exits and fails with EPIPE (EOF on Windows). +- const toolResult = { textResultForLlm: 'x'.repeat(4 * 1024 * 1024), resultType: 'success' as const }; +- const result = await hooks.onPostToolUse!({ toolName: 'view', toolArgs: {}, toolResult, timestamp: new Date(0), workingDirectory: '/', sessionId: 'test' }, { sessionId: 'test' }); +- assert.deepStrictEqual(result, expectedOutput); - }); - - test('onPostToolUse returns undefined when output is non-JSON', async () => { @@ -117217,30 +121024,24 @@ index 2a070ea454c..00000000000 - const dir = fileURLToPath(new URL('.', import.meta.url)).replace(/[\\/]$/, ''); - const filePath = `${dir}/vscode-test-hook-nonjson-${Date.now()}.js`; - writeFileSync(filePath, `process.stdout.write('not-json');\n`); -- try { -- const hookGroup = makeHookGroup('PostToolUse', `node ${filePath}`); -- const hooks = toSdkHooks([hookGroup]); -- const toolResult = { textResultForLlm: 'ok', resultType: 'success' as const }; -- const result = await hooks.onPostToolUse!({ toolName: 'memory', toolArgs: {}, toolResult, timestamp: new Date(0), workingDirectory: '/', sessionId: 'test' }, { sessionId: 'test' }); -- assert.strictEqual(result, undefined); -- } finally { -- try { unlinkSync(filePath); } catch { /* ignore */ } -- } +- disposables.add(toDisposable(() => { try { unlinkSync(filePath); } catch { /* ignore */ } })); +- const hookGroup = makeHookGroup('PostToolUse', `node ${filePath}`); +- const hooks = toSdkHooks([hookGroup]); +- const toolResult = { textResultForLlm: 'ok', resultType: 'success' as const }; +- const result = await hooks.onPostToolUse!({ toolName: 'memory', toolArgs: {}, toolResult, timestamp: new Date(0), workingDirectory: '/', sessionId: 'test' }, { sessionId: 'test' }); +- assert.strictEqual(result, undefined); - }); - - test('onPostToolUse returns undefined when command fails', async () => { - const dir = fileURLToPath(new URL('.', import.meta.url)).replace(/[\\/]$/, ''); - const filePath = `${dir}/vscode-test-hook-fail-${Date.now()}.js`; - writeFileSync(filePath, `process.exit(1);\n`); -- try { -- const hookGroup = makeHookGroup('PostToolUse', `node ${filePath}`); -- const hooks = toSdkHooks([hookGroup]); -- const toolResult = { textResultForLlm: 'ok', resultType: 'success' as const }; -- const result = await hooks.onPostToolUse!({ toolName: 'memory', toolArgs: {}, toolResult, timestamp: new Date(0), workingDirectory: '/', sessionId: 'test' }, { sessionId: 'test' }); -- assert.strictEqual(result, undefined); -- } finally { -- try { unlinkSync(filePath); } catch { /* ignore */ } -- } +- disposables.add(toDisposable(() => { try { unlinkSync(filePath); } catch { /* ignore */ } })); +- const hookGroup = makeHookGroup('PostToolUse', `node ${filePath}`); +- const hooks = toSdkHooks([hookGroup]); +- const toolResult = { textResultForLlm: 'ok', resultType: 'success' as const }; +- const result = await hooks.onPostToolUse!({ toolName: 'memory', toolArgs: {}, toolResult, timestamp: new Date(0), workingDirectory: '/', sessionId: 'test' }, { sessionId: 'test' }); +- assert.strictEqual(result, undefined); - }); - - test('onPostToolUse returns undefined when no commands', async () => { @@ -117251,44 +121052,38 @@ index 2a070ea454c..00000000000 - test('onPostToolUse calls editTrackingHooks and returns command output', async () => { - const expectedOutput = { additionalContext: 'context from hook' }; - const { command, cleanup } = echoJsonCmd(expectedOutput); -- try { -- const hookGroup = makeHookGroup('PostToolUse', command); -- let trackingInput: unknown; -- const editTrackingHooks = { -- onPreToolUse: async () => { }, -- onPostToolUse: async (input: unknown) => { trackingInput = input; return undefined; }, -- }; -- const hooks = toSdkHooks([hookGroup], editTrackingHooks); -- const toolResult = { textResultForLlm: 'ok', resultType: 'success' as const }; -- const callInput = { toolName: 'memory', toolArgs: {}, toolResult, timestamp: new Date(0), workingDirectory: '/', sessionId: 'test' }; -- const result = await hooks.onPostToolUse!(callInput, { sessionId: 'test' }); -- assert.deepStrictEqual(result, expectedOutput); -- assert.deepStrictEqual(trackingInput, callInput); -- } finally { -- cleanup(); -- } +- disposables.add(toDisposable(() => cleanup())); +- const hookGroup = makeHookGroup('PostToolUse', command); +- let trackingInput: unknown; +- const editTrackingHooks = { +- onPreToolUse: async () => { }, +- onPostToolUse: async (input: unknown) => { trackingInput = input; return undefined; }, +- }; +- const hooks = toSdkHooks([hookGroup], editTrackingHooks); +- const toolResult = { textResultForLlm: 'ok', resultType: 'success' as const }; +- const callInput = { toolName: 'memory', toolArgs: {}, toolResult, timestamp: new Date(0), workingDirectory: '/', sessionId: 'test' }; +- const result = await hooks.onPostToolUse!(callInput, { sessionId: 'test' }); +- assert.deepStrictEqual(result, expectedOutput); +- assert.deepStrictEqual(trackingInput, callInput); - }); - - test('tool hooks preserve host context alongside plugin output and after failures', async () => { - const { command, cleanup } = echoJsonCmd({ additionalContext: 'context from hook', suppressOutput: true }); -- try { -- const hooks = toSdkHooks([makeHookGroup('PostToolUse', command)], { -- onPreToolUse: async () => { }, -- onPostToolUse: async () => ({ additionalContext: 'host context' }), -- onPostToolUseFailure: () => ({ additionalContext: 'host context after a failure' }), -- }); -- const base = { toolName: 'edit', toolArgs: {}, timestamp: new Date(0), workingDirectory: '/', sessionId: 'test' }; +- disposables.add(toDisposable(() => cleanup())); +- const hooks = toSdkHooks([makeHookGroup('PostToolUse', command)], { +- onPreToolUse: async () => { }, +- onPostToolUse: async () => ({ additionalContext: 'host context' }), +- onPostToolUseFailure: () => ({ additionalContext: 'host context after a failure' }), +- }); +- const base = { toolName: 'edit', toolArgs: {}, timestamp: new Date(0), workingDirectory: '/', sessionId: 'test' }; - -- assert.deepStrictEqual({ -- success: await hooks.onPostToolUse!({ ...base, toolResult: { textResultForLlm: 'ok', resultType: 'success' } }, { sessionId: 'test' }), -- failure: await hooks.onPostToolUseFailure!({ ...base, error: 'No match found' }, { sessionId: 'test' }), -- }, { -- success: { additionalContext: 'host context\n\ncontext from hook', suppressOutput: true }, -- failure: { additionalContext: 'host context after a failure' }, -- }); -- } finally { -- cleanup(); -- } +- assert.deepStrictEqual({ +- success: await hooks.onPostToolUse!({ ...base, toolResult: { textResultForLlm: 'ok', resultType: 'success' } }, { sessionId: 'test' }), +- failure: await hooks.onPostToolUseFailure!({ ...base, error: 'No match found' }, { sessionId: 'test' }), +- }, { +- success: { additionalContext: 'host context\n\ncontext from hook', suppressOutput: true }, +- failure: { additionalContext: 'host context after a failure' }, +- }); - }); - - test('onPostToolUse returns VS Code\'s own result change without plugin hooks', async () => { @@ -117306,20 +121101,17 @@ index 2a070ea454c..00000000000 - const dir = fileURLToPath(new URL('.', import.meta.url)).replace(/[\\/]$/, ''); - const filePath = `${dir}/vscode-test-hook-echo-result-${Date.now()}.js`; - writeFileSync(filePath, `let input = ''; process.stdin.on('data', data => input += data).on('end', () => process.stdout.write(JSON.stringify({ additionalContext: 'saw: ' + JSON.parse(input).toolResult.textResultForLlm })));\n`); -- try { -- const modifiedResult = { textResultForLlm: 'ok\npath: /images/image.png', resultType: 'success' as const }; -- const hooks = toSdkHooks([makeHookGroup('PostToolUse', `node ${filePath}`)], { -- onPreToolUse: async () => { }, -- onPostToolUse: async () => ({ modifiedResult, additionalContext: 'from VS Code' }), -- }); -- const toolResult = { textResultForLlm: 'ok', resultType: 'success' as const }; +- disposables.add(toDisposable(() => { try { unlinkSync(filePath); } catch { /* ignore */ } })); +- const modifiedResult = { textResultForLlm: 'ok\npath: /images/image.png', resultType: 'success' as const }; +- const hooks = toSdkHooks([makeHookGroup('PostToolUse', `node ${filePath}`)], { +- onPreToolUse: async () => { }, +- onPostToolUse: async () => ({ modifiedResult, additionalContext: 'from VS Code' }), +- }); +- const toolResult = { textResultForLlm: 'ok', resultType: 'success' as const }; - -- const result = await hooks.onPostToolUse!({ toolName: 'image_generation', toolArgs: {}, toolResult, timestamp: new Date(0), workingDirectory: '/', sessionId: 'test' }, { sessionId: 'test' }); +- const result = await hooks.onPostToolUse!({ toolName: 'image_generation', toolArgs: {}, toolResult, timestamp: new Date(0), workingDirectory: '/', sessionId: 'test' }, { sessionId: 'test' }); - -- assert.deepStrictEqual(result, { modifiedResult, additionalContext: 'from VS Code\n\nsaw: ok\npath: /images/image.png' }); -- } finally { -- try { unlinkSync(filePath); } catch { /* ignore */ } -- } +- assert.deepStrictEqual(result, { modifiedResult, additionalContext: 'from VS Code\n\nsaw: ok\npath: /images/image.png' }); - }); - - test('onUserPromptSubmitted returns host context without rewriting the prompt', async () => { @@ -117391,6 +121183,7 @@ index 2a070ea454c..00000000000 - return { - ...makePlugin(), - pluginDir, +- sourceUri: URI.parse('client-plugin:/logical-source'), - nonce: 'same-content', - hooks: [{ - type: 'SessionStart', @@ -117423,7 +121216,7 @@ index 2a070ea454c..00000000000 - skills: [{ ...resource('skills/a/SKILL.md'), customization: stubSkillCustomization('a') }], - agents: [{ ...resource('agents/a.agent.md'), customization: { type: CustomizationType.Agent, id: 'agent:a', uri: 'file:///agent', name: 'a' } }], - instructions: [{ ...resource('rules/a.instructions.md'), customization: { type: CustomizationType.Rule, id: 'rule:a', uri: 'file:///rule', name: 'a' } }], -- } satisfies IParsedPlugin & { pluginDir: URI; nonce: string }; +- } satisfies IParsedPlugin & { sourceUri: URI; pluginDir: URI; nonce: string }; - } - - test('compares identical plugin content relative to each materialized root', () => { @@ -117454,7 +121247,39 @@ index 2a070ea454c..00000000000 - findRelocatedAgentName([applied], [{ ...published, nonce: 'changed-resource-body' }], agentUri), - findRelocatedAgentName([applied], [published], 'file:///unknown.agent.md'), - findRelocatedAgentName([applied], [{ ...makePlugin(), pluginDir: URI.file('/workspace') }, published], agentUri), -- ], [applied.agents[0].name, undefined, undefined, applied.agents[0].name]); +- ], [getSdkCustomAgentName(applied.agents[0].uri, applied), undefined, undefined, getSdkCustomAgentName(applied.agents[0].uri, applied)]); +- }); +- +- test('does not confuse different plugin sources with identical content during relocation', () => { +- const first = relocatedPlugin('/cache/first'); +- const second = { ...relocatedPlugin('/cache/second'), sourceUri: URI.parse('client-plugin:/other-source') }; +- const published = { ...relocatedPlugin('/cache/new'), sourceUri: second.sourceUri }; +- assert.deepStrictEqual({ +- equal: parsedPluginsEqual([first], [second]), +- selected: findRelocatedAgentName([first, second], [published], published.agents[0].uri.toString()), +- }, { equal: false, selected: getSdkCustomAgentName(second.agents[0].uri, second) }); +- }); +- +- test('does not normalize logical agent identities that also match the materialized root', () => { +- const plugin = (name: string) => { +- const root = URI.parse(`vscode-agent-host-file:/plugins/${name}`); +- return { +- ...makePlugin(), sourceUri: root, pluginDir: root, nonce: 'identical', +- agents: [toParsedAgent({ uri: URI.joinPath(root, 'agents', 'reviewer.agent.md'), name: 'Reviewer' })], +- }; +- }; +- const first = plugin('first'); +- const second = plugin('second'); +- assert.deepStrictEqual({ +- equal: parsedPluginsEqual([first], [second]), +- relocated: findRelocatedAgentName([first], [second], second.agents[0].uri.toString()), +- }, { equal: false, relocated: undefined }); +- }); +- +- test('ignores source-only relocation when no forwarded agent identity changes', () => { +- const first = { ...relocatedPlugin('/cache/first'), agents: [], sourceUri: URI.parse('client-plugin:/first') }; +- const second = { ...relocatedPlugin('/cache/second'), agents: [], sourceUri: URI.parse('client-plugin:/second') }; +- assert.strictEqual(parsedPluginsEqual([first], [second]), true); - }); - - test('preserves content changes when plugin roots differ', () => { @@ -119065,6 +122890,154 @@ index 63d6b6e2d75..00000000000 - } - }); -}); +diff --git a/src/vs/platform/agentHost/test/node/copilotSdkCustomizationDiscovery.integrationTest.ts b/src/vs/platform/agentHost/test/node/copilotSdkCustomizationDiscovery.integrationTest.ts +deleted file mode 100644 +index b59287e8f33..00000000000 +--- a/src/vs/platform/agentHost/test/node/copilotSdkCustomizationDiscovery.integrationTest.ts ++++ /dev/null +@@ -1,142 +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 assert from 'assert'; +-import { CopilotClient } from '@github/copilot-sdk'; +-import { mkdir, mkdtemp, realpath, rm, writeFile } from 'fs/promises'; +-import { tmpdir } from 'os'; +-import { CancellationToken } from '../../../../base/common/cancellation.js'; +-import { Disposable, DisposableStore, type IDisposable } from '../../../../base/common/lifecycle.js'; +-import { Schemas } from '../../../../base/common/network.js'; +-import { join } from '../../../../base/common/path.js'; +-import { URI } from '../../../../base/common/uri.js'; +-import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +-import { FileService } from '../../../files/common/fileService.js'; +-import { DiskFileSystemProvider } from '../../../files/node/diskFileSystemProvider.js'; +-import { NullLogService } from '../../../log/common/log.js'; +-import { hasReadableCustomizationContent } from '../../common/agentHostCustomizationUri.js'; +-import { readCustomizationDiscoveryMeta } from '../../common/meta/customizationDiscoveryMeta.js'; +-import { CustomizationType, McpServerStatus } from '../../common/state/protocol/state.js'; +-import { createCopilotCliEnvironment } from '../../node/copilot/copilotCliEnvironment.js'; +-import { SessionCustomizationDiscovery } from '../../node/copilot/sessionCustomizationDiscovery.js'; +-import { initTestGitRepo } from './e2e/harness/agentHostE2ETestHarness.js'; +-import { createIsolatedProviderEnvironment } from './providerTestEnvironment.js'; +- +-suite('Copilot SDK - customization discovery', function () { +- this.timeout(60_000); +- const disposables = ensureNoDisposablesAreLeakedInTestSuite(); +- +- test('fills the standalone SDK workspace hook discovery gap without starting a session', async () => { +- const store = disposables.add(new DisposableStore()); +- const home = await realpath(await mkdtemp(join(tmpdir(), 'copilot-workspace-hooks-'))); +- const workspace = join(home, 'workspace'); +- const client = new CopilotClient({ +- mode: 'copilot-cli', baseDirectory: home, useLoggedInUser: false, +- env: createCopilotCliEnvironment(createIsolatedProviderEnvironment(home, { +- PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, WINDIR: process.env.WINDIR, +- COPILOT_TELEMETRY_ENABLED: 'false', +- })), +- }); +- try { +- await mkdir(join(workspace, '.github', 'hooks'), { recursive: true }); +- initTestGitRepo(workspace); +- await writeFile(join(workspace, '.github', 'hooks', 'test.json'), JSON.stringify({ +- version: 1, hooks: { preToolUse: [{ type: 'command', bash: 'exit 1', powershell: 'exit 1' }] }, +- })); +- await client.start(); +- const native = await client.rpc.hooks.discover({ projectPaths: [workspace] }); +- const log = new NullLogService(); +- const files = store.add(new FileService(log)); +- store.add(files.registerProvider(Schemas.file, store.add(new class extends DiskFileSystemProvider { +- override watch(): IDisposable { return Disposable.None; } +- }(log)))); +- const discovery = store.add(new SessionCustomizationDiscovery([URI.file(workspace)], URI.file(home), URI.file, files, log)); +- const result = await discovery.discover(client, CancellationToken.None); +- // Remove the native-gap assertion with the workaround when runtime #30644 is fixed. +- assert.deepStrictEqual({ +- native: native.hooks.map(hook => hook.hookType), +- published: result.flatMap(item => item.children ?? []).filter(item => item.type === CustomizationType.Hook).map(hook => ({ +- name: hook.name, enabled: hook.enabled, source: readCustomizationDiscoveryMeta(hook)?.source, +- readable: hasReadableCustomizationContent(URI.parse(hook.uri)), +- })), +- }, { +- native: [], +- published: [{ name: 'preToolUse', enabled: true, source: 'repository', readable: false }], +- }); +- } finally { +- store.clear(); +- try { +- await client.stop(); +- } finally { +- await rm(home, { recursive: true, force: true }); +- } +- } +- }); +- +- test('publishes hooks-only and MCP-only installations without starting a session', async () => { +- const store = disposables.add(new DisposableStore()); +- const home = await realpath(await mkdtemp(join(tmpdir(), 'copilot-discovery-'))); +- const client = new CopilotClient({ +- mode: 'copilot-cli', baseDirectory: home, useLoggedInUser: false, +- env: createCopilotCliEnvironment(createIsolatedProviderEnvironment(home, { +- PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, WINDIR: process.env.WINDIR, +- COPILOT_TELEMETRY_ENABLED: 'false', +- })), +- }); +- try { +- for (const name of ['hooks-inventory', 'mcp-inventory']) { +- const root = join(home, name); +- await mkdir(join(root, '.plugin'), { recursive: true }); +- await writeFile(join(root, '.plugin', 'plugin.json'), JSON.stringify({ name, version: '1.0.0' })); +- } +- await mkdir(join(home, 'hooks-inventory', 'hooks')); +- await writeFile(join(home, 'hooks-inventory', 'hooks', 'hooks.json'), JSON.stringify({ +- version: 1, hooks: { sessionStart: [{ type: 'command', bash: 'exit 1', powershell: 'exit 1' }] }, +- })); +- await writeFile(join(home, 'mcp-inventory', '.mcp.json'), JSON.stringify({ +- mcpServers: { 'inventory-server': { type: 'stdio', command: 'must-not-start-during-discovery', args: [], tools: ['*'] } }, +- })); +- await client.start(); +- const installations = []; +- for (const name of ['hooks-inventory', 'mcp-inventory']) { +- installations.push(await client.rpc.plugins.install({ source: join(home, name) })); +- } +- const log = new NullLogService(); +- const files = store.add(new FileService(log)); +- store.add(files.registerProvider(Schemas.file, store.add(new class extends DiskFileSystemProvider { +- // This test refreshes explicitly; filesystem watch behavior has separate unit coverage. +- override watch(): IDisposable { return Disposable.None; } +- }(log)))); +- const discovery = store.add(new SessionCustomizationDiscovery([URI.file(home)], URI.file(home), URI.file, files, log)); +- const customizations = await discovery.discover(client, CancellationToken.None); +- const children = customizations.flatMap(item => item.type === CustomizationType.McpServer ? [item] : item.children ?? []); +- assert.deepStrictEqual({ +- hooks: children.filter(item => item.type === CustomizationType.Hook).map(item => ({ +- name: item.name, enabled: item.enabled, source: readCustomizationDiscoveryMeta(item)?.source, +- readable: hasReadableCustomizationContent(URI.parse(item.uri)), +- })), +- mcp: children.filter(item => item.type === CustomizationType.McpServer).map(item => ({ +- name: item.name, state: item.state.kind, source: readCustomizationDiscoveryMeta(item)?.source, +- enabled: readCustomizationDiscoveryMeta(item)?.defaultEnabled, +- })), +- }, { +- hooks: [{ name: 'sessionStart', enabled: true, source: 'plugin', readable: false }], +- mcp: [{ name: 'inventory-server', state: McpServerStatus.Stopped, source: 'plugin', enabled: true }], +- }); +- for (const { plugin } of installations) { +- await client.rpc.plugins.uninstall({ name: plugin.name, directSourceId: plugin.directSourceId }); +- } +- const removed = await discovery.discover(client, CancellationToken.None); +- assert.deepStrictEqual(removed.flatMap(item => item.type === CustomizationType.McpServer ? [item] : item.children ?? []), []); +- } finally { +- store.clear(); +- try { +- await client.stop(); +- } finally { +- await rm(home, { recursive: true, force: true }); +- } +- } +- }); +-}); diff --git a/src/vs/platform/agentHost/test/node/copilotSdkImportSession.integrationTest.ts b/src/vs/platform/agentHost/test/node/copilotSdkImportSession.integrationTest.ts deleted file mode 100644 index 7888851692c..00000000000 @@ -119356,6 +123329,318 @@ index 6ea33135b61..00000000000 - }); - } -}); +diff --git a/src/vs/platform/agentHost/test/node/copilotSdkNativeAgents.integrationTest.ts b/src/vs/platform/agentHost/test/node/copilotSdkNativeAgents.integrationTest.ts +deleted file mode 100644 +index c1bb29c3636..00000000000 +--- a/src/vs/platform/agentHost/test/node/copilotSdkNativeAgents.integrationTest.ts ++++ /dev/null +@@ -1,306 +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 assert from 'assert'; +-import { CopilotClient, type CopilotSession, type ResumeSessionConfig, type SessionConfig } from '@github/copilot-sdk'; +-import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'fs/promises'; +-import { tmpdir } from 'os'; +-import { restore, stub } from 'sinon'; +-import { join } from '../../../../base/common/path.js'; +-import { VSBuffer } from '../../../../base/common/buffer.js'; +-import { URI } from '../../../../base/common/uri.js'; +-import { generateUuid } from '../../../../base/common/uuid.js'; +-import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +-import { upcastPartial } from '../../../../base/test/common/mock.js'; +-import { PluginFormat, toParsedAgent } from '../../../agentPlugins/common/pluginParsers.js'; +-import { IFileService, type IFileContent } from '../../../files/common/files.js'; +-import { CustomizationType, MessageKind, ResponsePartKind, TurnState } from '../../common/state/sessionState.js'; +-import { ActiveClientToolSet } from '../../node/activeClientState.js'; +-import { buildSessionEventLogFromTurns } from '../../node/copilot/buildSessionEvents.js'; +-import { createCopilotCliEnvironment } from '../../node/copilot/copilotCliEnvironment.js'; +-import { CopilotGitHubSessionCredentials } from '../../node/copilot/copilotGitHubCredentials.js'; +-import { getCopilotAgentSelection, getCopilotAgentSelectionName, type CopilotSessionLaunchPlan } from '../../node/copilot/copilotSessionLauncher.js'; +-import { getSdkCustomAgentName } from '../../node/copilot/copilotPluginConverters.js'; +-import { createTestLauncher, testRuntime } from './copilotSessionLauncherTestHelpers.js'; +-import { createIsolatedProviderEnvironment } from './providerTestEnvironment.js'; +- +-suite('Copilot SDK - native agent launch', function () { +- this.timeout(60_000); +- ensureNoDisposablesAreLeakedInTestSuite(); +- +- let home: string; +- let client: CopilotClient | undefined; +- +- setup(async () => { +- home = await realpath(await mkdtemp(join(tmpdir(), 'copilot-native-agent-'))); +- }); +- +- teardown(async () => { +- restore(); +- try { +- await client?.stop(); +- } finally { +- client = undefined; +- await rm(home, { recursive: true, force: true }); +- } +- }); +- +- for (const mode of ['copilot-cli', 'empty'] as const) { +- test(`${mode} resume: preserves a saved flat-layout selection registered with an authored SDK name`, async () => { +- const root = URI.file(join(home, 'cache', 'legacy-bundle', 'current')); +- const uri = URI.joinPath(root, 'agents', 'reviewer.agent.md'); +- const savedUri = URI.file(join(home, 'cache', 'legacy-bundle', 'previous', 'agents', 'reviewer.agent.md')).toString(); +- await mkdir(join(root.fsPath, 'agents'), { recursive: true }); +- await writeFile(uri.fsPath, '---\nname: Reviewer\n---\nLegacy prompt.'); +- const opens: string[] = []; +- client = new class extends CopilotClient { +- override async createSession(config: SessionConfig): Promise { +- opens.push('create'); +- return super.createSession(config); +- } +- override async resumeSession(id: string, config: ResumeSessionConfig): Promise { +- opens.push('resume'); +- return super.resumeSession(id, config); +- } +- }({ +- mode, baseDirectory: home, useLoggedInUser: false, +- env: createCopilotCliEnvironment(createIsolatedProviderEnvironment(home, { +- PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, WINDIR: process.env.WINDIR, +- COPILOT_TELEMETRY_ENABLED: 'false', +- })), +- }); +- await client.start(); +- const providers = [{ name: 'local', type: 'openai' as const, wireApi: 'completions' as const, baseUrl: 'http://127.0.0.1:1/v1' }]; +- const models = [{ id: 'legacy-test-model', provider: 'local' }]; +- const sessionId = generateUuid(); +- const original = await client.createSession({ +- sessionId, workingDirectory: home, model: 'legacy-test-model', providers, models, +- availableTools: [], +- customAgents: [{ name: 'Reviewer', displayName: 'Reviewer', prompt: 'Legacy prompt.' }], agent: 'Reviewer', +- }); +- const before = (await original.rpc.agent.getCurrent()).agent?.id; +- await original.disconnect(); +- const directory = join(home, 'session-state', sessionId); +- await mkdir(directory, { recursive: true }); +- await writeFile(join(directory, 'events.jsonl'), buildSessionEventLogFromTurns([{ +- id: generateUuid(), message: { text: 'Retained question', origin: { kind: MessageKind.User } }, +- responseParts: [{ id: generateUuid(), kind: ResponsePartKind.Markdown, content: 'Retained answer' }], +- state: TurnState.Complete, usage: undefined, +- }], { sessionId, workingDirectory: home })); +- const fileService = upcastPartial({ +- readFile: async resource => upcastPartial({ value: VSBuffer.wrap(await readFile(resource.fsPath)) }), +- }); +- const launcher = createTestLauncher(undefined, {}, undefined, undefined, undefined, undefined, fileService); +- stub(launcher, 'resolveByokSessionConfig').resolves({ providers, models }); +- const snapshot = { +- discoveredAgents: [], +- plugins: [{ +- format: PluginFormat.Copilot, nonce: 'current', resourceDir: root, sourceUri: URI.parse('client-customizations:/legacy'), +- agents: [toParsedAgent({ uri, name: 'Reviewer' })], skills: [], hooks: [], instructions: [], mcpServers: [], +- }], +- additionalRootInstructions: [], tools: [], mcpServers: {}, +- }; +- const wrapper = await launcher.launch({ +- kind: 'resume', client, sessionId, workingDirectory: URI.file(home), snapshot, +- resolvedAgentName: getCopilotAgentSelectionName(snapshot, savedUri, true), +- fallback: { model: { id: 'legacy-test-model' } }, +- activeClientToolSet: new ActiveClientToolSet(), shellManager: undefined, +- githubCredentials: CopilotGitHubSessionCredentials.fromToken(undefined), +- }, testRuntime); +- try { +- assert.deepStrictEqual({ +- before, after: (await wrapper.session.rpc.agent.getCurrent()).agent?.id, opens, +- restoredResource: getCopilotAgentSelection(snapshot, savedUri, true)?.uri, +- }, { +- before: 'Reviewer', after: getSdkCustomAgentName(uri, snapshot.plugins[0]), opens: ['create', 'resume'], +- restoredResource: uri.toString(), +- }); +- } finally { +- wrapper.dispose(); +- } +- }); +- } +- +- for (const mode of ['copilot-cli', 'empty'] as const) { +- for (const { kind, sharedBundle } of (['create', 'resume', 'recover'] as const).flatMap(kind => [false, true].map(sharedBundle => ({ kind, sharedBundle })))) { +- test(`${mode} ${kind}: distinguishes and switches forwarded agents with identical display names from ${sharedBundle ? 'one bundle' : 'different plugins'}`, async () => { +- const uris = [0, 1].map(index => URI.file(join(home, `source-${index}`, 'shared.agent.md'))); +- for (const [index, uri] of uris.entries()) { +- await mkdir(join(home, `source-${index}`), { recursive: true }); +- await writeFile(uri.fsPath, `---\nname: Friendly Reviewer\n---\nPrompt ${index}`); +- } +- const opens: { kind: string; agent: string | undefined }[] = []; +- client = new class extends CopilotClient { +- override async createSession(config: SessionConfig): Promise { +- opens.push({ kind: 'create', agent: config.agent }); +- return super.createSession(config); +- } +- override async resumeSession(id: string, config: ResumeSessionConfig): Promise { +- opens.push({ kind: 'resume', agent: config.agent }); +- return super.resumeSession(id, config); +- } +- }({ +- mode, baseDirectory: home, useLoggedInUser: false, +- env: createCopilotCliEnvironment(createIsolatedProviderEnvironment(home, { +- PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, WINDIR: process.env.WINDIR, +- COPILOT_TELEMETRY_ENABLED: 'false', +- })), +- }); +- await client.start(); +- const sessionId = generateUuid(); +- if (kind === 'resume') { +- const directory = join(home, 'session-state', sessionId); +- await mkdir(directory, { recursive: true }); +- await writeFile(join(directory, 'events.jsonl'), buildSessionEventLogFromTurns([{ +- id: generateUuid(), message: { text: 'Retained question', origin: { kind: MessageKind.User } }, +- responseParts: [{ id: generateUuid(), kind: ResponsePartKind.Markdown, content: 'Retained answer' }], +- state: TurnState.Complete, usage: undefined, +- }], { sessionId, workingDirectory: home })); +- } +- const fileService = upcastPartial({ +- readFile: async resource => upcastPartial({ value: VSBuffer.wrap(await readFile(resource.fsPath)) }), +- }); +- const launcher = createTestLauncher(undefined, {}, undefined, undefined, undefined, undefined, fileService); +- stub(launcher, 'resolveByokSessionConfig').resolves({ +- providers: [{ name: 'local', type: 'openai', wireApi: 'completions', baseUrl: 'http://127.0.0.1:1/v1' }], +- models: [{ id: 'forwarded-test-model', provider: 'local' }], +- }); +- const groups = sharedBundle ? [uris] : uris.map(uri => [uri]); +- const snapshot = { +- discoveredAgents: [], +- plugins: groups.map((group, index) => ({ +- format: PluginFormat.Copilot, nonce: 'content-revision', +- sourceUri: URI.parse(`client-plugin:/source-${index}`), +- resourceDir: URI.file(sharedBundle ? home : join(home, `source-${index}`)), +- agents: group.map(uri => toParsedAgent({ uri, name: 'Friendly Reviewer' })), +- skills: [], hooks: [], instructions: [], mcpServers: [], +- })), +- additionalRootInstructions: [], tools: [], mcpServers: {}, +- }; +- const sdkNames = uris.map((uri, index) => getSdkCustomAgentName(uri, snapshot.plugins[sharedBundle ? 0 : index])); +- const shared = { +- client, sessionId, workingDirectory: URI.file(home), snapshot, +- resolvedAgentName: getCopilotAgentSelectionName(snapshot, uris[0].toString()), +- activeClientToolSet: new ActiveClientToolSet(), shellManager: undefined, +- githubCredentials: CopilotGitHubSessionCredentials.fromToken(undefined), +- }; +- const plan: CopilotSessionLaunchPlan = kind === 'create' +- ? { ...shared, kind, model: { id: 'forwarded-test-model' } } +- : { ...shared, kind: 'resume', fallback: { model: { id: 'forwarded-test-model' } } }; +- const wrapper = await launcher.launch(plan, testRuntime); +- try { +- const initial = (await wrapper.session.rpc.agent.getCurrent()).agent; +- const catalog = (await wrapper.session.rpc.agent.list({ includeBuiltInAgents: false })).agents; +- await wrapper.session.rpc.agent.select({ name: getCopilotAgentSelectionName(snapshot, uris[1].toString())! }); +- const switched = (await wrapper.session.rpc.agent.getCurrent()).agent; +- assert.deepStrictEqual({ +- initial: initial?.id, +- switched: switched?.id, +- labels: sdkNames.map(name => catalog.find(agent => agent.id === name)?.displayName), +- opens, +- }, { +- initial: sdkNames[0], +- switched: sdkNames[1], +- labels: ['Friendly Reviewer', 'Friendly Reviewer'], +- opens: (kind === 'recover' ? ['resume', 'create'] : [kind]).map(kind => ({ kind, agent: sdkNames[0] })), +- }); +- } finally { +- wrapper.dispose(); +- } +- }); +- } +- } +- +- for (const mode of ['copilot-cli', 'empty'] as const) { +- for (const kind of ['create', 'resume', 'recover'] as const) { +- test(`${mode} ${kind}: ${mode === 'empty' ? 'surfaces missing native catalog support' : 'selects a native-only agent after construction'}`, async () => { +- const pluginDir = join(home, 'plugin'); +- const agentPath = join(pluginDir, 'agents', 'reviewer.agent.md'); +- await mkdir(join(pluginDir, '.plugin'), { recursive: true }); +- await mkdir(join(pluginDir, 'agents'), { recursive: true }); +- await writeFile(join(pluginDir, '.plugin', 'plugin.json'), JSON.stringify({ name: 'review-native', version: '1.0.0' })); +- await writeFile(agentPath, '---\nname: Reviewer\ndescription: Local reviewer\n---\nReview locally.'); +- +- const opens: { kind: string; agent: string | undefined; nativeDefinition: boolean }[] = []; +- const nativeAgent = { +- type: CustomizationType.Agent, id: 'review-native:reviewer', +- uri: URI.file(agentPath).toString(), name: 'Reviewer', +- } as const; +- const capture = (kind: string, config: ResumeSessionConfig | undefined) => { +- opens.push({ +- kind, agent: config?.agent, +- nativeDefinition: config?.customAgents?.some(agent => agent.name === nativeAgent.id || agent.name === nativeAgent.name) ?? false, +- }); +- }; +- client = new class extends CopilotClient { +- override async createSession(config: SessionConfig): Promise { +- capture('create', config); +- return super.createSession(config); +- } +- override async resumeSession(id: string, config: ResumeSessionConfig): Promise { +- capture('resume', config); +- return super.resumeSession(id, config); +- } +- }({ +- mode, baseDirectory: home, useLoggedInUser: false, +- env: createCopilotCliEnvironment(createIsolatedProviderEnvironment(home, { +- PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, WINDIR: process.env.WINDIR, +- COPILOT_TELEMETRY_ENABLED: 'false', +- })), +- }); +- await client.start(); +- +- const sessionId = generateUuid(); +- if (kind === 'resume') { +- const directory = join(home, 'session-state', sessionId); +- await mkdir(directory, { recursive: true }); +- await writeFile(join(directory, 'events.jsonl'), buildSessionEventLogFromTurns([{ +- id: generateUuid(), message: { text: 'Retained question', origin: { kind: MessageKind.User } }, +- responseParts: [{ id: generateUuid(), kind: ResponsePartKind.Markdown, content: 'Retained answer' }], +- state: TurnState.Complete, usage: undefined, +- }], { sessionId, workingDirectory: home })); +- } +- +- const launcher = createTestLauncher(); +- stub(launcher, 'resolveByokSessionConfig').resolves({ +- providers: [{ name: 'local', type: 'openai', wireApi: 'completions', baseUrl: 'http://127.0.0.1:1/v1' }], +- models: [{ id: 'native-test-model', provider: 'local' }], +- }); +- const shared = { +- client, sessionId, workingDirectory: URI.file(home), +- resolvedAgentName: nativeAgent.id, +- snapshot: { +- discoveredAgents: [], nativeAgents: [nativeAgent], additionalRootInstructions: [], tools: [], mcpServers: {}, +- plugins: [{ pluginDir: URI.file(pluginDir), format: PluginFormat.Copilot, agents: [], skills: [], instructions: [], hooks: [], mcpServers: [] }], +- }, +- activeClientToolSet: new ActiveClientToolSet(), shellManager: undefined, +- githubCredentials: CopilotGitHubSessionCredentials.fromToken(undefined), +- }; +- const plan: CopilotSessionLaunchPlan = kind === 'create' +- ? { ...shared, kind, model: { id: 'native-test-model' } } +- : { ...shared, kind: 'resume', fallback: { model: { id: 'native-test-model' } } }; +- +- if (mode === 'empty') { +- // The bundled empty-mode runtime omits plugin agents even after catalog discovery. +- await assert.rejects(launcher.launch(plan, testRuntime), /Custom agent 'review-native:reviewer' not found/); +- } else { +- const wrapper = await launcher.launch(plan, testRuntime); +- try { +- const current = await wrapper.session.rpc.agent.getCurrent(); +- const events = await wrapper.session.getEvents(); +- assert.deepStrictEqual({ +- selected: current.agent?.id, +- userMessages: events.filter(event => event.type === 'user.message').map(event => event.data.content), +- }, { selected: nativeAgent.id, userMessages: kind === 'resume' ? ['Retained question'] : [] }); +- } finally { +- wrapper.dispose(); +- } +- } +- assert.deepStrictEqual(opens, (kind === 'recover' ? ['resume', 'create'] : [kind]) +- .map(kind => ({ kind, agent: undefined, nativeDefinition: false }))); +- }); +- } +- } +-}); diff --git a/src/vs/platform/agentHost/test/node/copilotSecondaryAssignmentContext.test.ts b/src/vs/platform/agentHost/test/node/copilotSecondaryAssignmentContext.test.ts index d4de07adf63..d604221d4c9 100644 --- a/src/vs/platform/agentHost/test/node/copilotSecondaryAssignmentContext.test.ts @@ -119370,10 +123655,10 @@ index d4de07adf63..d604221d4c9 100644 import assert from 'assert'; diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts deleted file mode 100644 -index 2e9c69bc531..00000000000 +index b341a23097b..00000000000 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ /dev/null -@@ -1,4198 +0,0 @@ +@@ -1,4663 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -119381,6 +123666,7 @@ index 2e9c69bc531..00000000000 - -import type { CopilotClient, CopilotSession, PermissionHandler, PermissionRequest, ResumeSessionConfig, SessionConfig, Tool, Verbosity } from '@github/copilot-sdk'; -import assert from 'assert'; +-import { hasKey } from '../../../../base/common/types.js'; -import { isDeepStrictEqual } from 'util'; -import { useFakeTimers } from 'sinon'; -import { DeferredPromise } from '../../../../base/common/async.js'; @@ -119391,7 +123677,7 @@ index 2e9c69bc531..00000000000 -import { URI } from '../../../../base/common/uri.js'; -import { mock, upcastPartial } from '../../../../base/test/common/mock.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; --import { PluginFormat, type IMcpServerDefinition, type IParsedRule } from '../../../agentPlugins/common/pluginParsers.js'; +-import { PluginFormat, toParsedAgent, type IMcpServerDefinition, type IParsedRule } from '../../../agentPlugins/common/pluginParsers.js'; -import { IFileService, type IFileContent } from '../../../files/common/files.js'; -import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; -import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; @@ -119403,11 +123689,10 @@ index 2e9c69bc531..00000000000 -import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostCanvasesEnabledConfigKey, AgentHostMcpConnectorsEnabledConfigKey, AgentHostTelemetryLevelConfigKey, AgentHostWorkspaceTrustConfigKey, platformSessionSchema, type IAgentHostWorkspaceTrust, type SchemaValues } from '../../common/agentHostSchema.js'; -import type { IAgentHostManagedSettingsPermissions } from '../../common/agentHostManagedSettings.js'; -import { toClientPluginMcpDefaultCwdsMeta } from '../../common/meta/clientPluginCustomizationMeta.js'; --import { withAgentToolFullReferenceName } from '../../common/meta/agentToolDefinitionMeta.js'; --import { readSessionSandboxState } from '../../common/meta/agentSandboxStateMeta.js'; +-import { withAgentToolFullReferenceName, withAgentToolOrigin } from '../../common/meta/agentToolDefinitionMeta.js'; +-import { readChatSandboxState, readSessionSandboxState } from '../../common/meta/agentSandboxStateMeta.js'; -import { ISessionDataService } from '../../common/sessionDataService.js'; -import { CopilotCliConfigKey, CopilotCliVSCodeAssignmentContextKey, copilotCliConfigSchema } from '../../common/copilotCliConfig.js'; --import type { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; -import { reasoningEffortLevels } from '../../common/reasoningEffort.js'; -import { autoModeTiers, isAutoAdvertisedAsFusion, setAdvertisedAutoModeTiers } from '../../common/autoModeTiers.js'; -import { SEMANTIC_SEARCH_TOOL_NAME } from '../../common/semanticSearchConstants.js'; @@ -119416,9 +123701,8 @@ index 2e9c69bc531..00000000000 -import { COPILOT_DEFAULT_AVAILABLE_TOOLS } from '../../node/copilot/copilotBuiltinToolAllowlist.js'; -import { ActiveClientToolSet } from '../../node/activeClientState.js'; -import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; --import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentHostStateManager.js'; +-import { AgentHostStateManager, creationTimeChatStorageResource, IAgentHostStateManager } from '../../node/agentHostStateManager.js'; -import { AgentHostManagedSettingsService, IAgentHostManagedSettingsService } from '../../node/agentHostManagedSettingsService.js'; --import type { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; -import { ByokLmBridgeRegistry, IByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js'; -import { ByokLmProxyService, IByokLmProxyService, type IByokLmProxyHandle } from '../../node/copilot/byokLmProxyService.js'; -import { resolveCopilotMcpServerInfo, type ICopilotPluginInfo } from '../../node/copilot/copilotAgent.js'; @@ -119430,32 +123714,15 @@ index 2e9c69bc531..00000000000 -import type { CopilotSessionWrapper } from '../../node/copilot/copilotSessionWrapper.js'; -import { CopilotSessionSandbox } from '../../node/copilot/copilotSessionSandbox.js'; -import type { SandboxConfig, SandboxNetworkPolicy } from '../../node/copilot/sandboxConfigForSdk.js'; --import { composeByokSessionConfig, CopilotSessionLauncher, filterClientToolNames, findLocalModelDirectFamily, getCopilotAutoTier, getCopilotReasoningEffort, getCopilotWorkingDirectoryTrust, isCopilotReasoningEffort, mergeByokSessionConfig, synthesizeByokSessionConfig, withLocalModelSessionConfig, normalizeToolFilterPatterns, resolveConfiguredReasoningEffortOverride, resolveCopilotAutoTier, resolveCopilotReasoningEffort, toSdkToolFilterPatterns, type CopilotSessionLaunchPlan, type ICopilotClientToolConfiguration, type ICopilotSessionRuntime } from '../../node/copilot/copilotSessionLauncher.js'; +-import { composeByokSessionConfig, CopilotSessionLauncher, filterClientToolNames, findLocalModelDirectFamily, getCopilotAgentSelection, getCopilotAgentSelectionName, getCopilotAutoTier, getCopilotCustomAgents, getCopilotReasoningEffort, getCopilotWorkingDirectoryTrust, isCopilotReasoningEffort, mergeByokSessionConfig, synthesizeByokSessionConfig, withLocalModelSessionConfig, normalizeToolFilterPatterns, resolveConfiguredReasoningEffortOverride, resolveCopilotAutoTier, resolveCopilotReasoningEffort, toSdkToolFilterPatterns, type CopilotSessionLaunchPlan, type ICopilotClientToolConfiguration, type ICopilotSessionRuntime } from '../../node/copilot/copilotSessionLauncher.js'; +-import { getSdkCustomAgentName } from '../../node/copilot/copilotPluginConverters.js'; -import { createUnmanagedCopilotSettings } from './copilotTestEvents.js'; --import { buildDefaultChatUri, SessionStatus } from '../../common/state/sessionState.js'; +-import { buildChatUri, buildDefaultChatUri, SessionStatus } from '../../common/state/sessionState.js'; +-import { ActionType } from '../../common/state/sessionActions.js'; -import type { IAgentHostSessionOpenTelemetry } from '../../node/agentHostSessionOpenTelemetry.js'; -import { createSentinelContribution, FakeLocalModelProvider, MAI_ROW } from './localModelBridge/fakeLocalModelProvider.js'; - --const testRuntime: ICopilotSessionRuntime = { -- chatUri: URI.parse(buildDefaultChatUri('copilot:/sess-1')), -- launchOwner: URI.parse('copilot:/sess-1'), -- getLaunchSandboxConfig: () => undefined, -- applyLaunchSandbox: async session => { await session.rpc.options.update({ sandboxConfig: { enabled: false } }); }, -- handlePermissionRequest: async () => { throw new Error('Unexpected permission request'); }, -- handleExitPlanModeRequest: async () => { throw new Error('Unexpected exit plan mode request'); }, -- handleUserInputRequest: async () => { throw new Error('Unexpected user input request'); }, -- handleElicitationRequest: async () => { throw new Error('Unexpected elicitation request'); }, -- adoptLaunchConnectorReconcile: () => { }, -- handleMcpAuthRequest: async () => { throw new Error('Unexpected MCP auth request'); }, -- requestUnsandboxedCommandConfirmation: async () => false, -- handlePreToolUse: async () => { }, -- handlePostToolUse: async () => undefined, -- handlePostToolUseFailure: () => undefined, -- handleUserPromptSubmitted: () => undefined, -- createClientSdkTools: () => [], -- createServerSdkTools: () => [], -- reloadExtensions: async () => { }, --}; +-import { createTestLauncher, noopSessionOpenTelemetry, testRuntime } from './copilotSessionLauncherTestHelpers.js'; - -const testWorkingDirectory = URI.file(process.cwd()); -const disabledWorkflowTools = ['builtin:run_dynamic_workflow', 'builtin:dynamic_workflows_manage']; @@ -119506,52 +123773,6 @@ index 2e9c69bc531..00000000000 - } -} - --const noopSessionOpenTelemetry: IAgentHostSessionOpenTelemetry = { -- _serviceBrand: undefined, -- withSubscription: async (_resource, operation) => operation({ -- servedFromMemory: undefined, -- setServedFromMemory: () => { }, -- restoreStarted: () => { }, -- restoreCompleted: () => { }, -- setSession: () => { }, -- }), -- withSdkResume: async (_session, operation) => operation(), -- sdkResumeFallbackCreated: () => { }, --}; -- --function createTestLauncher(managedSettingsPermissions?: IAgentHostManagedSettingsPermissions, rootValues: Record = {}, logService: ILogService = new NullLogService(), sessionOpenTelemetry: IAgentHostSessionOpenTelemetry = noopSessionOpenTelemetry, configuration?: IAgentConfigurationService, telemetryService?: ITelemetryService): CopilotSessionLauncher { -- const configurationService = configuration ?? { -- getRootValue: (_schema: unknown, key: string) => rootValues[key], -- getRootConfigValues: () => rootValues, -- getSessionConfigValues: () => undefined, -- getChatConfigValues: () => undefined, -- resolveConfigurationOwner: (session: string) => session, -- getSessionSandboxPolicy: () => undefined, -- getSessionSandboxEnabled: () => undefined, -- getChatSandboxEnabled: () => undefined, -- setSessionSandboxPolicy: () => { }, -- setChatSandboxEnabled: () => { }, -- rejectChatSandboxChange: () => { }, -- } as Partial as IAgentConfigurationService; -- return new CopilotSessionLauncher( -- configurationService, -- { permissions: managedSettingsPermissions ?? {} } as IAgentHostManagedSettingsService, -- {} as IAgentHostTerminalManager, -- logService, -- {} as IFileService, -- { _serviceBrand: undefined, onDidCapTools: Event.None, start: async () => { throw new Error('Unexpected proxy start'); }, dispose: () => { } }, -- new ByokLmBridgeRegistry(), -- { -- _serviceBrand: undefined, -- getSessionTraceContext: () => undefined, -- releaseSessionTraceContext: () => { }, -- withTraceContext: (_context: undefined, fn: () => T): T => fn(), -- } as unknown as IAgentHostOTelService, -- sessionOpenTelemetry, -- telemetryService ?? ({ telemetryLevel: TelemetryLevel.USAGE } as ITelemetryService), -- ); --} -- -suite('CopilotSessionLauncher customization policy session', () => { - const store = ensureNoDisposablesAreLeakedInTestSuite(); - @@ -119934,6 +124155,7 @@ index 2e9c69bc531..00000000000 - const logService = new CapturingLogService(); - const launcher = createTestLauncher(undefined, {}, logService, noopSessionOpenTelemetry, configuration); - let attachedSession: CopilotSession | undefined; +- let sessionClosed = false; - const services = new ServiceCollection( - [IAgentConfigurationService, configuration], - [IAgentHostStateManager, manager], @@ -119951,25 +124173,272 @@ index 2e9c69bc531..00000000000 - getConfigWriteTarget: () => ({ session: owner, chat: testRuntime.chatUri.toString() }), - platform: process.platform, - getSession: () => attachedSession, +- isSessionClosed: () => sessionClosed, - getHostSupport: async () => ({ supported: true, capabilities: [] }), - getCertificateStatus: async () => undefined, - installCertificate: async () => { }, - rotateCertificate: async () => { }, - removeCertificate: async () => { }, - getShellInitDirectory: () => undefined, +- getAppliedPluginDirectories: () => [], +- getWorkingDirectory: () => undefined, +- getAdditionalWorkingDirectories: () => [], - shellManager: undefined, - })); - const runtime: ICopilotSessionRuntime = { - ...testRuntime, -- getLaunchSandboxConfig: () => sandbox.getLaunchConfig(), -- applyLaunchSandbox: async session => { -- await sandbox.applyLaunchConfig(session); +- getLaunchSandboxConfig: isResume => sandbox.getLaunchConfig(isResume), +- applyLaunchSandbox: async (session, isResume) => { +- await sandbox.applyLaunchConfig(session, isResume); - attachedSession = session; - }, - }; -- return { configuration, owner, updates, commands, launcher, plan, runtime, sandbox, logService, get sandboxState() { return readSessionSandboxState(manager.getSessionState(owner)); }, get disconnected() { return disconnected; }, get captured() { return captured; } }; +- return { manager, configuration, owner, updates, commands, launcher, plan, runtime, sandbox, raw, logService, set sessionClosed(value: boolean) { sessionClosed = value; }, set sandboxUpdateError(error: Error | undefined) { sandboxUpdateError = error; }, get sandboxState() { return readSessionSandboxState(manager.getSessionState(owner)); }, get disconnected() { return disconnected; }, get captured() { return captured; } }; - } - +- test('a sandbox update failing after shutdown does not broadcast a policy conflict', async () => { +- const fixture = setup('create'); +- const target = { session: fixture.owner, chat: buildDefaultChatUri(fixture.owner) }; +- fixture.configuration.updateChatConfig(target, { sandboxEnabled: 'on' }); +- store.add(await fixture.launcher.launch(fixture.plan, fixture.runtime)); +- fixture.configuration.updateChatConfig(target, { sandboxEnabled: 'off' }); +- const started = new DeferredPromise(); +- const release = new DeferredPromise(); +- fixture.raw.rpc.options.update = async () => { +- started.complete(); +- await release.p; +- throw new Error('Session not found'); +- }; +- const update = fixture.sandbox.applyConfig(); +- try { +- await started.p; +- fixture.sessionClosed = true; +- release.complete(); +- await update; +- assert.deepStrictEqual({ +- state: fixture.sandboxState, +- warnings: fixture.logService.warnings, +- errors: fixture.logService.errors, +- }, { state: { enabled: true }, warnings: [], errors: [] }); +- } finally { +- release.complete(); +- await update; +- } +- }); +- +- test('background sandbox conflicts are broadcast without disconnecting or claiming the rejected state', async () => { +- const fixture = setup('create'); +- const target = { session: fixture.owner, chat: buildDefaultChatUri(fixture.owner) }; +- fixture.configuration.updateChatConfig(target, { sandboxEnabled: 'on' }); +- store.add(await fixture.launcher.launch(fixture.plan, fixture.runtime)); +- fixture.sandboxUpdateError = Object.assign(new Error('Managed sandbox conflict'), { data: { code: 'managed_sandbox_policy_conflict' } }); +- fixture.configuration.updateChatConfig(target, { sandboxEnabled: 'off' }); +- const published: Array<{ enabled: boolean | undefined; message: string | undefined }> = []; +- let previousState = readChatSandboxState(fixture.manager.getSessionState(fixture.owner), target.chat); +- store.add(fixture.manager.onDidEmitEnvelope(envelope => { +- if (envelope.action.type === ActionType.SessionMetaChanged) { +- const state = readChatSandboxState(envelope.action, target.chat); +- if (!isDeepStrictEqual(state, previousState)) { +- previousState = state; +- published.push({ enabled: state?.enabled, message: state?.error?.message }); +- } +- } +- })); +- +- await fixture.sandbox.applyConfig(); +- await fixture.sandbox.applyConfig(); +- const failed = fixture.sandboxState; +- fixture.sandboxUpdateError = undefined; +- await fixture.sandbox.applyConfig(); +- +- assert.deepStrictEqual({ +- disconnected: fixture.disconnected, +- published, +- broadcast: failed?.error !== undefined && hasKey(failed.error, { id: true }), +- selection: fixture.configuration.getChatConfigValues(target)?.sandboxEnabled, +- applied: fixture.updates.filter(update => update.sandboxConfig).map(update => update.sandboxConfig?.enabled), +- recovered: fixture.sandboxState, +- }, { +- disconnected: false, +- published: [{ enabled: true, message: 'Managed sandbox conflict' }, { enabled: false, message: undefined }], +- broadcast: true, +- selection: 'off', +- applied: [true, false], +- recovered: { enabled: false }, +- }); +- }); +- +- test('create broadcasts a sandbox conflict even before any enablement has been confirmed', async () => { +- const error = Object.assign(new Error('Managed sandbox conflict'), { data: { code: 'managed_sandbox_policy_conflict' } }); +- const fixture = setup('create', true, false, false, undefined, error); +- store.add(await fixture.launcher.launch(fixture.plan, fixture.runtime)); +- +- assert.deepStrictEqual({ +- disconnected: fixture.disconnected, +- enabled: fixture.sandboxState?.enabled, +- message: fixture.sandboxState?.error?.message, +- broadcast: fixture.sandboxState?.error !== undefined && hasKey(fixture.sandboxState.error, { id: true }), +- }, { disconnected: false, enabled: undefined, message: error.message, broadcast: true }); +- }); +- +- test('resume preserves confirmed bypass with unchanged managed policy without changing sibling chats', async () => { +- const fixture = setup('resume', true, true, true); +- const target = { session: fixture.owner, chat: buildDefaultChatUri(fixture.owner) }; +- const sibling = { session: fixture.owner, chat: buildChatUri(fixture.owner, 'sibling') }; +- fixture.manager.addChat(fixture.owner, sibling.chat, creationTimeChatStorageResource(fixture.owner, sibling.chat, target.chat)); +- fixture.configuration.setSessionSandboxPolicy(fixture.owner, { enabled: true, allowBypass: true }); +- fixture.configuration.setChatSandboxEnabled(target, false); +- fixture.configuration.setChatSandboxEnabled(sibling, false); +- fixture.configuration.updateSessionConfig(fixture.owner, { sandboxEnabled: 'off' }); +- const published: boolean[] = []; +- store.add(fixture.manager.onDidEmitEnvelope(envelope => { +- if (envelope.action.type === ActionType.SessionMetaChanged) { +- const state = readChatSandboxState(envelope.action, target.chat); +- if (state?.enabled !== undefined) { +- published.push(state.enabled); +- } +- } +- })); +- +- store.add(await fixture.launcher.launch(fixture.plan, fixture.runtime)); +- await fixture.sandbox.applyConfig(); +- +- assert.deepStrictEqual({ +- opened: fixture.captured?.sandboxConfig?.enabled, +- applied: fixture.updates.filter(update => update.sandboxConfig).map(update => update.sandboxConfig?.enabled), +- selection: fixture.configuration.getChatConfigValues(target)?.sandboxEnabled, +- published, +- state: fixture.sandboxState, +- siblingSelection: fixture.configuration.getChatConfigValues(sibling)?.sandboxEnabled, +- siblingState: fixture.configuration.getChatSandboxEnabled(sibling), +- commands: fixture.commands, +- }, { +- opened: undefined, +- applied: [], +- selection: 'off', +- published: [], +- state: { enabled: false }, +- siblingSelection: 'off', +- siblingState: false, +- commands: ['disable session'], +- }); +- }); +- +- test('resume preserves confirmed off when bypass is first allowed during open', async () => { +- const fixture = setup('resume', true, true, true); +- const target = { session: fixture.owner, chat: buildDefaultChatUri(fixture.owner) }; +- fixture.configuration.setChatSandboxEnabled(target, false); +- fixture.configuration.updateChatConfig(target, { sandboxEnabled: 'off' }); +- +- store.add(await fixture.launcher.launch(fixture.plan, fixture.runtime)); +- +- assert.deepStrictEqual({ +- selection: fixture.configuration.getChatConfigValues(target)?.sandboxEnabled, +- state: fixture.sandboxState, +- applied: fixture.updates.filter(update => update.sandboxConfig).map(update => update.sandboxConfig?.enabled), +- commands: fixture.commands, +- }, { selection: 'off', state: { enabled: false }, applied: [], commands: ['disable session'] }); +- }); +- +- test('resume enforces on when bypass is revoked during open', async () => { +- const fixture = setup('resume', true, true, false); +- const target = { session: fixture.owner, chat: buildDefaultChatUri(fixture.owner) }; +- fixture.configuration.setSessionSandboxPolicy(fixture.owner, { enabled: true, allowBypass: true }); +- fixture.configuration.setChatSandboxEnabled(target, false); +- fixture.configuration.updateChatConfig(target, { sandboxEnabled: 'off' }); +- +- store.add(await fixture.launcher.launch(fixture.plan, fixture.runtime)); +- +- assert.deepStrictEqual({ +- selection: fixture.configuration.getChatConfigValues(target)?.sandboxEnabled, +- state: fixture.sandboxState, +- applied: fixture.updates.filter(update => update.sandboxConfig).map(update => update.sandboxConfig?.enabled), +- commands: fixture.commands, +- }, { selection: 'on', state: { enabled: true }, applied: [true], commands: [] }); +- }); +- +- test('resume honors a newer on selection over previously confirmed off metadata', async () => { +- const fixture = setup('resume', true, true, true); +- const target = { session: fixture.owner, chat: buildDefaultChatUri(fixture.owner) }; +- fixture.configuration.setSessionSandboxPolicy(fixture.owner, { enabled: true, allowBypass: true }); +- fixture.configuration.setChatSandboxEnabled(target, false); +- fixture.configuration.updateChatConfig(target, { sandboxEnabled: 'on' }); +- +- store.add(await fixture.launcher.launch(fixture.plan, fixture.runtime)); +- +- assert.deepStrictEqual({ +- opened: fixture.captured?.sandboxConfig?.enabled, +- selection: fixture.configuration.getChatConfigValues(target)?.sandboxEnabled, +- state: fixture.sandboxState, +- }, { opened: true, selection: 'on', state: { enabled: true } }); +- }); +- +- test('resume disconnects when the runtime rejects restoring confirmed bypass', async () => { +- const error = new Error('Session sandbox bypass rejected'); +- const fixture = setup('resume', true, true, true); +- const target = { session: fixture.owner, chat: buildDefaultChatUri(fixture.owner) }; +- fixture.configuration.setSessionSandboxPolicy(fixture.owner, { enabled: true, allowBypass: true }); +- fixture.configuration.setChatSandboxEnabled(target, false); +- fixture.configuration.updateChatConfig(target, { sandboxEnabled: 'off' }); +- const runtime: ICopilotSessionRuntime = { +- ...fixture.runtime, +- applyLaunchSandbox: async (session, isResume) => { +- session.rpc.commands.invoke = async () => ({ kind: 'text', text: error.message }); +- await fixture.runtime.applyLaunchSandbox(session, isResume); +- }, +- }; +- +- await assert.rejects(() => fixture.launcher.launch(fixture.plan, runtime), error); +- +- assert.deepStrictEqual({ +- disconnected: fixture.disconnected, +- selection: fixture.configuration.getChatConfigValues(target)?.sandboxEnabled, +- state: fixture.sandboxState, +- applied: fixture.updates.filter(update => update.sandboxConfig), +- }, { disconnected: true, selection: 'off', state: { enabled: false }, applied: [] }); +- }); +- +- test('resume does not overwrite a newer selection while applying the resolved sandbox', async () => { +- const fixture = setup('resume', true, true, true); +- const target = { session: fixture.owner, chat: buildDefaultChatUri(fixture.owner) }; +- fixture.configuration.setSessionSandboxPolicy(fixture.owner, { enabled: true, allowBypass: true }); +- fixture.configuration.setChatSandboxEnabled(target, false); +- fixture.configuration.updateChatConfig(target, { sandboxEnabled: 'off' }); +- const runtime: ICopilotSessionRuntime = { +- ...fixture.runtime, +- applyLaunchSandbox: async (session, isResume) => { +- const invoke = session.rpc.commands.invoke; +- session.rpc.commands.invoke = async params => { +- const result = await invoke(params); +- fixture.configuration.updateChatConfig(target, { sandboxEnabled: 'default' }); +- return result; +- }; +- await fixture.runtime.applyLaunchSandbox(session, isResume); +- }, +- }; +- +- store.add(await fixture.launcher.launch(fixture.plan, runtime)); +- +- assert.deepStrictEqual({ +- selection: fixture.configuration.getChatConfigValues(target)?.sandboxEnabled, +- state: fixture.sandboxState, +- }, { selection: 'default', state: { enabled: false } }); +- }); +- +- test('resume still honors a new pending bypass request', async () => { +- const fixture = setup('resume', true, true, true); +- fixture.configuration.setSessionSandboxPolicy(fixture.owner, { enabled: true, allowBypass: true }); +- fixture.configuration.setChatSandboxEnabled({ session: fixture.owner, chat: buildDefaultChatUri(fixture.owner) }, true); +- await fixture.sandbox.applyConfig(true, 'off'); +- +- store.add(await fixture.launcher.launch(fixture.plan, fixture.runtime)); +- +- assert.deepStrictEqual({ +- commands: fixture.commands, +- state: fixture.sandboxState, +- }, { commands: ['disable session'], state: { enabled: false } }); +- }); +- - test('sandbox config changes during launch apply to the launched session before attachment', async () => { - const fixture = setup('create', true, true, true); - const started = new DeferredPromise(); @@ -120255,24 +124724,34 @@ index 2e9c69bc531..00000000000 - ['message', new Error('Sandbox configuration update violates managed policy. Contact your administrator for more information.')], - ['wrapped message', new Error('Request session.options.update failed with message: Sandbox configuration update violates managed policy. Contact your administrator for more information.')], - ] as const) { -- test(`${kind} logs a ${format} sandbox conflict without disconnecting or publishing the rejected state`, async () => { +- test(`${kind} ${kind === 'resume' ? 'rejects' : 'tolerates'} a ${format} sandbox conflict without publishing the rejected state`, async () => { - const fixture = setup(kind, true, false, false, undefined, error); - fixture.configuration.setChatSandboxEnabled({ session: fixture.owner, chat: buildDefaultChatUri(fixture.owner) }, true); - -- store.add(await fixture.launcher.launch(fixture.plan, fixture.runtime)); +- if (kind === 'resume') { +- await assert.rejects(() => fixture.launcher.launch(fixture.plan, fixture.runtime), error); +- } else { +- store.add(await fixture.launcher.launch(fixture.plan, fixture.runtime)); +- } - - assert.deepStrictEqual({ - disconnected: fixture.disconnected, -- published: fixture.sandboxState, +- published: fixture.sandboxState?.enabled, +- conflict: fixture.sandboxState?.error?.message, +- broadcast: fixture.sandboxState?.error !== undefined && hasKey(fixture.sandboxState.error, { id: true }), - applied: fixture.updates.filter(update => update.sandboxConfig), - warnings: fixture.logService.warnings, - loggedSuccess: fixture.logService.infos.some(message => message.includes('Applied SDK sandboxConfig')), - }, { -- disconnected: false, -- published: { enabled: true }, +- disconnected: kind === 'resume', +- published: true, +- conflict: kind === 'resume' ? undefined : error.message, +- broadcast: kind === 'create', - applied: [], - warnings: [{ -- message: '[Copilot:sess-1] SDK sandboxConfig update conflicts with managed policy; continuing with the runtime\'s existing sandbox configuration', +- message: kind === 'resume' +- ? '[Copilot:sess-1] Failed to apply SDK sandboxConfig' +- : '[Copilot:sess-1] SDK sandboxConfig update conflicts with managed policy; continuing with the runtime\'s existing sandbox configuration', - args: [error], - }], - loggedSuccess: false, @@ -120624,7 +125103,7 @@ index 2e9c69bc531..00000000000 - -suite('composeByokSessionConfig', () => { - -- ensureNoDisposablesAreLeakedInTestSuite(); +- const store = ensureNoDisposablesAreLeakedInTestSuite(); - - const acme = { name: 'acme', type: 'openai' as const, wireApi: 'responses' as const, baseUrl: 'http://127.0.0.1:1/v/acme', bearerToken: 'nonce.sess-1' }; - const claude = { id: 'claude', provider: 'acme' }; @@ -120664,18 +125143,14 @@ index 2e9c69bc531..00000000000 - }); - - test('resolves the direct family from the provider-qualified selection id', () => { -- const provider = new FakeLocalModelProvider([MAI_ROW]); -- try { -- assert.deepStrictEqual([ -- findLocalModelDirectFamily(provider, 'vscode-on-device/mai-code-1.1-flash'), -- findLocalModelDirectFamily(provider, 'mai-code-1.1-flash-local'), -- findLocalModelDirectFamily(provider, 'auto'), -- findLocalModelDirectFamily(provider, undefined), -- findLocalModelDirectFamily(undefined, 'vscode-on-device/mai-code-1.1-flash'), -- ], ['mai-code-1.1-flash-local', undefined, undefined, undefined, undefined]); -- } finally { -- provider.dispose(); -- } +- const provider = store.add(new FakeLocalModelProvider([MAI_ROW])); +- assert.deepStrictEqual([ +- findLocalModelDirectFamily(provider, 'vscode-on-device/mai-code-1.1-flash'), +- findLocalModelDirectFamily(provider, 'mai-code-1.1-flash-local'), +- findLocalModelDirectFamily(provider, 'auto'), +- findLocalModelDirectFamily(provider, undefined), +- findLocalModelDirectFamily(undefined, 'vscode-on-device/mai-code-1.1-flash'), +- ], ['mai-code-1.1-flash-local', undefined, undefined, undefined, undefined]); - }); -}); - @@ -120688,7 +125163,7 @@ index 2e9c69bc531..00000000000 - */ -suite('CopilotSessionLauncher BYOK proxy lifecycle', () => { - -- ensureNoDisposablesAreLeakedInTestSuite(); +- const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - - const sessionId = 'sess-1'; - @@ -120764,25 +125239,21 @@ index 2e9c69bc531..00000000000 - }); - - test('does not synthesize BYOK session config while root configuration disables it', async () => { -- const store = new DisposableStore(); +- const store = disposables.add(new DisposableStore()); - const proxy = fakeProxyService(); - const registry = new ByokLmBridgeRegistry(); - store.add(registry.register('client-1', connectionOf(store, [{ vendor: 'acme', id: 'claude' }]))); - const launcher = createLauncher(store, proxy.service, registry, false); - -- try { -- const config = await launcher.resolveByokSessionConfig(sessionId); -- assert.deepStrictEqual({ config, proxyStarts: proxy.starts }, { config: {}, proxyStarts: 0 }); -- } finally { -- store.dispose(); -- } +- const config = await launcher.resolveByokSessionConfig(sessionId); +- assert.deepStrictEqual({ config, proxyStarts: proxy.starts }, { config: {}, proxyStarts: 0 }); - }); - -}); - -suite('CopilotSessionLauncher Connectors', () => { - -- ensureNoDisposablesAreLeakedInTestSuite(); +- const store = ensureNoDisposablesAreLeakedInTestSuite(); - - for (const scenario of ['create', 'resume', 'reconcileError', 'malformedStatus', 'unsupported', 'noAccount', 'disabled'] as const) { - test(`uses session-owned Connector accounts without delaying launch: ${scenario}`, async () => { @@ -120878,45 +125349,192 @@ index 2e9c69bc531..00000000000 - }; - - let launchConnectorReconcile: Parameters[0] | undefined; -- const launched = await launcher.launch(plan, { +- store.add(await launcher.launch(plan, { - ...testRuntime, - adoptLaunchConnectorReconcile: reconcile => launchConnectorReconcile = reconcile, +- })); +- const adopted = launchConnectorReconcile !== undefined; +- reconcileReleased.complete(); +- const connectorServers = await launchConnectorReconcile; +- assert.deepStrictEqual({ +- adopted, +- featureFlags, +- connectorCalls, +- connectorDisplayName: connectorServers?.servers.get('connector-mail')?.displayName, +- warnings: logService.warnings.map(warning => warning.message), +- }, { +- adopted: scenario !== 'disabled', +- featureFlags: { ...catalogInstallationFeatureFlags, AUTO_APPROVAL: true, CONNECTORS: scenario !== 'disabled', TGREP: true, CONTENT_EXCLUSION: true, DETACH_LONG_LIVED_SERVICES: true, copilot_swe_agent_memory_in_repo_store: false, ...(scenario === 'disabled' ? {} : { MANAGED_MCP_SERVERS: true }) }, +- connectorCalls: scenario === 'disabled' ? [] +- : scenario === 'unsupported' ? ['capabilities'] +- : scenario === 'noAccount' ? ['capabilities', 'account'] +- : ['capabilities', 'account', 'reconcile:session-account:true', ...(scenario === 'reconcileError' ? ['status'] : [])], +- connectorDisplayName: scenario === 'disabled' || scenario === 'unsupported' || scenario === 'noAccount' || scenario === 'malformedStatus' ? undefined : 'Work IQ Mail', +- warnings: scenario === 'unsupported' ? ['[Copilot:connector-session] Connector MCP reconciliation skipped because the runtime does not support session-owned Connector accounts'] +- : scenario === 'noAccount' ? ['[Copilot:connector-session] Connector MCP reconciliation skipped because the session account could not be resolved'] +- : scenario === 'reconcileError' ? ['[Copilot:connector-session] Connector MCP reconciliation failed; using the current Connector state when available: MCP server requires authentication'] +- : scenario === 'malformedStatus' ? ['[Copilot:connector-session] Connector MCP reconciliation failed unexpectedly'] +- : [], - }); -- try { -- const adopted = launchConnectorReconcile !== undefined; -- reconcileReleased.complete(); -- const connectorServers = await launchConnectorReconcile; -- assert.deepStrictEqual({ -- adopted, -- featureFlags, -- connectorCalls, -- connectorDisplayName: connectorServers?.servers.get('connector-mail')?.displayName, -- warnings: logService.warnings.map(warning => warning.message), -- }, { -- adopted: scenario !== 'disabled', -- featureFlags: { ...catalogInstallationFeatureFlags, AUTO_APPROVAL: true, CONNECTORS: scenario !== 'disabled', TGREP: false, CONTENT_EXCLUSION: true, DETACH_LONG_LIVED_SERVICES: true, copilot_swe_agent_memory_in_repo_store: false, ...(scenario === 'disabled' ? {} : { MANAGED_MCP_SERVERS: true }) }, -- connectorCalls: scenario === 'disabled' ? [] -- : scenario === 'unsupported' ? ['capabilities'] -- : scenario === 'noAccount' ? ['capabilities', 'account'] -- : ['capabilities', 'account', 'reconcile:session-account:true', ...(scenario === 'reconcileError' ? ['status'] : [])], -- connectorDisplayName: scenario === 'disabled' || scenario === 'unsupported' || scenario === 'noAccount' || scenario === 'malformedStatus' ? undefined : 'Work IQ Mail', -- warnings: scenario === 'unsupported' ? ['[Copilot:connector-session] Connector MCP reconciliation skipped because the runtime does not support session-owned Connector accounts'] -- : scenario === 'noAccount' ? ['[Copilot:connector-session] Connector MCP reconciliation skipped because the session account could not be resolved'] -- : scenario === 'reconcileError' ? ['[Copilot:connector-session] Connector MCP reconciliation failed; using the current Connector state when available: MCP server requires authentication'] -- : scenario === 'malformedStatus' ? ['[Copilot:connector-session] Connector MCP reconciliation failed unexpectedly'] -- : [], -- }); -- } finally { -- launched.dispose(); -- } - }); - } - -}); - +-suite('getCopilotAgentSelectionName', () => { +- ensureNoDisposablesAreLeakedInTestSuite(); +- +- test('restores only unambiguous flat selections from the same materialized cache namespace', () => { +- const root = URI.file('/cache/bundle/current'); +- const sourceUri = URI.parse('client-customizations:/bundle'); +- const uri = URI.joinPath(root, 'agents', 'solo.agent.md'); +- const old = URI.file('/cache/bundle/previous/agents/solo.agent.md').toString(); +- const plugin = { +- format: PluginFormat.OpenPlugin, sourceUri, nonce: 'current', pluginDir: root, +- agents: [ +- toParsedAgent({ uri: URI.joinPath(root, 'agents', 'Solo.agent.md'), name: 'Case-distinct Solo' }), +- toParsedAgent({ uri, name: 'Solo' }), +- ...['first', 'second'].map(key => toParsedAgent({ uri: URI.joinPath(root, 'agents', key, 'reviewer.agent.md'), name: 'Reviewer' })), +- ], +- skills: [], hooks: [], instructions: [], mcpServers: [], +- }; +- const snapshot = { plugins: [plugin], discoveredAgents: [], additionalRootInstructions: [] }; +- const secondRoot = URI.file('/cache/bundle/another'); +- assert.deepStrictEqual({ +- restored: getCopilotAgentSelection(snapshot, old, true), +- liveChange: getCopilotAgentSelectionName(snapshot, old), +- anotherSource: getCopilotAgentSelectionName(snapshot, URI.file('/cache/other/previous/agents/solo.agent.md').toString(), true), +- directHost: getCopilotAgentSelectionName({ +- ...snapshot, plugins: [{ ...plugin, sourceUri: URI.parse('vscode-agent-host-file:/cache/bundle/current') }], +- }, old, true), +- ambiguousFile: getCopilotAgentSelectionName(snapshot, URI.file('/cache/bundle/previous/agents/reviewer.agent.md').toString(), true), +- ambiguousRevision: getCopilotAgentSelectionName({ +- ...snapshot, +- plugins: [plugin, { +- ...plugin, nonce: 'another', pluginDir: secondRoot, +- agents: [toParsedAgent({ uri: URI.joinPath(secondRoot, 'agents', 'solo.agent.md'), name: 'Solo' })], +- }], +- }, old, true), +- outside: getCopilotAgentSelectionName(snapshot, URI.file('/cache/bundle/../agents/solo.agent.md').toString(), true), +- opaqueHost: getCopilotAgentSelectionName(snapshot, 'other-host:/cache/bundle/previous/agents/solo.agent.md', true), +- }, { +- restored: { uri: uri.toString(), name: getSdkCustomAgentName(uri, plugin) }, +- liveChange: undefined, anotherSource: undefined, directHost: undefined, ambiguousFile: undefined, ambiguousRevision: undefined, +- outside: undefined, opaqueHost: undefined, +- }); +- }); +- +- test('maps forwarded URI selections to unique SDK names while preserving native SDK IDs', () => { +- const uris = ['first', 'second'].map(source => URI.parse(`client-agent:/${source}`)); +- const nativeUri = URI.parse('sdk-native:/reviewer'); +- const customizations = { +- plugins: [], +- discoveredAgents: uris.map(uri => toParsedAgent({ uri, name: 'Friendly Reviewer' })), +- nativeAgents: [{ type: CustomizationType.Agent, id: 'opaque-native-sdk-id', uri: nativeUri.toString(), name: 'Friendly Reviewer' } as const], +- additionalRootInstructions: [], +- }; +- assert.deepStrictEqual([...uris, nativeUri, URI.parse('client-agent:/missing')].map(uri => +- getCopilotAgentSelectionName(customizations, uri.toString())), +- [...uris.map(uri => getSdkCustomAgentName(uri)), 'opaque-native-sdk-id', undefined]); +- }); +- +- test('keeps plugin and standalone SDK identities stable across cache revisions', () => { +- const sourceUri = URI.parse('client-plugin:/stable-source'); +- const expected = getSdkCustomAgentName(URI.joinPath(sourceUri, 'agents', 'shared.agent.md')); +- const results = [false, true].flatMap(standalone => ['old', 'new'].map(revision => { +- const root = URI.file(`/cache/${revision}`); +- const uri = URI.joinPath(root, 'agents', 'shared.agent.md'); +- const customizations = { +- plugins: [{ +- format: PluginFormat.OpenPlugin, sourceUri, nonce: revision, +- pluginDir: standalone ? undefined : root, +- resourceDir: standalone ? root : undefined, +- agents: [toParsedAgent({ uri, name: 'Friendly Reviewer' })], +- skills: [], hooks: [], instructions: [], mcpServers: [], +- }], +- discoveredAgents: [], additionalRootInstructions: [], +- }; +- return { +- registered: getCopilotCustomAgents(customizations)[0].sdkName, +- selected: getCopilotAgentSelectionName(customizations, uri.toString()), +- }; +- })); +- assert.deepStrictEqual(results, Array.from({ length: 4 }, () => ({ registered: expected, selected: expected }))); +- }); +-}); +- -suite('CopilotSessionLauncher shared session config', () => { - -- ensureNoDisposablesAreLeakedInTestSuite(); +- const store = ensureNoDisposablesAreLeakedInTestSuite(); +- +- for (const kind of ['create', 'resume', 'recover', 'recover-empty', 'create-selection-error', 'resume-selection-error'] as const) { +- test(`${kind} selects native plugin agents without registering their definitions`, async () => { +- let captured: ResumeSessionConfig | undefined; +- const calls: string[] = []; +- const selectionFails = kind.endsWith('-selection-error'); +- const planKind = kind.startsWith('create') ? 'create' : 'resume'; +- let disconnected = false; +- const session = { +- sessionId: 'native-agent', on: () => () => { }, disconnect: async () => { disconnected = true; }, +- rpc: { +- agent: { +- list: async () => { calls.push('list'); return { agents: [] }; }, +- select: async ({ name }: { name: string }) => { +- calls.push(`select:${name}`); +- if (selectionFails) { +- throw Object.assign(new Error(`Custom agent '${name}' not found`), { code: -32603 }); +- } +- return {}; +- }, +- }, +- options: { update: async () => ({ success: true }) }, +- }, +- } as unknown as CopilotSession; +- const initialize = (config: ResumeSessionConfig | undefined) => { +- captured = config; +- assert.strictEqual(config?.agent, undefined); +- reportManagedSettings(config); +- return session; +- }; +- const client = { +- createSession: async (config: SessionConfig) => { calls.push('create'); return initialize(config); }, +- resumeSession: async (_id: string, config: ResumeSessionConfig | undefined) => { +- calls.push('resume'); +- assert.strictEqual(config?.agent, undefined); +- if (kind.startsWith('recover')) { +- throw Object.assign(new Error(kind === 'recover-empty' ? 'Session has no events' : 'Session not found: native-agent'), { code: -32603 }); +- } +- return initialize(config); +- }, +- rpc: { managedSettings: createUnmanagedCopilotSettings() }, +- } as unknown as CopilotClient; +- const nativeAgent = { type: CustomizationType.Agent, id: 'reviewer@shop:review', uri: URI.file('/plugins/reviewer/agents/review.agent.md').toString(), name: 'Review' } as const; +- const shared = { +- client, sessionId: 'native-agent', workingDirectory: testWorkingDirectory, +- resolvedAgentName: nativeAgent.id, +- snapshot: { discoveredAgents: [], nativeAgents: [nativeAgent], additionalRootInstructions: [], tools: [], plugins: [], mcpServers: {} }, +- activeClientToolSet: new ActiveClientToolSet(), shellManager: undefined, +- githubCredentials: CopilotGitHubSessionCredentials.fromToken(undefined), +- }; +- const plan: CopilotSessionLaunchPlan = planKind === 'create' +- ? { ...shared, kind: planKind, model: undefined } +- : { ...shared, kind: 'resume', fallback: { model: undefined } }; +- if (selectionFails) { +- await assert.rejects(createTestLauncher().launch(plan, testRuntime), /Custom agent 'reviewer@shop:review' not found/); +- assert.deepStrictEqual({ calls, disconnected }, { calls: [planKind, 'list', `select:${nativeAgent.id}`], disconnected: true }); +- return; +- } +- store.add(await createTestLauncher().launch(plan, testRuntime)); +- assert.deepStrictEqual({ +- agent: captured?.agent, +- nativeDefinitions: captured?.customAgents?.filter(agent => agent.name === nativeAgent.name || agent.name === nativeAgent.id), +- calls, +- }, { +- agent: undefined, nativeDefinitions: [], +- calls: [...(kind.startsWith('recover') ? ['resume', 'create'] : [kind]), 'list', `select:${nativeAgent.id}`], +- }); +- }); +- } - - for (const scoped of [false, true]) { - test(`keeps configured tools and defaults with ${scoped ? 'client-owned' : 'legacy'} credentials`, async () => { @@ -120934,32 +125552,28 @@ index 2e9c69bc531..00000000000 - const launcher = createTestLauncher(undefined, { - [CopilotCliConfigKey.ModelCapabilityOverrides]: { '*': { availableTools: configuredTools } }, - }, logService); -- const wrapper = await launcher.launch({ +- store.add(await launcher.launch({ - kind: 'create', client, sessionId: 'tool-profile', model: undefined, workingDirectory: testWorkingDirectory, - resolvedAgentName: undefined, snapshot: { discoveredAgents: [], additionalRootInstructions: [], tools: [], plugins: [], mcpServers: {} }, - activeClientToolSet: new ActiveClientToolSet(), shellManager: undefined, - githubCredentials: scoped - ? CopilotGitHubSessionCredentials.fromCredential({ resource: 'https://api.github.com', token: 'scoped', expiresAt: undefined, revision: 1 }) - : CopilotGitHubSessionCredentials.fromToken('legacy'), -- }, testRuntime); -- try { -- assert.deepStrictEqual({ -- tools: configs[0].availableTools, -- instructions: configs[0].skipCustomInstructions, -- coauthor: configs[0].coauthorEnabled, -- builtinSkills: configs[0].includedBuiltinSkills, -- telemetry: configs[0].enableSessionTelemetry, -- gitContext: configs[0].enableHostGitOperations, -- environment: configs[0].systemMessage?.mode === 'customize' ? configs[0].systemMessage.sections?.environment_context : undefined, -- rejectedTools: logService.warnings.filter(warning => warning.message.includes('Scoped profile rejected')).map(warning => warning.message), -- }, { -- tools: configuredTools, -- instructions: undefined, coauthor: undefined, builtinSkills: undefined, -- telemetry: undefined, gitContext: undefined, environment: undefined, rejectedTools: [], -- }); -- } finally { -- wrapper.dispose(); -- } +- }, testRuntime)); +- assert.deepStrictEqual({ +- tools: configs[0].availableTools, +- instructions: configs[0].skipCustomInstructions, +- coauthor: configs[0].coauthorEnabled, +- builtinSkills: configs[0].includedBuiltinSkills, +- telemetry: configs[0].enableSessionTelemetry, +- gitContext: configs[0].enableHostGitOperations, +- environment: configs[0].systemMessage?.mode === 'customize' ? configs[0].systemMessage.sections?.environment_context : undefined, +- rejectedTools: logService.warnings.filter(warning => warning.message.includes('Scoped profile rejected')).map(warning => warning.message), +- }, { +- tools: configuredTools, +- instructions: undefined, coauthor: undefined, builtinSkills: undefined, +- telemetry: undefined, gitContext: undefined, environment: undefined, rejectedTools: [], +- }); - }); - } - @@ -121295,6 +125909,7 @@ index 2e9c69bc531..00000000000 - ephemeralImageGeneration: createConfigs[1].imageGeneration, - ephemeralCustomAgents: createConfigs[1].customAgents, - ephemeralAskUserVariant: createConfigs[1].askUserVariant, +- ephemeralTgrep: createConfigs[1].featureFlags?.TGREP, - ephemeralDetachLongLivedServices: createConfigs[1].featureFlags?.DETACH_LONG_LIVED_SERVICES, - ephemeralLocalMemoryStore: createConfigs[1].featureFlags?.copilot_swe_agent_memory_in_repo_store, - ephemeralMcpServers: createConfigs[1].mcpServers, @@ -121344,7 +125959,7 @@ index 2e9c69bc531..00000000000 - createLargeOutput: { maxSizeBytes: 8192 }, - createManagedSettings: { permissions: managedSettingsPermissions }, - createStrictModelEnforcement: undefined, -- createFeatureFlags: { ...catalogInstallationFeatureFlags, AUTO_APPROVAL: true, CONNECTORS: false, TGREP: false, CONTENT_EXCLUSION: true, DETACH_LONG_LIVED_SERVICES: true, copilot_swe_agent_memory_in_repo_store: true }, +- createFeatureFlags: { ...catalogInstallationFeatureFlags, AUTO_APPROVAL: true, CONNECTORS: false, TGREP: true, CONTENT_EXCLUSION: true, DETACH_LONG_LIVED_SERVICES: true, copilot_swe_agent_memory_in_repo_store: true }, - createStreaming: true, - createEnableSessionStore: true, - createRequestExtensions: true, @@ -121379,7 +125994,7 @@ index 2e9c69bc531..00000000000 - resumeLargeOutput: { maxSizeBytes: 8192 }, - resumeManagedSettings: { permissions: managedSettingsPermissions }, - resumeStrictModelEnforcement: undefined, -- resumeFeatureFlags: { ...catalogInstallationFeatureFlags, AUTO_APPROVAL: true, CONNECTORS: false, TGREP: false, CONTENT_EXCLUSION: true, DETACH_LONG_LIVED_SERVICES: true, copilot_swe_agent_memory_in_repo_store: true }, +- resumeFeatureFlags: { ...catalogInstallationFeatureFlags, AUTO_APPROVAL: true, CONNECTORS: false, TGREP: true, CONTENT_EXCLUSION: true, DETACH_LONG_LIVED_SERVICES: true, copilot_swe_agent_memory_in_repo_store: true }, - resumeStreaming: true, - resumeEnableSessionStore: true, - resumeRequestExtensions: true, @@ -121395,6 +126010,7 @@ index 2e9c69bc531..00000000000 - ephemeralImageGeneration: { enabled: true, watermark: false }, - ephemeralCustomAgents: [], - ephemeralAskUserVariant: 'elicitation', +- ephemeralTgrep: true, - ephemeralDetachLongLivedServices: true, - ephemeralLocalMemoryStore: false, - ephemeralMcpServers: {}, @@ -121651,7 +126267,7 @@ index 2e9c69bc531..00000000000 -}); - -suite('CopilotSessionLauncher canvas config', () => { -- ensureNoDisposablesAreLeakedInTestSuite(); +- const store = ensureNoDisposablesAreLeakedInTestSuite(); - - test('fails closed when a canvas-enabled client residency has no extension SDK path', async () => { - const launcher = createTestLauncher(undefined, { [AgentHostCanvasesEnabledConfigKey]: true }); @@ -121707,24 +126323,20 @@ index 2e9c69bc531..00000000000 - githubCredentials: CopilotGitHubSessionCredentials.fromToken(undefined), - }; - -- const wrapper = await launcher.launch(plan, testRuntime); -- try { -- assert.deepStrictEqual({ -- requestExtensions: captured?.requestExtensions, -- requestCanvasRenderer: captured?.requestCanvasRenderer, -- extensionSdkPath: captured?.extensionSdkPath, -- toolNames: captured?.tools?.map(tool => tool.name), -- wrapperCanvasRuntimeEnabled: wrapper.canvasRuntimeEnabled, -- }, { -- requestExtensions: false, -- requestCanvasRenderer: false, -- extensionSdkPath: undefined, -- toolNames: [], -- wrapperCanvasRuntimeEnabled: false, -- }); -- } finally { -- wrapper.dispose(); -- } +- const wrapper = store.add(await launcher.launch(plan, testRuntime)); +- assert.deepStrictEqual({ +- requestExtensions: captured?.requestExtensions, +- requestCanvasRenderer: captured?.requestCanvasRenderer, +- extensionSdkPath: captured?.extensionSdkPath, +- toolNames: captured?.tools?.map(tool => tool.name), +- wrapperCanvasRuntimeEnabled: wrapper.canvasRuntimeEnabled, +- }, { +- requestExtensions: false, +- requestCanvasRenderer: false, +- extensionSdkPath: undefined, +- toolNames: [], +- wrapperCanvasRuntimeEnabled: false, +- }); - }); - - test('does not request canvas extensions or rendering by default', async () => { @@ -121757,24 +126369,20 @@ index 2e9c69bc531..00000000000 - githubCredentials: CopilotGitHubSessionCredentials.fromToken(undefined), - }; - -- const wrapper = await launcher.launch(plan, testRuntime); -- try { -- assert.deepStrictEqual({ -- requestExtensions: captured?.requestExtensions, -- requestCanvasRenderer: captured?.requestCanvasRenderer, -- extensionSdkPath: captured?.extensionSdkPath, -- toolNames: captured?.tools?.map(tool => tool.name), -- wrapperCanvasRuntimeEnabled: wrapper.canvasRuntimeEnabled, -- }, { -- requestExtensions: false, -- requestCanvasRenderer: false, -- extensionSdkPath: undefined, -- toolNames: [], -- wrapperCanvasRuntimeEnabled: false, -- }); -- } finally { -- wrapper.dispose(); -- } +- const wrapper = store.add(await launcher.launch(plan, testRuntime)); +- assert.deepStrictEqual({ +- requestExtensions: captured?.requestExtensions, +- requestCanvasRenderer: captured?.requestCanvasRenderer, +- extensionSdkPath: captured?.extensionSdkPath, +- toolNames: captured?.tools?.map(tool => tool.name), +- wrapperCanvasRuntimeEnabled: wrapper.canvasRuntimeEnabled, +- }, { +- requestExtensions: false, +- requestCanvasRenderer: false, +- extensionSdkPath: undefined, +- toolNames: [], +- wrapperCanvasRuntimeEnabled: false, +- }); - }); -}); - @@ -121843,6 +126451,19 @@ index 2e9c69bc531..00000000000 - } - }); - +- test('does not clear a requested native selection after a resume agent-not-found error', async () => { +- const { launcher, plan, getCreateSessionCalls, interactiveUserOptions } = createResumeFailingLaunch('Custom agent \'native:reviewer\' not found'); +- const nativeAgent = { +- type: CustomizationType.Agent, id: 'native:reviewer', +- name: 'Reviewer', uri: URI.file('/plugin/agents/reviewer.agent.md').toString(), +- } as const; +- await assert.rejects(launcher.launch({ +- ...plan, resolvedAgentName: nativeAgent.id, +- snapshot: { ...plan.snapshot, nativeAgents: [nativeAgent] }, +- }, testRuntime), /Custom agent 'native:reviewer' not found/); +- assert.deepStrictEqual({ creates: getCreateSessionCalls(), interactiveUserOptions }, { creates: 0, interactiveUserOptions: [true] }); +- }); +- - test('reports SDK resume failure and fallback creation milestones', async () => { - const milestones: string[] = []; - const sessionOpenTelemetry: IAgentHostSessionOpenTelemetry = { @@ -121945,7 +126566,7 @@ index 2e9c69bc531..00000000000 -}); - -suite('CopilotSessionLauncher execution subagent', () => { -- ensureNoDisposablesAreLeakedInTestSuite(); +- const store = ensureNoDisposablesAreLeakedInTestSuite(); - - function setup(kind: 'create' | 'resume', defaultEnabled: boolean, runtimeAvailable: boolean, hasClientTool = true, setToolsError?: Error, catalogError?: Error) { - const toolUpdates: string[][] = []; @@ -122022,41 +126643,33 @@ index 2e9c69bc531..00000000000 - for (const kind of ['create', 'resume'] as const) { - test(`${kind}: SDK initialization uses the exact tool configuration compared during reconciliation`, async () => { - const fixture = setup(kind, true, false); -- const launched = await fixture.launch(); -- try { -- const configuration = fixture.configuration(); -- const actual = fixture.launchConfigurations[0]; -- assert.ok(configuration); -- assert.ok(actual); -- const keys = Object.keys(configuration.sessionConfig) as (keyof typeof configuration.sessionConfig)[]; -- assert.deepStrictEqual({ -- actual: Object.fromEntries(keys.map(key => [key, actual[key]])), -- desired: configuration.resolve(fixture.initialDefinitions).sessionConfig, -- }, { -- actual: configuration.sessionConfig, -- desired: configuration.sessionConfig, -- }); -- } finally { -- launched.dispose(); -- } +- store.add(await fixture.launch()); +- const configuration = fixture.configuration(); +- const actual = fixture.launchConfigurations[0]; +- assert.ok(configuration); +- assert.ok(actual); +- const keys = Object.keys(configuration.sessionConfig) as (keyof typeof configuration.sessionConfig)[]; +- assert.deepStrictEqual({ +- actual: Object.fromEntries(keys.map(key => [key, actual[key]])), +- desired: configuration.resolve(fixture.initialDefinitions).sessionConfig, +- }, { +- actual: configuration.sessionConfig, +- desired: configuration.sessionConfig, +- }); - }); - - for (const defaultEnabled of [false, true]) { - for (const runtimeAvailable of [false, true]) { - test(`${kind}: runtime execution availability=${runtimeAvailable} controls client tool suppression with VS Code default=${defaultEnabled}`, async () => { - const fixture = setup(kind, defaultEnabled, runtimeAvailable); -- const launched = await fixture.launch(); -- try { -- assert.deepStrictEqual({ -- descriptorRequests: fixture.descriptorRequests(), -- toolUpdates: fixture.toolUpdates, -- }, { -- descriptorRequests: 1, -- toolUpdates: runtimeAvailable ? [['clientRead']] : [], -- }); -- } finally { -- launched.dispose(); -- } +- store.add(await fixture.launch()); +- assert.deepStrictEqual({ +- descriptorRequests: fixture.descriptorRequests(), +- toolUpdates: fixture.toolUpdates, +- }, { +- descriptorRequests: 1, +- toolUpdates: runtimeAvailable ? [['clientRead']] : [], +- }); - }); - } - } @@ -122064,54 +126677,42 @@ index 2e9c69bc531..00000000000 - test(`${kind}: keeps the extension execution tool when the runtime catalog fails with ${message}`, async () => { - const error = new Error(message); - const fixture = setup(kind, false, false, true, undefined, error); -- const launched = await fixture.launch(); -- try { -- assert.deepStrictEqual({ -- descriptorRequests: fixture.descriptorRequests(), -- toolUpdates: fixture.toolUpdates, -- disconnects: fixture.disconnects(), -- warnings: fixture.logService.warnings, -- }, { -- descriptorRequests: 1, -- toolUpdates: [], -- disconnects: 0, -- warnings: [{ -- message: '[Copilot:execution-session] Could not resolve runtime tool catalog; keeping extension execution tool', -- args: [error], -- }], -- }); -- } finally { -- launched.dispose(); -- } +- store.add(await fixture.launch()); +- assert.deepStrictEqual({ +- descriptorRequests: fixture.descriptorRequests(), +- toolUpdates: fixture.toolUpdates, +- disconnects: fixture.disconnects(), +- warnings: fixture.logService.warnings, +- }, { +- descriptorRequests: 1, +- toolUpdates: [], +- disconnects: 0, +- warnings: [{ +- message: '[Copilot:execution-session] Could not resolve runtime tool catalog; keeping extension execution tool', +- args: [error], +- }], +- }); - }); - } - } - - test('does not resolve the runtime tool catalog when the extension execution tool is absent', async () => { - const fixture = setup('create', true, true, false); -- const launched = await fixture.launch(); -- try { -- assert.deepStrictEqual({ descriptorRequests: fixture.descriptorRequests(), toolUpdates: fixture.toolUpdates }, { -- descriptorRequests: 0, toolUpdates: [], -- }); -- } finally { -- launched.dispose(); -- } +- store.add(await fixture.launch()); +- assert.deepStrictEqual({ descriptorRequests: fixture.descriptorRequests(), toolUpdates: fixture.toolUpdates }, { +- descriptorRequests: 0, toolUpdates: [], +- }); - }); - - for (const initiallyPresent of [false, true]) { - test(`live replacement keeps the extension execution tool suppressed when initially present=${initiallyPresent}`, async () => { - const fixture = setup('create', true, true, initiallyPresent); -- const launched = await fixture.launch(); -- try { -- await fixture.replaceTools([{ name: 'executionSubagent', handler: () => 'done' }, { name: 'newClient', handler: () => 'done' }]); -- assert.deepStrictEqual({ requests: fixture.descriptorRequests(), updates: fixture.toolUpdates }, { -- requests: 1, -- updates: [...(initiallyPresent ? [['clientRead']] : []), ['newClient']], -- }); -- } finally { -- launched.dispose(); -- } +- store.add(await fixture.launch()); +- await fixture.replaceTools([{ name: 'executionSubagent', handler: () => 'done' }, { name: 'newClient', handler: () => 'done' }]); +- assert.deepStrictEqual({ requests: fixture.descriptorRequests(), updates: fixture.toolUpdates }, { +- requests: 1, +- updates: [...(initiallyPresent ? [['clientRead']] : []), ['newClient']], +- }); - }); - } - @@ -122525,6 +127126,41 @@ index 2e9c69bc531..00000000000 - assert.strictEqual(tier, undefined); - }); - +- test('pins inline chat on Fusion to the fast tier, whatever effort level the picker holds', () => { +- const log = new NullLogService(); +- const configOf = (override?: string) => ({ +- getRootValue: (_schema: unknown, key: string) => key === CopilotCliConfigKey.AutoModeTierOverride ? override : undefined, +- }) as Parameters[1]; +- const resolve = (model: ModelSelection, isInlineChat: boolean, override?: string) => resolveCopilotAutoTier(model, configOf(override), log, 'session', isInlineChat); +- const fusion: ModelSelection = { id: 'auto', config: { tier: 'hydrafusion' } }; +- let result; +- try { +- setAdvertisedAutoModeTiers(['balance', 'hydrafusion', 'fast'], true); +- const fusionTiers = { +- inline: resolve(fusion, true), +- inlineWithoutPreference: resolve({ id: 'auto' }, true), +- inlineConcreteModel: resolve({ id: 'gpt-5', config: { tier: 'hydrafusion' } }, true), +- inlineWithOverride: resolve(fusion, true, 'balance'), +- panel: resolve(fusion, false), +- }; +- setAdvertisedAutoModeTiers(['balance', 'hydrafusion'], true); +- const fastNotAccepted = resolve(fusion, true); +- setAdvertisedAutoModeTiers(['efficiency', 'balance', 'fast']); +- result = { ...fusionTiers, fastNotAccepted, inlineNotFusion: resolve({ id: 'auto', config: { tier: 'efficiency' } }, true) }; +- } finally { +- setAdvertisedAutoModeTiers(undefined); +- } +- assert.deepStrictEqual(result, { +- inline: 'fast', +- inlineWithoutPreference: 'fast', +- inlineConcreteModel: undefined, +- inlineWithOverride: 'balance', +- panel: 'hydrafusion', +- fastNotAccepted: 'hydrafusion', +- inlineNotFusion: 'efficiency', +- }); +- }); +- - test('resolves the override before picker preferences', () => { - const log = new NullLogService(); - const model: ModelSelection = { id: 'auto', config: { tier: 'efficiency' } }; @@ -122849,7 +127485,7 @@ index 2e9c69bc531..00000000000 - ]; - assert.deepStrictEqual({ - equivalent: changes.map(tools => isDeepStrictEqual(appliedConfiguration.sessionConfig, appliedConfiguration.resolve(tools).sessionConfig)), -- agentTools: configuration.sessionConfig.customAgents.find(agent => agent.name === 'reviewer')?.tools, +- agentTools: configuration.sessionConfig.customAgents.find(agent => agent.name === getSdkCustomAgentName(uri))?.tools, - reads, - }, { equivalent: [true, true, true, false, false], agentTools: ['zetaRead'], reads: 1 }); - }); @@ -122966,7 +127602,7 @@ index 2e9c69bc531..00000000000 - ...catalogInstallationFeatureFlags, - AUTO_APPROVAL: true, - CONNECTORS: false, -- TGREP: false, +- TGREP: true, - CONTENT_EXCLUSION: true, - DETACH_LONG_LIVED_SERVICES: true, - copilot_swe_agent_memory_in_repo_store: false, @@ -122978,7 +127614,7 @@ index 2e9c69bc531..00000000000 - ...catalogInstallationFeatureFlags, - AUTO_APPROVAL: true, - CONNECTORS: false, -- TGREP: false, +- TGREP: true, - CONTENT_EXCLUSION: true, - DETACH_LONG_LIVED_SERVICES: true, - copilot_swe_agent_memory_in_repo_store: false, @@ -122987,13 +127623,13 @@ index 2e9c69bc531..00000000000 - HYDRAFUSION_INTERRUPTED_COMMIT: true, - HYDRAFUSION_PLAN_V2: true, - }, -- v2WithoutHydraFusionFeatureFlags: { ...catalogInstallationFeatureFlags, AUTO_APPROVAL: true, CONNECTORS: false, TGREP: false, CONTENT_EXCLUSION: true, DETACH_LONG_LIVED_SERVICES: true, copilot_swe_agent_memory_in_repo_store: false }, +- v2WithoutHydraFusionFeatureFlags: { ...catalogInstallationFeatureFlags, AUTO_APPROVAL: true, CONNECTORS: false, TGREP: true, CONTENT_EXCLUSION: true, DETACH_LONG_LIVED_SERVICES: true, copilot_swe_agent_memory_in_repo_store: false }, - fusionExperimentalMode: true, - fusionFeatureFlags: { - ...catalogInstallationFeatureFlags, - AUTO_APPROVAL: true, - CONNECTORS: false, -- TGREP: false, +- TGREP: true, - CONTENT_EXCLUSION: true, - DETACH_LONG_LIVED_SERVICES: true, - copilot_swe_agent_memory_in_repo_store: false, @@ -123003,10 +127639,10 @@ index 2e9c69bc531..00000000000 - HYDRAFUSION_PLAN_V2: true, - }, - disabledExperimentalMode: undefined, -- disabledFeatureFlags: { ...catalogInstallationFeatureFlags, AUTO_APPROVAL: true, CONNECTORS: false, TGREP: false, CONTENT_EXCLUSION: true, DETACH_LONG_LIVED_SERVICES: true, copilot_swe_agent_memory_in_repo_store: false }, +- disabledFeatureFlags: { ...catalogInstallationFeatureFlags, AUTO_APPROVAL: true, CONNECTORS: false, TGREP: true, CONTENT_EXCLUSION: true, DETACH_LONG_LIVED_SERVICES: true, copilot_swe_agent_memory_in_repo_store: false }, - defaultExperimentalMode: undefined, -- defaultFeatureFlags: { ...catalogInstallationFeatureFlags, AUTO_APPROVAL: true, CONNECTORS: false, TGREP: false, CONTENT_EXCLUSION: true, DETACH_LONG_LIVED_SERVICES: true, copilot_swe_agent_memory_in_repo_store: false }, -- connectorFeatureFlags: { ...catalogInstallationFeatureFlags, AUTO_APPROVAL: true, CONNECTORS: true, TGREP: false, CONTENT_EXCLUSION: true, DETACH_LONG_LIVED_SERVICES: true, copilot_swe_agent_memory_in_repo_store: false, MANAGED_MCP_SERVERS: true }, +- defaultFeatureFlags: { ...catalogInstallationFeatureFlags, AUTO_APPROVAL: true, CONNECTORS: false, TGREP: true, CONTENT_EXCLUSION: true, DETACH_LONG_LIVED_SERVICES: true, copilot_swe_agent_memory_in_repo_store: false }, +- connectorFeatureFlags: { ...catalogInstallationFeatureFlags, AUTO_APPROVAL: true, CONNECTORS: true, TGREP: true, CONTENT_EXCLUSION: true, DETACH_LONG_LIVED_SERVICES: true, copilot_swe_agent_memory_in_repo_store: false, MANAGED_MCP_SERVERS: true }, - }); - }); - @@ -123029,17 +127665,17 @@ index 2e9c69bc531..00000000000 - }); - }); - -- test('configures repository-size-gated tgrep feature flag', async () => { +- test('enables repository-size-gated tgrep by default and ignores the removed opt-out', async () => { - const store = disposables.add(new DisposableStore()); -- const disabled = await buildResumeConfig(createLauncher(store, {}), { id: 'gpt-5' }); -- const enabled = await buildResumeConfig(createLauncher(store, { [CopilotCliConfigKey.Tgrep]: true }), { id: 'gpt-5' }); +- const defaultConfig = await buildResumeConfig(createLauncher(store, {}), { id: 'gpt-5' }); +- const legacyOptOut = await buildResumeConfig(createLauncher(store, { tgrep: false }), { id: 'gpt-5' }); - - assert.deepStrictEqual({ -- disabled: disabled.featureFlags, -- enabled: enabled.featureFlags, +- default: defaultConfig.featureFlags?.TGREP, +- legacyOptOut: legacyOptOut.featureFlags?.TGREP, - }, { -- disabled: { ...catalogInstallationFeatureFlags, AUTO_APPROVAL: true, CONNECTORS: false, TGREP: false, CONTENT_EXCLUSION: true, DETACH_LONG_LIVED_SERVICES: true, copilot_swe_agent_memory_in_repo_store: false }, -- enabled: { ...catalogInstallationFeatureFlags, AUTO_APPROVAL: true, CONNECTORS: false, TGREP: true, CONTENT_EXCLUSION: true, DETACH_LONG_LIVED_SERVICES: true, copilot_swe_agent_memory_in_repo_store: false }, +- default: true, +- legacyOptOut: true, - }); - }); - @@ -123119,8 +127755,8 @@ index 2e9c69bc531..00000000000 - disabled: disabled.featureFlags, - enabled: enabled.featureFlags, - }, { -- disabled: { ...catalogInstallationFeatureFlags, AUTO_APPROVAL: true, CONNECTORS: false, TGREP: false, CONTENT_EXCLUSION: true, DETACH_LONG_LIVED_SERVICES: true, copilot_swe_agent_memory_in_repo_store: false }, -- enabled: { ...catalogInstallationFeatureFlags, AUTO_APPROVAL: true, CONNECTORS: false, TGREP: false, CONTENT_EXCLUSION: true, DETACH_LONG_LIVED_SERVICES: true, copilot_swe_agent_memory_in_repo_store: false, STABILITY_ORDERED_SYSTEM_PROMPT_V2: true }, +- disabled: { ...catalogInstallationFeatureFlags, AUTO_APPROVAL: true, CONNECTORS: false, TGREP: true, CONTENT_EXCLUSION: true, DETACH_LONG_LIVED_SERVICES: true, copilot_swe_agent_memory_in_repo_store: false }, +- enabled: { ...catalogInstallationFeatureFlags, AUTO_APPROVAL: true, CONNECTORS: false, TGREP: true, CONTENT_EXCLUSION: true, DETACH_LONG_LIVED_SERVICES: true, copilot_swe_agent_memory_in_repo_store: false, STABILITY_ORDERED_SYSTEM_PROMPT_V2: true }, - }); - }); - @@ -123129,6 +127765,7 @@ index 2e9c69bc531..00000000000 - const snapshot = { discoveredAgents: [], additionalRootInstructions: [], tools: [{ name: SEMANTIC_SEARCH_TOOL_NAME }], plugins: [], mcpServers: {} }; - const disabled = await buildResumeConfig(createLauncher(store, {}), undefined, { discoveredAgents: [], additionalRootInstructions: [], tools: [], plugins: [], mcpServers: {} }); - const enabled = await buildResumeConfig(createLauncher(store, {}), undefined, snapshot); +- const app = await buildResumeConfig(createLauncher(store, {}), undefined, { ...snapshot, tools: snapshot.tools.map(tool => withAgentToolOrigin(tool, 'mcpApp')) }); - const filtered = await buildResumeConfig( - createLauncher(store, { modelCapabilityOverrides: { '*': { excludedTools: [`custom:${SEMANTIC_SEARCH_TOOL_NAME}`] } } }), - undefined, @@ -123136,12 +127773,68 @@ index 2e9c69bc531..00000000000 - ); - - assert.deepStrictEqual( -- [disabled.excludedTools, enabled.excludedTools, filtered.excludedTools], -- [[...defaultDisabledTools, `builtin:${SEMANTIC_SEARCH_TOOL_NAME}`], defaultDisabledTools, [`custom:${SEMANTIC_SEARCH_TOOL_NAME}`, ...defaultDisabledTools, `builtin:${SEMANTIC_SEARCH_TOOL_NAME}`]], +- [disabled.excludedTools, enabled.excludedTools, filtered.excludedTools, app.excludedTools], +- [[...defaultDisabledTools, `builtin:${SEMANTIC_SEARCH_TOOL_NAME}`], defaultDisabledTools, [`custom:${SEMANTIC_SEARCH_TOOL_NAME}`, ...defaultDisabledTools, `builtin:${SEMANTIC_SEARCH_TOOL_NAME}`], [...defaultDisabledTools, `builtin:${SEMANTIC_SEARCH_TOOL_NAME}`]], - ); - store.dispose(); - }); - +- test('withholds the semantic-search override while managed permission rules are active', async () => { +- const store = disposables.add(new DisposableStore()); +- const launcher = createLauncher(store, {}); +- const build = async (permissions: Record | undefined, failClosed = false) => { +- const withheld: (string[] | undefined)[] = []; +- let configuration: ICopilotClientToolConfiguration | undefined; +- const managedSettings = new class extends mock() { +- override resolve = async () => ({ +- resolved: { source: 'none' as const, serverManaged: permissions !== undefined, deviceManaged: false, clientManaged: false, failClosed, bypassPermissionsDisabled: false, managedKeys: permissions ? ['permissions'] : [], ...(permissions ? { settings: { permissions } } : {}) }, +- layers: [], diagnostics: [], +- }); +- }(); +- const { config } = await (launcher as unknown as { _buildSessionConfig(plan: unknown, runtime: unknown, onManagedSettingsResolved: () => void): Promise<{ config: ResumeSessionConfig }> })._buildSessionConfig({ +- kind: 'resume', +- client: { rpc: { managedSettings }, createSession: async () => { throw new Error('unused'); }, resumeSession: async () => { throw new Error('unused'); } }, +- extensionSdkPath: '/copilot-sdk', +- sessionId: 'sess-1', +- workingDirectory: URI.file('/workspace'), +- resolvedAgentName: undefined, +- snapshot: { discoveredAgents: [], additionalRootInstructions: [], tools: [{ name: SEMANTIC_SEARCH_TOOL_NAME }], plugins: [], mcpServers: {} }, +- activeClientToolSet: new ActiveClientToolSet(), +- shellManager: undefined, +- githubCredentials: CopilotGitHubSessionCredentials.fromToken('token'), +- fallback: { model: undefined }, +- }, { +- createClientSdkTools: (_toolSearchActive: boolean, _hostToolNames?: ReadonlySet, withheldToolNames?: ReadonlySet) => { +- withheld.push(withheldToolNames && [...withheldToolNames]); +- return []; +- }, +- configureClientTools: (value: ICopilotClientToolConfiguration) => { configuration = value; }, +- createServerSdkTools: () => [], +- }, () => { }); +- const builtinExcluded = (excludedTools: SessionConfig['excludedTools']) => Array.isArray(excludedTools) && excludedTools.includes(`builtin:${SEMANTIC_SEARCH_TOOL_NAME}`); +- assert.ok(configuration); +- return { +- withheld, +- liveUpdateWithheld: [...configuration.withheldToolNames], +- builtinExcluded: builtinExcluded(config.excludedTools), +- // A live update republishing the tool keeps the runtime's built-in excluded. +- liveUpdateBuiltinExcluded: builtinExcluded(configuration.resolve([{ name: SEMANTIC_SEARCH_TOOL_NAME }]).sessionConfig.excludedTools), +- }; +- }; +- +- assert.deepStrictEqual({ +- unmanaged: await build(undefined), +- ask: await build({ ask: ['Shell'] }), +- readDenied: await build({ deny: ['Read(/denied/**)'] }), +- failClosed: await build(undefined, true), +- }, { +- unmanaged: { withheld: [[]], liveUpdateWithheld: [], builtinExcluded: false, liveUpdateBuiltinExcluded: false }, +- ask: { withheld: [[SEMANTIC_SEARCH_TOOL_NAME]], liveUpdateWithheld: [SEMANTIC_SEARCH_TOOL_NAME], builtinExcluded: true, liveUpdateBuiltinExcluded: true }, +- readDenied: { withheld: [[SEMANTIC_SEARCH_TOOL_NAME]], liveUpdateWithheld: [SEMANTIC_SEARCH_TOOL_NAME], builtinExcluded: true, liveUpdateBuiltinExcluded: true }, +- failClosed: { withheld: [[SEMANTIC_SEARCH_TOOL_NAME]], liveUpdateWithheld: [SEMANTIC_SEARCH_TOOL_NAME], builtinExcluded: true, liveUpdateBuiltinExcluded: true }, +- }); +- }); +- - test('forwards a configured override on resume and leaves the effort untouched otherwise', async () => { - const store = new DisposableStore(); - const model: ModelSelection = { id: 'gpt-5', config: { thinkingLevel: 'medium' } }; @@ -123305,6 +127998,26 @@ index 2e9c69bc531..00000000000 - store.dispose(); - }); - +- test('an app named toolSearch does not activate the trusted tool-search override', async () => { +- const store = new DisposableStore(); +- const decisions: boolean[] = []; +- const config = await buildResumeConfig( +- createLauncher(store, { toolSearchEnabled: true }), +- { id: 'claude-opus-4.8' }, +- { +- discoveredAgents: [], additionalRootInstructions: [], +- tools: [withAgentToolOrigin({ name: CLIENT_TOOL_SEARCH_REFERENCE_NAME }, 'mcpApp')], +- plugins: [], mcpServers: {}, +- }, +- toolSearchActive => { +- decisions.push(toolSearchActive); +- return []; +- }, +- ); +- assert.deepStrictEqual({ config: config.toolSearch, decisions }, { config: { enabled: false }, decisions: [false] }); +- store.dispose(); +- }); +- - test('uses one launch-time tool-search decision for the config and client tools', async () => { - const store = new DisposableStore(); - const decisions: boolean[] = []; @@ -123334,6 +128047,18 @@ index 2e9c69bc531..00000000000 - }); - store.dispose(); - }); +- +- test('forwards the co-author trailer setting and otherwise leaves the SDK default', async () => { +- const store = new DisposableStore(); +- const coauthorEnabled = async (values: SchemaValues) => +- (await buildResumeConfig(createLauncher(store, values), undefined)).coauthorEnabled; +- +- assert.deepStrictEqual( +- [await coauthorEnabled({ includeCoAuthoredBy: false }), await coauthorEnabled({ includeCoAuthoredBy: true }), await coauthorEnabled({})], +- [false, true, undefined], +- ); +- store.dispose(); +- }); -}); - -/** Resume preserves runtime-owned routing state; a turn's selection is applied through setModel. */ @@ -123342,7 +128067,7 @@ index 2e9c69bc531..00000000000 - ensureNoDisposablesAreLeakedInTestSuite(); - - /** Launches a session and returns the `capi` options the SDK was called with. */ -- async function capiOptionsFor(kind: 'create' | 'resume' | 'fallback', model: ModelSelection | undefined, override?: string): Promise { +- async function capiOptionsFor(kind: 'create' | 'resume' | 'fallback', model: ModelSelection | undefined, override?: string, isInlineChatSurface?: boolean): Promise { - const capiCalls: SessionConfig['capi'][] = []; - const session = { - sessionId: 'session-1', @@ -123380,6 +128105,7 @@ index 2e9c69bc531..00000000000 - activeClientToolSet: new ActiveClientToolSet(), - shellManager: undefined, - githubCredentials: CopilotGitHubSessionCredentials.fromToken(undefined), +- isInlineChatSurface, - }; - const plan: CopilotSessionLaunchPlan = kind === 'create' - ? { ...base, kind: 'create', model } @@ -123445,6 +128171,30 @@ index 2e9c69bc531..00000000000 - }); - }); - } +- +- test('pins inline chat on Fusion to the fast tier when creating and resuming', async () => { +- const model: ModelSelection = { id: 'auto', config: { tier: 'hydrafusion' } }; +- let result; +- try { +- setAdvertisedAutoModeTiers(['balance', 'hydrafusion', 'fast'], true); +- result = { +- created: await capiOptionsFor('create', model, undefined, true), +- resumed: await capiOptionsFor('resume', model, undefined, true), +- emptyFallback: await capiOptionsFor('fallback', model, undefined, true), +- overridden: await capiOptionsFor('create', model, 'balance', true), +- panel: await capiOptionsFor('create', model), +- }; +- } finally { +- setAdvertisedAutoModeTiers(undefined); +- } +- assert.deepStrictEqual(result, { +- created: [{ autoTier: 'fast' }], +- resumed: [{ autoTier: 'fast' }], +- emptyFallback: [{ autoTier: 'fast' }, { autoTier: 'fast' }], +- overridden: [{ autoTier: 'balance' }], +- panel: [{ autoTier: 'hydrafusion' }], +- }); +- }); -}); - -suite('CopilotSessionLauncher working directory trust', () => { @@ -123572,12 +128322,106 @@ index 2e9c69bc531..00000000000 - assert.strictEqual(getCopilotWorkingDirectoryTrust({ workingDirectory: URI.file('/Repos/Project') }, trusted(folder)), isLinux ? undefined : 'session'); - }); -}); +diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncherTestHelpers.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncherTestHelpers.ts +deleted file mode 100644 +index 29dbd20103b..00000000000 +--- a/src/vs/platform/agentHost/test/node/copilotSessionLauncherTestHelpers.ts ++++ /dev/null +@@ -1,88 +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 { stub } from 'sinon'; +-import { Event } from '../../../../base/common/event.js'; +-import { URI } from '../../../../base/common/uri.js'; +-import { upcastPartial } from '../../../../base/test/common/mock.js'; +-import type { IFileService } from '../../../files/common/files.js'; +-import { ILogService, NullLogService } from '../../../log/common/log.js'; +-import { type ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; +-import type { IAgentHostManagedSettingsPermissions } from '../../common/agentHostManagedSettings.js'; +-import type { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; +-import { buildDefaultChatUri } from '../../common/state/sessionState.js'; +-import type { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; +-import type { IAgentHostManagedSettingsService } from '../../node/agentHostManagedSettingsService.js'; +-import type { IAgentHostSessionOpenTelemetry } from '../../node/agentHostSessionOpenTelemetry.js'; +-import type { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; +-import { ByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js'; +-import { CopilotSessionLauncher, type ICopilotSessionRuntime } from '../../node/copilot/copilotSessionLauncher.js'; +- +-export const testRuntime: ICopilotSessionRuntime = { +- chatUri: URI.parse(buildDefaultChatUri('copilot:/sess-1')), +- launchOwner: URI.parse('copilot:/sess-1'), +- getLaunchSandboxConfig: () => undefined, +- applyLaunchSandbox: async session => { await session.rpc.options.update({ sandboxConfig: { enabled: false } }); }, +- handlePermissionRequest: async () => { throw new Error('Unexpected permission request'); }, +- handleExitPlanModeRequest: async () => { throw new Error('Unexpected exit plan mode request'); }, +- handleUserInputRequest: async () => { throw new Error('Unexpected user input request'); }, +- handleElicitationRequest: async () => { throw new Error('Unexpected elicitation request'); }, +- adoptLaunchConnectorReconcile: () => { }, +- handleMcpAuthRequest: async () => { throw new Error('Unexpected MCP auth request'); }, +- requestUnsandboxedCommandConfirmation: async () => false, +- handlePreToolUse: async () => { }, +- handlePostToolUse: async () => undefined, +- handlePostToolUseFailure: () => undefined, +- handleUserPromptSubmitted: () => undefined, +- createClientSdkTools: () => [], +- createServerSdkTools: () => [], +- reloadExtensions: async () => { }, +-}; +- +-export const noopSessionOpenTelemetry: IAgentHostSessionOpenTelemetry = { +- _serviceBrand: undefined, +- withSubscription: async (_resource, operation) => operation({ +- servedFromMemory: undefined, +- setServedFromMemory: () => { }, +- restoreStarted: () => { }, +- restoreCompleted: () => { }, +- setSession: () => { }, +- }), +- withSdkResume: async (_resource, operation) => operation(), +- sdkResumeFallbackCreated: () => { }, +-}; +- +-export function createTestLauncher(managedSettingsPermissions?: IAgentHostManagedSettingsPermissions, rootValues: Record = {}, logService: ILogService = new NullLogService(), sessionOpenTelemetry: IAgentHostSessionOpenTelemetry = noopSessionOpenTelemetry, configuration?: IAgentConfigurationService, telemetryService?: ITelemetryService, fileService: IFileService = upcastPartial({})): CopilotSessionLauncher { +- const configurationService = configuration ?? upcastPartial({ +- getRootValue: stub().callsFake((_schema: unknown, key: string) => rootValues[key]), +- getRootConfigValues: () => rootValues, +- getSessionConfigValues: () => undefined, +- getChatConfigValues: () => undefined, +- resolveConfigurationOwner: (session: string) => session, +- getSessionSandboxPolicy: () => undefined, +- getSessionSandboxEnabled: () => undefined, +- getChatSandboxEnabled: () => undefined, +- setSessionSandboxPolicy: () => { }, +- setChatSandboxEnabled: () => { }, +- rejectChatSandboxChange: () => { }, +- }); +- return new CopilotSessionLauncher( +- configurationService, +- upcastPartial({ permissions: managedSettingsPermissions ?? {} }), +- upcastPartial({}), +- logService, +- fileService, +- { _serviceBrand: undefined, onDidCapTools: Event.None, start: async () => { throw new Error('Unexpected proxy start'); }, dispose: () => { } }, +- new ByokLmBridgeRegistry(), +- upcastPartial({ +- _serviceBrand: undefined, +- getSessionTraceContext: () => undefined, +- releaseSessionTraceContext: () => { }, +- withTraceContext: (_context, fn) => fn(), +- }), +- sessionOpenTelemetry, +- telemetryService ?? upcastPartial({ telemetryLevel: TelemetryLevel.USAGE }), +- ); +-} diff --git a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts deleted file mode 100644 -index 5080f53da0a..00000000000 +index b4515afb276..00000000000 --- a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts +++ /dev/null -@@ -1,1202 +0,0 @@ +@@ -1,1206 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -123726,6 +128570,9 @@ index 5080f53da0a..00000000000 - getChatOwnConfigValues: () => ({}), - updateChatConfig: (target, config) => service.updateSessionConfig(target.session, config), - seedChatConfig: () => ({ values: {}, dispose: () => { } }), +- completeChatConfigs: () => { }, +- getChatPersistedConfigValues: () => undefined, +- getCompleteChatConfigValues: (_session: string, own: Readonly>) => ({ ...own }), - getEffectiveWorkingDirectories: () => undefined, - getSessionConfigValues: session => sessionValues.get(session), - resolveConfigurationOwner: session => session, @@ -123734,6 +128581,7 @@ index 5080f53da0a..00000000000 - getChatSandboxEnabled: () => undefined, - getChatAttachmentScopes: target => [target.session], - setChatSandboxEnabled: () => { }, +- reportChatSandboxConflict: () => { }, - rejectChatSandboxChange: () => { }, - setSessionSandboxPolicy: (session, policy) => { - policies.set(session, policy); @@ -126205,10 +131053,10 @@ index 642ae0a4100..dcae8221677 100644 import * as marked from '../../../../base/common/marked/marked.js'; diff --git a/src/vs/platform/agentHost/test/node/localModelBridge/copilotRequestHandler.test.ts b/src/vs/platform/agentHost/test/node/localModelBridge/copilotRequestHandler.test.ts deleted file mode 100644 -index 360f55e4184..00000000000 +index 8aab990da38..00000000000 --- a/src/vs/platform/agentHost/test/node/localModelBridge/copilotRequestHandler.test.ts +++ /dev/null -@@ -1,1218 +0,0 @@ +@@ -1,1264 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -126577,7 +131425,7 @@ index 360f55e4184..00000000000 - test('re-issues /auto cloud-only when the local pick has no usable fallback', async () => { - const h = createHarness(); - h.onProxy = (_path, body) => body?.local_models -- ? json({ ...A2_RESPONSE, cloud_fallback_model: { id: 'responses-only', supported_endpoints: ['/responses'] } }) +- ? json({ ...A2_RESPONSE, cloud_fallback_model: { id: 'messages-only', supported_endpoints: ['/v1/messages'] } }) - : json({ session_token: 'cloud-token', selected_model: { id: 'gpt-5' } }); - const answer = await (await h.handler.handleRequest(autoRequest(), context(AUTO_URL))).json(); - assert.deepStrictEqual({ answer, declared: h.fetches.map(f => !!f.body?.local_models), routes: h.routeTable.size, telemetry: h.telemetry.map(e => `${e.selection}:${e.reason}:${e.tier}`) }, { @@ -127251,6 +132099,52 @@ index 360f55e4184..00000000000 - assert.deepStrictEqual({ status: response.status, body: await response.json() }, { status: 401, body: { error: 'token expired' } }); - }); - +- test('routes a local pick whose fallback serves only /responses, and falls back through the Responses API without streaming', async () => { +- const h = createHarness(); +- h.onProxy = path => path === '/auto' +- ? json({ ...A2_RESPONSE, cloud_fallback_model: { ...A2_RESPONSE.cloud_fallback_model, id: 'gpt-6-luna', supported_endpoints: ['/responses', 'ws:/responses'] } }) +- : json({ id: 'resp_1', created_at: 1, model: 'gpt-6-luna', status: 'completed', output: [{ type: 'message', content: [{ type: 'output_text', text: 'cloud answer' }] }], usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 } }); +- await declare(h); +- const routes = h.routeTable.size; +- h.onLoopback = () => json({ error: { message: 'busy' } }, 503); +- const generation = h.ledger.beginTurn('session-1'); +- const streamed = await h.handler.handleRequest(chatRequest(), context(CHAT_URL)); +- const streamedAnswer = { status: streamed.status, contentType: streamed.headers.get('content-type'), text: await streamed.text() }; +- const nonStreaming = await h.handler.handleRequest(chatRequest({ stream: false }), context(CHAT_URL)); +- const nonStreamingAnswer = { status: nonStreaming.status, body: await nonStreaming.json() }; +- const turn = h.ledger.endTurn('session-1', generation); +- // CAPI errors, such as an expired route token, reach the runtime as CAPI sent them. +- h.onProxy = () => json({ error: 'token expired' }, 401); +- const expired = await h.handler.handleRequest(chatRequest(), context(CHAT_URL)); +- +- const message = { role: 'assistant', content: 'cloud answer' }; +- const usage = { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }; +- assert.deepStrictEqual({ +- routes, +- sent: h.fetches.filter(f => f.via === 'proxy').map(f => ({ path: f.path, model: f.body?.model, stream: f.body?.stream, input: f.body?.input, maxOutputTokens: f.body?.max_output_tokens, token: f.headers['copilot-session-token'] })), +- streamedAnswer, +- nonStreamingAnswer, +- expired: { status: expired.status, body: await expired.json() }, +- turn, +- telemetry: h.telemetry.map(e => `${e.selection}:${e.reason}`), +- }, { +- routes: 1, +- sent: [0, 1, 2].map(() => ({ path: '/responses', model: 'gpt-6-luna', stream: false, input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hi' }] }], maxOutputTokens: 64000, token: TOKEN })), +- streamedAnswer: { +- status: 200, +- contentType: 'text/event-stream', +- text: `data: ${JSON.stringify({ id: 'resp_1', object: 'chat.completion.chunk', created: 1, model: 'gpt-6-luna', choices: [{ index: 0, delta: message, finish_reason: 'stop' }], usage })}\n\ndata: [DONE]\n\n`, +- }, +- nonStreamingAnswer: { +- status: 200, +- body: { id: 'resp_1', object: 'chat.completion', created: 1, model: 'gpt-6-luna', choices: [{ index: 0, message, finish_reason: 'stop' }], usage }, +- }, +- expired: { status: 401, body: { error: 'token expired' } }, +- turn: { execution: 'fallback', fallbackReason: 'httpError', fallbackModel: 'gpt-6-luna', familyId: MAI_IDENTITY.familyId }, +- telemetry: ['fallback:httpError', 'fallback:httpError', 'fallback:httpError'], +- }); +- }); +- - test('rejects other sentinel endpoints and -local ids on other CAPI paths without forwarding', async () => { - const h = createHarness(); - const models = await h.handler.handleRequest(new Request(`${SENTINEL_ORIGIN}/v1/models`), context(`${SENTINEL_ORIGIN}/v1/models`)); @@ -127429,10 +132323,10 @@ index 360f55e4184..00000000000 -}); diff --git a/src/vs/platform/agentHost/test/node/localModelBridge/downloader.test.ts b/src/vs/platform/agentHost/test/node/localModelBridge/downloader.test.ts deleted file mode 100644 -index c999548efd8..00000000000 +index 59c9adab559..00000000000 --- a/src/vs/platform/agentHost/test/node/localModelBridge/downloader.test.ts +++ /dev/null -@@ -1,877 +0,0 @@ +@@ -1,873 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -127544,7 +132438,7 @@ index c999548efd8..00000000000 -} - -suite('localModel downloader', () => { -- ensureNoDisposablesAreLeakedInTestSuite(); +- const store = ensureNoDisposablesAreLeakedInTestSuite(); - - let root: string; - let familyDir: string; @@ -128007,7 +132901,7 @@ index c999548efd8..00000000000 - test('download diagnostics: the request service logs only hosts and statuses (logUrl: false), the downloader only byte counts and codes', async () => { - const log = new RecordingLogService(); - const reply = blobServer({ 'model.gguf': PRIMARY, 'llama-default.ini': INI }, (_request, name) => name === 'broken.gguf' ? { status: 500, headers: { etag: '"etag-broken.gguf"' } } : undefined); -- const requestService = new class extends AbstractRequestService { +- const requestService = store.add(new class extends AbstractRequestService { - async request(options: IRequestOptions): Promise { - return this.logAndRequest(options, async () => { - const fake = reply({ url: options.url ?? '', method: 'GET', headers: options.headers as Record | undefined }); @@ -128018,32 +132912,28 @@ index c999548efd8..00000000000 - async lookupAuthorization(): Promise { return undefined; } - async lookupKerberosAuthorization(): Promise { return undefined; } - async loadCertificates(): Promise { return []; } -- }(log); -- try { -- await fs.promises.mkdir(targetDir, { recursive: true }); -- await fs.promises.writeFile(path.join(targetDir, 'llama-default.ini'), INI); -- const downloader = new ModelDownloader({ http: new RequestServiceHttpClient(requestService), log, renameRetryDelayMs: 1, retryDelay: async () => { } }); -- const downloaded = await run(downloader, [spec('model.gguf', PRIMARY), spec('llama-default.ini', INI)]); -- const failed = await run(downloader, [spec('broken.gguf', PRIMARY)]); -- // An HTTP 500 is transient: 8 attempts, the retries logged with byte counts only. -- const failedAttempts = Array.from({ length: 8 }, (_, attempt) => [ -- `#${attempt + 2}: acct.blob.core.windows.net - begin GET`, -- `#${attempt + 2}: acct.blob.core.windows.net - end GET 500`, -- ...(attempt < 7 ? [`[LocalModelDownloader] The transfer was interrupted after 0 of ${PRIMARY.byteLength} bytes; retrying (${attempt + 1}/7) in ${Math.min(30_000, 1000 * 2 ** attempt)} ms`] : []), -- ]).flat(); -- assert.deepStrictEqual({ downloaded: typeof downloaded === 'object' ? downloaded.reusedFiles : downloaded, failed, logged: log.messages }, { -- downloaded: ['llama-default.ini'], -- failed: 'network', -- logged: [ -- '#1: acct.blob.core.windows.net - begin GET', -- '#1: acct.blob.core.windows.net - end GET 200', -- `[LocalModelDownloader] Reusing a verified file (${INI.byteLength} bytes)`, -- ...failedAttempts, -- ], -- }); -- } finally { -- requestService.dispose(); -- } +- }(log)); +- await fs.promises.mkdir(targetDir, { recursive: true }); +- await fs.promises.writeFile(path.join(targetDir, 'llama-default.ini'), INI); +- const downloader = new ModelDownloader({ http: new RequestServiceHttpClient(requestService), log, renameRetryDelayMs: 1, retryDelay: async () => { } }); +- const downloaded = await run(downloader, [spec('model.gguf', PRIMARY), spec('llama-default.ini', INI)]); +- const failed = await run(downloader, [spec('broken.gguf', PRIMARY)]); +- // An HTTP 500 is transient: 8 attempts, the retries logged with byte counts only. +- const failedAttempts = Array.from({ length: 8 }, (_, attempt) => [ +- `#${attempt + 2}: acct.blob.core.windows.net - begin GET`, +- `#${attempt + 2}: acct.blob.core.windows.net - end GET 500`, +- ...(attempt < 7 ? [`[LocalModelDownloader] The transfer was interrupted after 0 of ${PRIMARY.byteLength} bytes; retrying (${attempt + 1}/7) in ${Math.min(30_000, 1000 * 2 ** attempt)} ms`] : []), +- ]).flat(); +- assert.deepStrictEqual({ downloaded: typeof downloaded === 'object' ? downloaded.reusedFiles : downloaded, failed, logged: log.messages }, { +- downloaded: ['llama-default.ini'], +- failed: 'network', +- logged: [ +- '#1: acct.blob.core.windows.net - begin GET', +- '#1: acct.blob.core.windows.net - end GET 200', +- `[LocalModelDownloader] Reusing a verified file (${INI.byteLength} bytes)`, +- ...failedAttempts, +- ], +- }); - }); - - test('rename retries for antivirus locks; verified final copies are reused', async () => { @@ -130566,10 +135456,10 @@ index 347eb437111..00000000000 -}); diff --git a/src/vs/platform/agentHost/test/node/localModelBridge/localModelSetupService.test.ts b/src/vs/platform/agentHost/test/node/localModelBridge/localModelSetupService.test.ts deleted file mode 100644 -index cbbfd279abe..00000000000 +index 273f6ddd4e9..00000000000 --- a/src/vs/platform/agentHost/test/node/localModelBridge/localModelSetupService.test.ts +++ /dev/null -@@ -1,85 +0,0 @@ +@@ -1,93 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -130607,9 +135497,17 @@ index cbbfd279abe..00000000000 - const configurationService = store.add(new AgentConfigurationService(stateManager, logService)); - const proxyResolver: IAgentHostProxyResolver = { - _serviceBrand: undefined, +- clientCertificates: [], +- registerCertificateProvider: () => toDisposable(() => { }), +- loadClientCertificates: async () => [], +- setClientCertificates: () => { }, +- loadCertificates: async () => [], - onDidRegisterConnection: Event.None, - onDidChangeConfiguration: Event.None, - register: () => toDisposable(() => { }), +- registerSharedNetworkClient: () => toDisposable(() => { }), +- notifySharedNetworkClientChanged: () => { }, +- lookupClientKerberosAuthorization: async () => undefined, - getConfigurationValue: () => undefined, - resolveProxy: async () => undefined, - fetch: () => Promise.reject(new Error('not used')), @@ -131789,10 +136687,10 @@ index 9a5b164b114..00000000000 - diff --git a/src/vs/platform/agentHost/test/node/localModelBridge/requestRouter.test.ts b/src/vs/platform/agentHost/test/node/localModelBridge/requestRouter.test.ts deleted file mode 100644 -index 53078620a54..00000000000 +index a00c7807780..00000000000 --- a/src/vs/platform/agentHost/test/node/localModelBridge/requestRouter.test.ts +++ /dev/null -@@ -1,639 +0,0 @@ +@@ -1,746 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -131804,6 +136702,7 @@ index 53078620a54..00000000000 - adaptLocalJsonResponse, - adaptLocalRequestBody, - buildFallbackChatRequest, +- buildFallbackResponsesRequest, - buildInjectedAutoRequest, - classifyLocalModelRequest, - computeLocalModelLimits, @@ -131821,6 +136720,7 @@ index 53078620a54..00000000000 - readLocalPromptProgress, - resolveLocalModelCapiBaseUrls, - SseCommitScanner, +- toChatCompletionFromResponses, - toStreamingLocalRequestBody, - type ILocalModelBufferedRequest, - type LocalModelJsonBody, @@ -131988,7 +136888,7 @@ index 53078620a54..00000000000 - }, - }, - token: 'jwt-for-fallback', -- fallback: { id: 'cloud-model', maxContextWindowTokens: 200000, maxPromptTokens: 168000, maxOutputTokens: 64000, reasoningEfforts: undefined }, +- fallback: { id: 'cloud-model', endpoint: 'chatCompletions', maxContextWindowTokens: 200000, maxPromptTokens: 168000, maxOutputTokens: 64000, reasoningEfforts: undefined }, - limits: { maxContextWindowTokens: 200000, maxPromptTokens: 148976, maxOutputTokens: 50000 }, - expiresAtMs: 1791400000000, - }); @@ -132027,7 +136927,7 @@ index 53078620a54..00000000000 - const serving = createServingSnapshot(); - const evaluate = (response: LocalModelJsonBody) => { - const outcome = evaluateAutoResponse(response, serving); -- return outcome.kind === 'reissue' ? `reissue:${outcome.reason}` : outcome.kind; +- return outcome.kind === 'reissue' ? `reissue:${outcome.reason}` : outcome.kind === 'patch' ? `patch:${outcome.fallback.endpoint}` : outcome.kind; - }; - const { cloud_fallback_model: fallback, ...withoutFallback } = A2_RESPONSE; - assert.deepStrictEqual([ @@ -132036,16 +136936,21 @@ index 53078620a54..00000000000 - evaluate(A2_RESPONSE), - evaluate({ ...withoutFallback, fallback_model: fallback }), - evaluate(withoutFallback), -- evaluate({ ...A2_RESPONSE, cloud_fallback_model: { id: 'cloud-model', supported_endpoints: ['/responses'] } }), +- evaluate({ ...A2_RESPONSE, cloud_fallback_model: { id: 'cloud-model', supported_endpoints: ['/responses', 'ws:/responses'] } }), +- evaluate({ ...A2_RESPONSE, cloud_fallback_model: { id: 'cloud-model', supported_endpoints: ['/v1/messages'] } }), +- evaluate({ ...A2_RESPONSE, cloud_fallback_model: { id: 'cloud-model' } }), - evaluate({ ...A2_RESPONSE, cloud_fallback_model: { id: 'other-local', supported_endpoints: ['/chat/completions'] } }), - evaluate({ ...A2_RESPONSE, session_token: undefined }), - evaluate({ prompt: 'no selection' }), - ], [ - 'notOurs', - 'reissue:otherLocalModel', -- 'patch', -- 'patch', +- 'patch:chatCompletions', +- 'patch:chatCompletions', - 'reissue:noFallback', +- // A Responses-only fallback (most current models) is called through the Responses API. +- 'patch:responses', +- 'reissue:fallbackNotChat', - 'reissue:fallbackNotChat', - 'reissue:fallbackNotChat', - 'reissue:noSessionToken', @@ -132170,9 +137075,9 @@ index 53078620a54..00000000000 - 'content-length': '2', - 'copilot-session-token': 'stale', - }); -- const fallback = { id: 'cloud-model', maxOutputTokens: 64000, reasoningEfforts: ['low', 'high'] }; +- const fallback = { id: 'cloud-model', endpoint: 'chatCompletions', maxOutputTokens: 64000, reasoningEfforts: ['low', 'high'] } as const; - const request = buildFallbackChatRequest(original, { model: 'mai-code-1.1-flash-local', max_tokens: 100000, reasoning_effort: 'medium', stream: true }, fallback, 'jwt-for-fallback'); -- const withoutEffort = buildFallbackChatRequest(original, { model: 'mai-code-1.1-flash-local', reasoning_effort: 'medium' }, { id: 'cloud-model' }, 'jwt-for-fallback'); +- const withoutEffort = buildFallbackChatRequest(original, { model: 'mai-code-1.1-flash-local', reasoning_effort: 'medium' }, { id: 'cloud-model', endpoint: 'chatCompletions' }, 'jwt-for-fallback'); - assert.deepStrictEqual({ - url: request.url.href, - headers: Object.fromEntries(request.headers.entries()), @@ -132186,6 +137091,106 @@ index 53078620a54..00000000000 - }); - }); - +- test('builds a non-streaming Responses API fallback request from the runtime\'s chat completions request', () => { +- const original = buffered('https://copilot-proxy.contoso.com/api/chat/completions', '{}', 'POST', { +- 'authorization': '******', +- 'copilot-integration-id': 'vscode-chat', +- 'content-length': '2', +- 'copilot-session-token': 'stale', +- }); +- const request = buildFallbackResponsesRequest(original, { +- model: 'mai-code-1.1-flash-local', +- stream: true, +- max_tokens: 100000, +- reasoning_effort: 'medium', +- temperature: 0.1, +- copilot_thread_id: 't', +- tool_choice: 'auto', +- parallel_tool_calls: false, +- tools: [{ type: 'function', function: { name: 'read', description: 'Reads a file.', parameters: { type: 'object', properties: { path: { type: 'string' } } } } }, { type: 'web_search' }], +- messages: [ +- { role: 'system', content: 'Be brief.' }, +- { role: 'user', content: [{ type: 'text', text: 'What is in ' }, { type: 'image_url', image_url: { url: 'data:image/png;base64,AA', detail: 'low' } }] }, +- { role: 'assistant', content: null, reasoning_text: 'thinking', reasoning_opaque: 'opaque', tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'read', arguments: '{"path":"a.ts"}' } }] }, +- { role: 'tool', tool_call_id: 'call_1', content: [{ type: 'text', text: 'const a = 1;' }] }, +- { role: 'assistant', content: 'It defines a.' }, +- { role: 'user', content: 'Thanks' }, +- ], +- }, { id: 'gpt-6-luna', endpoint: 'responses', maxOutputTokens: 64000, reasoningEfforts: ['low', 'high'] }, 'jwt-for-fallback'); +- const minimal = buildFallbackResponsesRequest(original, { reasoning_effort: 'high', tools: [{ type: 'web_search' }], tool_choice: 'required', messages: [{ role: 'user', content: 'hi' }] }, { id: 'gpt-6-luna', endpoint: 'responses' }, 'jwt-for-fallback'); +- let unknownRole: string | undefined; +- try { +- buildFallbackResponsesRequest(original, { messages: [{ role: 'function', content: 'x' }] }, { id: 'gpt-6-luna', endpoint: 'responses' }, 'jwt-for-fallback'); +- } catch (error) { +- unknownRole = error instanceof Error ? error.message : 'not an Error'; +- } +- assert.deepStrictEqual({ +- url: request.url.href, +- headers: Object.fromEntries(request.headers.entries()), +- body: JSON.parse(decode(request.body)), +- minimal: JSON.parse(decode(minimal.body)), +- unknownRole, +- }, { +- url: 'https://copilot-proxy.contoso.com/api/responses', +- headers: { 'authorization': '******', 'copilot-integration-id': 'vscode-chat', 'copilot-session-token': 'jwt-for-fallback' }, +- body: { +- model: 'gpt-6-luna', +- // Earlier reasoning is not replayed: nothing tells which model produced it. +- input: [ +- { type: 'message', role: 'system', content: [{ type: 'input_text', text: 'Be brief.' }] }, +- { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'What is in ' }, { type: 'input_image', image_url: 'data:image/png;base64,AA', detail: 'low' }] }, +- { type: 'function_call', call_id: 'call_1', name: 'read', arguments: '{"path":"a.ts"}' }, +- { type: 'function_call_output', call_id: 'call_1', output: 'const a = 1;' }, +- { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'It defines a.' }] }, +- { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Thanks' }] }, +- ], +- store: false, +- stream: false, +- tools: [{ type: 'function', name: 'read', description: 'Reads a file.', parameters: { type: 'object', properties: { path: { type: 'string' } } }, strict: false }], +- tool_choice: 'auto', +- parallel_tool_calls: false, +- max_output_tokens: 64000, +- reasoning: { effort: 'low' }, +- }, +- // No function tools and no reasoning efforts declared for F: neither tools nor reasoning are sent. +- minimal: { model: 'gpt-6-luna', input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hi' }] }], store: false, stream: false }, +- unknownRole: 'A message role has no Responses API equivalent', +- }); +- }); +- +- test('translates the Responses API fallback answer to a chat completion, or to one chunk for a streaming request', () => { +- const completed: LocalModelJsonBody = { +- id: 'resp_1', object: 'response', created_at: 1791400000, model: 'gpt-6-luna', status: 'completed', error: null, +- output: [ +- { type: 'reasoning', id: 'rs_1', summary: [], encrypted_content: 'secret' }, +- { type: 'message', id: 'msg_1', role: 'assistant', content: [{ type: 'output_text', text: 'Reading ', annotations: [] }, { type: 'output_text', text: 'a.ts' }] }, +- { type: 'function_call', id: 'fc_1', call_id: 'call_1', name: 'read', arguments: '{"path":"a.ts"}' }, +- ], +- usage: { input_tokens: 120, input_tokens_details: { cached_tokens: 100 }, output_tokens: 30, total_tokens: 150 }, +- }; +- const call = { id: 'call_1', type: 'function', function: { name: 'read', arguments: '{"path":"a.ts"}' } }; +- const usage = { prompt_tokens: 120, completion_tokens: 30, total_tokens: 150 }; +- assert.deepStrictEqual({ +- completion: toChatCompletionFromResponses(completed, 'gpt-6-luna', false), +- chunk: toChatCompletionFromResponses(completed, 'gpt-6-luna', true), +- incomplete: toChatCompletionFromResponses({ status: 'incomplete', output: [{ type: 'message', content: [{ type: 'output_text', text: 'Trunc' }] }] }, 'gpt-6-luna', false)?.choices, +- failed: toChatCompletionFromResponses({ status: 'failed', error: { message: 'boom' } }, 'gpt-6-luna', false), +- }, { +- completion: { +- id: 'resp_1', object: 'chat.completion', created: 1791400000, model: 'gpt-6-luna', +- choices: [{ index: 0, message: { role: 'assistant', content: 'Reading a.ts', tool_calls: [call] }, finish_reason: 'tool_calls' }], +- usage, +- }, +- chunk: { +- id: 'resp_1', object: 'chat.completion.chunk', created: 1791400000, model: 'gpt-6-luna', +- choices: [{ index: 0, delta: { role: 'assistant', content: 'Reading a.ts', tool_calls: [{ index: 0, ...call }] }, finish_reason: 'tool_calls' }], +- usage, +- }, +- incomplete: [{ index: 0, message: { role: 'assistant', content: 'Trunc' }, finish_reason: 'length' }], +- failed: undefined, +- }); +- }); +- - test('maps reasoning effort to the nearest allowed value', () => { - assert.deepStrictEqual([ - mapReasoningEffort('medium', ['low', 'medium', 'high']), @@ -132434,7 +137439,7 @@ index 53078620a54..00000000000 -}); diff --git a/src/vs/platform/agentHost/test/node/localModelBridge/routeTable.test.ts b/src/vs/platform/agentHost/test/node/localModelBridge/routeTable.test.ts deleted file mode 100644 -index 7f2faa72da2..00000000000 +index 0e94eb42f52..00000000000 --- a/src/vs/platform/agentHost/test/node/localModelBridge/routeTable.test.ts +++ /dev/null @@ -1,110 +0,0 @@ @@ -132454,7 +137459,7 @@ index 7f2faa72da2..00000000000 - - function record(clock: FakeClock, overrides: Partial = {}): IRouteRecordInit { - return { -- fallback: { id: 'cloud-model' }, +- fallback: { id: 'cloud-model', endpoint: 'chatCompletions' }, - serving: MAI_IDENTITY, - capiOrigin: 'https://api.githubcopilot.com', - accountGeneration: 1, @@ -134254,10 +139259,10 @@ index 729050d2ad0..00000000000 -}); diff --git a/src/vs/platform/agentHost/test/node/localModelBridge/shimLocalModelProvider.test.ts b/src/vs/platform/agentHost/test/node/localModelBridge/shimLocalModelProvider.test.ts deleted file mode 100644 -index 7fb82ae016e..00000000000 +index af433b6c466..00000000000 --- a/src/vs/platform/agentHost/test/node/localModelBridge/shimLocalModelProvider.test.ts +++ /dev/null -@@ -1,2292 +0,0 @@ +@@ -1,2300 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -134570,9 +139575,17 @@ index 7fb82ae016e..00000000000 - -const proxyResolver: IAgentHostProxyResolver = { - _serviceBrand: undefined, +- clientCertificates: [], +- registerCertificateProvider: () => toDisposable(() => { }), +- loadClientCertificates: async () => [], +- setClientCertificates: () => { }, +- loadCertificates: async () => [], - onDidRegisterConnection: Event.None, - onDidChangeConfiguration: Event.None, - register: () => toDisposable(() => { }), +- registerSharedNetworkClient: () => toDisposable(() => { }), +- notifySharedNetworkClientChanged: () => { }, +- lookupClientKerberosAuthorization: async () => undefined, - getConfigurationValue: () => undefined, - resolveProxy: async () => undefined, - fetch: () => Promise.reject(new Error('no network in tests')), @@ -139162,10 +144175,10 @@ index 2f859e81825..00000000000 -}); diff --git a/src/vs/platform/agentHost/test/node/providerIntegration/copilotCustomizations.integrationTest.ts b/src/vs/platform/agentHost/test/node/providerIntegration/copilotCustomizations.integrationTest.ts deleted file mode 100644 -index 1ef93f05871..00000000000 +index 349eb1b9ade..00000000000 --- a/src/vs/platform/agentHost/test/node/providerIntegration/copilotCustomizations.integrationTest.ts +++ /dev/null -@@ -1,1344 +0,0 @@ +@@ -1,1353 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -139522,6 +144535,7 @@ index 1ef93f05871..00000000000 - { type: CustomizationType.Directory, contents: CustomizationType.Agent, uri: URI.file(join(userHomeDir, '.copilot', 'agents')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: URI.file(join(workspaceDir, '.github', 'hooks')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: URI.file(join(userHomeDir, '.copilot', 'hooks')).toString(), children: [] }, +- { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: 'agent-discovery://copilot/hooks', children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Rule, uri: URI.file(join(workspaceDir, '.github', 'instructions')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Rule, uri: URI.file(join(userHomeDir, '.copilot', 'instructions')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Skill, uri: URI.file(join(workspaceDir, '.agents', 'skills')).toString(), children: [] }, @@ -139629,14 +144643,15 @@ index 1ef93f05871..00000000000 - const expectedCustomizations = [ - { type: CustomizationType.Directory, contents: CustomizationType.Skill, uri: URI.file(join(userHomeDir, '.agents', 'skills')).toString(), children: [URI.file(userSkillFile).toString()] }, - { type: CustomizationType.Directory, contents: CustomizationType.Agent, uri: URI.file(join(userHomeDir, '.copilot', 'agents')).toString(), children: [URI.file(userAgentFile).toString()] }, -- { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: URI.file(join(userHomeDir, '.copilot', 'hooks')).toString(), children: [URI.file(userHookFile).toString()] }, +- { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: URI.file(join(userHomeDir, '.copilot', 'hooks')).toString(), children: [] }, +- { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: 'agent-discovery://copilot/hooks', children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Skill, uri: URI.file(join(userHomeDir, '.copilot', 'skills')).toString(), children: [URI.file(userCopilotSkillFile).toString()] }, - { type: CustomizationType.Directory, contents: CustomizationType.Skill, uri: URI.file(join(workspaceDir, '.agents', 'skills')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Agent, uri: URI.file(join(workspaceDir, '.claude', 'agents')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Rule, uri: URI.file(join(workspaceDir, '.claude', 'rules')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Skill, uri: URI.file(join(workspaceDir, '.claude', 'skills')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Agent, uri: URI.file(join(workspaceDir, '.github', 'agents')).toString(), children: [URI.file(join(agentsDir, 'hello.agent.md')).toString()] }, -- { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: URI.file(join(workspaceDir, '.github', 'hooks')).toString(), children: [URI.file(join(hooksDir, 'pre-tool.json')).toString()] }, +- { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: URI.file(join(workspaceDir, '.github', 'hooks')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Rule, uri: URI.file(join(workspaceDir, '.github', 'instructions')).toString(), children: [URI.file(join(instructionsDir, 'policy.instructions.md')).toString()] }, - { type: CustomizationType.Directory, contents: CustomizationType.Skill, uri: URI.file(join(workspaceDir, '.github', 'skills')).toString(), children: [URI.file(join(skillsDir, 'SKILL.md')).toString()] }, - { @@ -139731,6 +144746,7 @@ index 1ef93f05871..00000000000 - { type: CustomizationType.Directory, contents: CustomizationType.Agent, uri: URI.file(join(userHomeDir, '.copilot', 'agents')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: URI.file(join(workspaceDir, '.github', 'hooks')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: URI.file(join(userHomeDir, '.copilot', 'hooks')).toString(), children: [] }, +- { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: 'agent-discovery://copilot/hooks', children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Rule, uri: URI.file(join(workspaceDir, '.github', 'instructions')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Rule, uri: URI.file(join(userHomeDir, '.copilot', 'instructions')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Skill, uri: URI.file(join(workspaceDir, '.agents', 'skills')).toString(), children: [] }, @@ -139831,6 +144847,7 @@ index 1ef93f05871..00000000000 - { type: CustomizationType.Directory, contents: CustomizationType.Agent, uri: URI.file(join(userHomeDir, '.copilot', 'agents')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: URI.file(join(workspaceDir, '.github', 'hooks')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: URI.file(join(userHomeDir, '.copilot', 'hooks')).toString(), children: [] }, +- { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: 'agent-discovery://copilot/hooks', children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Rule, uri: URI.file(join(workspaceDir, '.github', 'instructions')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Rule, uri: URI.file(join(userHomeDir, '.copilot', 'instructions')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Skill, uri: URI.file(join(workspaceDir, '.agents', 'skills')).toString(), children: [] }, @@ -140031,6 +145048,7 @@ index 1ef93f05871..00000000000 - { type: CustomizationType.Directory, contents: CustomizationType.Agent, uri: URI.file(join(userHomeDir, '.copilot', 'agents')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: URI.file(join(workspaceDir, '.github', 'hooks')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: URI.file(join(userHomeDir, '.copilot', 'hooks')).toString(), children: [] }, +- { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: 'agent-discovery://copilot/hooks', children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Rule, uri: URI.file(join(workspaceDir, '.github', 'instructions')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Rule, uri: URI.file(join(userHomeDir, '.copilot', 'instructions')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Skill, uri: URI.file(join(workspaceDir, '.agents', 'skills')).toString(), children: [] }, @@ -140131,6 +145149,7 @@ index 1ef93f05871..00000000000 - { type: CustomizationType.Directory, contents: CustomizationType.Agent, uri: URI.file(join(userHomeDir, '.copilot', 'agents')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: URI.file(join(workspaceDir, '.github', 'hooks')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: URI.file(join(userHomeDir, '.copilot', 'hooks')).toString(), children: [] }, +- { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: 'agent-discovery://copilot/hooks', children: [] }, - { - type: CustomizationType.Directory, - contents: CustomizationType.Rule, @@ -140229,6 +145248,7 @@ index 1ef93f05871..00000000000 - { type: CustomizationType.Directory, contents: CustomizationType.Agent, uri: URI.file(join(userHomeDir, '.copilot', 'agents')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: URI.file(join(workspaceDir, '.github', 'hooks')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: URI.file(join(userHomeDir, '.copilot', 'hooks')).toString(), children: [] }, +- { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: 'agent-discovery://copilot/hooks', children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Rule, uri: URI.file(join(workspaceDir, '.github', 'instructions')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Rule, uri: URI.file(join(userHomeDir, '.copilot', 'instructions')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Skill, uri: URI.file(join(workspaceDir, '.agents', 'skills')).toString(), children: [] }, @@ -140355,6 +145375,7 @@ index 1ef93f05871..00000000000 - { type: CustomizationType.Directory, contents: CustomizationType.Agent, uri: URI.file(join(userHomeDir, '.copilot', 'agents')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: URI.file(join(workspaceDir, '.github', 'hooks')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: URI.file(join(userHomeDir, '.copilot', 'hooks')).toString(), children: [] }, +- { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: 'agent-discovery://copilot/hooks', children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Rule, uri: URI.file(join(workspaceDir, '.github', 'instructions')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Rule, uri: URI.file(join(userHomeDir, '.copilot', 'instructions')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Skill, uri: URI.file(join(workspaceDir, '.agents', 'skills')).toString(), children: [] }, @@ -140461,6 +145482,7 @@ index 1ef93f05871..00000000000 - { type: CustomizationType.Directory, contents: CustomizationType.Agent, uri: URI.file(join(userHomeDir, '.copilot', 'agents')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: URI.file(join(workspaceDir, '.github', 'hooks')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: URI.file(join(userHomeDir, '.copilot', 'hooks')).toString(), children: [] }, +- { type: CustomizationType.Directory, contents: CustomizationType.Hook, uri: 'agent-discovery://copilot/hooks', children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Rule, uri: URI.file(join(workspaceDir, '.github', 'instructions')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Rule, uri: URI.file(join(userHomeDir, '.copilot', 'instructions')).toString(), children: [] }, - { type: CustomizationType.Directory, contents: CustomizationType.Skill, uri: URI.file(join(workspaceDir, '.agents', 'skills')).toString(), children: [] }, @@ -140512,10 +145534,10 @@ index 1ef93f05871..00000000000 -}); diff --git a/src/vs/platform/agentHost/test/node/providerIntegration/copilotLocalModel.integrationTest.ts b/src/vs/platform/agentHost/test/node/providerIntegration/copilotLocalModel.integrationTest.ts deleted file mode 100644 -index 64b1a540935..00000000000 +index bc305807987..00000000000 --- a/src/vs/platform/agentHost/test/node/providerIntegration/copilotLocalModel.integrationTest.ts +++ /dev/null -@@ -1,726 +0,0 @@ +@@ -1,767 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -140650,6 +145672,16 @@ index 64b1a540935..00000000000 - response.end('data: [DONE]\n\n'); -} - +-/** Answers with `text` as a complete (non-streaming) Responses API response. */ +-function writeResponsesResponse(response: ServerResponse, model: string, text: string): void { +- response.writeHead(200, { 'content-type': 'application/json' }); +- response.end(JSON.stringify({ +- id: 'resp_test', object: 'response', created_at: 0, model, status: 'completed', error: null, +- output: [{ type: 'message', id: 'msg_test', status: 'completed', role: 'assistant', content: [{ type: 'output_text', text, annotations: [] }] }], +- usage: { input_tokens: USAGE.prompt_tokens, output_tokens: USAGE.completion_tokens, total_tokens: USAGE.total_tokens }, +- })); +-} +- -function isStreaming(body: string): boolean { - try { - return (JSON.parse(body) as { stream?: unknown }).stream === true; @@ -140754,6 +145786,8 @@ index 64b1a540935..00000000000 - let serving: FakeServingAccess; - /** `capabilities.supports.reasoning_effort` of the cloud fallback `/auto` returns. */ - let fallbackReasoningEfforts: string[] | undefined; +- /** `supported_endpoints` of the cloud fallback `/auto` returns, when not the catalog's. */ +- let fallbackEndpoints: string[] | undefined; - - const reportTool = defineTool('report_value', { - description: 'Reports a numeric value to the test.', @@ -140788,6 +145822,7 @@ index 64b1a540935..00000000000 - llamaContextOverflows = 0; - llamaPromptProgress = undefined; - fallbackReasoningEfforts = undefined; +- fallbackEndpoints = undefined; - localResponses = []; - localStreams = []; - directWaits = []; @@ -140811,6 +145846,7 @@ index 64b1a540935..00000000000 - const routing = JSON.parse(body) as { local_models?: unknown }; - autoRequests.push(routing); - const declared = Array.isArray(routing.local_models) && routing.local_models.includes(LOCAL_CAPI_ID); +- const fallback = withReasoningEfforts(catalog.data.find(model => model.id === FALLBACK_MODEL), fallbackReasoningEfforts); - json({ - session_token: ROUTE_TOKEN, - expires_at: Math.floor(Date.now() / 1000) + 3600, @@ -140823,10 +145859,12 @@ index 64b1a540935..00000000000 - supported_endpoints: ['/chat/completions'], - capabilities: { type: 'chat', family: LOCAL_CAPI_ID, object: 'model_capabilities', limits: {}, supports: {} }, - } : catalog.data.find(model => model.id === FALLBACK_MODEL), -- ...(declared ? { cloud_fallback_model: withReasoningEfforts(catalog.data.find(model => model.id === FALLBACK_MODEL), fallbackReasoningEfforts) } : {}), +- ...(declared ? { cloud_fallback_model: fallbackEndpoints ? { ...fallback, supported_endpoints: fallbackEndpoints } : fallback } : {}), - }); - } else if (path === '/chat/completions' && request.method === 'POST' && parseModel(body) === FALLBACK_MODEL) { - writeChatResponse(response, FALLBACK_MODEL, isStreaming(body), { kind: 'text', text: 'CLOUD FALLBACK OK' }); +- } else if (path === '/responses' && request.method === 'POST' && parseModel(body) === FALLBACK_MODEL) { +- writeResponsesResponse(response, FALLBACK_MODEL, 'CLOUD RESPONSES FALLBACK OK'); - } else { - const stub = getAncillaryStub(request.method ?? 'GET', path, body); - if (stub) { @@ -140981,7 +146019,7 @@ index 64b1a540935..00000000000 - localRequests: llamaRequests.map(request => ({ path: request.path, model: parseModel(request.body), stream: isStreaming(request.body), includeUsage: (JSON.parse(request.body) as { stream_options?: { include_usage?: unknown } }).stream_options?.include_usage, returnProgress: parseJson(request.body)?.return_progress })), - // The runtime asked for JSON; the handler streamed from the device and assembled one completion. - runtimeResponses: localResponses, -- autoContexts: observed.filter(entry => entry.path === '/auto').map(entry => ({ transport: entry.transport, sessionId: entry.sessionId, agentId: entry.agentId, interactionType: entry.interactionType })), +- autoContexts: observed.filter(entry => entry.path === '/auto').map(entry => ({ transport: entry.transport, sessionId: entry.sessionId === session.sessionId, agentId: entry.agentId, interactionType: entry.interactionType })), - chatContexts: observed.filter(entry => entry.target === 'capi' && entry.model === LOCAL_CAPI_ID).map(entry => ({ transport: entry.transport, sessionId: entry.sessionId === session.sessionId, agentId: entry.agentId === session.sessionId, interactionType: entry.interactionType })), - routes: routes.map(route => route.selection), - knownLocalId: KNOWN_AUTO_LOCAL_IDS.includes(LOCAL_CAPI_ID), @@ -140998,8 +146036,8 @@ index 64b1a540935..00000000000 - { status: 200, contentType: 'application/json', body: { id: 'chatcmpl-test', object: 'chat.completion', created: 0, model: LOCAL_CAPI_ID, usage: USAGE, choices: [{ index: 0, finish_reason: 'tool_calls', message: { role: 'assistant', content: null, tool_calls: [{ id: TOOL_CALL_ID, type: 'function', function: { name: 'report_value', arguments: '{"value":42}' } }] } }] } }, - { status: 200, contentType: 'application/json', body: { id: 'chatcmpl-test', object: 'chat.completion', created: 0, model: LOCAL_CAPI_ID, usage: USAGE, choices: [{ index: 0, finish_reason: 'stop', message: { role: 'assistant', content: 'LOCAL DONE' } }] } }, - ], -- // /auto carries no session, agent or interaction type; chat requests carry all three. -- autoContexts: [{ transport: 'http', sessionId: undefined, agentId: undefined, interactionType: undefined }], +- // /auto is attributed to the session but carries no agent or interaction type; chat requests carry all three. +- autoContexts: [{ transport: 'http', sessionId: true, agentId: undefined, interactionType: undefined }], - chatContexts: [ - { transport: 'http', sessionId: true, agentId: true, interactionType: 'conversation-agent' }, - { transport: 'http', sessionId: true, agentId: true, interactionType: 'conversation-agent' }, @@ -141031,6 +146069,31 @@ index 64b1a540935..00000000000 - assertSecurityInvariants(); - }); - +- test('routes a local pick whose cloud fallback serves only the Responses API, and falls back through it', async () => { +- fallbackEndpoints = ['/responses', 'ws:/responses']; +- llamaDiesBeforeOutput = true; +- const session = await client.createSession({ ...sessionOptions, model: 'auto', ...withLocalModelSessionConfig({}, provider.contributeSessionConfig()) }); +- const turn = await runTurn(session, 'Say hello.'); +- await session.disconnect(); +- +- const fallbackRequests = capiRequests.filter(request => parseModel(request.body) === FALLBACK_MODEL && request.path !== '/auto'); +- assert.deepStrictEqual({ +- turn, +- // The local pick was kept (not re-issued cloud-only) and served on the device first. +- declared: autoRequests.map(request => request.local_models), +- localRequests: llamaRequests.length, +- fallback: fallbackRequests.map(request => ({ path: request.path, token: request.headers['copilot-session-token'], stream: isStreaming(request.body), store: parseJson(request.body)?.store })), +- routes: routes.map(route => `${route.selection}:${route.reason}`), +- }, { +- turn: { reply: 'CLOUD RESPONSES FALLBACK OK', usageMeta: { 'vscode.localModel': { execution: 'fallback', fallbackReason: 'localError', fallbackModel: FALLBACK_MODEL, familyId: MAI_ROW.familyId } } }, +- declared: [[LOCAL_CAPI_ID]], +- localRequests: 1, +- fallback: [{ path: '/responses', token: ROUTE_TOKEN, stream: false, store: false }], +- routes: ['fallback:localError'], +- }); +- assertSecurityInvariants(); +- }); +- - test('serves the direct sentinel row at create, at resume and after provider.sync', async () => { - const contribution = provider.contributeSessionConfig(); - /** A turn of one phase (create, resume, after `provider.sync`): what answered, and which endpoints the runtime used for it. */ @@ -141594,10 +146657,10 @@ index aacdcdbef57..00000000000 -}); diff --git a/src/vs/platform/agentHost/test/node/providerIntegration/copilotMockLlm.integrationTest.ts b/src/vs/platform/agentHost/test/node/providerIntegration/copilotMockLlm.integrationTest.ts deleted file mode 100644 -index 51b323bb1fb..00000000000 +index 4d17b0f4f7b..00000000000 --- a/src/vs/platform/agentHost/test/node/providerIntegration/copilotMockLlm.integrationTest.ts +++ /dev/null -@@ -1,817 +0,0 @@ +@@ -1,875 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -141619,7 +146682,7 @@ index 51b323bb1fb..00000000000 -import { hasKey } from '../../../../../base/common/types.js'; -import { ActionType, type ChatResponsePartAction, type ChatToolCallCompleteAction, type ChatToolCallReadyAction, type ChatToolCallStartAction, type ChatTurnCompleteAction, type ChatTurnStartedAction } from '../../../common/state/sessionActions.js'; -import { PROTOCOL_VERSION } from '../../../common/state/protocol/version/registry.js'; --import { customizationId, CustomizationType, MessageKind, PendingMessageKind, ResponsePartKind, ROOT_STATE_URI, SessionStatus, ToolCallContributorKind, ToolResultContentType, type ClientPluginCustomization, type ISessionWithDefaultChat, type PluginCustomization, type RootState, type ToolDefinition, type URI as ProtocolURI } from '../../../common/state/sessionState.js'; +-import { customizationId, CustomizationLoadStatus, CustomizationType, MessageKind, PendingMessageKind, ResponsePartKind, ROOT_STATE_URI, SessionStatus, ToolCallContributorKind, ToolResultContentType, type ClientPluginCustomization, type ISessionWithDefaultChat, type PluginCustomization, type RootState, type ToolDefinition, type URI as ProtocolURI } from '../../../common/state/sessionState.js'; -import { ToolCallConfirmationReason } from '../../../common/state/protocol/channels-chat/state.js'; -import type { CanvasState } from '../../../common/state/protocol/channels-canvas/state.js'; -import type { SubscribeResult } from '../../../common/state/protocol/common/commands.js'; @@ -141658,6 +146721,32 @@ index 51b323bb1fb..00000000000 - let suiteHome: string; - const createdSessions: string[] = []; - const tempDirs: string[] = []; +- let restrictedAgentType: string | undefined; +- +- function forwardedAgentType(name: string): string { +- const isRecord = (value: unknown): value is Readonly> => +- typeof value === 'object' && value !== null && !Array.isArray(value); +- const captured = server.mockLlm?.getRequests?.().at(-1); +- assert.ok(isRecord(captured) && isRecord(captured.body) && Array.isArray(captured.body.tools)); +- const tools: readonly unknown[] = captured.body.tools; +- for (const raw of tools) { +- const tool = isRecord(raw) && isRecord(raw.function) ? raw.function : raw; +- if (!isRecord(tool) || tool.name !== 'task' || typeof tool.description !== 'string') { +- continue; +- } +- const schema = tool.input_schema ?? tool.parameters; +- assert.ok(isRecord(schema) && isRecord(schema.properties) && isRecord(schema.properties.agent_type)); +- const agentType = schema.properties.agent_type; +- assert.ok(Array.isArray(agentType.enum)); +- const values: readonly unknown[] = agentType.enum; +- const description = tool.description; +- const matches = values.filter((id): id is string => typeof id === 'string').filter(id => +- description.split('\n').some(line => line.startsWith(`- **${id}**: ${name}:`) || line === `- **${id}**: ${name}`)); +- assert.strictEqual(matches.length, 1, `The task catalog must expose exactly one agent named ${name}`); +- return matches[0]; +- } +- throw new Error('The model request did not expose the task tool'); +- } - - suiteSetup(async function () { - this.timeout(120_000); @@ -141722,11 +146811,14 @@ index 51b323bb1fb..00000000000 - kind: 'tool-calls', - toolCalls: [{ - toolNamePattern: /^task$/, -- arguments: { -- name: 'plugin-sibling', -- description: 'Run the plugin sibling', -- prompt: 'Complete the delegated task.', -- agent_type: 'plugin-sibling', +- arguments: () => { +- restrictedAgentType = forwardedAgentType('plugin-restricted'); +- return { +- name: 'plugin-sibling', +- description: 'Run the plugin sibling', +- prompt: 'Complete the delegated task.', +- agent_type: forwardedAgentType('plugin-sibling'), +- }; - }, - }], - }, @@ -141742,11 +146834,14 @@ index 51b323bb1fb..00000000000 - kind: 'tool-calls', - toolCalls: [{ - toolNamePattern: /^task$/, -- arguments: { -- name: 'plugin-restricted', -- description: 'Run the restricted plugin sibling', -- prompt: 'Complete the restricted delegated task.', -- agent_type: 'plugin-restricted', +- arguments: () => { +- assert.ok(restrictedAgentType, 'the first turn must discover the agent before invocation is restricted'); +- return { +- name: 'plugin-restricted', +- description: 'Run the restricted plugin sibling', +- prompt: 'Complete the restricted delegated task.', +- agent_type: restrictedAgentType, +- }; - }, - }], - }, @@ -141827,7 +146922,7 @@ index 51b323bb1fb..00000000000 - writeFile(join(pluginDir, '.plugin', 'plugin.json'), JSON.stringify({ name: 'Plugin Delegation' })), - writeFile(parentAgentFile, '---\nname: plugin-parent\ndescription: Delegates plugin work\n---\nDelegate work to plugin-sibling.'), - writeFile(siblingAgentFile, '---\nname: plugin-sibling\ndescription: Completes plugin work\n---\nComplete the delegated task.'), -- writeFile(restrictedAgentFile, '---\nname: plugin-restricted\ndescription: Only runs when selected by the user\ninfer: false\n---\nComplete the restricted task.'), +- writeFile(restrictedAgentFile, '---\nname: plugin-restricted\n---\nComplete the restricted task.'), - ]); - - const clientId = 'real-sdk-plugin-subagents'; @@ -141853,7 +146948,7 @@ index 51b323bb1fb..00000000000 - && getActionEnvelope(notification).channel === sessionUri, - 30_000, - ); -- const parentAgentUri = await retry(async () => { +- let parentAgentUri = await retry(async () => { - const session = await fetchSessionWithChat(client, sessionUri); - const plugin = session.customizations?.find((customization): customization is PluginCustomization => - customization.type === CustomizationType.Plugin && customization.uri === pluginUri @@ -141910,9 +147005,35 @@ index 51b323bb1fb..00000000000 - return { taskSucceeded: completeAction.result.success, parentResponse: response }; - }; - +- const modelInvocable = await delegate(2, 'turn-plugin-subagent', PLUGIN_SIBLING_SCENARIO_ID); +- await writeFile(restrictedAgentFile, '---\nname: plugin-restricted\ninfer: false\n---\nComplete the restricted task.'); +- client.clearReceived(); +- client.dispatch({ +- channel: sessionUri, +- clientSeq: 3, +- action: { +- type: ActionType.SessionActiveClientSet, +- activeClient: { clientId, tools: [], customizations: [{ ...pluginCustomization, nonce: '2' }] }, +- }, +- }); +- const updated = await client.waitForNotification(notification => { +- if (!isActionNotification(notification, ActionType.SessionCustomizationUpdated)) { +- return false; +- } +- const { channel, action } = getActionEnvelope(notification); +- return channel === sessionUri && action.type === ActionType.SessionCustomizationUpdated +- && action.customization.type === CustomizationType.Plugin && action.customization.uri === pluginUri +- && action.customization.load?.kind === CustomizationLoadStatus.Loaded +- && action.customization.children?.some(child => child.name === 'plugin-parent') === true; +- }, 30_000); +- const update = getActionEnvelope(updated).action; +- assert.ok(update.type === ActionType.SessionCustomizationUpdated && update.customization.type === CustomizationType.Plugin); +- const parent = update.customization.children?.find(child => child.name === 'plugin-parent'); +- assert.ok(parent); +- parentAgentUri = parent.uri; - assert.deepStrictEqual({ -- modelInvocable: await delegate(2, 'turn-plugin-subagent', PLUGIN_SIBLING_SCENARIO_ID), -- modelRestricted: await delegate(3, 'turn-plugin-restricted-subagent', PLUGIN_RESTRICTED_SIBLING_SCENARIO_ID), +- modelInvocable, +- modelRestricted: await delegate(4, 'turn-plugin-restricted-subagent', PLUGIN_RESTRICTED_SIBLING_SCENARIO_ID), - }, { - modelInvocable: { - taskSucceeded: true, @@ -142610,22 +147731,27 @@ index adfb44eba0c..00000000000 -}); diff --git a/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts b/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts deleted file mode 100644 -index f4f2a6fb219..00000000000 +index d00ef5fc162..00000000000 --- a/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts +++ /dev/null -@@ -1,493 +0,0 @@ +@@ -1,651 +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 assert from 'assert'; +-import { homedir } from 'os'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +-import { toAgentHostUri } from '../../common/agentHostUri.js'; +-import { getAttachedLocalPaths } from '../../common/attachedLocalPaths.js'; +-import { toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; +-import { MessageAttachmentKind, type MessageAttachment } from '../../common/state/protocol/state.js'; -import { AgentHostSandboxConfigKey, AgentHostSandboxKey, sandboxConfigSchema, type ISandboxConfigValue } from '../../common/sandboxConfigSchema.js'; -import { getVSCodeSandboxReadRoots } from '../../common/vscodeSandboxPaths.js'; -import { AgentSandboxEnabledValue, type IAgentSandboxUserConfiguredPaths } from '../../../sandbox/common/settings.js'; --import { buildSandboxConfigForSdk, type SandboxConfig } from '../../node/copilot/sandboxConfigForSdk.js'; +-import { buildSandboxConfigForSdk, resolveAttachedPathGrants, toSandboxFileSystemPath, type SandboxConfig } from '../../node/copilot/sandboxConfigForSdk.js'; - -suite('VS Code sandbox read roots', () => { - ensureNoDisposablesAreLeakedInTestSuite(); @@ -143106,13 +148232,166 @@ index f4f2a6fb219..00000000000 - assert.strictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.Off), ['/data/shellInit/s1']), undefined); - }); - }); +- +- suite('attachedPaths', () => { +- const attach = (paths: string[], workingDirectory?: string) => ({ paths, ...(workingDirectory ? { workingDirectory } : {}) }); +- +- test('grants attached files and folders read-only, exactly as attached', () => { +- assert.deepStrictEqual({ +- filesystem: buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.On), ['/data/attachments'], undefined, attach(['/home/u/Desktop/mockup.txt', '/home/u/notes', '/home/u/notes']))?.userPolicy?.filesystem, +- grants: resolveAttachedPathGrants('linux', sandbox('linux', AgentSandboxEnabledValue.On), undefined, attach(['/home/u/Desktop/mockup.txt', '/home/u/notes'])), +- }, { +- filesystem: { readonlyPaths: ['/data/attachments', '/home/u/Desktop/mockup.txt', '/home/u/notes'] }, +- grants: { granted: ['/home/u/Desktop/mockup.txt', '/home/u/notes'], blocked: [], containsWritable: [] }, +- }); +- }); +- +- test('keeps paths that are, or are inside, a denied path denied', () => { +- const config = sandbox('linux', AgentSandboxEnabledValue.On, { deniedPaths: ['/home/u/secret', '/home/u/Desktop/private.txt'] }); +- const attached = attach(['/home/u/secret', '/home/u/secret/key.txt', '/home/u/Desktop/private.txt', '/home/u/secretive.txt']); +- assert.deepStrictEqual({ +- filesystem: buildSandboxConfigForSdk('linux', config, undefined, undefined, attached)?.userPolicy?.filesystem, +- grants: resolveAttachedPathGrants('linux', config, undefined, attached), +- }, { +- filesystem: { deniedPaths: ['/home/u/secret', '/home/u/Desktop/private.txt'], readonlyPaths: ['/home/u/secretive.txt'] }, +- grants: { granted: ['/home/u/secretive.txt'], blocked: ['/home/u/secret', '/home/u/secret/key.txt', '/home/u/Desktop/private.txt'], containsWritable: [] }, +- }); +- }); +- +- test('expands a home-relative denied path when checking attachments', () => { +- assert.deepStrictEqual(resolveAttachedPathGrants('linux', sandbox('linux', AgentSandboxEnabledValue.On, { deniedPaths: ['~/.ssh'] }), undefined, attach([`${homedir()}/.ssh/id_rsa`])), { +- granted: [], +- blocked: [`${homedir()}/.ssh/id_rsa`], +- containsWritable: [], +- }); +- }); +- +- test('respects a managed read-only list, including an empty one, and reports the attachments it blocks', () => { +- assert.deepStrictEqual([undefined, ['/managed'], []].map(readonlyPaths => { +- const policy = { enabled: true, ...(readonlyPaths ? { readonlyPaths } : {}) }; +- const config = sandbox('linux', AgentSandboxEnabledValue.On, { readonlyPaths: readonlyPaths ?? [] }); +- const attached = attach(['/home/u/Desktop/mockup.txt', '/managed/inside.txt']); +- return { +- filesystem: buildSandboxConfigForSdk('linux', config, undefined, policy, attached)?.userPolicy?.filesystem, +- grants: resolveAttachedPathGrants('linux', config, policy, attached), +- }; +- }), [ +- { filesystem: { readonlyPaths: ['/home/u/Desktop/mockup.txt', '/managed/inside.txt'] }, grants: { granted: ['/home/u/Desktop/mockup.txt', '/managed/inside.txt'], blocked: [], containsWritable: [] } }, +- { filesystem: { readonlyPaths: ['/managed'] }, grants: { granted: [], blocked: ['/home/u/Desktop/mockup.txt'], containsWritable: [] } }, +- { filesystem: undefined, grants: { granted: [], blocked: ['/home/u/Desktop/mockup.txt', '/managed/inside.txt'], containsWritable: [] } }, +- ]); +- }); +- +- test('never adds a read-only entry for a path that is already readable or writable', () => { +- const config = sandbox('linux', AgentSandboxEnabledValue.On, { readwritePaths: ['/scratch'], readonlyPaths: ['/docs'] }); +- assert.deepStrictEqual([undefined, false].map(addCurrentWorkingDirectory => +- buildSandboxConfigForSdk('linux', { ...config, ...(addCurrentWorkingDirectory !== undefined ? { addCurrentWorkingDirectory } : {}) }, undefined, undefined, +- attach(['/scratch/a.txt', '/docs/b.md', '/repo/src/c.ts', '/repo'], '/repo'))?.userPolicy?.filesystem, +- ), [ +- { readonlyPaths: ['/docs'], readwritePaths: ['/scratch'] }, +- { readonlyPaths: ['/docs', '/repo/src/c.ts', '/repo'], readwritePaths: ['/scratch'] }, +- ]); +- }); +- +- test('normalizes Windows paths and compares macOS and Windows paths case-insensitively', () => { +- assert.deepStrictEqual({ +- win32: buildSandboxConfigForSdk('win32', sandbox('win32', AgentSandboxEnabledValue.On, { deniedPaths: ['C:/Secret'] }), undefined, undefined, attach(['c:/secret/a.txt', 'C:/Users/u/Desktop/b.txt']))?.userPolicy?.filesystem, +- darwin: resolveAttachedPathGrants('darwin', sandbox('darwin', AgentSandboxEnabledValue.On, { deniedPaths: ['/Users/u/Secret'] }), undefined, attach(['/users/u/secret/a.txt'])), +- linux: resolveAttachedPathGrants('linux', sandbox('linux', AgentSandboxEnabledValue.On, { deniedPaths: ['/home/u/Secret'] }), undefined, attach(['/home/u/secret/a.txt'])), +- }, { +- win32: { deniedPaths: ['C:\\Secret'], readonlyPaths: ['C:\\Users\\u\\Desktop\\b.txt'] }, +- darwin: { granted: [], blocked: ['/users/u/secret/a.txt'], containsWritable: [] }, +- linux: { granted: ['/home/u/secret/a.txt'], blocked: [], containsWritable: [] }, +- }); +- }); +- +- test('does not grant a folder that contains a writable folder, so the writable folder stays writable', () => { +- const config = sandbox('linux', AgentSandboxEnabledValue.On, { readwritePaths: ['/scratch/out'] }); +- const attached = { paths: ['/home/u', '/home/u/demo', '/home', '/scratch', '/data', '/home/u/Desktop/mockup.txt'], workingDirectory: '/home/u/demo', additionalWorkingDirectories: ['/data/extra'] }; +- assert.deepStrictEqual({ +- filesystem: buildSandboxConfigForSdk('linux', config, undefined, undefined, attached)?.userPolicy?.filesystem, +- grants: resolveAttachedPathGrants('linux', config, undefined, attached), +- withoutWorkingDirectoryGrant: resolveAttachedPathGrants('linux', { ...config, addCurrentWorkingDirectory: false }, undefined, attached).granted, +- }, { +- filesystem: { readonlyPaths: ['/home/u/Desktop/mockup.txt'], readwritePaths: ['/scratch/out'] }, +- grants: { granted: ['/home/u/Desktop/mockup.txt'], blocked: [], containsWritable: ['/home/u', '/home', '/scratch', '/data'] }, +- withoutWorkingDirectoryGrant: ['/home/u', '/home/u/demo', '/home', '/home/u/Desktop/mockup.txt'], +- }); +- }); +- +- test('resolves dot segments with the target platform rules before comparing and granting', () => { +- assert.deepStrictEqual({ +- linux: resolveAttachedPathGrants('linux', sandbox('linux', AgentSandboxEnabledValue.On, { deniedPaths: ['/home/u/./secret/'] }), undefined, +- { paths: ['/workspace/../outside.txt', '/workspace/./src/../a.ts', '/home/u/notes/../secret/key.txt', '/home/u/notes/./'], workingDirectory: '/workspace' }), +- win32: resolveAttachedPathGrants('win32', sandbox('win32', AgentSandboxEnabledValue.On), undefined, +- { paths: ['C:\\work\\..\\out.txt', 'C:\\work\\src\\..\\a.ts'], workingDirectory: 'C:\\work' }), +- }, { +- linux: { granted: ['/outside.txt', '/home/u/notes'], blocked: ['/home/u/secret/key.txt'], containsWritable: [] }, +- win32: { granted: ['C:\\out.txt'], blocked: [], containsWritable: [] }, +- }); +- }); +- +- test('grants nothing when sandboxing is off', () => { +- assert.deepStrictEqual({ +- config: buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.Off), undefined, undefined, attach(['/home/u/a.txt'])), +- grants: resolveAttachedPathGrants('linux', sandbox('linux', AgentSandboxEnabledValue.Off), undefined, attach(['/home/u/a.txt'])), +- }, { config: undefined, grants: { granted: [], blocked: [], containsWritable: [] } }); +- }); +- }); +-}); +- +-suite('getAttachedLocalPaths', () => { +- ensureNoDisposablesAreLeakedInTestSuite(); +- +- test('keeps local files and folders, unwraps client URIs, and ignores everything else', () => { +- const file = URI.file('/home/u/Desktop/mockup.txt'); +- const folder = URI.file('/home/u/notes'); +- const attachments: MessageAttachment[] = [ +- { type: MessageAttachmentKind.Resource, uri: file.toString(), label: 'mockup.txt', displayKind: 'file' }, +- { type: MessageAttachmentKind.Resource, uri: folder.toString(), label: 'notes', displayKind: 'directory' }, +- { type: MessageAttachmentKind.Resource, uri: file.toString(), label: 'mockup.txt', displayKind: 'selection', selection: { range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } } }, +- { type: MessageAttachmentKind.Resource, uri: toAgentHostUri(URI.file('/home/u/wrapped.txt'), 'my-server').toString(), label: 'wrapped.txt' }, +- { type: MessageAttachmentKind.Resource, uri: toAgentHostUri(URI.from({ scheme: 'git', path: '/home/u/repo/a.ts' }), 'my-server').toString(), label: 'a.ts' }, +- { type: MessageAttachmentKind.Resource, uri: 'untitled:Untitled-1', label: 'Untitled-1' }, +- { type: MessageAttachmentKind.Resource, uri: 'vscode-remote://ssh-remote+box/home/u/remote.txt', label: 'remote.txt' }, +- { type: MessageAttachmentKind.Resource, uri: URI.file('/data/attachments/snapshot.txt').toString(), label: 'snapshot', _meta: toHostSnapshotAttachmentMeta('text/plain') }, +- { type: MessageAttachmentKind.EmbeddedResource, data: 'aGk=', contentType: 'text/plain', label: 'inline' }, +- { type: MessageAttachmentKind.Simple, label: 'simple', modelRepresentation: 'text' }, +- ]; +- assert.deepStrictEqual(getAttachedLocalPaths(attachments).map(uri => uri.toString()), [file, folder, file, URI.file('/home/u/wrapped.txt')].map(uri => uri.toString())); +- }); +- +- test('resolves dot segments in attached URIs', () => { +- const attachments: MessageAttachment[] = [ +- { type: MessageAttachmentKind.Resource, uri: 'file:///workspace/../outside.txt', label: 'outside.txt', displayKind: 'file' }, +- { type: MessageAttachmentKind.Resource, uri: 'file:///home/u/./notes/', label: 'notes', displayKind: 'directory' }, +- ]; +- assert.deepStrictEqual(getAttachedLocalPaths(attachments).map(uri => uri.path), ['/outside.txt', '/home/u/notes/']); +- }); +-}); +- +-suite('toSandboxFileSystemPath', () => { +- ensureNoDisposablesAreLeakedInTestSuite(); +- +- test('uses the target platform syntax regardless of the host OS', () => { +- const uris = [ +- URI.from({ scheme: 'file', path: '/home/u/Desktop/mockup.txt' }), +- URI.from({ scheme: 'file', path: '/C:/Users/u/notes' }), +- URI.from({ scheme: 'file', authority: 'server', path: '/share/a.txt' }), +- ]; +- assert.deepStrictEqual((['linux', 'darwin', 'win32'] as const).map(platform => uris.map(uri => toSandboxFileSystemPath(uri, platform))), [ +- ['/home/u/Desktop/mockup.txt', 'c:/Users/u/notes', '//server/share/a.txt'], +- ['/home/u/Desktop/mockup.txt', 'c:/Users/u/notes', '//server/share/a.txt'], +- ['\\home\\u\\Desktop\\mockup.txt', 'c:\\Users\\u\\notes', '\\\\server\\share\\a.txt'], +- ]); +- }); -}); diff --git a/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts b/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts deleted file mode 100644 -index 8745bd15bd1..00000000000 +index 24bdae28e43..00000000000 --- a/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts +++ /dev/null -@@ -1,880 +0,0 @@ +@@ -1,1163 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -143124,16 +148403,18 @@ index 8745bd15bd1..00000000000 -import { VSBuffer } from '../../../../base/common/buffer.js'; -import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; -import { CancellationError } from '../../../../base/common/errors.js'; --import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { Schemas } from '../../../../base/common/network.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { FileService } from '../../../files/common/fileService.js'; -import { IFileService } from '../../../files/common/files.js'; --import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +-import { createInMemoryFileService } from '../../../files/test/common/inMemoryFileService.js'; -import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js'; -import { ILogService, NullLogService } from '../../../log/common/log.js'; --import { CustomizationType, type SkillCustomization } from '../../common/state/sessionState.js'; +-import { CustomizationLoadStatus, CustomizationType, type SkillCustomization } from '../../common/state/sessionState.js'; +-import { McpServerStatus } from '../../common/state/protocol/state.js'; +-import { readCustomizationDiscoveryMeta } from '../../common/meta/customizationDiscoveryMeta.js'; +-import { hasReadableCustomizationContent } from '../../common/agentHostCustomizationUri.js'; -import { readSkillArgumentHint } from '../../common/meta/skillCustomizationMeta.js'; -import { SessionCustomizationDiscovery } from '../../node/copilot/sessionCustomizationDiscovery.js'; - @@ -143141,16 +148422,14 @@ index 8745bd15bd1..00000000000 - -suite('SessionCustomizationDiscovery', () => { - -- const disposables = new DisposableStore(); +- const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - let fileService: FileService; - let instantiationService: TestInstantiationService; - let workspace: URI; - let userHome: URI; - - setup(() => { -- fileService = disposables.add(new FileService(new NullLogService())); -- const memFs = disposables.add(new InMemoryFileSystemProvider()); -- disposables.add(fileService.registerProvider(Schemas.inMemory, memFs)); +- fileService = createInMemoryFileService(disposables, Schemas.inMemory); - - instantiationService = disposables.add(new TestInstantiationService()); - instantiationService.stub(IFileService, fileService); @@ -143160,10 +148439,6 @@ index 8745bd15bd1..00000000000 - userHome = URI.from({ scheme: Schemas.inMemory, path: '/home' }); - }); - -- teardown(() => { -- disposables.clear(); -- }); -- ensureNoDisposablesAreLeakedInTestSuite(); - - async function seed(path: string, content = ''): Promise { - const uri = URI.from({ scheme: Schemas.inMemory, path }); @@ -143176,10 +148451,24 @@ index 8745bd15bd1..00000000000 - // on Windows too, where `URI.fsPath` yields backslashes. - const inMemoryPathToUri = (path: string) => URI.from({ scheme: Schemas.inMemory, path: path.replace(/\\/g, '/') }); - +- function hookDiscoveryClient(discover: CopilotClient['rpc']['hooks']['discover']): CopilotClient { +- return { +- rpc: { +- hooks: { discover }, +- mcp: { discover: async () => ({ servers: [] }) }, +- agents: { getDiscoveryPaths: async () => ({ paths: [] }), discover: async () => ({ agents: [] }) }, +- instructions: { getDiscoveryPaths: async () => ({ paths: [] }), discover: async () => ({ sources: [] }) }, +- skills: { getDiscoveryPaths: async () => ({ paths: [] }), discover: async () => ({ skills: [] }) }, +- }, +- } as unknown as CopilotClient; +- } +- - test('groups discovered customizations by parent folder', async () => { - const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, inMemoryPathToUri)); - const client = { - rpc: { +- hooks: { discover: async () => ({ hooks: [], warnings: [], errors: [] }) }, +- mcp: { discover: async () => ({ servers: [] }) }, - agents: { - discover: async () => ({ - agents: [ @@ -143223,7 +148512,8 @@ index 8745bd15bd1..00000000000 - - const pluginRoot = '/home/.copilot/installed-plugins/example'; - await seed(`${pluginRoot}/.github/plugin/plugin.json`, JSON.stringify({ name: 'example-plugin', version: '1.2.3' })); -- const pluginAgent = await seed(`${pluginRoot}/agents/reviewer.agent.md`, '---\nname: reviewer\ndescription: Reviews changes\n---\n'); +- const pluginAgent = await seed(`${pluginRoot}/agents/reviewer.agent.md`, '---\nname: reviewer\ndescription: Reviews changes\nuser-invocable: false\ndisable-model-invocation: true\nmodel: test-model\ntools: [read, search]\n---\n'); +- await seed(`${pluginRoot}/agents/not-discovered.agent.md`, '---\nname: not-discovered\n---\n'); - const pluginSkill = await seed(`${pluginRoot}/skills/example/SKILL.md`, '---\nname: example\ndescription: Example skill\n---\n'); - const pluginRule = await seed(`${pluginRoot}/rules/example.instructions.md`, '---\nname: Example instruction\n---\n'); - const builtinSkill = await seed('/runtime/skills/builtin/SKILL.md', '---\nname: builtin\n---\n'); @@ -143231,10 +148521,12 @@ index 8745bd15bd1..00000000000 - const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, inMemoryPathToUri)); - const client = { - rpc: { +- hooks: { discover: async () => ({ hooks: [], warnings: [], errors: [] }) }, +- mcp: { discover: async () => ({ servers: [] }) }, - agents: { - getDiscoveryPaths: async () => ({ paths: [] }), - discover: async () => ({ -- agents: [{ id: 'reviewer', name: 'reviewer', description: 'Reviews changes', path: pluginAgent.path, source: 'plugin', userInvocable: true }], +- agents: [{ id: 'example-plugin@marketplace:reviewer', name: 'example-plugin:reviewer', description: 'Reviews changes', path: pluginAgent.path, source: 'plugin', userInvocable: false }], - }), - }, - instructions: { @@ -143260,6 +148552,13 @@ index 8745bd15bd1..00000000000 - - assert.deepStrictEqual({ - errors, +- nativeAgents: customizations.flatMap(customization => customization.type === CustomizationType.Plugin +- ? customization.children?.filter(child => child.type === CustomizationType.Agent).map(child => ({ +- id: child.id, name: child.name, meta: child._meta, +- disableUserInvocation: child.disableUserInvocation, disableModelInvocation: child.disableModelInvocation, +- model: child.model, tools: child.tools, +- })) ?? [] +- : []), - skillDirectories: customizations - .filter(customization => customization.type === CustomizationType.Directory && customization.contents === 'skill') - .map(customization => ({ @@ -143276,6 +148575,10 @@ index 8745bd15bd1..00000000000 - })), - }, { - errors: [], +- nativeAgents: [{ +- id: 'example-plugin@marketplace:reviewer', name: 'reviewer', meta: { userInvocable: false }, +- disableUserInvocation: true, disableModelInvocation: true, model: 'test-model', tools: ['read', 'search'], +- }], - skillDirectories: [{ - uri: URI.from({ scheme: Schemas.inMemory, path: '/workspace/.github/skills' }).toString(), - children: [projectSkill.toString()], @@ -143293,6 +148596,45 @@ index 8745bd15bd1..00000000000 - }); - }); - +- test('does not re-register native agents as loose files when their plugin manifest is missing', async () => { +- const warnings: string[] = []; +- instantiationService.stub(ILogService, new class extends NullLogService { +- override warn(message: string): void { +- warnings.push(message); +- } +- }()); +- const pluginAgent = await seed('/workspace/.github/agents/native.agent.md', '---\nname: native\n---\n'); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, inMemoryPathToUri)); +- const client = { +- rpc: { +- hooks: { discover: async () => ({ hooks: [], warnings: [], errors: [] }) }, +- mcp: { discover: async () => ({ servers: [] }) }, +- agents: { +- getDiscoveryPaths: async () => ({ paths: [] }), +- discover: async () => ({ +- agents: [{ id: 'plugin:native', name: 'native', path: pluginAgent.path, source: 'plugin' }], +- }), +- }, +- instructions: { +- getDiscoveryPaths: async () => ({ paths: [] }), +- discover: async () => ({ sources: [] }), +- }, +- skills: { +- getDiscoveryPaths: async () => ({ paths: [] }), +- discover: async () => ({ skills: [] }), +- }, +- }, +- } as unknown as CopilotClient; +- const customizations = await discovery.discover(client, CancellationToken.None); +- assert.deepStrictEqual({ +- agents: customizations.flatMap(customization => customization.children?.filter(child => child.type === CustomizationType.Agent) ?? []), +- warned: warnings.some(message => message.includes('Could not find a plugin manifest')), +- }, { +- agents: [], +- warned: true, +- }); +- }); +- - test('keeps plugin, workspace, and user agents in their source containers', async () => { - const pluginRoot = '/home/.copilot/installed-plugins/multi-agent'; - await seed(`${pluginRoot}/.github/plugin/plugin.json`, JSON.stringify({ name: 'multi-agent-plugin', version: '1.0.0' })); @@ -143303,6 +148645,8 @@ index 8745bd15bd1..00000000000 - const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, inMemoryPathToUri)); - const client = { - rpc: { +- hooks: { discover: async () => ({ hooks: [], warnings: [], errors: [] }) }, +- mcp: { discover: async () => ({ servers: [] }) }, - agents: { - getDiscoveryPaths: async () => ({ paths: [] }), - discover: async () => ({ @@ -143366,13 +148710,15 @@ index 8745bd15bd1..00000000000 - }); - }); - -- test('discover includes hooks from recursive and fixed hook locations', async () => { +- test('discover does not publish hook files absent from SDK discovery', async () => { - await seed('/workspace/.github/hooks/pre-tool.json', '{"PreToolUse": []}'); - await seed('/workspace/.github/copilot/settings.json', '{"hooks": {"PreToolUse": []}}'); - - const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, inMemoryPathToUri)); - const client = { - rpc: { +- hooks: { discover: async () => ({ hooks: [], warnings: [], errors: [] }) }, +- mcp: { discover: async () => ({ servers: [] }) }, - agents: { - getDiscoveryPaths: async () => ({ paths: [] }), - discover: async () => ({ agents: [] }), @@ -143399,11 +148745,201 @@ index 8745bd15bd1..00000000000 - - assert.deepStrictEqual(hookDirectories, [ - { uri: '/home/.copilot/hooks', children: [] }, -- { uri: '/workspace/.github/copilot', children: ['/workspace/.github/copilot/settings.json'] }, -- { uri: '/workspace/.github/hooks', children: ['/workspace/.github/hooks/pre-tool.json'] }, +- { uri: '/workspace/.github/hooks', children: [] }, - ]); - }); - +- for (const nativeRepositoryHook of [false, true]) { +- test(`workspace hook fallback preserves SDK identities and disabled state (native repository hook: ${nativeRepositoryHook})`, async () => { +- await seed('/workspace/.github/hooks/test.json', 'must be parsed by the SDK, not the host'); +- await seed('/workspace2/.github/hooks/secondary.json'); +- const secondWorkspace = inMemoryPathToUri('/workspace2'); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace, secondWorkspace], userHome, inMemoryPathToUri)); +- const requests: Parameters[0][] = []; +- const client = hookDiscoveryClient(async request => { +- requests.push(request); +- const hooks: Awaited>['hooks'] = [ +- { id: 'opaque-plugin', hookType: 'sessionStart', origin: 'plugin', enabled: false }, +- { id: 'opaque-user', hookType: 'sessionStart', origin: 'user', enabled: false }, +- ]; +- if (nativeRepositoryHook || request.hookDirectories) { +- hooks.push({ id: 'opaque-workspace', hookType: 'preToolUse', origin: nativeRepositoryHook ? 'repository' : 'user', enabled: false }); +- } +- if (request.hookDirectories) { +- hooks.push({ id: 'opaque-workspace', hookType: 'preToolUse', origin: 'user', enabled: true }); +- } +- return { hooks, warnings: ['SDK warning'], errors: ['SDK partial error'] }; +- }); +- const result = await discovery.discover(client, CancellationToken.None); +- const hooks = result.find(item => item.contents === CustomizationType.Hook && item.children?.length); +- assert.deepStrictEqual({ +- hooks: hooks?.children?.map(hook => ({ +- id: hook.id, source: readCustomizationDiscoveryMeta(hook)?.source, +- enabled: hook.type === CustomizationType.Hook ? hook.enabled : undefined, +- readable: hasReadableCustomizationContent(URI.parse(hook.uri)), +- })), +- load: hooks?.type === CustomizationType.Directory ? hooks.load : undefined, +- requests, +- }, { +- hooks: [ +- { id: 'opaque-plugin', source: 'plugin', enabled: false, readable: false }, +- { id: 'opaque-user', source: 'user', enabled: false, readable: false }, +- { id: 'opaque-workspace', source: 'repository', enabled: false, readable: false }, +- ], +- load: { kind: CustomizationLoadStatus.Degraded, message: 'SDK partial error\nSDK warning' }, +- requests: [ +- { projectPaths: [workspace.fsPath] }, +- ...nativeRepositoryHook ? [] : [{ projectPaths: [workspace.fsPath], hookDirectories: [inMemoryPathToUri('/workspace/.github/hooks').fsPath] }], +- ], +- }); +- }); +- } +- +- test('workspace hook fallback failure preserves successful native discovery', async () => { +- await seed('/workspace/.github/hooks/test.json'); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, inMemoryPathToUri)); +- const client = hookDiscoveryClient(async request => { +- if (request.hookDirectories) { +- throw new Error('SDK fallback unavailable'); +- } +- return { hooks: [{ id: 'native', hookType: 'sessionStart', origin: 'plugin', enabled: true }], errors: [], warnings: [] }; +- }); +- const result = await discovery.discover(client, CancellationToken.None); +- const hooks = result.find(item => item.contents === CustomizationType.Hook && item.children?.length); +- assert.deepStrictEqual({ +- ids: hooks?.children?.map(hook => hook.id), +- load: hooks?.type === CustomizationType.Directory ? hooks.load : undefined, +- }, { +- ids: ['native'], +- load: { kind: CustomizationLoadStatus.Degraded, message: 'Workspace hook discovery failed: SDK fallback unavailable' }, +- }); +- }); +- +- test('workspace hook fallback propagates cancellation', async () => { +- await seed('/workspace/.github/hooks/test.json'); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, inMemoryPathToUri)); +- const cancellation = disposables.add(new CancellationTokenSource()); +- const enteredFallback = new DeferredPromise(); +- const pendingFallback = new DeferredPromise>>(); +- const client = hookDiscoveryClient(async request => { +- if (request.hookDirectories) { +- await enteredFallback.complete(); +- return pendingFallback.p; +- } +- return { hooks: [], errors: [], warnings: [] }; +- }); +- const result = discovery.discover(client, cancellation.token); +- await enteredFallback.p; +- cancellation.cancel(); +- try { +- await assert.rejects(result, CancellationError); +- } finally { +- await pendingFallback.complete({ hooks: [], errors: [], warnings: [] }); +- } +- }); +- +- test('publishes hooks-only and MCP-only native contributions with SDK provenance and partial diagnostics', async () => { +- const messages: string[] = []; +- instantiationService.stub(ILogService, new class extends NullLogService { +- override warn(message: string): void { messages.push(message); } +- override error(message: string | Error): void { messages.push(String(message)); } +- }()); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, inMemoryPathToUri)); +- const mcpRequests: Parameters[0][] = []; +- const client = { +- rpc: { +- agents: { getDiscoveryPaths: async () => ({ paths: [] }), discover: async () => ({ agents: [] }) }, +- instructions: { getDiscoveryPaths: async () => ({ paths: [] }), discover: async () => ({ sources: [] }) }, +- skills: { getDiscoveryPaths: async () => ({ paths: [] }), discover: async () => ({ skills: [] }) }, +- hooks: { +- discover: async () => ({ +- hooks: [ +- { id: 'opaque:one', hookType: 'sessionStart', origin: 'plugin', source: 'hooks-only', enabled: true }, +- { id: 'opaque:two', hookType: 'sessionStart', origin: 'plugin', source: 'hooks-only', enabled: false }, +- ], +- warnings: ['Recovered source'], errors: ['Another source failed'], +- }) +- }, +- mcp: { +- discover: async (request: Parameters[0]) => { +- mcpRequests.push(request); +- return { +- servers: [{ +- name: 'native', source: 'plugin', enabled: false, +- effectiveSource: { kind: 'plugin', id: 'do-not-parse', editability: 'read-only', plugin: { id: 'opaque-plugin', name: 'mcp-only' }, file: { uri: 'file:///host/plugins/mcp.json' } }, +- }] +- }; +- } +- }, +- }, +- } as unknown as CopilotClient; +- const customizations = await discovery.discover(client, CancellationToken.None); +- const hooks = customizations.find(customization => customization.contents === CustomizationType.Hook && customization.children?.length); +- const mcp = customizations.find(customization => customization.type === CustomizationType.McpServer); +- assert.deepStrictEqual({ +- hooks: hooks?.children?.map(hook => ({ id: hook.id, name: hook.name, enabled: hook.type === CustomizationType.Hook ? hook.enabled : undefined, readable: hasReadableCustomizationContent(URI.parse(hook.uri)), provenance: readCustomizationDiscoveryMeta(hook) })), +- load: hooks?.type === CustomizationType.Directory ? hooks.load : undefined, +- mcp: mcp?.type === CustomizationType.McpServer ? { name: mcp.name, uri: mcp.uri, state: mcp.state, provenance: readCustomizationDiscoveryMeta(mcp) } : undefined, +- requests: mcpRequests, +- diagnostics: messages.filter(message => message.includes('Hook discovery:')), +- }, { +- hooks: [true, false].map((enabled, index) => ({ +- id: index === 0 ? 'opaque:one' : 'opaque:two', name: 'sessionStart', enabled, readable: false, +- provenance: { source: 'plugin', sourceLabel: 'hooks-only', hookType: 'sessionStart', defaultEnabled: undefined, sourceId: undefined, pluginId: undefined }, +- })), +- load: { kind: CustomizationLoadStatus.Degraded, message: 'Another source failed\nRecovered source' }, +- mcp: { name: 'native', uri: inMemoryPathToUri('/host/plugins/mcp.json').toString(), state: { kind: McpServerStatus.Stopped }, provenance: { source: 'plugin', sourceLabel: undefined, hookType: undefined, defaultEnabled: false, sourceId: 'do-not-parse', pluginId: 'opaque-plugin' } }, +- requests: [{ workingDirectory: workspace.fsPath, includeEffectiveSource: true }], +- diagnostics: ['[SessionCustomizationDiscovery] Hook discovery: Recovered source', '[SessionCustomizationDiscovery] Hook discovery: Another source failed'], +- }); +- client.rpc.hooks.discover = async () => ({ hooks: [], errors: [], warnings: [] }); +- client.rpc.mcp.discover = async () => ({ servers: [] }); +- const refreshed = await discovery.discover(client, CancellationToken.None); +- assert.deepStrictEqual(refreshed.flatMap(customization => customization.type === CustomizationType.McpServer ? [customization.name] : customization.children?.map(child => child.name) ?? []), []); +- +- for (const name of ['mcp-only', 'mixed']) { +- await seed(`/host/plugins/${name}/.plugin/plugin.json`, JSON.stringify({ name })); +- await seed(`/host/plugins/${name}/.mcp.json`, JSON.stringify({ mcpServers: { [name]: { command: 'node' } } })); +- } +- await seed('/host/plugins/mixed/agents/review.agent.md', '---\nname: review\n---\nReview'); +- await seed('/host/plugins/mixed/hooks/hooks.json', '{"hooks":{"sessionStart":[{"type":"command","bash":"echo hook"}]}}'); +- client.rpc.agents.discover = async () => ({ agents: [{ id: 'native-review', name: 'review', displayName: 'review', description: '', path: '/host/plugins/mixed/agents/review.agent.md', source: 'plugin' }] }); +- client.rpc.hooks.discover = async () => ({ +- hooks: [{ id: 'mixed-hook', hookType: 'sessionStart', origin: 'plugin', enabled: true, source: 'mixed' }], errors: [], warnings: [], +- }); +- client.rpc.mcp.discover = async () => ({ +- servers: ['mcp-only', 'mixed'].map(name => ({ +- name, type: 'stdio', source: 'plugin', enabled: false, +- effectiveSource: { kind: 'plugin', id: `opaque:${name}`, editability: 'read-only', plugin: { id: name }, file: { uri: URI.file(`/host/plugins/${name}/.mcp.json`).toString() } }, +- })) +- }); +- const native = await discovery.discover(client, CancellationToken.None); +- assert.deepStrictEqual(native.flatMap(item => { +- if (item.type === CustomizationType.Plugin) { +- return [{ name: item.name, children: item.children?.map(child => ({ name: child.name, type: child.type })) }]; +- } +- if (item.type === CustomizationType.McpServer || item.children?.length) { +- return [{ name: item.name, children: item.children?.map(child => ({ name: child.name, type: child.type })) }]; +- } +- return []; +- }), [ +- { name: 'Copilot hooks', children: [{ name: 'sessionStart', type: CustomizationType.Hook }] }, +- { name: 'mcp-only', children: [{ name: 'mcp-only', type: CustomizationType.McpServer }] }, +- { name: 'mixed', children: [{ name: 'review', type: CustomizationType.Agent }, { name: 'mixed', type: CustomizationType.McpServer }] }, +- ]); +- +- client.rpc.hooks.discover = async () => { throw new Error('hooks unavailable'); }; +- client.rpc.mcp.discover = async () => { throw new Error('MCP unavailable'); }; +- const partial = await discovery.discover(client, CancellationToken.None); +- assert.deepStrictEqual({ +- failures: partial.filter(item => item.type === CustomizationType.Directory && item.load?.kind === CustomizationLoadStatus.Error).map(item => item.type === CustomizationType.Directory && item.load), +- agents: partial.flatMap(item => item.children?.filter(child => child.type === CustomizationType.Agent).map(child => child.id) ?? []), +- }, { +- failures: [{ kind: CustomizationLoadStatus.Error, message: 'hooks unavailable' }, { kind: CustomizationLoadStatus.Error, message: 'MCP unavailable' }], +- agents: ['native-review'], +- }); +- }); +- - test('marks agent instruction rule sources as always apply', async () => { - await seed('/workspace/AGENTS.md', 'workspace agents instructions'); - await seed('/workspace/.github/instructions/rule.instructions.md', 'scoped instruction'); @@ -143411,6 +148947,8 @@ index 8745bd15bd1..00000000000 - const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, inMemoryPathToUri)); - const client = { - rpc: { +- hooks: { discover: async () => ({ hooks: [], warnings: [], errors: [] }) }, +- mcp: { discover: async () => ({ servers: [] }) }, - agents: { - getDiscoveryPaths: async () => ({ paths: [] }), - discover: async () => ({ agents: [] }), @@ -143458,6 +148996,8 @@ index 8745bd15bd1..00000000000 - const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, inMemoryPathToUri)); - const client = { - rpc: { +- hooks: { discover: async () => ({ hooks: [], warnings: [], errors: [] }) }, +- mcp: { discover: async () => ({ servers: [] }) }, - agents: { - getDiscoveryPaths: async () => ({ paths: [] }), - discover: async () => ({ agents: [] }), @@ -143510,6 +149050,8 @@ index 8745bd15bd1..00000000000 - const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, inMemoryPathToUri)); - const client = { - rpc: { +- hooks: { discover: async () => ({ hooks: [], warnings: [], errors: [] }) }, +- mcp: { discover: async () => ({ servers: [] }) }, - agents: { - getDiscoveryPaths: async () => ({ paths: [{ path: '/workspace/.github/agents' }] }), - discover: async () => ({ @@ -143559,8 +149101,7 @@ index 8745bd15bd1..00000000000 - { contents: 'hook', uri: '/home/.copilot/hooks', writable: true, children: [] }, - { contents: 'rule', uri: '/workspace', writable: false, children: ['/workspace/.github/copilot-instructions.md', '/workspace/AGENTS.md'] }, - { contents: 'agent', uri: '/workspace/.github/agents', writable: true, children: ['/workspace/.github/agents/foo.agent.md'] }, -- { contents: 'hook', uri: '/workspace/.github/copilot', writable: true, children: ['/workspace/.github/copilot/settings.json'] }, -- { contents: 'hook', uri: '/workspace/.github/hooks', writable: true, children: ['/workspace/.github/hooks/pre-tool.json'] }, +- { contents: 'hook', uri: '/workspace/.github/hooks', writable: true, children: [] }, - { contents: 'rule', uri: '/workspace/.github/instructions', writable: true, children: ['/workspace/.github/instructions/baz.instructions.md'] }, - { contents: 'skill', uri: '/workspace/.github/skills', writable: true, children: ['/workspace/.github/skills/bar/SKILL.md'] }, - ]); @@ -143574,6 +149115,8 @@ index 8745bd15bd1..00000000000 - const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, inMemoryPathToUri)); - const client = { - rpc: { +- hooks: { discover: async () => ({ hooks: [], warnings: [], errors: [] }) }, +- mcp: { discover: async () => ({ servers: [] }) }, - agents: { - getDiscoveryPaths: async () => ({ paths: [] }), - discover: async () => ({ agents: [] }), @@ -143650,6 +149193,8 @@ index 8745bd15bd1..00000000000 - const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, inMemoryPathToUri)); - const client = { - rpc: { +- hooks: { discover: async () => ({ hooks: [], warnings: [], errors: [] }) }, +- mcp: { discover: async () => ({ servers: [] }) }, - agents: { - getDiscoveryPaths: async () => ({ paths: [] }), - discover: async () => ({ agents: [] }), @@ -143693,6 +149238,8 @@ index 8745bd15bd1..00000000000 - const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], caseVariantUserHome, inMemoryPathToUri)); - const client = { - rpc: { +- hooks: { discover: async () => ({ hooks: [], warnings: [], errors: [] }) }, +- mcp: { discover: async () => ({ servers: [] }) }, - agents: { - getDiscoveryPaths: async () => ({ paths: [] }), - discover: async () => ({ agents: [] }), @@ -143735,6 +149282,8 @@ index 8745bd15bd1..00000000000 - const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, inMemoryPathToUri)); - const client = { - rpc: { +- hooks: { discover: async () => ({ hooks: [], warnings: [], errors: [] }) }, +- mcp: { discover: async () => ({ servers: [] }) }, - agents: { - getDiscoveryPaths: async () => ({ paths: [] }), - discover: async () => ({ agents: [] }), @@ -143775,6 +149324,8 @@ index 8745bd15bd1..00000000000 - const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, inMemoryPathToUri)); - const client = { - rpc: { +- hooks: { discover: async () => ({ hooks: [], warnings: [], errors: [] }) }, +- mcp: { discover: async () => ({ servers: [] }) }, - agents: { - getDiscoveryPaths: async () => ({ paths: [] }), - discover: async () => ({ agents: [] }), @@ -143840,6 +149391,8 @@ index 8745bd15bd1..00000000000 - const agentDiscovery = new DeferredPromise<{ agents: [] }>(); - const client = { - rpc: { +- hooks: { discover: async () => ({ hooks: [], warnings: [], errors: [] }) }, +- mcp: { discover: async () => ({ servers: [] }) }, - agents: { - getDiscoveryPaths: async () => ({ paths: [] }), - discover: () => { @@ -143881,19 +149434,24 @@ index 8745bd15bd1..00000000000 - const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace, secondWorkspace], userHome, inMemoryPathToUri)); - const client = { - rpc: { +- hooks: { discover: async () => ({ hooks: [], warnings: [], errors: [] }) }, +- mcp: { discover: async () => ({ servers: [] }) }, - agents: { getDiscoveryPaths: async () => ({ paths: [] }), discover: async () => ({ agents: [] }) }, - instructions: { getDiscoveryPaths: async () => ({ paths: [] }), discover: async () => ({ sources: [] }) }, - skills: { getDiscoveryPaths: async () => ({ paths: [] }), discover: async () => ({ skills: [] }) }, - }, - } as unknown as CopilotClient; - -- const hookChildren = (await discovery.discover(client, CancellationToken.None)) -- .filter(customization => customization.contents === 'hook') -- .flatMap(customization => (customization.children ?? []).map(child => URI.parse(child.uri).path)) -- .sort(); -- -- // Hooks come only from the primary root (`/workspace`), never `/workspace2`. -- assert.deepStrictEqual(hookChildren, ['/workspace/.github/hooks/pre-tool.json']); +- const requests: Parameters[0][] = []; +- client.rpc.hooks.discover = async request => { +- requests.push(request); +- return { hooks: [], warnings: [], errors: [] }; +- }; +- await discovery.discover(client, CancellationToken.None); +- assert.deepStrictEqual(requests, [ +- { projectPaths: [workspace.fsPath] }, +- { projectPaths: [workspace.fsPath], hookDirectories: [inMemoryPathToUri('/workspace/.github/hooks').fsPath] }, +- ]); - }); - - test('discover resolves relative instructions against their attributed project root and groups per root', async () => { @@ -143905,6 +149463,8 @@ index 8745bd15bd1..00000000000 - const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace, secondWorkspace], userHome, inMemoryPathToUri)); - const client = { - rpc: { +- hooks: { discover: async () => ({ hooks: [], warnings: [], errors: [] }) }, +- mcp: { discover: async () => ({ servers: [] }) }, - agents: { getDiscoveryPaths: async () => ({ paths: [] }), discover: async () => ({ agents: [] }) }, - instructions: { - getDiscoveryPaths: async () => ({ @@ -143952,6 +149512,8 @@ index 8745bd15bd1..00000000000 - const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace, secondWorkspace], userHome, inMemoryPathToUri)); - const client = { - rpc: { +- hooks: { discover: async () => ({ hooks: [], warnings: [], errors: [] }) }, +- mcp: { discover: async () => ({ servers: [] }) }, - agents: { - getDiscoveryPaths: async () => ({ paths: [] }), - discover: async (request: AgentsDiscoverRequest) => { @@ -143995,16 +149557,19 @@ index 8745bd15bd1..00000000000 -}); diff --git a/src/vs/platform/agentHost/test/node/sessionSandbox.test.ts b/src/vs/platform/agentHost/test/node/sessionSandbox.test.ts deleted file mode 100644 -index 2a1f0c1e9ab..00000000000 +index 4174b7e4352..00000000000 --- a/src/vs/platform/agentHost/test/node/sessionSandbox.test.ts +++ /dev/null -@@ -1,1361 +0,0 @@ +@@ -1,1497 +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 assert from 'assert'; +-import { timeout } from '../../../../base/common/async.js'; +-import { ChatProvidersConfigKey } from '../../common/agentHostChatProviders.js'; +-import { hasKey } from '../../../../base/common/types.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { NullLogService } from '../../../log/common/log.js'; @@ -144013,8 +149578,9 @@ index 2a1f0c1e9ab..00000000000 -import { readSessionSandboxPolicy } from '../../common/meta/agentSandboxPolicyMeta.js'; -import { readChatSandboxState, readChatSandboxStates, readSessionSandboxState, withChatSandboxState, withSessionSandboxState } from '../../common/meta/agentSandboxStateMeta.js'; -import { omitTransientSessionConfigValues, SessionConfigKey } from '../../common/sessionConfigKeys.js'; --import { buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, ChatOriginKind, MessageKind, SessionStatus, ToolCallStatus } from '../../common/state/sessionState.js'; +-import { buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, ChatOriginKind, SessionStatus, ToolCallStatus } from '../../common/state/sessionState.js'; -import { ActionType } from '../../common/state/sessionActions.js'; +-import { chatToolCallStart, chatTurnStarted } from '../common/chatActionBuilders.js'; -import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; -import { AgentHostStateManager, creationTimeChatStorageResource } from '../../node/agentHostStateManager.js'; -import { getCopilotBrowserSandboxNetworkRestrictions, projectCopilotSandboxPolicy } from '../../node/copilot/copilotSandboxPolicy.js'; @@ -144095,6 +149661,42 @@ index 2a1f0c1e9ab..00000000000 - }, { values: {}, property: ['default', 'on', 'off'], mutable: true }); - }); - +- test('resolution preserves confirmed bypass metadata without mutating applied state', () => { +- const { configuration, create } = setupSession(); +- const owner = create('resume'); +- const target = { session: owner, chat: buildDefaultChatUri(owner) }; +- configuration.setSessionSandboxPolicy(owner, { enabled: true, allowBypass: true }); +- configuration.setChatSandboxEnabled(target, false); +- configuration.updateSessionConfig(owner, { sandboxEnabled: 'off' }); +- +- assert.deepStrictEqual({ +- enabled: getSessionSandboxConfig(configuration, target).enabled, +- applied: configuration.getChatSandboxEnabled(target), +- selection: configuration.getChatConfigValues(target)?.sandboxEnabled, +- }, { enabled: 'off', applied: false, selection: 'off' }); +- }); +- +- test('new managed policy retains confirmed off metadata only when bypass is allowed', () => { +- const results = []; +- for (const allowBypass of [undefined, false, true]) { +- const { configuration, create } = setupSession(); +- const owner = create(`confirmed-off-${allowBypass}`, { sandboxEnabled: 'off' }); +- const target = { session: owner, chat: buildDefaultChatUri(owner) }; +- configuration.setChatSandboxEnabled(target, false); +- configuration.setSessionSandboxPolicy(owner, { enabled: true, ...(allowBypass !== undefined ? { allowBypass } : {}) }); +- results.push({ +- enabled: getSessionSandboxConfig(configuration, target).enabled, +- applied: configuration.getChatSandboxEnabled(target), +- selection: configuration.getChatConfigValues(target)?.sandboxEnabled, +- }); +- } +- assert.deepStrictEqual(results, [ +- { enabled: 'on', applied: undefined, selection: 'on' }, +- { enabled: 'on', applied: undefined, selection: 'on' }, +- { enabled: 'off', applied: false, selection: 'off' }, +- ]); +- }); +- - test('publishes session-scoped policy changes in serializable subscription state', () => { - const { manager, configuration, create } = setupSession(); - const owner = create('managed'); @@ -144316,6 +149918,83 @@ index 2a1f0c1e9ab..00000000000 - }); - }); - +- test('broadcasts background conflicts per chat, preserving confirmed state and selections', () => { +- const { manager, configuration, create } = setupSession(); +- for (const enabled of [true, false, undefined]) { +- const owner = create(`conflict-${enabled}`, { sandboxEnabled: 'off' }); +- const target = { session: owner, chat: buildDefaultChatUri(owner) }; +- const sibling = { session: owner, chat: buildChatUri(owner, 'sibling') }; +- manager.addChat(owner, sibling.chat, creationTimeChatStorageResource(owner, sibling.chat, target.chat)); +- configuration.setChatSandboxEnabled(sibling, true); +- if (enabled !== undefined) { +- configuration.setChatSandboxEnabled(target, enabled); +- } +- const updates: ActionType[] = []; +- store.add(manager.onDidEmitEnvelope(envelope => updates.push(envelope.action.type))); +- +- configuration.reportChatSandboxConflict(target, 'Managed sandbox conflict'); +- const failed = readChatSandboxState(manager.getSessionState(owner), target.chat); +- configuration.reportChatSandboxConflict(target, 'Managed sandbox conflict'); +- +- assert.deepStrictEqual({ +- enabled: failed?.enabled, +- message: failed?.error?.message, +- hasId: failed?.error !== undefined && hasKey(failed.error, { id: true }) && failed.error.id.length > 0, +- rejectedSelection: failed?.rejectedSelection, +- selection: configuration.getChatConfigValues(target)?.sandboxEnabled, +- sibling: readChatSandboxState(manager.getSessionState(owner), sibling.chat), +- updates, +- sessionState: readSessionSandboxState(manager.getSessionState(owner)), +- }, { +- enabled, +- message: 'Managed sandbox conflict', +- hasId: true, +- rejectedSelection: undefined, +- selection: 'off', +- sibling: { enabled: true }, +- updates: [ActionType.SessionMetaChanged], +- sessionState: failed, +- }); +- } +- }); +- +- test('validates host sandbox conflicts without inventing confirmed enablement', () => { +- const parse = (state: Record) => readSessionSandboxState({ _meta: { 'vscode.sandboxState': state } }); +- const error = { id: 'conflict-1', message: 'Managed sandbox conflict' }; +- assert.deepStrictEqual([ +- parse({ error }), +- parse({ enabled: false, error }), +- parse({}), +- parse({ enabled: 'off', error }), +- parse({ error: { ...error, id: '' } }), +- parse({ error: { ...error, id: 1 } }), +- parse({ error: { ...error, message: 1 } }), +- parse({ error: { ...error, clientId: 'client', clientSeq: 1 } }), +- parse({ error: { clientId: 'client', clientSeq: 1, message: 'Rejected' } }), +- ], [ +- { enabled: undefined, error }, +- { enabled: false, error }, +- undefined, undefined, undefined, undefined, undefined, undefined, undefined, +- ]); +- }); +- +- test('transfers a sandbox conflict with unknown enablement without authorizing rollback', () => { +- const { manager, configuration, create } = setupSession(); +- const source = create('source-conflict'); +- const target = create('target-conflict', { sandboxEnabled: 'off' }); +- const sourceChat = { session: source, chat: buildDefaultChatUri(source) }; +- const targetChat = { session: target, chat: buildDefaultChatUri(target) }; +- configuration.reportChatSandboxConflict(sourceChat, 'Managed sandbox conflict'); +- const state = readChatSandboxState(manager.getSessionState(source), sourceChat.chat)!; +- configuration.setChatSandboxEnabled(targetChat, state.enabled, state.error); +- configuration.rejectChatSandboxChange(targetChat, configuration.getChatConfigValues(targetChat), { clientId: 'client', clientSeq: 1 }, 'Rejected'); +- +- assert.deepStrictEqual({ +- state: readChatSandboxState(manager.getSessionState(target), targetChat.chat), +- selection: configuration.getChatConfigValues(targetChat)?.sandboxEnabled, +- }, { state, selection: 'off' }); +- }); +- - test('publishes the error to its client before restoring the last successful sandbox value', () => { - const { manager, configuration, create } = setupSession(); - const owner = create('rollback', { sandboxEnabled: 'on', mode: 'plan' }); @@ -144913,6 +150592,29 @@ index 2a1f0c1e9ab..00000000000 - }); - }); - +- test('with chats with their own agents on, the session\'s "Disable sandboxing" replaces the default chat\'s own selection, not a peer\'s, and the default chat follows a managed floor', async () => { +- const { manager, configuration, owner, peer, defaultTarget, peerTarget } = setupChats({ sandboxEnabled: 'on' }); +- configuration.updateRootConfig({ [ChatProvidersConfigKey.Enabled]: true }); +- await timeout(0); +- manager.dispatchClientAction(defaultTarget.chat, { type: ActionType.ChatConfigChanged, config: { sandboxEnabled: 'on' } }, { clientId: 'client', clientSeq: 1 }); +- manager.dispatchClientAction(peer, { type: ActionType.ChatConfigChanged, config: { sandboxEnabled: 'on' } }, { clientId: 'client', clientSeq: 2 }); +- manager.dispatchClientAction(owner, { type: ActionType.SessionConfigChanged, config: { sandboxEnabled: 'off' } }, { clientId: 'client', clientSeq: 3 }); +- const read = () => ({ +- enabled: [defaultTarget, peerTarget].map(target => getSessionSandboxConfig(configuration, target).enabled), +- published: manager.getChatConfig(defaultTarget.chat)?.values.sandboxEnabled, +- defaultOwn: configuration.getChatPersistedConfigValues(defaultTarget.chat)?.sandboxEnabled, +- peerOwn: configuration.getChatOwnConfigValues(peerTarget).sandboxEnabled, +- }); +- const disabled = read(); +- // A managed floor without bypass rolls the session's off back; the default chat follows it. +- configuration.setSessionSandboxPolicy(owner, { enabled: true }); +- assert.deepStrictEqual({ disabled, floored: read(), session: configuration.getSessionConfigValues(owner)?.sandboxEnabled }, { +- disabled: { enabled: ['off', 'on'], published: 'off', defaultOwn: undefined, peerOwn: 'on' }, +- floored: { enabled: ['on', 'on'], published: 'on', defaultOwn: undefined, peerOwn: 'on' }, +- session: 'on', +- }); +- }); +- - test('a client\'s own off for a chat survives policy republishing while the policy allows bypass, and not its revocation', () => { - const { manager, configuration, owner, peer, peerTarget } = setupChats({ sandboxEnabled: 'on' }); - configuration.setSessionSandboxPolicy(owner, { enabled: true, allowBypass: true }); @@ -145260,11 +150962,8 @@ index 2a1f0c1e9ab..00000000000 - const peer = buildChatUri(owner, 'peer'); - manager.addChat(owner, peer, creationTimeChatStorageResource(owner, peer, buildDefaultChatUri(owner))); - const permissions = store.add(new SessionPermissionManager(manager, {}, configuration, new NullLogService(), createSessionDataService(), createTestChatOwnershipService(manager))); -- manager.dispatchServerAction(peer, { -- type: ActionType.ChatTurnStarted, turnId: 'turn', startedAt: '2026-01-01T00:00:00Z', -- message: { text: 'run', origin: { kind: MessageKind.User } }, -- }); -- manager.dispatchServerAction(peer, { type: ActionType.ChatToolCallStart, turnId: 'turn', toolCallId: 'tool', toolName: 'bash', displayName: 'Bash' }); +- manager.dispatchServerAction(peer, chatTurnStarted('turn', 'run', '2026-01-01T00:00:00Z')); +- manager.dispatchServerAction(peer, chatToolCallStart('turn', 'tool', 'bash', 'Bash')); - const ready = permissions.createToolReadyAction({ - kind: 'pending_confirmation', chat: URI.parse(peer), requestSandboxBypass: true, - state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tool', toolName: 'bash', displayName: 'Bash', invocationMessage: 'run', confirmationTitle: 'Outside sandbox?' }, @@ -145288,7 +150987,6 @@ index 2a1f0c1e9ab..00000000000 - test('managed enablement rejects direct Off but permits confirmed opt-out and re-enablement', () => { - const { configuration, create } = setupSession(); - const owner = create('bypass', { sandboxEnabled: 'off' }); -- configuration.setChatSandboxEnabled({ session: owner, chat: buildDefaultChatUri(owner) }, false); - configuration.setSessionSandboxPolicy(owner, { enabled: true, allowBypass: true }); - const beforeApproval = { selection: configuration.getSessionConfigValues(owner)?.sandboxEnabled, enabled: getSessionSandboxOverrides(configuration, { session: owner, chat: buildDefaultChatUri(owner) }).enabled }; - configuration.setChatSandboxEnabled({ session: owner, chat: buildDefaultChatUri(owner) }, false); diff --git a/upstream/stable.json b/upstream/stable.json index 60cac40..fb5a590 100644 --- a/upstream/stable.json +++ b/upstream/stable.json @@ -1,4 +1,4 @@ { - "tag": "1.144.0", - "commit": "2c33b50b16c472efe58357d1d2a8a5414bc23fbf" + "tag": "1.145.0", + "commit": "a717861c07586a53d83b80d742cd3f59b20a6273" }