From 457197219fd4bdc7a7e097fcbaf30e95930c6479 Mon Sep 17 00:00:00 2001 From: sbs44 <83440025+sbs44@users.noreply.github.com> Date: Sat, 8 Aug 2026 04:12:10 -0400 Subject: [PATCH 1/3] fix(patches): rebase 9 patches for VS Code 1.132.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream drift 1.126.0 → 1.132.0 broke 10 of 36 patches (12-update-add-cooldown was cascade-only). Notable ports beyond context re-rolls: - 11-update-use-github-release: merged upstream's mid-check cancellation machinery (checkCancellationTokenSource) and collectGarbage/getWin32UpdateType refactors with the GitHub-release update flow. - 21-policy-use-custom-lib: CopilotManagedSettingsService was renamed upstream to NativeManagedSettingsService; re-ported the @vscodium/policy-watcher vendor-name adapter there. - 51-ext-copilot-remove-it: upstream added new @github/copilot-sdk consumers; shimmed type-only imports (GitHubTelemetryNotification, SectionOverride, SessionEvent, SessionFs*, CopilotClient) and deleted three tests that import SDK runtime values or removed modules. Pins upstream/stable.json at 1.132.0 (df53daab). --- patches/00-brand-remove-branding.patch | 1583 +- ...00-build-download-extensions-from-gh.patch | 6 +- patches/00-copilot-fix-action-condition.patch | 122 +- patches/00-vsce-use-custom-lib.patch | 74 +- patches/11-update-use-github-release.patch | 169 +- patches/20-keymap-use-custom-lib.patch | 96 +- patches/21-policy-use-custom-lib.patch | 181 +- patches/50-build-improve-gulp-tasks.patch | 25 +- patches/51-ext-copilot-remove-it.patch | 27657 ++++++++++++---- upstream/stable.json | 4 +- 10 files changed, 21275 insertions(+), 8642 deletions(-) diff --git a/patches/00-brand-remove-branding.patch b/patches/00-brand-remove-branding.patch index 4ec7494..3832484 100644 --- a/patches/00-brand-remove-branding.patch +++ b/patches/00-brand-remove-branding.patch @@ -2,1067 +2,752 @@ diff --git a/build/win32/i18n/messages.en.isl b/build/win32/i18n/messages.en.isl index 6255123d..6e6013e7 100644 --- a/build/win32/i18n/messages.en.isl +++ b/build/win32/i18n/messages.en.isl -@@ -14,4 +14,4 @@ RunAfter=Run %1 after installation - Other=Other: - SourceFile=%1 Source File +@@ -16,2 +16,2 @@ SourceFile=%1 Source File OpenWithCodeContextMenu=Open w&ith %1 -UpdatingVisualStudioCode=Updating Visual Studio Code... -+UpdatingVisualStudioCode=Updating !!APP_NAME!!... ++UpdatingVisualStudioCode=Updating BradfordCode... diff --git a/build/win32/i18n/messages.es.isl b/build/win32/i18n/messages.es.isl index 7f6f1bd0..f508cf3a 100644 --- a/build/win32/i18n/messages.es.isl +++ b/build/win32/i18n/messages.es.isl -@@ -7,4 +7,4 @@ RunAfter=Ejecutar %1 después de la instalación - Other=Otros: - SourceFile=Archivo de origen %1 +@@ -9,2 +9,2 @@ SourceFile=Archivo de origen %1 OpenWithCodeContextMenu=Abrir &con %1 -UpdatingVisualStudioCode=Actualizando Visual Studio Code... \ No newline at end of file -+UpdatingVisualStudioCode=Actualizando !!APP_NAME!!... ++UpdatingVisualStudioCode=Actualizando BradfordCode... \ No newline at end of file diff --git a/build/win32/i18n/messages.it.isl b/build/win32/i18n/messages.it.isl index ac64aae7..7184230e 100644 --- a/build/win32/i18n/messages.it.isl +++ b/build/win32/i18n/messages.it.isl -@@ -7,4 +7,4 @@ RunAfter=Esegui %1 dopo l'installazione - Other=Altro: - SourceFile=File di origine %1 +@@ -9,2 +9,2 @@ SourceFile=File di origine %1 OpenWithCodeContextMenu=Apri con %1 -UpdatingVisualStudioCode=Aggiornamento di Visual Studio Code... \ No newline at end of file -+UpdatingVisualStudioCode=Aggiornamento di !!APP_NAME!!... ++UpdatingVisualStudioCode=Aggiornamento di BradfordCode... \ No newline at end of file diff --git a/build/win32/i18n/messages.pt-br.isl b/build/win32/i18n/messages.pt-br.isl index 08c77290..550d805d 100644 --- a/build/win32/i18n/messages.pt-br.isl +++ b/build/win32/i18n/messages.pt-br.isl -@@ -7,4 +7,4 @@ RunAfter=Executar %1 após a instalação - Other=Outros: - SourceFile=Arquivo Fonte %1 +@@ -9,2 +9,2 @@ SourceFile=Arquivo Fonte %1 OpenWithCodeContextMenu=Abrir com %1 -UpdatingVisualStudioCode=Atualizando o Visual Studio Code... \ No newline at end of file -+UpdatingVisualStudioCode=Atualizando o !!APP_NAME!!... ++UpdatingVisualStudioCode=Atualizando o BradfordCode... \ No newline at end of file diff --git a/extensions/configuration-editing/src/configurationEditingMain.ts b/extensions/configuration-editing/src/configurationEditingMain.ts index 2578270c..9589db2a 100644 --- a/extensions/configuration-editing/src/configurationEditingMain.ts +++ b/extensions/configuration-editing/src/configurationEditingMain.ts -@@ -52,8 +52,8 @@ function registerVariableCompletions(pattern: string): vscode.Disposable { - } - +@@ -54,4 +54,4 @@ function registerVariableCompletions(pattern: string): vscode.Disposable { return [ - { label: 'workspaceFolder', detail: vscode.l10n.t("The path of the folder opened in VS Code") }, - { label: 'workspaceFolderBasename', detail: vscode.l10n.t("The name of the folder opened in VS Code without any slashes (/)") }, -+ { label: 'workspaceFolder', detail: vscode.l10n.t("The path of the folder opened in !!APP_NAME!!") }, -+ { label: 'workspaceFolderBasename', detail: vscode.l10n.t("The name of the folder opened in !!APP_NAME!! without any slashes (/)") }, ++ { label: 'workspaceFolder', detail: vscode.l10n.t("The path of the folder opened in BradfordCode") }, ++ { label: 'workspaceFolderBasename', detail: vscode.l10n.t("The name of the folder opened in BradfordCode without any slashes (/)") }, { label: 'fileWorkspaceFolderBasename', detail: vscode.l10n.t("The current opened file workspace folder name without any slashes (/)") }, - { label: 'relativeFile', detail: vscode.l10n.t("The current opened file relative to ${workspaceFolder}") }, - { label: 'relativeFileDirname', detail: vscode.l10n.t("The current opened file's dirname relative to ${workspaceFolder}") }, diff --git a/extensions/configuration-editing/src/settingsDocumentHelper.ts b/extensions/configuration-editing/src/settingsDocumentHelper.ts index 12b50f31..19e38322 100644 --- a/extensions/configuration-editing/src/settingsDocumentHelper.ts +++ b/extensions/configuration-editing/src/settingsDocumentHelper.ts -@@ -121,7 +121,7 @@ export class SettingsDocument { - completions.push(this.newSimpleCompletionItem(getText('rootPath'), range, vscode.l10n.t("file path of the workspace (e.g. /Users/Development/myWorkspace)"))); - completions.push(this.newSimpleCompletionItem(getText('folderName'), range, vscode.l10n.t("name of the workspace folder the file is contained in (e.g. myFolder)"))); +@@ -123,3 +123,3 @@ export class SettingsDocument { completions.push(this.newSimpleCompletionItem(getText('folderPath'), range, vscode.l10n.t("file path of the workspace folder the file is contained in (e.g. /Users/Development/myFolder)"))); - completions.push(this.newSimpleCompletionItem(getText('appName'), range, vscode.l10n.t("e.g. VS Code"))); -+ completions.push(this.newSimpleCompletionItem(getText('appName'), range, vscode.l10n.t("e.g. !!APP_NAME!!"))); ++ completions.push(this.newSimpleCompletionItem(getText('appName'), range, vscode.l10n.t("e.g. BradfordCode"))); completions.push(this.newSimpleCompletionItem(getText('remoteName'), range, vscode.l10n.t("e.g. SSH"))); - completions.push(this.newSimpleCompletionItem(getText('dirty'), range, vscode.l10n.t("an indicator for when the active editor has unsaved changes"))); - completions.push(this.newSimpleCompletionItem(getText('separator'), range, vscode.l10n.t("a conditional separator (' - ') that only shows when surrounded by variables with values"))); diff --git a/extensions/css-language-features/package.nls.json b/extensions/css-language-features/package.nls.json index d3de2241..91f278e6 100644 --- a/extensions/css-language-features/package.nls.json +++ b/extensions/css-language-features/package.nls.json -@@ -2,8 +2,8 @@ - "displayName": "CSS Language Features", - "description": "Provides rich language support for CSS, LESS and SCSS files.", +@@ -4,4 +4,4 @@ "css.title": "CSS", - "css.customData.desc": "A list of relative file paths pointing to JSON files following the [custom data format](https://github.com/microsoft/vscode-css-languageservice/blob/master/docs/customData.md).\n\nVS Code loads custom data on startup to enhance its CSS support for CSS custom properties (variables), at-rules, pseudo-classes, and pseudo-elements you specify in the JSON files.\n\nThe file paths are relative to workspace and only workspace folder settings are considered.", - "css.completion.triggerPropertyValueCompletion.desc": "By default, VS Code triggers property value completion after selecting a CSS property. Use this setting to disable this behavior.", -+ "css.customData.desc": "A list of relative file paths pointing to JSON files following the [custom data format](https://github.com/microsoft/vscode-css-languageservice/blob/master/docs/customData.md).\n\n!!APP_NAME!! loads custom data on startup to enhance its CSS support for CSS custom properties (variables), at-rules, pseudo-classes, and pseudo-elements you specify in the JSON files.\n\nThe file paths are relative to workspace and only workspace folder settings are considered.", -+ "css.completion.triggerPropertyValueCompletion.desc": "By default, !!APP_NAME!! triggers property value completion after selecting a CSS property. Use this setting to disable this behavior.", ++ "css.customData.desc": "A list of relative file paths pointing to JSON files following the [custom data format](https://github.com/microsoft/vscode-css-languageservice/blob/master/docs/customData.md).\n\nBradfordCode loads custom data on startup to enhance its CSS support for CSS custom properties (variables), at-rules, pseudo-classes, and pseudo-elements you specify in the JSON files.\n\nThe file paths are relative to workspace and only workspace folder settings are considered.", ++ "css.completion.triggerPropertyValueCompletion.desc": "By default, BradfordCode triggers property value completion after selecting a CSS property. Use this setting to disable this behavior.", "css.completion.completePropertyWithSemicolon.desc": "Insert semicolon at end of line when completing CSS properties.", - "css.lint.argumentsInColorFunction.desc": "Invalid number of parameters.", - "css.lint.boxModel.desc": "Do not use `width` or `height` when using `padding` or `border`.", -@@ -25,7 +25,7 @@ - "css.lint.unknownVendorSpecificProperties.desc": "Unknown vendor specific property.", - "css.lint.vendorPrefix.desc": "When using a vendor-specific prefix, also include the standard property.", +@@ -27,3 +27,3 @@ "css.lint.zeroUnits.desc": "No unit for zero needed.", - "css.trace.server.desc": "Traces the communication between VS Code and the CSS language server.", -+ "css.trace.server.desc": "Traces the communication between !!APP_NAME!! and the CSS language server.", ++ "css.trace.server.desc": "Traces the communication between BradfordCode and the CSS language server.", "css.validate.title": "Controls CSS validation and problem severities.", - "css.validate.desc": "Enables or disables all validations.", - "css.hover.documentation": "Show property and value documentation in CSS hovers.", -@@ -38,7 +38,7 @@ - "css.format.preserveNewLines.desc": "Whether existing line breaks before rules and declarations should be preserved.", - "css.format.maxPreserveNewLines.desc": "Maximum number of line breaks to be preserved in one chunk, when `#css.format.preserveNewLines#` is enabled.", +@@ -40,3 +40,3 @@ "less.title": "LESS", - "less.completion.triggerPropertyValueCompletion.desc": "By default, VS Code triggers property value completion after selecting a CSS property. Use this setting to disable this behavior.", -+ "less.completion.triggerPropertyValueCompletion.desc": "By default, !!APP_NAME!! triggers property value completion after selecting a CSS property. Use this setting to disable this behavior.", ++ "less.completion.triggerPropertyValueCompletion.desc": "By default, BradfordCode triggers property value completion after selecting a CSS property. Use this setting to disable this behavior.", "less.completion.completePropertyWithSemicolon.desc": "Insert semicolon at end of line when completing CSS properties.", - "less.lint.argumentsInColorFunction.desc": "Invalid number of parameters.", - "less.lint.boxModel.desc": "Do not use `width` or `height` when using `padding` or `border`.", -@@ -72,7 +72,7 @@ - "less.format.preserveNewLines.desc": "Whether existing line breaks before rules and declarations should be preserved.", - "less.format.maxPreserveNewLines.desc": "Maximum number of line breaks to be preserved in one chunk, when `#less.format.preserveNewLines#` is enabled.", +@@ -74,3 +74,3 @@ "scss.title": "SCSS (Sass)", - "scss.completion.triggerPropertyValueCompletion.desc": "By default, VS Code triggers property value completion after selecting a CSS property. Use this setting to disable this behavior.", -+ "scss.completion.triggerPropertyValueCompletion.desc": "By default, !!APP_NAME!! triggers property value completion after selecting a CSS property. Use this setting to disable this behavior.", ++ "scss.completion.triggerPropertyValueCompletion.desc": "By default, BradfordCode triggers property value completion after selecting a CSS property. Use this setting to disable this behavior.", "scss.completion.completePropertyWithSemicolon.desc": "Insert semicolon at end of line when completing CSS properties.", - "scss.lint.argumentsInColorFunction.desc": "Invalid number of parameters.", - "scss.lint.boxModel.desc": "Do not use `width` or `height` when using `padding` or `border`.", diff --git a/extensions/emmet/package.nls.json b/extensions/emmet/package.nls.json index 683bcc7f..fb6b0523 100644 --- a/extensions/emmet/package.nls.json +++ b/extensions/emmet/package.nls.json -@@ -1,5 +1,5 @@ +@@ -1,3 +1,3 @@ { - "description": "Emmet support for VS Code", -+ "description": "Emmet support for !!APP_NAME!!", ++ "description": "Emmet support for BradfordCode", "command.wrapWithAbbreviation": "Wrap with Abbreviation", - "command.removeTag": "Remove Tag", - "command.updateTag": "Update Tag", diff --git a/extensions/extension-editing/src/constants.ts b/extensions/extension-editing/src/constants.ts index 1be4d0e1..d60d8357 100644 --- a/extensions/extension-editing/src/constants.ts +++ b/extensions/extension-editing/src/constants.ts -@@ -6,4 +6,4 @@ - import { l10n } from 'vscode'; - +@@ -8,2 +8,2 @@ import { l10n } from 'vscode'; export const implicitActivationEvent = l10n.t("This activation event cannot be explicitly listed by your extension."); -export const redundantImplicitActivationEvent = l10n.t("This activation event can be removed as VS Code generates these automatically from your package.json contribution declarations."); -+export const redundantImplicitActivationEvent = l10n.t("This activation event can be removed as !!APP_NAME!! generates these automatically from your package.json contribution declarations."); ++export const redundantImplicitActivationEvent = l10n.t("This activation event can be removed as BradfordCode generates these automatically from your package.json contribution declarations."); diff --git a/extensions/extension-editing/src/extensionLinter.ts b/extensions/extension-editing/src/extensionLinter.ts index a6ab287a..dc928226 100644 --- a/extensions/extension-editing/src/extensionLinter.ts +++ b/extensions/extension-editing/src/extensionLinter.ts -@@ -32,7 +32,7 @@ const embeddedSvgsNotValid = l10n.t("Embedded SVGs are not a valid image source. - const dataUrlsNotValid = l10n.t("Data URLs are not a valid image source."); - const relativeUrlRequiresHttpsRepository = l10n.t("Relative image URLs require a repository with HTTPS protocol to be specified in the package.json."); +@@ -34,3 +34,3 @@ const relativeUrlRequiresHttpsRepository = l10n.t("Relative image URLs require a const relativeBadgeUrlRequiresHttpsRepository = l10n.t("Relative badge URLs require a repository with HTTPS protocol to be specified in this package.json."); -const apiProposalNotListed = l10n.t("This proposal cannot be used because for this extension the product defines a fixed set of API proposals. You can test your extension but before publishing you MUST reach out to the VS Code team."); -+const apiProposalNotListed = l10n.t("This proposal cannot be used because for this extension the product defines a fixed set of API proposals. You can test your extension but before publishing you MUST reach out to the !!APP_NAME!! team."); ++const apiProposalNotListed = l10n.t("This proposal cannot be used because for this extension the product defines a fixed set of API proposals. You can test your extension but before publishing you MUST reach out to the BradfordCode team."); const apiProposalVersionNotSupported = l10n.t("API proposal versions are no longer supported. Remove the '@' suffix."); - - const starActivation = l10n.t("Using '*' activation is usually a bad idea as it impacts performance."); diff --git a/extensions/git/package.nls.json b/extensions/git/package.nls.json index 147a75f9..33aeb0d4 100644 --- a/extensions/git/package.nls.json +++ b/extensions/git/package.nls.json -@@ -257,7 +257,7 @@ - "message": "List of git commands (ex: commit, push) that would have their `stdout` logged to the [git output](command:git.showOutput). If the git command has a client-side hook configured, the client-side hook's `stdout` will also be logged to the [git output](command:git.showOutput).", - "comment": [ +@@ -259,3 +259,3 @@ "{Locked='](command:git.showOutput'}", - "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", -+ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for !!APP_NAME!!", ++ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for BradfordCode", "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" - ] - }, -@@ -285,8 +285,8 @@ - "config.untrackedChanges.hidden": "Untracked changes are hidden and excluded from several actions.", - "config.requireGitUserConfig": "Controls whether to require explicit Git user configuration or allow Git to guess if missing.", +@@ -287,4 +287,4 @@ "config.showCommitInput": "Controls whether to show the commit input in the Git source control panel.", - "config.terminalAuthentication": "Controls whether to enable VS Code to be the authentication handler for Git processes spawned in the Integrated Terminal. Note: Terminals need to be restarted to pick up a change in this setting.", - "config.terminalGitEditor": "Controls whether to enable VS Code to be the Git editor for Git processes spawned in the integrated terminal. Note: Terminals need to be restarted to pick up a change in this setting.", -+ "config.terminalAuthentication": "Controls whether to enable !!APP_NAME!! to be the authentication handler for Git processes spawned in the Integrated Terminal. Note: Terminals need to be restarted to pick up a change in this setting.", -+ "config.terminalGitEditor": "Controls whether to enable !!APP_NAME!! to be the Git editor for Git processes spawned in the integrated terminal. Note: Terminals need to be restarted to pick up a change in this setting.", ++ "config.terminalAuthentication": "Controls whether to enable BradfordCode to be the authentication handler for Git processes spawned in the Integrated Terminal. Note: Terminals need to be restarted to pick up a change in this setting.", ++ "config.terminalGitEditor": "Controls whether to enable BradfordCode to be the Git editor for Git processes spawned in the integrated terminal. Note: Terminals need to be restarted to pick up a change in this setting.", "config.timeline.showAuthor": "Controls whether to show the commit author in the Timeline view.", - "config.timeline.showUncommitted": "Controls whether to show uncommitted changes in the Timeline view.", - "config.timeline.date": "Controls which date to use for items in the Timeline view.", -@@ -356,7 +356,7 @@ - "{Locked='](command:workbench.action.reloadWindow'}", - "{Locked='](command:git.showOutput'}", +@@ -358,3 +358,3 @@ "{Locked='](command:workbench.extensions.search?%22%40category%3A%5C%22scm%20providers%5C%22%22'}", - "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", -+ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for !!APP_NAME!!", ++ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for BradfordCode", "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" - ] - }, -@@ -366,7 +366,7 @@ - "{Locked='](command:workbench.action.reloadWindow'}", - "{Locked='](command:git.showOutput'}", +@@ -368,3 +368,3 @@ "{Locked='](command:workbench.extensions.search?%22%40category%3A%5C%22scm%20providers%5C%22%22'}", - "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", -+ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for !!APP_NAME!!", ++ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for BradfordCode", "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" - ] - }, -@@ -376,7 +376,7 @@ - "{Locked='](command:workbench.action.reloadWindow'}", - "{Locked='](command:git.showOutput'}", +@@ -378,3 +378,3 @@ "{Locked='](command:workbench.extensions.search?%22%40category%3A%5C%22scm%20providers%5C%22%22'}", - "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", -+ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for !!APP_NAME!!", ++ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for BradfordCode", "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" - ] - }, -@@ -388,42 +388,42 @@ - ] - }, +@@ -390,6 +390,6 @@ "view.workbench.scm.disabled": { - "message": "If you would like to use Git features, please enable Git in your [settings](command:workbench.action.openSettings?%5B%22git.enabled%22%5D).\nTo learn more about how to use Git and source control in VS Code [read our docs](https://aka.ms/vscode-scm).", -+ "message": "If you would like to use Git features, please enable Git in your [settings](command:workbench.action.openSettings?%5B%22git.enabled%22%5D).\nTo learn more about how to use Git and source control in !!APP_NAME!! [read our docs](https://aka.ms/vscode-scm).", ++ "message": "If you would like to use Git features, please enable Git in your [settings](command:workbench.action.openSettings?%5B%22git.enabled%22%5D).\nTo learn more about how to use Git and source control in BradfordCode [read our docs](https://aka.ms/vscode-scm).", "comment": [ "{Locked='](command:workbench.action.openSettings?%5B%22git.enabled%22%5D'}", - "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", -+ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for !!APP_NAME!!", ++ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for BradfordCode", "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" - ] - }, +@@ -398,6 +398,6 @@ "view.workbench.scm.empty": { - "message": "In order to use Git features, you can open a folder containing a Git repository or clone from a URL.\n[Open Folder](command:vscode.openFolder)\n[Clone Repository](command:git.cloneRecursive)\nTo learn more about how to use Git and source control in VS Code [read our docs](https://aka.ms/vscode-scm).", -+ "message": "In order to use Git features, you can open a folder containing a Git repository or clone from a URL.\n[Open Folder](command:vscode.openFolder)\n[Clone Repository](command:git.cloneRecursive)\nTo learn more about how to use Git and source control in !!APP_NAME!! [read our docs](https://aka.ms/vscode-scm).", ++ "message": "In order to use Git features, you can open a folder containing a Git repository or clone from a URL.\n[Open Folder](command:vscode.openFolder)\n[Clone Repository](command:git.cloneRecursive)\nTo learn more about how to use Git and source control in BradfordCode [read our docs](https://aka.ms/vscode-scm).", "comment": [ "{Locked='](command:vscode.openFolder'}", - "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", -+ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for !!APP_NAME!!", ++ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for BradfordCode", "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" - ] - }, +@@ -406,6 +406,6 @@ "view.workbench.scm.folder": { - "message": "The folder currently open doesn't have a Git repository. You can initialize a repository which will enable source control features powered by Git.\n[Initialize Repository](command:git.init?%5Btrue%5D)\nTo learn more about how to use Git and source control in VS Code [read our docs](https://aka.ms/vscode-scm).", -+ "message": "The folder currently open doesn't have a Git repository. You can initialize a repository which will enable source control features powered by Git.\n[Initialize Repository](command:git.init?%5Btrue%5D)\nTo learn more about how to use Git and source control in !!APP_NAME!! [read our docs](https://aka.ms/vscode-scm).", ++ "message": "The folder currently open doesn't have a Git repository. You can initialize a repository which will enable source control features powered by Git.\n[Initialize Repository](command:git.init?%5Btrue%5D)\nTo learn more about how to use Git and source control in BradfordCode [read our docs](https://aka.ms/vscode-scm).", "comment": [ "{Locked='](command:git.init?%5Btrue%5D'}", - "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", -+ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for !!APP_NAME!!", ++ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for BradfordCode", "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" - ] - }, +@@ -414,6 +414,6 @@ "view.workbench.scm.workspace": { - "message": "The workspace currently open doesn't have any folders containing Git repositories. You can initialize a repository on a folder which will enable source control features powered by Git.\n[Initialize Repository](command:git.init)\nTo learn more about how to use Git and source control in VS Code [read our docs](https://aka.ms/vscode-scm).", -+ "message": "The workspace currently open doesn't have any folders containing Git repositories. You can initialize a repository on a folder which will enable source control features powered by Git.\n[Initialize Repository](command:git.init)\nTo learn more about how to use Git and source control in !!APP_NAME!! [read our docs](https://aka.ms/vscode-scm).", ++ "message": "The workspace currently open doesn't have any folders containing Git repositories. You can initialize a repository on a folder which will enable source control features powered by Git.\n[Initialize Repository](command:git.init)\nTo learn more about how to use Git and source control in BradfordCode [read our docs](https://aka.ms/vscode-scm).", "comment": [ "{Locked='](command:git.init'}", - "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", -+ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for !!APP_NAME!!", ++ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for BradfordCode", "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" - ] - }, +@@ -422,6 +422,6 @@ "view.workbench.scm.emptyWorkspace": { - "message": "The workspace currently open doesn't have any folders containing Git repositories.\n[Add Folder to Workspace](command:workbench.action.addRootFolder)\nTo learn more about how to use Git and source control in VS Code [read our docs](https://aka.ms/vscode-scm).", -+ "message": "The workspace currently open doesn't have any folders containing Git repositories.\n[Add Folder to Workspace](command:workbench.action.addRootFolder)\nTo learn more about how to use Git and source control in !!APP_NAME!! [read our docs](https://aka.ms/vscode-scm).", ++ "message": "The workspace currently open doesn't have any folders containing Git repositories.\n[Add Folder to Workspace](command:workbench.action.addRootFolder)\nTo learn more about how to use Git and source control in BradfordCode [read our docs](https://aka.ms/vscode-scm).", "comment": [ "{Locked='](command:workbench.action.addRootFolder'}", - "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", -+ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for !!APP_NAME!!", ++ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for BradfordCode", "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" - ] - }, -@@ -438,7 +438,7 @@ - "comment": [ - "{Locked='](command:git.openRepositoriesInParentFolders'}", +@@ -440,3 +440,3 @@ "{Locked='](command:workbench.action.openSettings?%5B%22git.openRepositoryInParentFolders%22%5D'}", - "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", -+ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for !!APP_NAME!!", ++ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for BradfordCode", "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" - ] - }, -@@ -447,7 +447,7 @@ - "comment": [ - "{Locked='](command:git.openRepositoriesInParentFolders'}", +@@ -449,3 +449,3 @@ "{Locked='](command:workbench.action.openSettings?%5B%22git.openRepositoryInParentFolders%22%5D'}", - "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", -+ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for !!APP_NAME!!", ++ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for BradfordCode", "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" - ] - }, -@@ -455,7 +455,7 @@ - "message": "The detected Git repository is potentially unsafe as the folder is owned by someone other than the current user.\n[Manage Unsafe Repositories](command:git.manageUnsafeRepositories)\nTo learn more about unsafe repositories [read our docs](https://aka.ms/vscode-git-unsafe-repository).", - "comment": [ +@@ -457,3 +457,3 @@ "{Locked='](command:git.manageUnsafeRepositories'}", - "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", -+ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for !!APP_NAME!!", ++ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for BradfordCode", "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" - ] - }, -@@ -463,23 +463,23 @@ - "message": "The detected Git repositories are potentially unsafe as the folders are owned by someone other than the current user.\n[Manage Unsafe Repositories](command:git.manageUnsafeRepositories)\nTo learn more about unsafe repositories [read our docs](https://aka.ms/vscode-git-unsafe-repository).", - "comment": [ +@@ -465,3 +465,3 @@ "{Locked='](command:git.manageUnsafeRepositories'}", - "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", -+ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for !!APP_NAME!!", ++ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for BradfordCode", "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" - ] - }, +@@ -470,6 +470,6 @@ "view.workbench.scm.closedRepository": { - "message": "A Git repository was found that was previously closed.\n[Reopen Closed Repository](command:git.reopenClosedRepositories)\nTo learn more about how to use Git and source control in VS Code [read our docs](https://aka.ms/vscode-scm).", -+ "message": "A Git repository was found that was previously closed.\n[Reopen Closed Repository](command:git.reopenClosedRepositories)\nTo learn more about how to use Git and source control in !!APP_NAME!! [read our docs](https://aka.ms/vscode-scm).", ++ "message": "A Git repository was found that was previously closed.\n[Reopen Closed Repository](command:git.reopenClosedRepositories)\nTo learn more about how to use Git and source control in BradfordCode [read our docs](https://aka.ms/vscode-scm).", "comment": [ "{Locked='](command:git.reopenClosedRepositories'}", - "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", -+ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for !!APP_NAME!!", ++ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for BradfordCode", "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" - ] - }, +@@ -478,6 +478,6 @@ "view.workbench.scm.closedRepositories": { - "message": "Git repositories were found that were previously closed.\n[Reopen Closed Repositories](command:git.reopenClosedRepositories)\nTo learn more about how to use Git and source control in VS Code [read our docs](https://aka.ms/vscode-scm).", -+ "message": "Git repositories were found that were previously closed.\n[Reopen Closed Repositories](command:git.reopenClosedRepositories)\nTo learn more about how to use Git and source control in !!APP_NAME!! [read our docs](https://aka.ms/vscode-scm).", ++ "message": "Git repositories were found that were previously closed.\n[Reopen Closed Repositories](command:git.reopenClosedRepositories)\nTo learn more about how to use Git and source control in BradfordCode [read our docs](https://aka.ms/vscode-scm).", "comment": [ "{Locked='](command:git.reopenClosedRepositories'}", - "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", -+ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for !!APP_NAME!!", ++ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for BradfordCode", "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" - ] - }, -@@ -487,9 +487,9 @@ - "message": "You can clone a repository locally.\n[Clone Repository](command:git.clone 'Clone a repository once the Git extension has activated')", - "comment": [ +@@ -489,3 +489,3 @@ "{Locked='](command:git.clone'}", - "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", -+ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for !!APP_NAME!!", ++ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for BradfordCode", "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" - ] +@@ -493,3 +493,3 @@ }, - "view.workbench.learnMore": "To learn more about how to use Git and source control in VS Code [read our docs](https://aka.ms/vscode-scm)." -+ "view.workbench.learnMore": "To learn more about how to use Git and source control in !!APP_NAME!! [read our docs](https://aka.ms/vscode-scm)." ++ "view.workbench.learnMore": "To learn more about how to use Git and source control in BradfordCode [read our docs](https://aka.ms/vscode-scm)." } diff --git a/extensions/github/package.nls.json b/extensions/github/package.nls.json index 04497595..aa29d00c 100644 --- a/extensions/github/package.nls.json +++ b/extensions/github/package.nls.json -@@ -1,6 +1,6 @@ - { +@@ -2,3 +2,3 @@ "displayName": "GitHub", - "description": "GitHub features for VS Code", -+ "description": "GitHub features for !!APP_NAME!!", ++ "description": "GitHub features for BradfordCode", "command.copyVscodeDevLink": "Copy vscode.dev Link", - "command.publish": "Publish to GitHub", - "command.openOnGitHub": "Open on GitHub", -@@ -8,7 +8,7 @@ - "command.createPullRequest": "Create PR", - "command.openPullRequest": "Open PR", +@@ -10,3 +10,3 @@ "config.branchProtection": "Controls whether to query repository rules for GitHub repositories", - "config.gitAuthentication": "Controls whether to enable automatic GitHub authentication for git commands within VS Code.", -+ "config.gitAuthentication": "Controls whether to enable automatic GitHub authentication for git commands within !!APP_NAME!!.", ++ "config.gitAuthentication": "Controls whether to enable automatic GitHub authentication for git commands within BradfordCode.", "config.gitProtocol": "Controls which protocol is used to clone a GitHub repository", - "config.showAvatar": "Controls whether to show the GitHub avatar of the commit author in various hovers (ex: Git blame, Timeline, Source Control Graph, etc.)", - "welcome.publishFolder": { -@@ -17,7 +17,7 @@ - "{Locked='$(github)'}", - "Do not translate '$(github)'. It will be rendered as an icon", +@@ -19,3 +19,3 @@ "{Locked='](command:github.publish'}", - "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", -+ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for !!APP_NAME!!", ++ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for BradfordCode", "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" - ] - }, -@@ -27,7 +27,7 @@ - "{Locked='$(github)'}", - "Do not translate '$(github)'. It will be rendered as an icon", +@@ -29,3 +29,3 @@ "{Locked='](command:github.publish'}", - "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", -+ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for !!APP_NAME!!", ++ "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for BradfordCode", "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" - ] - } diff --git a/extensions/grunt/package.nls.json b/extensions/grunt/package.nls.json index 789a579c..7dae48d2 100644 --- a/extensions/grunt/package.nls.json +++ b/extensions/grunt/package.nls.json -@@ -1,6 +1,6 @@ +@@ -1,4 +1,4 @@ { - "description": "Extension to add Grunt capabilities to VS Code.", - "displayName": "Grunt support for VS Code", -+ "description": "Extension to add Grunt capabilities to !!APP_NAME!!.", -+ "displayName": "Grunt support for !!APP_NAME!!", ++ "description": "Extension to add Grunt capabilities to BradfordCode.", ++ "displayName": "Grunt support for BradfordCode", "config.grunt.autoDetect": "Controls enablement of Grunt task detection. Grunt task detection can cause files in any open workspace to be executed.", - "grunt.taskDefinition.type.description": "The Grunt task to customize.", - "grunt.taskDefinition.args.description": "Command line arguments to pass to the grunt task", diff --git a/extensions/html-language-features/client/src/htmlClient.ts b/extensions/html-language-features/client/src/htmlClient.ts index cfb91c1f..ba6e0733 100644 --- a/extensions/html-language-features/client/src/htmlClient.ts +++ b/extensions/html-language-features/client/src/htmlClient.ts -@@ -107,7 +107,7 @@ export async function startClient(context: ExtensionContext, newLanguageClient: - context.globalState.update(promptForLinkedEditingKey, false); - activeEditorListener.dispose(); +@@ -109,3 +109,3 @@ export async function startClient(context: ExtensionContext, newLanguageClient: const configure = l10n.t('Configure'); - const res = await window.showInformationMessage(l10n.t('VS Code now has built-in support for auto-renaming tags. Do you want to enable it?'), configure); -+ const res = await window.showInformationMessage(l10n.t('!!APP_NAME!! now has built-in support for auto-renaming tags. Do you want to enable it?'), configure); ++ const res = await window.showInformationMessage(l10n.t('BradfordCode now has built-in support for auto-renaming tags. Do you want to enable it?'), configure); if (res === configure) { - commands.executeCommand('workbench.action.openSettings', SettingIds.linkedEditing); - } diff --git a/extensions/html-language-features/package.nls.json b/extensions/html-language-features/package.nls.json index d8390703..b4185c22 100644 --- a/extensions/html-language-features/package.nls.json +++ b/extensions/html-language-features/package.nls.json -@@ -1,7 +1,7 @@ - { - "displayName": "HTML Language Features", +@@ -3,3 +3,3 @@ "description": "Provides rich language support for HTML and Handlebar files", - "html.customData.desc": "A list of relative file paths pointing to JSON files following the [custom data format](https://github.com/microsoft/vscode-html-languageservice/blob/master/docs/customData.md).\n\nVS Code loads custom data on startup to enhance its HTML support for the custom HTML tags, attributes and attribute values you specify in the JSON files.\n\nThe file paths are relative to workspace and only workspace folder settings are considered.", -+ "html.customData.desc": "A list of relative file paths pointing to JSON files following the [custom data format](https://github.com/microsoft/vscode-html-languageservice/blob/master/docs/customData.md).\n\n!!APP_NAME!! loads custom data on startup to enhance its HTML support for the custom HTML tags, attributes and attribute values you specify in the JSON files.\n\nThe file paths are relative to workspace and only workspace folder settings are considered.", ++ "html.customData.desc": "A list of relative file paths pointing to JSON files following the [custom data format](https://github.com/microsoft/vscode-html-languageservice/blob/master/docs/customData.md).\n\nBradfordCode loads custom data on startup to enhance its HTML support for the custom HTML tags, attributes and attribute values you specify in the JSON files.\n\nThe file paths are relative to workspace and only workspace folder settings are considered.", "html.format.enable.desc": "Enable/disable default HTML formatter.", - "html.format.wrapLineLength.desc": "Maximum amount of characters per line (0 = disable).", - "html.format.unformatted.desc": "List of tags, comma separated, that shouldn't be reformatted. `null` defaults to all tags listed at https://www.w3.org/TR/html5/dom.html#phrasing-content.", -@@ -24,7 +24,7 @@ - "html.format.wrapAttributesIndentSize.desc": "Indent wrapped attributes to after N characters. Use `null` to use the default indent size. Ignored if `#html.format.wrapAttributes#` is set to `aligned`.", - "html.suggest.html5.desc": "Controls whether the built-in HTML language support suggests HTML5 tags, properties and values.", +@@ -26,3 +26,3 @@ "html.suggest.hideEndTagSuggestions.desc": "Controls whether the built-in HTML language support suggests closing tags. When disabled, end tag completions like `` will not be shown.", - "html.trace.server.desc": "Traces the communication between VS Code and the HTML language server.", -+ "html.trace.server.desc": "Traces the communication between !!APP_NAME!! and the HTML language server.", ++ "html.trace.server.desc": "Traces the communication between BradfordCode and the HTML language server.", "html.validate.scripts": "Controls whether the built-in HTML language support validates embedded scripts.", - "html.validate.styles": "Controls whether the built-in HTML language support validates embedded styles.", - "html.autoCreateQuotes": "Enable/disable auto creation of quotes for HTML attribute assignment. The type of quotes can be configured by `#html.completion.attributeDefaultValue#`.", diff --git a/extensions/html-language-features/schemas/package.schema.json b/extensions/html-language-features/schemas/package.schema.json index 205143c3..cd246290 100644 --- a/extensions/html-language-features/schemas/package.schema.json +++ b/extensions/html-language-features/schemas/package.schema.json -@@ -7,7 +7,7 @@ - "properties": { - "html.customData": { +@@ -9,3 +9,3 @@ "type": "array", - "markdownDescription": "A list of relative file paths pointing to JSON files following the [custom data format](https://github.com/microsoft/vscode-html-languageservice/blob/master/docs/customData.md).\n\nVS Code loads custom data on startup to enhance its HTML support for the custom HTML tags, attributes and attribute values you specify in the JSON files.\n\nThe file paths are relative to workspace and only workspace folder settings are considered.", -+ "markdownDescription": "A list of relative file paths pointing to JSON files following the [custom data format](https://github.com/microsoft/vscode-html-languageservice/blob/master/docs/customData.md).\n\n!!APP_NAME!! loads custom data on startup to enhance its HTML support for the custom HTML tags, attributes and attribute values you specify in the JSON files.\n\nThe file paths are relative to workspace and only workspace folder settings are considered.", ++ "markdownDescription": "A list of relative file paths pointing to JSON files following the [custom data format](https://github.com/microsoft/vscode-html-languageservice/blob/master/docs/customData.md).\n\nBradfordCode loads custom data on startup to enhance its HTML support for the custom HTML tags, attributes and attribute values you specify in the JSON files.\n\nThe file paths are relative to workspace and only workspace folder settings are considered.", "items": { - "type": "string", - "description": "Relative path to a HTML custom data file" diff --git a/extensions/jake/package.nls.json b/extensions/jake/package.nls.json index e82030ef..d9706343 100644 --- a/extensions/jake/package.nls.json +++ b/extensions/jake/package.nls.json -@@ -1,6 +1,6 @@ +@@ -1,4 +1,4 @@ { - "description": "Extension to add Jake capabilities to VS Code.", - "displayName": "Jake support for VS Code", -+ "description": "Extension to add Jake capabilities to !!APP_NAME!!.", -+ "displayName": "Jake support for !!APP_NAME!!", ++ "description": "Extension to add Jake capabilities to BradfordCode.", ++ "displayName": "Jake support for BradfordCode", "jake.taskDefinition.type.description": "The Jake task to customize.", - "jake.taskDefinition.file.description": "The Jake file that provides the task. Can be omitted.", - "config.jake.autoDetect": "Controls enablement of Jake task detection. Jake task detection can cause files in any open workspace to be executed." diff --git a/extensions/json-language-features/package.nls.json b/extensions/json-language-features/package.nls.json index 30199b2b..6e3820d5 100644 --- a/extensions/json-language-features/package.nls.json +++ b/extensions/json-language-features/package.nls.json -@@ -9,7 +9,7 @@ - "json.format.enable.desc": "Enable/disable default JSON formatter", - "json.format.keepLines.desc": "Keep all existing new lines when formatting.", +@@ -11,3 +11,3 @@ "json.validate.enable.desc": "Enable/disable JSON validation.", - "json.tracing.desc": "Traces the communication between VS Code and the JSON language server.", -+ "json.tracing.desc": "Traces the communication between !!APP_NAME!! and the JSON language server.", ++ "json.tracing.desc": "Traces the communication between BradfordCode and the JSON language server.", "json.colorDecorators.enable.desc": "Enables or disables color decorators", - "json.colorDecorators.enable.deprecationMessage": "The setting `json.colorDecorators.enable` has been deprecated in favor of `editor.colorDecorators`.", - "json.schemaResolutionErrorMessage": "Unable to resolve schema.", diff --git a/extensions/markdown-language-features/package.nls.json b/extensions/markdown-language-features/package.nls.json index cb292ba5..0855e6a0 100644 --- a/extensions/markdown-language-features/package.nls.json +++ b/extensions/markdown-language-features/package.nls.json -@@ -28,7 +28,7 @@ - "markdown.togglePreview.title": "Toggle Preview", - "markdown.showPreviewSecuritySelector.title": "Change Preview Security Settings", +@@ -30,3 +30,3 @@ "markdown.trace.extension.desc": "Enable debug logging for the Markdown extension.", - "markdown.trace.server.desc": "Traces the communication between VS Code and the Markdown language server.", -+ "markdown.trace.server.desc": "Traces the communication between !!APP_NAME!! and the Markdown language server.", ++ "markdown.trace.server.desc": "Traces the communication between BradfordCode and the Markdown language server.", "markdown.server.log.desc": "Controls the logging level of the Markdown language server.", - "markdown.preview.refresh.title": "Refresh Preview", - "markdown.preview.toggleLock.title": "Toggle Preview Locking", -@@ -86,7 +86,7 @@ - "configuration.markdown.copyFiles.destination": { - "message": "Configures the path and file name of files created by copy/paste or drag and drop. This is a map of globs that match against a Markdown document path to the destination path where the new file should be created.\n\nThe destination path may use the following variables:\n\n- `${documentDirName}` — Absolute parent directory path of the Markdown document, e.g. `/Users/me/myProject/docs`.\n- `${documentRelativeDirName}` — Relative parent directory path of the Markdown document, e.g. `docs`. This is the same as `${documentDirName}` if the file is not part of a workspace.\n- `${documentFileName}` — The full filename of the Markdown document, e.g. `README.md`.\n- `${documentBaseName}` — The basename of the Markdown document, e.g. `README`.\n- `${documentExtName}` — The extension of the Markdown document, e.g. `md`.\n- `${documentFilePath}` — Absolute path of the Markdown document, e.g. `/Users/me/myProject/docs/README.md`.\n- `${documentRelativeFilePath}` — Relative path of the Markdown document, e.g. `docs/README.md`. This is the same as `${documentFilePath}` if the file is not part of a workspace.\n- `${documentWorkspaceFolder}` — The workspace folder for the Markdown document, e.g. `/Users/me/myProject`. This is the same as `${documentDirName}` if the file is not part of a workspace.\n- `${fileName}` — The file name of the dropped file, e.g. `image.png`.\n- `${fileExtName}` — The extension of the dropped file, e.g. `png`.\n- `${unixTime}` — The current Unix timestamp in milliseconds.\n- `${isoTime}` — The current time in ISO 8601 format, e.g. '2025-06-06T08:40:32.123Z'.", +@@ -88,3 +88,3 @@ "comment": [ - "This setting is use the user drops or pastes image data into the editor. In this case, VS Code automatically creates a new image file in the workspace containing the dropped/pasted image.", -+ "This setting is use the user drops or pastes image data into the editor. In this case, !!APP_NAME!! automatically creates a new image file in the workspace containing the dropped/pasted image.", ++ "This setting is use the user drops or pastes image data into the editor. In this case, BradfordCode automatically creates a new image file in the workspace containing the dropped/pasted image.", "It's easier to explain this setting with an example. For example, let's say the setting value was:", - "", - "{ 'docs/*.md': '${documentDirName}/images/${fileName}' }", diff --git a/extensions/media-preview/package.nls.json b/extensions/media-preview/package.nls.json index 920ced76..f7ca6a48 100644 --- a/extensions/media-preview/package.nls.json +++ b/extensions/media-preview/package.nls.json -@@ -1,6 +1,6 @@ - { +@@ -2,3 +2,3 @@ "displayName": "Media Preview", - "description": "Provides VS Code's built-in previews for images, audio, and video", -+ "description": "Provides !!APP_NAME!!'s built-in previews for images, audio, and video", ++ "description": "Provides BradfordCode's built-in previews for images, audio, and video", "customEditor.audioPreview.displayName": "Audio Preview", - "customEditor.imagePreview.displayName": "Image Preview", - "customEditor.videoPreview.displayName": "Video Preview", diff --git a/extensions/media-preview/src/audioPreview.ts b/extensions/media-preview/src/audioPreview.ts index 01aaf33a..639d9ba9 100644 --- a/extensions/media-preview/src/audioPreview.ts +++ b/extensions/media-preview/src/audioPreview.ts -@@ -83,11 +83,11 @@ class AudioPreview extends MediaPreview { -
-
+@@ -85,3 +85,3 @@ class AudioPreview extends MediaPreview {

${vscode.l10n.t("An error occurred while loading the audio file.")}

- ${vscode.l10n.t("Open file using VS Code's standard text/binary editor?")} -+ ${vscode.l10n.t("Open file using !!APP_NAME!!'s standard text/binary editor?")} ++ ${vscode.l10n.t("Open file using BradfordCode's standard text/binary editor?")}
-
+@@ -89,3 +89,3 @@ class AudioPreview extends MediaPreview {

${vscode.l10n.t("The audio file is stored with Git LFS and is not available for preview.")}

- ${vscode.l10n.t("Open file using VS Code's standard text/binary editor?")} -+ ${vscode.l10n.t("Open file using !!APP_NAME!!'s standard text/binary editor?")} ++ ${vscode.l10n.t("Open file using BradfordCode's standard text/binary editor?")}
- - diff --git a/extensions/media-preview/src/imagePreview/index.ts b/extensions/media-preview/src/imagePreview/index.ts index 79cb70c9..cac55284 100644 --- a/extensions/media-preview/src/imagePreview/index.ts +++ b/extensions/media-preview/src/imagePreview/index.ts -@@ -208,11 +208,11 @@ class ImagePreview extends MediaPreview { -
-
+@@ -210,3 +210,3 @@ class ImagePreview extends MediaPreview {

${vscode.l10n.t("An error occurred while loading the image.")}

- ${vscode.l10n.t("Open file using VS Code's standard text/binary editor?")} -+ ${vscode.l10n.t("Open file using !!APP_NAME!!'s standard text/binary editor?")} ++ ${vscode.l10n.t("Open file using BradfordCode's standard text/binary editor?")}
-
+@@ -214,3 +214,3 @@ class ImagePreview extends MediaPreview {

${vscode.l10n.t("The image is stored with Git LFS and is not available for preview.")}

- ${vscode.l10n.t("Open file using VS Code's standard text/binary editor?")} -+ ${vscode.l10n.t("Open file using !!APP_NAME!!'s standard text/binary editor?")} ++ ${vscode.l10n.t("Open file using BradfordCode's standard text/binary editor?")}
- - diff --git a/extensions/media-preview/src/videoPreview.ts b/extensions/media-preview/src/videoPreview.ts index 2ee1169f..79c54036 100644 --- a/extensions/media-preview/src/videoPreview.ts +++ b/extensions/media-preview/src/videoPreview.ts -@@ -87,11 +87,11 @@ class VideoPreview extends MediaPreview { -
-
+@@ -89,3 +89,3 @@ class VideoPreview extends MediaPreview {

${vscode.l10n.t("An error occurred while loading the video file.")}

- ${vscode.l10n.t("Open file using VS Code's standard text/binary editor?")} -+ ${vscode.l10n.t("Open file using !!APP_NAME!!'s standard text/binary editor?")} ++ ${vscode.l10n.t("Open file using BradfordCode's standard text/binary editor?")}
-
+@@ -93,3 +93,3 @@ class VideoPreview extends MediaPreview {

${vscode.l10n.t("The video file is stored with Git LFS and is not available for preview.")}

- ${vscode.l10n.t("Open file using VS Code's standard text/binary editor?")} -+ ${vscode.l10n.t("Open file using !!APP_NAME!!'s standard text/binary editor?")} ++ ${vscode.l10n.t("Open file using BradfordCode's standard text/binary editor?")}
- - diff --git a/extensions/notebook-renderers/package.json b/extensions/notebook-renderers/package.json index 032dd35d..0b893063 100644 --- a/extensions/notebook-renderers/package.json +++ b/extensions/notebook-renderers/package.json -@@ -20,7 +20,7 @@ - { - "id": "vscode.builtin-renderer", +@@ -22,3 +22,3 @@ "entrypoint": "./renderer-out/index.js", - "displayName": "VS Code Builtin Notebook Output Renderer", -+ "displayName": "!!APP_NAME!! Builtin Notebook Output Renderer", ++ "displayName": "BradfordCode Builtin Notebook Output Renderer", "requiresMessaging": "never", - "mimeTypes": [ - "image/gif", diff --git a/extensions/npm/package.nls.json b/extensions/npm/package.nls.json index 5c77708d..8a988891 100644 --- a/extensions/npm/package.nls.json +++ b/extensions/npm/package.nls.json -@@ -1,6 +1,6 @@ - { +@@ -2,3 +2,3 @@ "description": "Extension to add task support for npm scripts.", - "displayName": "NPM support for VS Code", -+ "displayName": "NPM support for !!APP_NAME!!", ++ "displayName": "NPM support for BradfordCode", "workspaceTrust": "This extension executes tasks, which require trust to run.", - "virtualWorkspaces": "Functionality that requires running the 'npm' command is not available in virtual workspaces.", - "config.npm.autoDetect": "Controls whether npm scripts should be automatically detected.", diff --git a/extensions/swift/syntaxes/swift.tmLanguage.json b/extensions/swift/syntaxes/swift.tmLanguage.json index d52cabb8..f4f26d79 100644 --- a/extensions/swift/syntaxes/swift.tmLanguage.json +++ b/extensions/swift/syntaxes/swift.tmLanguage.json -@@ -258,7 +258,7 @@ - "builtin-properties": { - "patterns": [ +@@ -260,3 +260,3 @@ { - "comment": "The simpler (?<=\\bProcess\\.|\\bCommandLine\\.) breaks VS Code / Atom, see https://github.com/textmate/swift.tmbundle/issues/29", -+ "comment": "The simpler (?<=\\bProcess\\.|\\bCommandLine\\.) breaks !!APP_NAME!! / Atom, see https://github.com/textmate/swift.tmbundle/issues/29", ++ "comment": "The simpler (?<=\\bProcess\\.|\\bCommandLine\\.) breaks BradfordCode / Atom, see https://github.com/textmate/swift.tmbundle/issues/29", "name": "support.variable.swift", - "match": "(?<=^Process\\.|\\WProcess\\.|^CommandLine\\.|\\WCommandLine\\.)(arguments|argc|unsafeArgv)" - }, diff --git a/extensions/typescript-language-features/package.nls.json b/extensions/typescript-language-features/package.nls.json index a697f8c0..f55ecc02 100644 --- a/extensions/typescript-language-features/package.nls.json +++ b/extensions/typescript-language-features/package.nls.json -@@ -134,9 +134,9 @@ - "configuration.tsserver.heapProfile.interval": "Sampling interval in bytes for TypeScript server heap profiling by passing `--heap-prof-interval`.", - "configuration.tsserver.experimental.enableProjectDiagnostics": "Enables project wide error reporting.", +@@ -136,5 +136,5 @@ "configuration.tsserver.experimental.enableProjectDiagnostics.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.tsserver.experimental.enableProjectDiagnostics#` instead.", - "typescript.locale": "Sets the locale used to report JavaScript and TypeScript errors. Defaults to use VS Code's locale.", -+ "typescript.locale": "Sets the locale used to report JavaScript and TypeScript errors. Defaults to use !!APP_NAME!!'s locale.", ++ "typescript.locale": "Sets the locale used to report JavaScript and TypeScript errors. Defaults to use BradfordCode's locale.", "typescript.locale.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.locale#` instead.", - "typescript.locale.auto": "Use VS Code's configured display language.", -+ "typescript.locale.auto": "Use !!APP_NAME!!'s configured display language.", ++ "typescript.locale.auto": "Use BradfordCode's configured display language.", "configuration.implicitProjectConfig.module": "Sets the module system for the program. See more: https://www.typescriptlang.org/tsconfig#module.", - "configuration.implicitProjectConfig.target": "Set target JavaScript language version for emitted JavaScript and include library declarations. See more: https://www.typescriptlang.org/tsconfig#target.", - "configuration.implicitProjectConfig.checkJs": "Enable/disable semantic checking of JavaScript files. Existing `jsconfig.json` or `tsconfig.json` files override this setting.", -@@ -245,7 +245,7 @@ - "typescript.preferences.preferTypeOnlyAutoImports": "Include the `type` keyword in auto-imports whenever possible. Requires using TypeScript 5.3+ in the workspace.", - "typescript.workspaceSymbols.excludeLibrarySymbols": "Exclude symbols that come from library files in `Go to Symbol in Workspace` results. Requires using TypeScript 5.3+ in the workspace.", +@@ -247,3 +247,3 @@ "configuration.workspaceSymbols.excludeLibrarySymbols.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.workspaceSymbols.excludeLibrarySymbols#` instead.", - "typescript.updateImportsOnFileMove.enabled": "Enable/disable automatic updating of import paths when you rename or move a file in VS Code.", -+ "typescript.updateImportsOnFileMove.enabled": "Enable/disable automatic updating of import paths when you rename or move a file in !!APP_NAME!!.", ++ "typescript.updateImportsOnFileMove.enabled": "Enable/disable automatic updating of import paths when you rename or move a file in BradfordCode.", "typescript.updateImportsOnFileMove.enabled.prompt": "Prompt on each rename.", - "typescript.updateImportsOnFileMove.enabled.always": "Always update paths automatically.", - "typescript.updateImportsOnFileMove.enabled.never": "Never rename paths and don't prompt.", -@@ -256,9 +256,9 @@ - "configuration.suggest.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.suggest.enabled#` instead.", - "configuration.suggest.jsdoc.enabled": "Enable/disable suggestion to complete JSDoc comments.", +@@ -258,5 +258,5 @@ "configuration.suggest.completeJSDocs.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.suggest.jsdoc.enabled#` instead.", - "configuration.tsserver.useVsCodeWatcher": "Use VS Code's file watchers instead of TypeScript's. Requires using TypeScript 5.4+ in the workspace.", -+ "configuration.tsserver.useVsCodeWatcher": "Use !!APP_NAME!!'s file watchers instead of TypeScript's. Requires using TypeScript 5.4+ in the workspace.", ++ "configuration.tsserver.useVsCodeWatcher": "Use BradfordCode's file watchers instead of TypeScript's. Requires using TypeScript 5.4+ in the workspace.", "configuration.tsserver.watchOptions": "Configure which watching strategies should be used to keep track of files and directories.", - "configuration.tsserver.watchOptions.vscode": "Use VS Code's file watchers instead of TypeScript's. Requires using TypeScript 5.4+ in the workspace.", -+ "configuration.tsserver.watchOptions.vscode": "Use !!APP_NAME!!'s file watchers instead of TypeScript's. Requires using TypeScript 5.4+ in the workspace.", ++ "configuration.tsserver.watchOptions.vscode": "Use BradfordCode's file watchers instead of TypeScript's. Requires using TypeScript 5.4+ in the workspace.", "configuration.tsserver.watchOptions.watchFile": "Strategy for how individual files are watched.", - "configuration.tsserver.watchOptions.watchFile.fixedChunkSizePolling": "Polls files in chunks at regular interval.", - "configuration.tsserver.watchOptions.watchFile.fixedPollingInterval": "Check every file for changes several times a second at a fixed interval.", -@@ -311,13 +311,13 @@ - "configuration.suggest.classMemberSnippets.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.suggest.classMemberSnippets.enabled#` instead.", - "configuration.suggest.objectLiteralMethodSnippets.enabled": "Enable/disable snippet completions for methods in object literals.", +@@ -313,3 +313,3 @@ "configuration.suggest.objectLiteralMethodSnippets.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.suggest.objectLiteralMethodSnippets.enabled#` instead.", - "configuration.tsserver.web.projectWideIntellisense.enabled": "Enable/disable project-wide IntelliSense on web. Requires that VS Code is running in a trusted context.", -+ "configuration.tsserver.web.projectWideIntellisense.enabled": "Enable/disable project-wide IntelliSense on web. Requires that !!APP_NAME!! is running in a trusted context.", ++ "configuration.tsserver.web.projectWideIntellisense.enabled": "Enable/disable project-wide IntelliSense on web. Requires that BradfordCode is running in a trusted context.", "configuration.tsserver.web.projectWideIntellisense.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.tsserver.web.projectWideIntellisense.enabled#` instead.", - "configuration.tsserver.web.projectWideIntellisense.suppressSemanticErrors": "Suppresses semantic errors on web even when project wide IntelliSense is enabled. This is always on when project wide IntelliSense is not enabled or available. See `#js/ts.tsserver.web.projectWideIntellisense.enabled#`", - "configuration.tsserver.web.projectWideIntellisense.suppressSemanticErrors.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.tsserver.web.projectWideIntellisense.suppressSemanticErrors#` instead.", - "configuration.tsserver.web.typeAcquisition.enabled": "Enable/disable package acquisition on the web. This enables IntelliSense for imported packages. Requires `#js/ts.tsserver.web.projectWideIntellisense.enabled#`. Currently not supported for Safari.", +@@ -319,3 +319,3 @@ "configuration.tsserver.web.typeAcquisition.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.tsserver.web.typeAcquisition.enabled#` instead.", - "configuration.tsserver.nodePath": "Run TS Server on a custom Node installation. This can be a path to a Node executable, or `node` if you want VS Code to detect a Node installation.", -+ "configuration.tsserver.nodePath": "Run TS Server on a custom Node installation. This can be a path to a Node executable, or `node` if you want !!APP_NAME!! to detect a Node installation.", ++ "configuration.tsserver.nodePath": "Run TS Server on a custom Node installation. This can be a path to a Node executable, or `node` if you want BradfordCode to detect a Node installation.", "configuration.tsserver.nodePath.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.tsserver.node.path#` instead.", - "configuration.tsserver.watchOptions.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.tsserver.watchOptions#` instead.", - "configuration.updateImportsOnPaste": "Automatically update imports when pasting code. Requires TypeScript 5.6+.", -@@ -332,9 +332,9 @@ - "walkthroughs.nodejsWelcome.makeJsFile.title": "Create a JavaScript File", - "walkthroughs.nodejsWelcome.makeJsFile.description": "Let's write our first JavaScript file. We'll have to create a new file and save it with the ``.js`` extension at the end of the file name.\n[Create a JavaScript File](command:javascript-walkthrough.commands.createJsFile)", +@@ -334,7 +334,7 @@ "walkthroughs.nodejsWelcome.debugJsFile.title": "Run and Debug your JavaScript", - "walkthroughs.nodejsWelcome.debugJsFile.description": "Once you've installed Node.js, you can run JavaScript programs at a terminal by entering ``node your-file-name.js``\nAnother easy way to run Node.js programs is by using VS Code's debugger which lets you run your code, pause at different points, and help you understand what's going on step-by-step.\n[Start Debugging](command:javascript-walkthrough.commands.debugJsFile)", - "walkthroughs.nodejsWelcome.debugJsFile.altText": "Debug and run your JavaScript code in Node.js with Visual Studio Code.", -+ "walkthroughs.nodejsWelcome.debugJsFile.description": "Once you've installed Node.js, you can run JavaScript programs at a terminal by entering ``node your-file-name.js``\nAnother easy way to run Node.js programs is by using !!APP_NAME!!'s debugger which lets you run your code, pause at different points, and help you understand what's going on step-by-step.\n[Start Debugging](command:javascript-walkthrough.commands.debugJsFile)", -+ "walkthroughs.nodejsWelcome.debugJsFile.altText": "Debug and run your JavaScript code in Node.js with !!APP_NAME!!.", ++ "walkthroughs.nodejsWelcome.debugJsFile.description": "Once you've installed Node.js, you can run JavaScript programs at a terminal by entering ``node your-file-name.js``\nAnother easy way to run Node.js programs is by using BradfordCode's debugger which lets you run your code, pause at different points, and help you understand what's going on step-by-step.\n[Start Debugging](command:javascript-walkthrough.commands.debugJsFile)", ++ "walkthroughs.nodejsWelcome.debugJsFile.altText": "Debug and run your JavaScript code in Node.js with BradfordCode.", "walkthroughs.nodejsWelcome.learnMoreAboutJs.title": "Explore More", - "walkthroughs.nodejsWelcome.learnMoreAboutJs.description": "Want to get more comfortable with JavaScript, Node.js, and VS Code? Be sure to check out our docs!\nWe've got lots of resources for learning [JavaScript](https://code.visualstudio.com/docs/nodejs/working-with-javascript) and [Node.js](https://code.visualstudio.com/docs/nodejs/nodejs-tutorial).\n\n[Learn More](https://code.visualstudio.com/docs/nodejs/nodejs-tutorial)", - "walkthroughs.nodejsWelcome.learnMoreAboutJs.altText": "Learn more about JavaScript and Node.js in Visual Studio Code." -+ "walkthroughs.nodejsWelcome.learnMoreAboutJs.description": "Want to get more comfortable with JavaScript, Node.js, and !!APP_NAME!!? Be sure to check out our docs!\nWe've got lots of resources for learning [JavaScript](https://code.visualstudio.com/docs/nodejs/working-with-javascript) and [Node.js](https://code.visualstudio.com/docs/nodejs/nodejs-tutorial).\n\n[Learn More](https://code.visualstudio.com/docs/nodejs/nodejs-tutorial)", -+ "walkthroughs.nodejsWelcome.learnMoreAboutJs.altText": "Learn more about JavaScript and Node.js in !!APP_NAME!!." ++ "walkthroughs.nodejsWelcome.learnMoreAboutJs.description": "Want to get more comfortable with JavaScript, Node.js, and BradfordCode? Be sure to check out our docs!\nWe've got lots of resources for learning [JavaScript](https://code.visualstudio.com/docs/nodejs/working-with-javascript) and [Node.js](https://code.visualstudio.com/docs/nodejs/nodejs-tutorial).\n\n[Learn More](https://code.visualstudio.com/docs/nodejs/nodejs-tutorial)", ++ "walkthroughs.nodejsWelcome.learnMoreAboutJs.altText": "Learn more about JavaScript and Node.js in BradfordCode." } diff --git a/extensions/typescript-language-features/src/tsServer/versionManager.ts b/extensions/typescript-language-features/src/tsServer/versionManager.ts index 1154a29c..a42a1d25 100644 --- a/extensions/typescript-language-features/src/tsServer/versionManager.ts +++ b/extensions/typescript-language-features/src/tsServer/versionManager.ts -@@ -110,7 +110,7 @@ export class TypeScriptVersionManager extends Disposable { - return { - label: (!this.useWorkspaceTsdkSetting || !vscode.workspace.isTrusted +@@ -112,3 +112,3 @@ export class TypeScriptVersionManager extends Disposable { ? '• ' - : '') + vscode.l10n.t("Use VS Code's Version"), -+ : '') + vscode.l10n.t("Use !!APP_NAME!!'s Version"), ++ : '') + vscode.l10n.t("Use BradfordCode's Version"), description: bundledVersion.displayName, - detail: bundledVersion.pathLabel, - run: async () => { diff --git a/extensions/typescript-language-features/src/tsServer/versionProvider.electron.ts b/extensions/typescript-language-features/src/tsServer/versionProvider.electron.ts index 12cb1cca..6b681ae1 100644 --- a/extensions/typescript-language-features/src/tsServer/versionProvider.electron.ts +++ b/extensions/typescript-language-features/src/tsServer/versionProvider.electron.ts -@@ -68,7 +68,7 @@ export class DiskTypeScriptVersionProvider implements ITypeScriptVersionProvider - return version; - } +@@ -70,3 +70,3 @@ export class DiskTypeScriptVersionProvider implements ITypeScriptVersionProvider - vscode.window.showErrorMessage(vscode.l10n.t("VS Code\'s tsserver was deleted by another application such as a misbehaving virus detection tool. Please reinstall VS Code.")); -+ vscode.window.showErrorMessage(vscode.l10n.t("!!APP_NAME!!\'s tsserver was deleted by another application such as a misbehaving virus detection tool. Please reinstall !!APP_NAME!!.")); ++ vscode.window.showErrorMessage(vscode.l10n.t("BradfordCode\'s tsserver was deleted by another application such as a misbehaving virus detection tool. Please reinstall BradfordCode.")); throw new Error('Could not find bundled tsserver.js'); - } - diff --git a/extensions/typescript-language-features/src/tsconfig.ts b/extensions/typescript-language-features/src/tsconfig.ts index 823985e9..a4a18aa8 100644 --- a/extensions/typescript-language-features/src/tsconfig.ts +++ b/extensions/typescript-language-features/src/tsconfig.ts -@@ -144,7 +144,7 @@ export async function openProjectConfigForFile( - const rootPath = client.getWorkspaceRootForResource(resource); - if (!rootPath) { +@@ -146,3 +146,3 @@ export async function openProjectConfigForFile( vscode.window.showInformationMessage( - vscode.l10n.t("Please open a folder in VS Code to use a TypeScript or JavaScript project")); -+ vscode.l10n.t("Please open a folder in !!APP_NAME!! to use a TypeScript or JavaScript project")); ++ vscode.l10n.t("Please open a folder in BradfordCode to use a TypeScript or JavaScript project")); return; - } - diff --git a/extensions/typescript-language-features/src/typescriptServiceClient.ts b/extensions/typescript-language-features/src/typescriptServiceClient.ts -index ae18dff7..fb9a8791 100644 +index d6873bc0..fd0dc267 100644 --- a/extensions/typescript-language-features/src/typescriptServiceClient.ts +++ b/extensions/typescript-language-features/src/typescriptServiceClient.ts -@@ -658,7 +658,7 @@ export default class TypeScriptServiceClient extends Disposable implements IType - this.hasServerFatallyCrashedTooManyTimes = true; - if (this.pluginManager.plugins.length) { +@@ -664,3 +664,3 @@ export default class TypeScriptServiceClient extends Disposable implements IType prompt = vscode.window.showErrorMessage( - vscode.l10n.t("The JS/TS language service immediately crashed 5 times. The service will not be restarted.\nThis may be caused by a plugin contributed by one of these extensions: {0}.\nPlease try disabling these extensions before filing an issue against VS Code.", pluginExtensionList)); -+ vscode.l10n.t("The JS/TS language service immediately crashed 5 times. The service will not be restarted.\nThis may be caused by a plugin contributed by one of these extensions: {0}.\nPlease try disabling these extensions before filing an issue against !!APP_NAME!!.", pluginExtensionList)); - } else { - prompt = vscode.window.showErrorMessage( - vscode.l10n.t("The JS/TS language service immediately crashed 5 times. The service will not be restarted."), -@@ -679,7 +679,7 @@ export default class TypeScriptServiceClient extends Disposable implements IType - if (!this._isPromptingAfterCrash) { - if (this.pluginManager.plugins.length) { ++ vscode.l10n.t("The JS/TS language service immediately crashed 5 times. The service will not be restarted.\nThis may be caused by a plugin contributed by one of these extensions: {0}.\nPlease try disabling these extensions before filing an issue against BradfordCode.", pluginExtensionList)); + } else if (!plugins.length) { +@@ -685,3 +685,3 @@ export default class TypeScriptServiceClient extends Disposable implements IType prompt = vscode.window.showWarningMessage( - vscode.l10n.t("The JS/TS language service crashed 5 times in the last 5 Minutes.\nThis may be caused by a plugin contributed by one of these extensions: {0}\nPlease try disabling these extensions before filing an issue against VS Code.", pluginExtensionList)); -+ vscode.l10n.t("The JS/TS language service crashed 5 times in the last 5 Minutes.\nThis may be caused by a plugin contributed by one of these extensions: {0}\nPlease try disabling these extensions before filing an issue against !!APP_NAME!!.", pluginExtensionList)); - } else { - prompt = vscode.window.showWarningMessage( - vscode.l10n.t("The JS/TS language service crashed 5 times in the last 5 Minutes."), -@@ -693,7 +693,7 @@ export default class TypeScriptServiceClient extends Disposable implements IType - if (!this._isPromptingAfterCrash) { - if (this.pluginManager.plugins.length) { ++ vscode.l10n.t("The JS/TS language service crashed 5 times in the last 5 Minutes.\nThis may be caused by a plugin contributed by one of these extensions: {0}\nPlease try disabling these extensions before filing an issue against BradfordCode.", pluginExtensionList)); + } else if (!plugins.length) { +@@ -699,3 +699,3 @@ export default class TypeScriptServiceClient extends Disposable implements IType prompt = vscode.window.showWarningMessage( - vscode.l10n.t("The JS/TS language service crashed.\nThis may be caused by a plugin contributed by one of these extensions: {0}.\nPlease try disabling these extensions before filing an issue against VS Code.", pluginExtensionList)); -+ vscode.l10n.t("The JS/TS language service crashed.\nThis may be caused by a plugin contributed by one of these extensions: {0}.\nPlease try disabling these extensions before filing an issue against !!APP_NAME!!.", pluginExtensionList)); - } else { - prompt = vscode.window.showWarningMessage( - vscode.l10n.t("The JS/TS language service crashed."), ++ vscode.l10n.t("The JS/TS language service crashed.\nThis may be caused by a plugin contributed by one of these extensions: {0}.\nPlease try disabling these extensions before filing an issue against BradfordCode.", pluginExtensionList)); + } else if (!plugins.length) { diff --git a/extensions/vscode-api-tests/package.json b/extensions/vscode-api-tests/package.json -index d56cbf59..705fca8b 100644 +index bcc31d36..5774db13 100644 --- a/extensions/vscode-api-tests/package.json +++ b/extensions/vscode-api-tests/package.json -@@ -1,6 +1,6 @@ - { +@@ -2,3 +2,3 @@ "name": "vscode-api-tests", - "description": "API tests for VS Code", -+ "description": "API tests for !!APP_NAME!!", ++ "description": "API tests for BradfordCode", "version": "0.0.1", - "publisher": "vscode", - "license": "MIT", diff --git a/extensions/vscode-colorize-tests/package.json b/extensions/vscode-colorize-tests/package.json index f3253b70..af413e79 100644 --- a/extensions/vscode-colorize-tests/package.json +++ b/extensions/vscode-colorize-tests/package.json -@@ -1,6 +1,6 @@ - { +@@ -2,3 +2,3 @@ "name": "vscode-colorize-tests", - "description": "Colorize tests for VS Code", -+ "description": "Colorize tests for !!APP_NAME!!", ++ "description": "Colorize tests for BradfordCode", "version": "0.0.1", - "publisher": "vscode", - "license": "MIT", diff --git a/extensions/vscode-colorize-tests/test/colorize-fixtures/14119.less b/extensions/vscode-colorize-tests/test/colorize-fixtures/14119.less index a0006d85..3947d588 100644 --- a/extensions/vscode-colorize-tests/test/colorize-fixtures/14119.less +++ b/extensions/vscode-colorize-tests/test/colorize-fixtures/14119.less -@@ -1,3 +1,3 @@ +@@ -1,2 +1,2 @@ -#f(@hm: "broken highlighting in VS Code") { -+#f(@hm: "broken highlighting in !!APP_NAME!!") { ++#f(@hm: "broken highlighting in BradfordCode") { content: ""; - } -\ No newline at end of file diff --git a/extensions/vscode-colorize-tests/test/colorize-results/14119_less.json b/extensions/vscode-colorize-tests/test/colorize-results/14119_less.json index 313fe87c..a9608805 100644 --- a/extensions/vscode-colorize-tests/test/colorize-results/14119_less.json +++ b/extensions/vscode-colorize-tests/test/colorize-results/14119_less.json -@@ -128,7 +128,7 @@ - } - }, +@@ -130,3 +130,3 @@ { - "c": "broken highlighting in VS Code", -+ "c": "broken highlighting in !!APP_NAME!!", ++ "c": "broken highlighting in BradfordCode", "t": "source.css.less meta.selector.less meta.group.less meta.property-value.less string.quoted.double.less", - "r": { - "dark_plus": "string: #CE9178", diff --git a/extensions/vscode-test-resolver/package.json b/extensions/vscode-test-resolver/package.json index 9b0de50d..27b109e8 100644 --- a/extensions/vscode-test-resolver/package.json +++ b/extensions/vscode-test-resolver/package.json -@@ -1,6 +1,6 @@ - { +@@ -2,3 +2,3 @@ "name": "vscode-test-resolver", - "description": "Test resolver for VS Code", -+ "description": "Test resolver for !!APP_NAME!!", ++ "description": "Test resolver for BradfordCode", "version": "0.0.1", - "publisher": "vscode", - "license": "MIT", diff --git a/extensions/vscode-test-resolver/src/download.ts b/extensions/vscode-test-resolver/src/download.ts index a351aa77..8d4e933d 100644 --- a/extensions/vscode-test-resolver/src/download.ts +++ b/extensions/vscode-test-resolver/src/download.ts -@@ -30,18 +30,18 @@ async function downloadVSCodeServerArchive(updateUrl: string, commit: string, qu - const downloadUrl = getDownloadUrl(updateUrl, commit, platform, quality); - +@@ -32,3 +32,3 @@ async function downloadVSCodeServerArchive(updateUrl: string, commit: string, qu return new Promise((resolve, reject) => { - log(`Downloading VS Code Server from: ${downloadUrl}`); -+ log(`Downloading !!APP_NAME!! Server from: ${downloadUrl}`); ++ log(`Downloading BradfordCode Server from: ${downloadUrl}`); const requestOptions: https.RequestOptions = parseUrl(downloadUrl); - - https.get(requestOptions, res => { +@@ -37,3 +37,3 @@ async function downloadVSCodeServerArchive(updateUrl: string, commit: string, qu if (res.statusCode !== 302) { - reject('Failed to get VS Code server archive location'); -+ reject('Failed to get !!APP_NAME!! server archive location'); ++ reject('Failed to get BradfordCode server archive location'); res.resume(); // read the rest of the response data and discard it - return; - } - const archiveUrl = res.headers.location; +@@ -43,3 +43,3 @@ async function downloadVSCodeServerArchive(updateUrl: string, commit: string, qu if (!archiveUrl) { - reject('Failed to get VS Code server archive location'); -+ reject('Failed to get !!APP_NAME!! server archive location'); ++ reject('Failed to get BradfordCode server archive location'); res.resume(); // read the rest of the response data and discard it - return; - } -@@ -66,7 +66,7 @@ async function downloadVSCodeServerArchive(updateUrl: string, commit: string, qu - } - +@@ -68,3 +68,3 @@ async function downloadVSCodeServerArchive(updateUrl: string, commit: string, qu /** - * Unzip a .zip or .tar.gz VS Code archive -+ * Unzip a .zip or .tar.gz !!APP_NAME!! archive ++ * Unzip a .zip or .tar.gz BradfordCode archive */ - function unzipVSCodeServer(vscodeArchivePath: string, extractDir: string, destDir: string, log: (messsage: string) => void) { - log(`Extracting ${vscodeArchivePath}`); -@@ -100,7 +100,7 @@ export async function downloadAndUnzipVSCodeServer(updateUrl: string, commit: st - if (fs.existsSync(extractDir)) { - log(`Found ${extractDir}. Skipping download.`); +@@ -102,3 +102,3 @@ export async function downloadAndUnzipVSCodeServer(updateUrl: string, commit: st } else { - log(`Downloading VS Code Server ${quality} - ${commit} into ${extractDir}.`); -+ log(`Downloading !!APP_NAME!! Server ${quality} - ${commit} into ${extractDir}.`); ++ log(`Downloading BradfordCode Server ${quality} - ${commit} into ${extractDir}.`); try { - const vscodeArchivePath = await downloadVSCodeServerArchive(updateUrl, commit, quality, destDir, log); - if (fs.existsSync(vscodeArchivePath)) { -@@ -109,7 +109,7 @@ export async function downloadAndUnzipVSCodeServer(updateUrl: string, commit: st - fs.unlinkSync(vscodeArchivePath); - } +@@ -111,3 +111,3 @@ export async function downloadAndUnzipVSCodeServer(updateUrl: string, commit: st } catch (err) { - throw Error(`Failed to download and unzip VS Code ${quality} - ${commit}`); -+ throw Error(`Failed to download and unzip !!APP_NAME!! ${quality} - ${commit}`); ++ throw Error(`Failed to download and unzip BradfordCode ${quality} - ${commit}`); } - } - return Promise.resolve(extractDir); diff --git a/extensions/vscode-test-resolver/src/extension.ts b/extensions/vscode-test-resolver/src/extension.ts -index 5c9b4785..dfe21380 100644 +index 32a93fe3..aab9f635 100644 --- a/extensions/vscode-test-resolver/src/extension.ts +++ b/extensions/vscode-test-resolver/src/extension.ts -@@ -213,7 +213,7 @@ export function activate(context: vscode.ExtensionContext) { - let serverLocation = env['VSCODE_REMOTE_SERVER_PATH']; // support environment variable to specify location of server on disk - if (!serverLocation) { +@@ -215,3 +215,3 @@ export function activate(context: vscode.ExtensionContext) { const serverBin = path.join(remoteDataDir, 'bin'); - progress.report({ message: 'Installing VSCode Server' }); -+ progress.report({ message: 'Installing !!APP_NAME!! Server' }); ++ progress.report({ message: 'Installing BradfordCode Server' }); serverLocation = await downloadAndUnzipVSCodeServer(updateUrl, commit, quality, serverBin, m => outputChannel.appendLine(m)); - } - diff --git a/src/main.ts b/src/main.ts -index f70cf87e..a3aa84bf 100644 +index 085290ba..6164e371 100644 --- a/src/main.ts +++ b/src/main.ts -@@ -419,16 +419,16 @@ function createDefaultArgvConfigSync(argvConfigPath: string): void { - - // Default argv content +@@ -422,3 +422,3 @@ function createDefaultArgvConfigSync(argvConfigPath: string): void { const defaultArgvConfigContent = [ - '// This configuration file allows you to pass permanent command line arguments to VS Code.', -+ '// This configuration file allows you to pass permanent command line arguments to !!APP_NAME!!.', ++ '// This configuration file allows you to pass permanent command line arguments to BradfordCode.', '// Only a subset of arguments is currently supported to reduce the likelihood of breaking', - '// the installation.', - '//', - '// PLEASE DO NOT CHANGE WITHOUT UNDERSTANDING THE IMPACT', +@@ -428,6 +428,6 @@ function createDefaultArgvConfigSync(argvConfigPath: string): void { '//', - '// NOTE: Changing this file requires a restart of VS Code.', -+ '// NOTE: Changing this file requires a restart of !!APP_NAME!!.', ++ '// NOTE: Changing this file requires a restart of BradfordCode.', '{', ' // Use software rendering instead of hardware accelerated rendering.', - ' // This can help in cases where you see rendering issues in VS Code.', -+ ' // This can help in cases where you see rendering issues in !!APP_NAME!!.', ++ ' // This can help in cases where you see rendering issues in BradfordCode.', ' // "disable-hardware-acceleration": true', - '}' - ]; diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts -index 811673d0..6860fa57 100644 +index 60aec35c..f09c8556 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts -@@ -608,7 +608,7 @@ export class CodeApplication extends Disposable { - } - +@@ -671,3 +671,3 @@ export class CodeApplication extends Disposable { async startup(): Promise { - this.logService.debug('Starting VS Code'); -+ this.logService.debug('Starting !!APP_NAME!!'); ++ this.logService.debug('Starting BradfordCode'); this.logService.debug(`from: ${this.environmentMainService.appRoot}`); - this.logService.debug('args:', this.environmentMainService.args); - diff --git a/src/vs/editor/contrib/toggleTabFocusMode/browser/toggleTabFocusMode.ts b/src/vs/editor/contrib/toggleTabFocusMode/browser/toggleTabFocusMode.ts index a200bf81..7572f34b 100644 --- a/src/vs/editor/contrib/toggleTabFocusMode/browser/toggleTabFocusMode.ts +++ b/src/vs/editor/contrib/toggleTabFocusMode/browser/toggleTabFocusMode.ts -@@ -17,7 +17,7 @@ export class ToggleTabFocusModeAction extends Action2 { - constructor() { - super({ +@@ -19,3 +19,3 @@ export class ToggleTabFocusModeAction extends Action2 { id: ToggleTabFocusModeAction.ID, - title: nls.localize2({ key: 'toggle.tabMovesFocus', comment: ['Turn on/off use of tab key for moving focus around VS Code'] }, 'Toggle Tab Key Moves Focus'), -+ title: nls.localize2({ key: 'toggle.tabMovesFocus', comment: ['Turn on/off use of tab key for moving focus around !!APP_NAME!!'] }, 'Toggle Tab Key Moves Focus'), ++ title: nls.localize2({ key: 'toggle.tabMovesFocus', comment: ['Turn on/off use of tab key for moving focus around BradfordCode'] }, 'Toggle Tab Key Moves Focus'), precondition: undefined, - keybinding: { - primary: KeyMod.CtrlCmd | KeyCode.KeyM, diff --git a/src/vs/platform/contextkey/common/contextkeys.ts b/src/vs/platform/contextkey/common/contextkeys.ts index c256dba0..9ca1305c 100644 --- a/src/vs/platform/contextkey/common/contextkeys.ts +++ b/src/vs/platform/contextkey/common/contextkeys.ts -@@ -17,7 +17,7 @@ export const IsIOSContext = new RawContextKey('isIOS', isIOS, localize( - export const IsMobileContext = new RawContextKey('isMobile', isMobile, localize('isMobile', "Whether the platform is a mobile web browser")); - +@@ -19,3 +19,3 @@ export const IsMobileContext = new RawContextKey('isMobile', isMobile, export const IsDevelopmentContext = new RawContextKey('isDevelopment', false, true); -export const ProductQualityContext = new RawContextKey('productQualityType', '', localize('productQualityType', "Quality type of VS Code")); -+export const ProductQualityContext = new RawContextKey('productQualityType', '', localize('productQualityType', "Quality type of !!APP_NAME!!")); ++export const ProductQualityContext = new RawContextKey('productQualityType', '', localize('productQualityType', "Quality type of BradfordCode")); - export const InputFocusedContextKey = 'inputFocus'; - export const InputFocusedContext = new RawContextKey(InputFocusedContextKey, false, localize('inputFocus', "Whether keyboard focus is inside an input box")); diff --git a/src/vs/platform/extensionManagement/node/extensionManagementService.ts b/src/vs/platform/extensionManagement/node/extensionManagementService.ts index 6eefd716..4e8cb6af 100644 --- a/src/vs/platform/extensionManagement/node/extensionManagementService.ts +++ b/src/vs/platform/extensionManagement/node/extensionManagementService.ts -@@ -151,7 +151,7 @@ export class ExtensionManagementService extends AbstractExtensionManagementServi - const manifest = await getManifest(path.resolve(location.fsPath)); - const extensionId = getGalleryExtensionId(manifest.publisher, manifest.name); +@@ -153,3 +153,3 @@ export class ExtensionManagementService extends AbstractExtensionManagementServi if (manifest.engines && manifest.engines.vscode && !isEngineValid(manifest.engines.vscode, this.productService.version, this.productService.date)) { - throw new Error(nls.localize('incompatible', "Unable to install extension '{0}' as it is not compatible with VS Code '{1}'.", extensionId, this.productService.version)); -+ throw new Error(nls.localize('incompatible', "Unable to install extension '{0}' as it is not compatible with !!APP_NAME!! '{1}'.", extensionId, this.productService.version)); ++ throw new Error(nls.localize('incompatible', "Unable to install extension '{0}' as it is not compatible with BradfordCode '{1}'.", extensionId, this.productService.version)); } - - const allowedToInstall = this.allowedExtensionsService.isAllowed({ id: extensionId, version: manifest.version, publisherDisplayName: undefined }); -@@ -1100,7 +1100,7 @@ class InstallExtensionInProfileTask extends AbstractExtensionTask { +@@ -93,3 +93,3 @@ export async function main(desc: ProductDescription, args: string[]): Promise('authentication.clientIdUsage', { extensionId }); diff --git a/src/vs/workbench/api/browser/viewsExtensionPoint.ts b/src/vs/workbench/api/browser/viewsExtensionPoint.ts index 83378707..67f454a2 100644 --- a/src/vs/workbench/api/browser/viewsExtensionPoint.ts +++ b/src/vs/workbench/api/browser/viewsExtensionPoint.ts -@@ -41,7 +41,7 @@ const viewsContainerSchema: IJSONSchema = { - type: 'object', - properties: { +@@ -43,3 +43,3 @@ const viewsContainerSchema: IJSONSchema = { id: { - description: localize({ key: 'vscode.extension.contributes.views.containers.id', comment: ['Contribution refers to those that an extension contributes to VS Code through an extension/contribution point. '] }, "Unique id used to identify the container in which views can be contributed using 'views' contribution point"), -+ description: localize({ key: 'vscode.extension.contributes.views.containers.id', comment: ['Contribution refers to those that an extension contributes to !!APP_NAME!! through an extension/contribution point. '] }, "Unique id used to identify the container in which views can be contributed using 'views' contribution point"), ++ description: localize({ key: 'vscode.extension.contributes.views.containers.id', comment: ['Contribution refers to those that an extension contributes to BradfordCode through an extension/contribution point. '] }, "Unique id used to identify the container in which views can be contributed using 'views' contribution point"), type: 'string', - pattern: '^[a-zA-Z0-9_-]+$' - }, diff --git a/src/vs/workbench/api/common/extHostApiCommands.ts b/src/vs/workbench/api/common/extHostApiCommands.ts index 2785bbd7..c6d79962 100644 --- a/src/vs/workbench/api/common/extHostApiCommands.ts +++ b/src/vs/workbench/api/common/extHostApiCommands.ts -@@ -443,7 +443,7 @@ const newCommands: ApiCommand[] = [ - 'vscode.openWith', '_workbench.openWith', 'Opens the provided resource with a specific editor.', - [ +@@ -445,3 +445,3 @@ const newCommands: ApiCommand[] = [ ApiCommandArgument.Uri.with('resource', 'Resource to open'), - ApiCommandArgument.String.with('viewId', 'Custom editor view id. This should be the viewType string for custom editors or the notebookType string for notebooks. Use \'default\' to use VS Code\'s default text editor'), -+ ApiCommandArgument.String.with('viewId', 'Custom editor view id. This should be the viewType string for custom editors or the notebookType string for notebooks. Use \'default\' to use !!APP_NAME!!\'s default text editor'), ++ ApiCommandArgument.String.with('viewId', 'Custom editor view id. This should be the viewType string for custom editors or the notebookType string for notebooks. Use \'default\' to use BradfordCode\'s default text editor'), new ApiCommandArgument('columnOrOptions', 'Either the column in which to open or editor options, see vscode.TextDocumentShowOptions', - v => v === undefined || typeof v === 'number' || typeof v === 'object', - v => !v ? v : typeof v === 'number' ? [typeConverters.ViewColumn.from(v), undefined] : [typeConverters.ViewColumn.from(v.viewColumn), typeConverters.TextEditorOpenOptions.from(v)], diff --git a/src/vs/workbench/api/common/extHostCommands.ts b/src/vs/workbench/api/common/extHostCommands.ts index 92e874dc..b34e04b5 100644 --- a/src/vs/workbench/api/common/extHostCommands.ts +++ b/src/vs/workbench/api/common/extHostCommands.ts -@@ -462,8 +462,8 @@ export class ApiCommandArgument { - - static readonly CallHierarchyItem = new ApiCommandArgument('item', 'A call hierarchy item', v => v instanceof extHostTypes.CallHierarchyItem, extHostTypeConverter.CallHierarchyItem.from); +@@ -464,4 +464,4 @@ export class ApiCommandArgument { static readonly TypeHierarchyItem = new ApiCommandArgument('item', 'A type hierarchy item', v => v instanceof extHostTypes.TypeHierarchyItem, extHostTypeConverter.TypeHierarchyItem.from); - static readonly TestItem = new ApiCommandArgument('testItem', 'A VS Code TestItem', v => v instanceof TestItemImpl, extHostTypeConverter.TestItem.from); - static readonly TestProfile = new ApiCommandArgument('testProfile', 'A VS Code test profile', v => v instanceof extHostTypes.TestRunProfileBase, extHostTypeConverter.TestRunProfile.from); -+ static readonly TestItem = new ApiCommandArgument('testItem', 'A !!APP_NAME!! TestItem', v => v instanceof TestItemImpl, extHostTypeConverter.TestItem.from); -+ static readonly TestProfile = new ApiCommandArgument('testProfile', 'A !!APP_NAME!! test profile', v => v instanceof extHostTypes.TestRunProfileBase, extHostTypeConverter.TestRunProfile.from); ++ static readonly TestItem = new ApiCommandArgument('testItem', 'A BradfordCode TestItem', v => v instanceof TestItemImpl, extHostTypeConverter.TestItem.from); ++ static readonly TestProfile = new ApiCommandArgument('testProfile', 'A BradfordCode test profile', v => v instanceof extHostTypes.TestRunProfileBase, extHostTypeConverter.TestRunProfile.from); - constructor( - readonly name: string, diff --git a/src/vs/workbench/api/node/loopbackServer.ts b/src/vs/workbench/api/node/loopbackServer.ts index 1d32a486..ae5c4c7e 100644 --- a/src/vs/workbench/api/node/loopbackServer.ts +++ b/src/vs/workbench/api/node/loopbackServer.ts -@@ -188,11 +188,6 @@ export class LoopbackAuthServer implements ILoopbackServer { - getHtml(): string { - // TODO: Bring this in via mixin. Skipping exploration for now. +@@ -190,7 +190,2 @@ export class LoopbackAuthServer implements ILoopbackServer { let backgroundImage = 'url(\'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABAAAAAQABAMAAACNMzawAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAJ1BMVEUAAAD///9Qm8+ozed8tNsWer+Lvd9trNfF3u9Ck8slgsMzi8eZxeM/Qa6mAAAAAXRSTlMAQObYZgAAAAFiS0dEAf8CLd4AAAAHdElNRQfiCwYULRt0g+ZLAAAJRUlEQVR42u3SUY0CQRREUSy0hSZtBA+wfOwv4wAPYwAJSMAfAthkB6YD79HnKqikzmYjSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIk6cmKhg4AAASAABAAAkAACAABIAAEgAAQAAJAAAgAAaBvBVAjtV2wfFe1ogcA+0gdFgBoe60IAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAnAjAP/1MkWoAvBvAsUTqBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJAAgAASAABIAAEAACQAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKA7gDlbFwC6AijZagAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQDM2boA0BXAqAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIATARAAAkAAvF7N1hWArgBKthoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfBrAlK0bAF0BjBoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4EQABIAAEwOtN2boB0BVAyVYDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgE8DqNm6AtAVwKgBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAEwEQAAJAAPzd7xypMwDvBnAskToBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAIAAkAACAABIAAEgAAQAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAkAACAABIAAEgAAQAAJAAAgAASAABIAAEAACQAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIBKBGarsAwK5qRQ8ANGYAACAABIAAEAACQAAIAAEgAASAABAAAkDfCUCSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJC3uDtO80OSql+i8AAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDE4LTExLTA2VDIwOjQ1OjI3KzAwOjAwEjLurQAAACV0RVh0ZGF0ZTptb2RpZnkAMjAxOC0xMS0wNlQyMDo0NToyNyswMDowMGNvVhEAAAAZdEVYdFNvZnR3YXJlAEFkb2JlIEltYWdlUmVhZHlxyWU8AAAAAElFTkSuQmCC\')'; - if (this._appName === 'Visual Studio Code') { - backgroundImage = 'url(\'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjU2IiBoZWlnaHQ9IjI1NiIgdmlld0JveD0iMCAwIDI1NiAyNTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxtYXNrIGlkPSJtYXNrMCIgbWFzay10eXBlPSJhbHBoYSIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeD0iMCIgeT0iMCIgd2lkdGg9IjI1NiIgaGVpZ2h0PSIyNTYiPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE4MS41MzQgMjU0LjI1MkMxODUuNTY2IDI1NS44MjMgMTkwLjE2NCAyNTUuNzIyIDE5NC4yMzQgMjUzLjc2NEwyNDYuOTQgMjI4LjQwM0MyNTIuNDc4IDIyNS43MzggMjU2IDIyMC4xMzIgMjU2IDIxMy45ODNWNDIuMDE4MUMyNTYgMzUuODY4OSAyNTIuNDc4IDMwLjI2MzggMjQ2Ljk0IDI3LjU5ODhMMTk0LjIzNCAyLjIzNjgxQzE4OC44OTMgLTAuMzMzMTMyIDE4Mi42NDIgMC4yOTYzNDQgMTc3Ljk1NSAzLjcwNDE4QzE3Ny4yODUgNC4xOTEgMTc2LjY0NyA0LjczNDU0IDE3Ni4wNDkgNS4zMzM1NEw3NS4xNDkgOTcuMzg2MkwzMS4xOTkyIDY0LjAyNDdDMjcuMTA3OSA2MC45MTkxIDIxLjM4NTMgNjEuMTczNSAxNy41ODU1IDY0LjYzTDMuNDg5MzYgNzcuNDUyNUMtMS4xNTg1MyA4MS42ODA1IC0xLjE2Mzg2IDg4Ljk5MjYgMy40Nzc4NSA5My4yMjc0TDQxLjU5MjYgMTI4TDMuNDc3ODUgMTYyLjc3M0MtMS4xNjM4NiAxNjcuMDA4IC0xLjE1ODUzIDE3NC4zMiAzLjQ4OTM2IDE3OC41NDhMMTcuNTg1NSAxOTEuMzdDMjEuMzg1MyAxOTQuODI3IDI3LjEwNzkgMTk1LjA4MSAzMS4xOTkyIDE5MS45NzZMNzUuMTQ5IDE1OC42MTRMMTc2LjA0OSAyNTAuNjY3QzE3Ny42NDUgMjUyLjI2NCAxNzkuNTE5IDI1My40NjcgMTgxLjUzNCAyNTQuMjUyWk0xOTIuMDM5IDY5Ljg4NTNMMTE1LjQ3OSAxMjhMMTkyLjAzOSAxODYuMTE1VjY5Ljg4NTNaIiBmaWxsPSJ3aGl0ZSIvPgo8L21hc2s+CjxnIG1hc2s9InVybCgjbWFzazApIj4KPHBhdGggZD0iTTI0Ni45NCAyNy42MzgzTDE5NC4xOTMgMi4yNDEzOEMxODguMDg4IC0wLjY5ODMwMiAxODAuNzkxIDAuNTQxNzIxIDE3NS45OTkgNS4zMzMzMkwzLjMyMzcxIDE2Mi43NzNDLTEuMzIwODIgMTY3LjAwOCAtMS4zMTU0OCAxNzQuMzIgMy4zMzUyMyAxNzguNTQ4TDE3LjQzOTkgMTkxLjM3QzIxLjI0MjEgMTk0LjgyNyAyNi45NjgyIDE5NS4wODEgMzEuMDYxOSAxOTEuOTc2TDIzOS4wMDMgMzQuMjI2OUMyNDUuOTc5IDI4LjkzNDcgMjU1Ljk5OSAzMy45MTAzIDI1NS45OTkgNDIuNjY2N1Y0Mi4wNTQzQzI1NS45OTkgMzUuOTA3OCAyNTIuNDc4IDMwLjMwNDcgMjQ2Ljk0IDI3LjYzODNaIiBmaWxsPSIjMDA2NUE5Ii8+CjxnIGZpbHRlcj0idXJsKCNmaWx0ZXIwX2QpIj4KPHBhdGggZD0iTTI0Ni45NCAyMjguMzYyTDE5NC4xOTMgMjUzLjc1OUMxODguMDg4IDI1Ni42OTggMTgwLjc5MSAyNTUuNDU4IDE3NS45OTkgMjUwLjY2N0wzLjMyMzcxIDkzLjIyNzJDLTEuMzIwODIgODguOTkyNSAtMS4zMTU0OCA4MS42ODAyIDMuMzM1MjMgNzcuNDUyM0wxNy40Mzk5IDY0LjYyOThDMjEuMjQyMSA2MS4xNzMzIDI2Ljk2ODIgNjAuOTE4OCAzMS4wNjE5IDY0LjAyNDVMMjM5LjAwMyAyMjEuNzczQzI0NS45NzkgMjI3LjA2NSAyNTUuOTk5IDIyMi4wOSAyNTUuOTk5IDIxMy4zMzNWMjEzLjk0NkMyNTUuOTk5IDIyMC4wOTIgMjUyLjQ3OCAyMjUuNjk1IDI0Ni45NCAyMjguMzYyWiIgZmlsbD0iIzAwN0FDQyIvPgo8L2c+CjxnIGZpbHRlcj0idXJsKCNmaWx0ZXIxX2QpIj4KPHBhdGggZD0iTTE5NC4xOTYgMjUzLjc2M0MxODguMDg5IDI1Ni43IDE4MC43OTIgMjU1LjQ1OSAxNzYgMjUwLjY2N0MxODEuOTA0IDI1Ni41NzEgMTkyIDI1Mi4zODkgMTkyIDI0NC4wMzlWMTEuOTYwNkMxOTIgMy42MTA1NyAxODEuOTA0IC0wLjU3MTE3NSAxNzYgNS4zMzMyMUMxODAuNzkyIDAuNTQxMTY2IDE4OC4wODkgLTAuNzAwNjA3IDE5NC4xOTYgMi4yMzY0OEwyNDYuOTM0IDI3LjU5ODVDMjUyLjQ3NiAzMC4yNjM1IDI1NiAzNS44Njg2IDI1NiA0Mi4wMTc4VjIxMy45ODNDMjU2IDIyMC4xMzIgMjUyLjQ3NiAyMjUuNzM3IDI0Ni45MzQgMjI4LjQwMkwxOTQuMTk2IDI1My43NjNaIiBmaWxsPSIjMUY5Q0YwIi8+CjwvZz4KPGcgc3R5bGU9Im1peC1ibGVuZC1tb2RlOm92ZXJsYXkiIG9wYWNpdHk9IjAuMjUiPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE4MS4zNzggMjU0LjI1MkMxODUuNDEgMjU1LjgyMiAxOTAuMDA4IDI1NS43MjIgMTk0LjA3NyAyNTMuNzY0TDI0Ni43ODMgMjI4LjQwMkMyNTIuMzIyIDIyNS43MzcgMjU1Ljg0NCAyMjAuMTMyIDI1NS44NDQgMjEzLjk4M1Y0Mi4wMTc5QzI1NS44NDQgMzUuODY4NyAyNTIuMzIyIDMwLjI2MzYgMjQ2Ljc4NCAyNy41OTg2TDE5NC4wNzcgMi4yMzY2NUMxODguNzM3IC0wLjMzMzI5OSAxODIuNDg2IDAuMjk2MTc3IDE3Ny43OTggMy43MDQwMUMxNzcuMTI5IDQuMTkwODMgMTc2LjQ5MSA0LjczNDM3IDE3NS44OTIgNS4zMzMzN0w3NC45OTI3IDk3LjM4NkwzMS4wNDI5IDY0LjAyNDVDMjYuOTUxNyA2MC45MTg5IDIxLjIyOSA2MS4xNzM0IDE3LjQyOTIgNjQuNjI5OEwzLjMzMzExIDc3LjQ1MjNDLTEuMzE0NzggODEuNjgwMyAtMS4zMjAxMSA4OC45OTI1IDMuMzIxNiA5My4yMjczTDQxLjQzNjQgMTI4TDMuMzIxNiAxNjIuNzczQy0xLjMyMDExIDE2Ny4wMDggLTEuMzE0NzggMTc0LjMyIDMuMzMzMTEgMTc4LjU0OEwxNy40MjkyIDE5MS4zN0MyMS4yMjkgMTk0LjgyNyAyNi45NTE3IDE5NS4wODEgMzEuMDQyOSAxOTEuOTc2TDc0Ljk5MjcgMTU4LjYxNEwxNzUuODkyIDI1MC42NjdDMTc3LjQ4OCAyNTIuMjY0IDE3OS4zNjMgMjUzLjQ2NyAxODEuMzc4IDI1NC4yNTJaTTE5MS44ODMgNjkuODg1MUwxMTUuMzIzIDEyOEwxOTEuODgzIDE4Ni4xMTVWNjkuODg1MVoiIGZpbGw9InVybCgjcGFpbnQwX2xpbmVhcikiLz4KPC9nPgo8L2c+CjxkZWZzPgo8ZmlsdGVyIGlkPSJmaWx0ZXIwX2QiIHg9Ii0yMS40ODk2IiB5PSI0MC41MjI1IiB3aWR0aD0iMjk4LjgyMiIgaGVpZ2h0PSIyMzYuMTQ5IiBmaWx0ZXJVbml0cz0idXNlclNwYWNlT25Vc2UiIGNvbG9yLWludGVycG9sYXRpb24tZmlsdGVycz0ic1JHQiI+CjxmZUZsb29kIGZsb29kLW9wYWNpdHk9IjAiIHJlc3VsdD0iQmFja2dyb3VuZEltYWdlRml4Ii8+CjxmZUNvbG9yTWF0cml4IGluPSJTb3VyY2VBbHBoYSIgdHlwZT0ibWF0cml4IiB2YWx1ZXM9IjAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDEyNyAwIi8+CjxmZU9mZnNldC8+CjxmZUdhdXNzaWFuQmx1ciBzdGREZXZpYXRpb249IjEwLjY2NjciLz4KPGZlQ29sb3JNYXRyaXggdHlwZT0ibWF0cml4IiB2YWx1ZXM9IjAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAuMjUgMCIvPgo8ZmVCbGVuZCBtb2RlPSJvdmVybGF5IiBpbjI9IkJhY2tncm91bmRJbWFnZUZpeCIgcmVzdWx0PSJlZmZlY3QxX2Ryb3BTaGFkb3ciLz4KPGZlQmxlbmQgbW9kZT0ibm9ybWFsIiBpbj0iU291cmNlR3JhcGhpYyIgaW4yPSJlZmZlY3QxX2Ryb3BTaGFkb3ciIHJlc3VsdD0ic2hhcGUiLz4KPC9maWx0ZXI+CjxmaWx0ZXIgaWQ9ImZpbHRlcjFfZCIgeD0iMTU0LjY2NyIgeT0iLTIwLjY3MzUiIHdpZHRoPSIxMjIuNjY3IiBoZWlnaHQ9IjI5Ny4zNDciIGZpbHRlclVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgY29sb3ItaW50ZXJwb2xhdGlvbi1maWx0ZXJzPSJzUkdCIj4KPGZlRmxvb2QgZmxvb2Qtb3BhY2l0eT0iMCIgcmVzdWx0PSJCYWNrZ3JvdW5kSW1hZ2VGaXgiLz4KPGZlQ29sb3JNYXRyaXggaW49IlNvdXJjZUFscGhhIiB0eXBlPSJtYXRyaXgiIHZhbHVlcz0iMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMTI3IDAiLz4KPGZlT2Zmc2V0Lz4KPGZlR2F1c3NpYW5CbHVyIHN0ZERldmlhdGlvbj0iMTAuNjY2NyIvPgo8ZmVDb2xvck1hdHJpeCB0eXBlPSJtYXRyaXgiIHZhbHVlcz0iMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMC4yNSAwIi8+CjxmZUJsZW5kIG1vZGU9Im92ZXJsYXkiIGluMj0iQmFja2dyb3VuZEltYWdlRml4IiByZXN1bHQ9ImVmZmVjdDFfZHJvcFNoYWRvdyIvPgo8ZmVCbGVuZCBtb2RlPSJub3JtYWwiIGluPSJTb3VyY2VHcmFwaGljIiBpbjI9ImVmZmVjdDFfZHJvcFNoYWRvdyIgcmVzdWx0PSJzaGFwZSIvPgo8L2ZpbHRlcj4KPGxpbmVhckdyYWRpZW50IGlkPSJwYWludDBfbGluZWFyIiB4MT0iMTI3Ljg0NCIgeTE9IjAuNjU5OTg4IiB4Mj0iMTI3Ljg0NCIgeTI9IjI1NS4zNCIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPgo8c3RvcCBzdG9wLWNvbG9yPSJ3aGl0ZSIvPgo8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IndoaXRlIiBzdG9wLW9wYWNpdHk9IjAiLz4KPC9saW5lYXJHcmFkaWVudD4KPC9kZWZzPgo8L3N2Zz4K\')'; @@ -1070,754 +755,504 @@ index 1d32a486..ae5c4c7e 100644 - backgroundImage = 'url(\'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjU2IiBoZWlnaHQ9IjI1NiIgdmlld0JveD0iMCAwIDI1NiAyNTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxtYXNrIGlkPSJtYXNrMCIgbWFzay10eXBlPSJhbHBoYSIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeD0iMCIgeT0iMCIgd2lkdGg9IjI1NiIgaGVpZ2h0PSIyNTYiPgo8cGF0aCBkPSJNMTc2LjA0OSAyNTAuNjY5QzE4MC44MzggMjU1LjQ1OSAxODguMTMgMjU2LjcgMTk0LjIzNCAyNTMuNzY0TDI0Ni45NCAyMjguNDE5QzI1Mi40NzggMjI1Ljc1NSAyNTYgMjIwLjE1NCAyNTYgMjE0LjAwOFY0Mi4xNDc5QzI1NiAzNi4wMDI1IDI1Mi40NzggMzAuNDAwOCAyNDYuOTQgMjcuNzM3NEwxOTQuMjM0IDIuMzkwODlDMTg4LjEzIC0wLjU0NDQxNiAxODAuODM4IDAuNjk2NjA3IDE3Ni4wNDkgNS40ODU3MkMxODEuOTUgLTAuNDE1MDYgMTkyLjAzOSAzLjc2NDEzIDE5Mi4wMzkgMTIuMTA5MVYyNDQuMDQ2QzE5Mi4wMzkgMjUyLjM5MSAxODEuOTUgMjU2LjU3IDE3Ni4wNDkgMjUwLjY2OVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0xODEuMzc5IDE4MC42NDZMMTE0LjMzIDEyOC42MzNMMTgxLjM3OSA3NS41MTE0VjE3Ljc5NEMxODEuMzc5IDEwLjg0NzcgMTczLjEyOCA3LjIwNjczIDE2Ny45OTYgMTEuODg2Mkw3NC42NTE0IDk3Ljg1MThMMzEuMTk5NCA2NC4xNDM4QzI3LjEwODEgNjEuMDM5IDIxLjM4NTEgNjEuMjk0IDE3LjU4NTMgNjQuNzQ3NkwzLjQ4OTc0IDc3LjU2MjdDLTEuMTU4NDcgODEuNzg5MyAtMS4xNjM2NyA4OS4wOTQ4IDMuNDc2NzIgOTMuMzI5MkwxNjcuOTggMjQ0LjE4NUMxNzMuMTA3IDI0OC44ODcgMTgxLjM3OSAyNDUuMjQ5IDE4MS4zNzkgMjM4LjI5MlYxODAuNjQ2WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTM2LjY5MzcgMTM0LjE5NUwzLjQ3NjcyIDE2Mi44MjhDLTEuMTYzNjcgMTY3LjA2MiAtMS4xNTg0NyAxNzQuMzcgMy40ODk3NCAxNzguNTk0TDE3LjU4NTMgMTkxLjQwOUMyMS4zODUxIDE5NC44NjMgMjcuMTA4MSAxOTUuMTE4IDMxLjE5OTQgMTkyLjAxM0w2OS40NDcyIDE2NC4wNTdMMzYuNjkzNyAxMzQuMTk1WiIgZmlsbD0id2hpdGUiLz4KPC9tYXNrPgo8ZyBtYXNrPSJ1cmwoI21hc2swKSI+CjxwYXRoIGQ9Ik0xNjcuOTk2IDExLjg4NTdDMTczLjEyOCA3LjIwNjI3IDE4MS4zNzkgMTAuODQ3MyAxODEuMzc5IDE3Ljc5MzZWNzUuNTEwOUwxMDQuOTM4IDEzNi4wNzNMNjUuNTc0MiAxMDYuMjExTDE2Ny45OTYgMTEuODg1N1oiIGZpbGw9IiMwMDlBN0MiLz4KPHBhdGggZD0iTTM2LjY5MzcgMTM0LjE5NEwzLjQ3NjcyIDE2Mi44MjdDLTEuMTYzNjcgMTY3LjA2MiAtMS4xNTg0NyAxNzQuMzcgMy40ODk3NCAxNzguNTk0TDE3LjU4NTMgMTkxLjQwOUMyMS4zODUxIDE5NC44NjMgMjcuMTA4MSAxOTUuMTE4IDMxLjE5OTQgMTkyLjAxM0w2OS40NDcyIDE2NC4wNTZMMzYuNjkzNyAxMzQuMTk0WiIgZmlsbD0iIzAwOUE3QyIvPgo8ZyBmaWx0ZXI9InVybCgjZmlsdGVyMF9kKSI+CjxwYXRoIGQ9Ik0xODEuMzc5IDE4MC42NDVMMzEuMTk5NCA2NC4xNDI3QzI3LjEwODEgNjEuMDM3OSAyMS4zODUxIDYxLjI5MjkgMTcuNTg1MyA2NC43NDY1TDMuNDg5NzQgNzcuNTYxNkMtMS4xNTg0NyA4MS43ODgyIC0xLjE2MzY3IDg5LjA5MzcgMy40NzY3MiA5My4zMjgxTDE2Ny45NzIgMjQ0LjE3NkMxNzMuMTAyIDI0OC44ODEgMTgxLjM3OSAyNDUuMjQxIDE4MS4zNzkgMjM4LjI4VjE4MC42NDVaIiBmaWxsPSIjMDBCMjk0Ii8+CjwvZz4KPGcgZmlsdGVyPSJ1cmwoI2ZpbHRlcjFfZCkiPgo8cGF0aCBkPSJNMTk0LjIzMyAyNTMuNzY2QzE4OC4xMyAyNTYuNzAxIDE4MC44MzcgMjU1LjQ2IDE3Ni4wNDggMjUwLjY3MUMxODEuOTQ5IDI1Ni41NzEgMTkyLjAzOSAyNTIuMzkyIDE5Mi4wMzkgMjQ0LjA0N1YxMi4xMTAzQzE5Mi4wMzkgMy43NjUzNSAxODEuOTQ5IC0wLjQxMzgzOSAxNzYuMDQ4IDUuNDg2OTRDMTgwLjgzNyAwLjY5NzgyNCAxODguMTI5IC0wLjU0MzE5MSAxOTQuMjMzIDIuMzkyMUwyNDYuOTM5IDI3LjczODZDMjUyLjQ3OCAzMC40MDIgMjU2IDM2LjAwMzcgMjU2IDQyLjE0OTFWMjE0LjAwOUMyNTYgMjIwLjE1NSAyNTIuNDc4IDIyNS43NTcgMjQ2LjkzOSAyMjguNDJMMTk0LjIzMyAyNTMuNzY2WiIgZmlsbD0iIzI0QkZBNSIvPgo8L2c+CjwvZz4KPGRlZnM+CjxmaWx0ZXIgaWQ9ImZpbHRlcjBfZCIgeD0iLTIxLjMzMzMiIHk9IjQwLjY0MTMiIHdpZHRoPSIyMjQuMDQ1IiBoZWlnaHQ9IjIyNi45ODgiIGZpbHRlclVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgY29sb3ItaW50ZXJwb2xhdGlvbi1maWx0ZXJzPSJzUkdCIj4KPGZlRmxvb2QgZmxvb2Qtb3BhY2l0eT0iMCIgcmVzdWx0PSJCYWNrZ3JvdW5kSW1hZ2VGaXgiLz4KPGZlQ29sb3JNYXRyaXggaW49IlNvdXJjZUFscGhhIiB0eXBlPSJtYXRyaXgiIHZhbHVlcz0iMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMTI3IDAiLz4KPGZlT2Zmc2V0Lz4KPGZlR2F1c3NpYW5CbHVyIHN0ZERldmlhdGlvbj0iMTAuNjY2NyIvPgo8ZmVDb2xvck1hdHJpeCB0eXBlPSJtYXRyaXgiIHZhbHVlcz0iMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMC4xNSAwIi8+CjxmZUJsZW5kIG1vZGU9Im5vcm1hbCIgaW4yPSJCYWNrZ3JvdW5kSW1hZ2VGaXgiIHJlc3VsdD0iZWZmZWN0MV9kcm9wU2hhZG93Ii8+CjxmZUJsZW5kIG1vZGU9Im5vcm1hbCIgaW49IlNvdXJjZUdyYXBoaWMiIGluMj0iZWZmZWN0MV9kcm9wU2hhZG93IiByZXN1bHQ9InNoYXBlIi8+CjwvZmlsdGVyPgo8ZmlsdGVyIGlkPSJmaWx0ZXIxX2QiIHg9IjE1NC43MTUiIHk9Ii0yMC41MTY5IiB3aWR0aD0iMTIyLjYxOCIgaGVpZ2h0PSIyOTcuMTkxIiBmaWx0ZXJVbml0cz0idXNlclNwYWNlT25Vc2UiIGNvbG9yLWludGVycG9sYXRpb24tZmlsdGVycz0ic1JHQiI+CjxmZUZsb29kIGZsb29kLW9wYWNpdHk9IjAiIHJlc3VsdD0iQmFja2dyb3VuZEltYWdlRml4Ii8+CjxmZUNvbG9yTWF0cml4IGluPSJTb3VyY2VBbHBoYSIgdHlwZT0ibWF0cml4IiB2YWx1ZXM9IjAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDEyNyAwIi8+CjxmZU9mZnNldC8+CjxmZUdhdXNzaWFuQmx1ciBzdGREZXZpYXRpb249IjEwLjY2NjciLz4KPGZlQ29sb3JNYXRyaXggdHlwZT0ibWF0cml4IiB2YWx1ZXM9IjAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAuMjUgMCIvPgo8ZmVCbGVuZCBtb2RlPSJvdmVybGF5IiBpbjI9IkJhY2tncm91bmRJbWFnZUZpeCIgcmVzdWx0PSJlZmZlY3QxX2Ryb3BTaGFkb3ciLz4KPGZlQmxlbmQgbW9kZT0ibm9ybWFsIiBpbj0iU291cmNlR3JhcGhpYyIgaW4yPSJlZmZlY3QxX2Ryb3BTaGFkb3ciIHJlc3VsdD0ic2hhcGUiLz4KPC9maWx0ZXI+CjwvZGVmcz4KPC9zdmc+Cg==\')'; - } return ` - - diff --git a/src/vs/workbench/api/test/browser/extHostNotebook.test.ts b/src/vs/workbench/api/test/browser/extHostNotebook.test.ts index 0d713847..33749645 100644 --- a/src/vs/workbench/api/test/browser/extHostNotebook.test.ts +++ b/src/vs/workbench/api/test/browser/extHostNotebook.test.ts -@@ -362,7 +362,7 @@ suite('NotebookCell#Document', function () { - }); - +@@ -364,3 +364,3 @@ suite('NotebookCell#Document', function () { - test('Opening a notebook results in VS Code firing the event onDidChangeActiveNotebookEditor twice #118470', function () { -+ test('Opening a notebook results in !!APP_NAME!! firing the event onDidChangeActiveNotebookEditor twice #118470', function () { ++ test('Opening a notebook results in BradfordCode firing the event onDidChangeActiveNotebookEditor twice #118470', function () { let count = 0; - disposables.add(extHostNotebooks.onDidChangeActiveNotebookEditor(() => count += 1)); - diff --git a/src/vs/workbench/browser/actions/developerActions.ts b/src/vs/workbench/browser/actions/developerActions.ts -index ee85640c..bddb01c5 100644 +index bceb9ef9..12dcb083 100644 --- a/src/vs/workbench/browser/actions/developerActions.ts +++ b/src/vs/workbench/browser/actions/developerActions.ts -@@ -688,7 +688,7 @@ class PolicyDiagnosticsAction extends Action2 { - - const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); +@@ -763,3 +763,3 @@ class PolicyDiagnosticsAction extends Action2 { - let content = '# VS Code Policy Diagnostics\n\n'; -+ let content = '# !!APP_NAME!! Policy Diagnostics\n\n'; ++ let content = '# BradfordCode Policy Diagnostics\n\n'; content += '*WARNING: This file may contain sensitive information.*\n\n'; - content += '## System Information\n\n'; - content += '| Property | Value |\n'; diff --git a/src/vs/workbench/browser/actions/helpActions.ts b/src/vs/workbench/browser/actions/helpActions.ts index 8251c4a4..19bee6b0 100644 --- a/src/vs/workbench/browser/actions/helpActions.ts +++ b/src/vs/workbench/browser/actions/helpActions.ts -@@ -162,7 +162,7 @@ class OpenNewsletterSignupUrlAction extends Action2 { - constructor() { - super({ +@@ -164,3 +164,3 @@ class OpenNewsletterSignupUrlAction extends Action2 { id: OpenNewsletterSignupUrlAction.ID, - title: localize2('newsletterSignup', 'Signup for the VS Code Newsletter'), -+ title: localize2('newsletterSignup', 'Signup for the !!APP_NAME!! Newsletter'), ++ title: localize2('newsletterSignup', 'Signup for the BradfordCode Newsletter'), category: Categories.Help, - f1: true - }); diff --git a/src/vs/workbench/browser/web.factory.ts b/src/vs/workbench/browser/web.factory.ts index e342f838..98e500bc 100644 --- a/src/vs/workbench/browser/web.factory.ts +++ b/src/vs/workbench/browser/web.factory.ts -@@ -33,7 +33,7 @@ export function create(domElement: HTMLElement, options: IWorkbenchConstructionO - // Assert that the workbench is not created more than once. We currently - // do not support this and require a full context switch to clean-up. +@@ -35,3 +35,3 @@ export function create(domElement: HTMLElement, options: IWorkbenchConstructionO if (created) { - throw new Error('Unable to create the VSCode workbench more than once.'); -+ throw new Error('Unable to create the !!APP_NAME!! workbench more than once.'); ++ throw new Error('Unable to create the BradfordCode workbench more than once.'); } else { - created = true; - } diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts -index f2a3b35e..1fecb536 100644 +index 5024adde..78e6d589 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts -@@ -833,7 +833,7 @@ const registry = Registry.as(ConfigurationExtensions.Con - localize('rootNameShort', "`${rootNameShort}`: shortened name of the workspace without suffixes (e.g. myFolder, myRemoteFolder or myWorkspace)."), - localize('rootPath', "`${rootPath}`: file path of the opened workspace or folder (e.g. /Users/Development/myWorkspace)."), +@@ -840,3 +840,3 @@ const registry = Registry.as(ConfigurationExtensions.Con localize('profileName', "`${profileName}`: name of the profile in which the workspace is opened (e.g. Data Science (Profile)). Ignored if default profile is used."), - localize('appName', "`${appName}`: e.g. VS Code."), -+ localize('appName', "`${appName}`: e.g. !!APP_NAME!!."), ++ localize('appName', "`${appName}`: e.g. BradfordCode."), localize('remoteName', "`${remoteName}`: e.g. SSH"), - localize('dirty', "`${dirty}`: an indicator for when the active editor has unsaved changes."), - localize('focusedView', "`${focusedView}`: the name of the view that is currently focused."), diff --git a/src/vs/workbench/common/contextkeys.ts b/src/vs/workbench/common/contextkeys.ts -index 51a4a3f2..0e3b27dc 100644 +index ae371f31..3c03de2b 100644 --- a/src/vs/workbench/common/contextkeys.ts +++ b/src/vs/workbench/common/contextkeys.ts -@@ -38,7 +38,7 @@ export const HasWebFileSystemAccess = new RawContextKey('hasWebFileSyst - - export const EmbedderIdentifierContext = new RawContextKey('embedderIdentifier', undefined, localize('embedderIdentifier', 'The identifier of the embedder according to the product service, if one is defined')); +@@ -40,3 +40,3 @@ export const EmbedderIdentifierContext = new RawContextKey(' -export const InAutomationContext = new RawContextKey('inAutomation', false, localize('inAutomation', "Whether VS Code is running under automation/smoke test")); -+export const InAutomationContext = new RawContextKey('inAutomation', false, localize('inAutomation', "Whether !!APP_NAME!! is running under automation/smoke test")); - - //#endregion ++export const InAutomationContext = new RawContextKey('inAutomation', false, localize('inAutomation', "Whether BradfordCode is running under automation/smoke test")); diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts -index 883dab9e..3fcf5140 100644 +index b0348dbb..2a5ae09b 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts -@@ -802,7 +802,7 @@ configurationRegistry.registerConfiguration({ - ], - enumDescriptions: [ +@@ -961,3 +961,3 @@ configurationRegistry.registerConfiguration({ nls.localize('chat.mcp.access.none', "No access to MCP servers."), -- nls.localize('chat.mcp.access.registry', "Allows access to MCP servers installed from the registry that VS Code is connected to."), -+ nls.localize('chat.mcp.access.registry', "Allows access to MCP servers installed from the registry that !!APP_NAME!! is connected to."), +- nls.localize('chat.mcp.access.registry', "Allows access to MCP servers listed in the registry that VS Code is connected to."), ++ nls.localize('chat.mcp.access.registry', "Allows access to MCP servers listed in the registry that BradfordCode is connected to."), nls.localize('chat.mcp.access.any', "Allow access to any installed MCP server.") - ], - default: McpAccessValue.All, -@@ -829,7 +829,7 @@ configurationRegistry.registerConfiguration({ - key: 'chat.mcp.access.none', value: nls.localize('chat.mcp.access.none', "No access to MCP servers."), - }, +@@ -988,3 +988,3 @@ configurationRegistry.registerConfiguration({ { -- key: 'chat.mcp.access.registry', value: nls.localize('chat.mcp.access.registry', "Allows access to MCP servers installed from the registry that VS Code is connected to."), -+ key: 'chat.mcp.access.registry', value: nls.localize('chat.mcp.access.registry', "Allows access to MCP servers installed from the registry that !!APP_NAME!! is connected to."), +- key: 'chat.mcp.access.registry', value: nls.localize('chat.mcp.access.registry', "Allows access to MCP servers listed in the registry that VS Code is connected to."), ++ 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."), }, - { - key: 'chat.mcp.access.any', value: nls.localize('chat.mcp.access.any', "Allow access to any installed MCP server.") diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts -index 1417e0dc..cc064c82 100644 +index c4c6fe75..1edcc06f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts -@@ -118,7 +118,7 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { - const disposables = new DisposableStore(); - +@@ -120,3 +120,3 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { // Register VSCode agent - 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 !!APP_NAME!!").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); - - // Register workspace agent -@@ -135,8 +135,8 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { - source: ToolDataSource.Internal, - icon: Codicon.newFolder, +@@ -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 !!APP_NAME!!', -+ userDescription: localize('setupToolsDescription', "Scaffold a new workspace in !!APP_NAME!!"), ++ modelDescription: 'Scaffold a new workspace in BradfordCode', ++ userDescription: localize('setupToolsDescription', "Scaffold a new workspace in BradfordCode"), canBeReferencedInPrompt: true, - toolReferenceName: 'new', - when: ContextKeyExpr.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 d3be4e1e..60e06e85 100644 +index 06a36292..eb54b3b5 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts -@@ -288,21 +288,21 @@ export class PromptValidator { - // ignore for now as we don't have a full list of supported attributes for claude target - } else { +@@ -308,5 +308,5 @@ export class PromptValidator { if (validGithubCopilotAttributeNames.value.has(attribute.key)) { - report(toMarker(localize('promptValidator.ignoredAttribute.vscode-agent', "Attribute '{0}' is ignored when running locally in VS Code.", attribute.key), attribute.range, MarkerSeverity.Hint, [MarkerTag.Unnecessary])); -+ report(toMarker(localize('promptValidator.ignoredAttribute.vscode-agent', "Attribute '{0}' is ignored when running locally in !!APP_NAME!!.", attribute.key), attribute.range, MarkerSeverity.Hint, [MarkerTag.Unnecessary])); ++ report(toMarker(localize('promptValidator.ignoredAttribute.vscode-agent', "Attribute '{0}' is ignored when running locally in BradfordCode.", attribute.key), attribute.range, MarkerSeverity.Hint, [MarkerTag.Unnecessary])); } else { - report(toMarker(localize('promptValidator.unknownAttribute.vscode-agent', "Attribute '{0}' is not supported in VS Code agent files. Supported: {1}.", attribute.key, supportedNames.value), attribute.range, MarkerSeverity.Hint, [MarkerTag.Unnecessary])); -+ report(toMarker(localize('promptValidator.unknownAttribute.vscode-agent', "Attribute '{0}' is not supported in !!APP_NAME!! agent files. Supported: {1}.", attribute.key, supportedNames.value), attribute.range, MarkerSeverity.Hint, [MarkerTag.Unnecessary])); ++ report(toMarker(localize('promptValidator.unknownAttribute.vscode-agent', "Attribute '{0}' is not supported in BradfordCode agent files. Supported: {1}.", attribute.key, supportedNames.value), attribute.range, MarkerSeverity.Hint, [MarkerTag.Unnecessary])); } - } - break; - case PromptsType.instructions: +@@ -316,3 +316,3 @@ export class PromptValidator { if (target === Target.Claude) { - report(toMarker(localize('promptValidator.unknownAttribute.rules', "Attribute '{0}' is not supported in rules files by VS Code agents. Supported: {1}.", attribute.key, supportedNames.value), attribute.range, MarkerSeverity.Hint, [MarkerTag.Unnecessary])); -+ report(toMarker(localize('promptValidator.unknownAttribute.rules', "Attribute '{0}' is not supported in rules files by !!APP_NAME!! agents. Supported: {1}.", attribute.key, supportedNames.value), attribute.range, MarkerSeverity.Hint, [MarkerTag.Unnecessary])); ++ report(toMarker(localize('promptValidator.unknownAttribute.rules', "Attribute '{0}' is not supported in rules files by BradfordCode agents. Supported: {1}.", attribute.key, supportedNames.value), attribute.range, MarkerSeverity.Hint, [MarkerTag.Unnecessary])); } else { - report(toMarker(localize('promptValidator.unknownAttribute.instructions', "Attribute '{0}' is not supported in instructions files. Supported: {1}.", attribute.key, supportedNames.value), attribute.range, MarkerSeverity.Hint, [MarkerTag.Unnecessary])); - } - break; +@@ -322,3 +322,3 @@ export class PromptValidator { case PromptsType.skill: - report(toMarker(localize('promptValidator.unknownAttribute.skill', "Attribute '{0}' is not supported by VS Code agents. Supported: {1}.", attribute.key, supportedNames.value), attribute.range, MarkerSeverity.Hint, [MarkerTag.Unnecessary])); -+ report(toMarker(localize('promptValidator.unknownAttribute.skill', "Attribute '{0}' is not supported by !!APP_NAME!! agents. Supported: {1}.", attribute.key, supportedNames.value), attribute.range, MarkerSeverity.Hint, [MarkerTag.Unnecessary])); ++ report(toMarker(localize('promptValidator.unknownAttribute.skill', "Attribute '{0}' is not supported by BradfordCode agents. Supported: {1}.", attribute.key, supportedNames.value), attribute.range, MarkerSeverity.Hint, [MarkerTag.Unnecessary])); break; - } - } -@@ -1105,7 +1105,7 @@ export function mapClaudeModels(claudeModelNames: readonly string[]): readonly s - } - +@@ -1226,3 +1226,3 @@ export function mapClaudeModels(claudeModelNames: readonly string[]): readonly s /** - * Maps Claude tool names to their VS Code tool equivalents. -+ * Maps Claude tool names to their !!APP_NAME!! tool equivalents. ++ * Maps Claude tool names to their BradfordCode tool equivalents. */ - export function mapClaudeTools(claudeToolNames: readonly string[]): string[] { - const result: string[] = []; diff --git a/src/vs/workbench/contrib/debug/browser/debugAdapterManager.ts b/src/vs/workbench/contrib/debug/browser/debugAdapterManager.ts index 0b9ced32..b5ed7134 100644 --- a/src/vs/workbench/contrib/debug/browser/debugAdapterManager.ts +++ b/src/vs/workbench/contrib/debug/browser/debugAdapterManager.ts -@@ -175,7 +175,7 @@ export class AdapterManager extends Disposable implements IAdapterManager { - }, - 'debugServer': { +@@ -177,3 +177,3 @@ export class AdapterManager extends Disposable implements IAdapterManager { type: 'number', - description: nls.localize('debugServer', "For debug extension development only: if a port is specified VS Code tries to connect to a debug adapter running in server mode"), -+ description: nls.localize('debugServer', "For debug extension development only: if a port is specified !!APP_NAME!! tries to connect to a debug adapter running in server mode"), ++ description: nls.localize('debugServer', "For debug extension development only: if a port is specified BradfordCode tries to connect to a debug adapter running in server mode"), default: 4711 - }, - 'preLaunchTask': { diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts -index 0c0965d3..f4b0faef 100644 +index b04aae0a..5d36c3ae 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts -@@ -417,24 +417,24 @@ CommandsRegistry.registerCommand({ - { - name: 'options', +@@ -416,3 +416,3 @@ CommandsRegistry.registerCommand({ description: '(optional) Options for installing the extension. Object with the following properties: ' + - '`installOnlyNewlyAddedFromExtensionPackVSIX`: When enabled, VS Code installs only newly added extensions from the extension pack VSIX. This option is considered only when installing VSIX. ', -+ '`installOnlyNewlyAddedFromExtensionPackVSIX`: When enabled, !!APP_NAME!! installs only newly added extensions from the extension pack VSIX. This option is considered only when installing VSIX. ', ++ '`installOnlyNewlyAddedFromExtensionPackVSIX`: When enabled, BradfordCode installs only newly added extensions from the extension pack VSIX. This option is considered only when installing VSIX. ', isOptional: true, - schema: { - 'type': 'object', - 'properties': { - 'installOnlyNewlyAddedFromExtensionPackVSIX': { +@@ -423,3 +423,3 @@ CommandsRegistry.registerCommand({ 'type': 'boolean', - 'description': localize('workbench.extensions.installExtension.option.installOnlyNewlyAddedFromExtensionPackVSIX', "When enabled, VS Code installs only newly added extensions from the extension pack VSIX. This option is considered only while installing a VSIX."), -+ 'description': localize('workbench.extensions.installExtension.option.installOnlyNewlyAddedFromExtensionPackVSIX', "When enabled, !!APP_NAME!! installs only newly added extensions from the extension pack VSIX. This option is considered only while installing a VSIX."), ++ 'description': localize('workbench.extensions.installExtension.option.installOnlyNewlyAddedFromExtensionPackVSIX', "When enabled, BradfordCode installs only newly added extensions from the extension pack VSIX. This option is considered only while installing a VSIX."), default: false - }, - 'installPreReleaseVersion': { +@@ -428,3 +428,3 @@ CommandsRegistry.registerCommand({ 'type': 'boolean', - 'description': localize('workbench.extensions.installExtension.option.installPreReleaseVersion', "When enabled, VS Code installs the pre-release version of the extension if available."), -+ 'description': localize('workbench.extensions.installExtension.option.installPreReleaseVersion', "When enabled, !!APP_NAME!! installs the pre-release version of the extension if available."), ++ 'description': localize('workbench.extensions.installExtension.option.installPreReleaseVersion', "When enabled, BradfordCode installs the pre-release version of the extension if available."), default: false - }, - 'donotSync': { +@@ -433,3 +433,3 @@ CommandsRegistry.registerCommand({ 'type': 'boolean', - 'description': localize('workbench.extensions.installExtension.option.donotSync', "When enabled, VS Code do not sync this extension when Settings Sync is on."), -+ 'description': localize('workbench.extensions.installExtension.option.donotSync', "When enabled, !!APP_NAME!! do not sync this extension when Settings Sync is on."), ++ 'description': localize('workbench.extensions.installExtension.option.donotSync', "When enabled, BradfordCode do not sync this extension when Settings Sync is on."), default: false - }, - 'justification': { -@@ -959,8 +959,8 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi - if (requireReload) { - notificationService.prompt( +@@ -958,4 +958,4 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi Severity.Info, - vsixs.length > 1 ? localize('InstallVSIXs.successReload', "Completed installing extensions. Please reload Visual Studio Code to enable them.") - : localize('InstallVSIXAction.successReload', "Completed installing extension. Please reload Visual Studio Code to enable it."), -+ vsixs.length > 1 ? localize('InstallVSIXs.successReload', "Completed installing extensions. Please reload !!APP_NAME!! to enable them.") -+ : localize('InstallVSIXAction.successReload', "Completed installing extension. Please reload !!APP_NAME!! to enable it."), ++ vsixs.length > 1 ? localize('InstallVSIXs.successReload', "Completed installing extensions. Please reload BradfordCode to enable them.") ++ : localize('InstallVSIXAction.successReload', "Completed installing extension. Please reload BradfordCode to enable it."), [{ - label: localize('InstallVSIXAction.reloadNow', "Reload Now"), - run: () => hostService.reload() diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts -index 452db88d..282c9b5a 100644 +index 83d08390..e4420488 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts -@@ -107,7 +107,7 @@ export class PromptExtensionInstallFailureAction extends Action { - this.logService.error(this.error); - +@@ -110,3 +110,3 @@ export class PromptExtensionInstallFailureAction extends Action { if (this.error.name === ExtensionManagementErrorCode.Unsupported) { - const productName = isWeb ? localize('VS Code for Web', "{0} for the Web", this.productService.nameLong) : this.productService.nameLong; -+ const productName = isWeb ? localize('!!APP_NAME!! for Web', "{0} for the Web", this.productService.nameLong) : this.productService.nameLong; ++ const productName = isWeb ? localize('BradfordCode for Web', "{0} for the Web", this.productService.nameLong) : this.productService.nameLong; const message = localize('cannot be installed', "The '{0}' extension is not available in {1}. Click 'More Information' to learn more.", this.extension.displayName || this.extension.identifier.id, productName); - const { confirmed } = await this.dialogService.confirm({ - type: Severity.Info, -@@ -551,7 +551,7 @@ export class InstallAction extends ExtensionAction { - } - }); +@@ -554,3 +554,3 @@ export class InstallAction extends ExtensionAction { } else if (this.extension.deprecationInfo.settings) { - detail = localize('deprecated with alternate settings message', "This extension is deprecated as this functionality is now built-in to VS Code."); -+ detail = localize('deprecated with alternate settings message', "This extension is deprecated as this functionality is now built-in to !!APP_NAME!!."); - - const settings = this.extension.deprecationInfo.settings; - buttons.push({ -@@ -940,7 +940,7 @@ export class UninstallAction extends ExtensionAction { ++ detail = localize('deprecated with alternate settings message', "This extension is deprecated as this functionality is now built-in to BradfordCode."); - try { +@@ -943,3 +943,3 @@ export class UninstallAction extends ExtensionAction { await this.extensionsWorkbenchService.uninstall(this.extension); - alert(localize('uninstallExtensionComplete', "Please reload Visual Studio Code to complete the uninstallation of the extension {0}.", this.extension.displayName)); -+ alert(localize('uninstallExtensionComplete', "Please reload !!APP_NAME!! to complete the uninstallation of the extension {0}.", this.extension.displayName)); ++ alert(localize('uninstallExtensionComplete', "Please reload BradfordCode to complete the uninstallation of the extension {0}.", this.extension.displayName)); } catch (error) { - if (!isCancellationError(error)) { - this.dialogService.error(getErrorMessage(error)); -@@ -2823,7 +2823,7 @@ export class ExtensionStatusAction extends ExtensionAction { - this.updateStatus({ icon: warningIcon, message: new MarkdownString(localize('deprecated with alternate extension tooltip', "This extension is deprecated. Use the {0} extension instead.", link)) }, true); - } else if (this.extension.deprecationInfo.settings) { +@@ -2822,3 +2822,3 @@ export class ExtensionStatusAction extends ExtensionAction { const link = `[${localize('settings', "settings")}](${createCommandUri('workbench.action.openSettings', this.extension.deprecationInfo.settings.map(setting => `@id:${setting}`).join(' '))}})`; - this.updateStatus({ icon: warningIcon, message: new MarkdownString(localize('deprecated with alternate settings tooltip', "This extension is deprecated as this functionality is now built-in to VS Code. Configure these {0} to use this functionality.", link)) }, true); -+ this.updateStatus({ icon: warningIcon, message: new MarkdownString(localize('deprecated with alternate settings tooltip', "This extension is deprecated as this functionality is now built-in to !!APP_NAME!!. Configure these {0} to use this functionality.", link)) }, true); ++ this.updateStatus({ icon: warningIcon, message: new MarkdownString(localize('deprecated with alternate settings tooltip', "This extension is deprecated as this functionality is now built-in to BradfordCode. Configure these {0} to use this functionality.", link)) }, true); } else { - const message = new MarkdownString(localize('deprecated tooltip', "This extension is deprecated as it is no longer being maintained.")); - if (this.extension.deprecationInfo.additionalInfo) { diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts index a58d77d1..cc5004c7 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts -@@ -477,7 +477,7 @@ export class Extension implements IExtension { - - if (this.type === ExtensionType.System) { +@@ -479,3 +479,3 @@ export class Extension implements IExtension { return Promise.resolve(`# ${this.displayName || this.name} -**Notice:** This extension is bundled with Visual Studio Code. It can be disabled but not uninstalled. -+**Notice:** This extension is bundled with !!APP_NAME!!. It can be disabled but not uninstalled. ++**Notice:** This extension is bundled with BradfordCode. It can be disabled but not uninstalled. ## Features - ${this.description} - `); -@@ -515,7 +515,7 @@ ${this.description} - } - +@@ -517,3 +517,3 @@ ${this.description} if (this.type === ExtensionType.System) { - return Promise.resolve(`Please check the [VS Code Release Notes](command:${ShowCurrentReleaseNotesActionId}) for changes to the built-in extensions.`); -+ return Promise.resolve(`Please check the [!!APP_NAME!! Release Notes](command:${ShowCurrentReleaseNotesActionId}) for changes to the built-in extensions.`); ++ return Promise.resolve(`Please check the [BradfordCode Release Notes](command:${ShowCurrentReleaseNotesActionId}) for changes to the built-in extensions.`); } - - return Promise.reject(new Error('not available')); diff --git a/src/vs/workbench/contrib/extensions/common/extensionsFileTemplate.ts b/src/vs/workbench/contrib/extensions/common/extensionsFileTemplate.ts index 818e6628..19722f3f 100644 --- a/src/vs/workbench/contrib/extensions/common/extensionsFileTemplate.ts +++ b/src/vs/workbench/contrib/extensions/common/extensionsFileTemplate.ts -@@ -27,7 +27,7 @@ export const ExtensionsConfigurationSchema: IJSONSchema = { - }, - unwantedRecommendations: { +@@ -29,3 +29,3 @@ export const ExtensionsConfigurationSchema: IJSONSchema = { type: 'array', - description: localize('app.extensions.json.unwantedRecommendations', "List of extensions recommended by VS Code that should not be recommended for users of this workspace. The identifier of an extension is always '${publisher}.${name}'. For example: 'vscode.csharp'."), -+ description: localize('app.extensions.json.unwantedRecommendations', "List of extensions recommended by !!APP_NAME!! that should not be recommended for users of this workspace. The identifier of an extension is always '${publisher}.${name}'. For example: 'vscode.csharp'."), ++ description: localize('app.extensions.json.unwantedRecommendations', "List of extensions recommended by BradfordCode that should not be recommended for users of this workspace. The identifier of an extension is always '${publisher}.${name}'. For example: 'vscode.csharp'."), items: { - type: 'string', - pattern: EXTENSION_IDENTIFIER_PATTERN, -@@ -46,7 +46,7 @@ export const ExtensionsConfigurationInitialContent: string = [ - '\t"recommendations": [', - '\t\t', +@@ -48,3 +48,3 @@ export const ExtensionsConfigurationInitialContent: string = [ '\t],', - '\t// List of extensions recommended by VS Code that should not be recommended for users of this workspace.', -+ '\t// List of extensions recommended by !!APP_NAME!! that should not be recommended for users of this workspace.', ++ '\t// List of extensions recommended by BradfordCode that should not be recommended for users of this workspace.', '\t"unwantedRecommendations": [', - '\t\t', - '\t]', diff --git a/src/vs/workbench/contrib/extensions/common/installExtensionsTool.ts b/src/vs/workbench/contrib/extensions/common/installExtensionsTool.ts index d174c8c9..69d8d43e 100644 --- a/src/vs/workbench/contrib/extensions/common/installExtensionsTool.ts +++ b/src/vs/workbench/contrib/extensions/common/installExtensionsTool.ts -@@ -17,7 +17,7 @@ export const InstallExtensionsToolData: IToolData = { - toolReferenceName: 'installExtensions', - canBeReferencedInPrompt: true, +@@ -19,3 +19,3 @@ export const InstallExtensionsToolData: IToolData = { displayName: localize('installExtensionsTool.displayName', 'Install Extensions'), - modelDescription: 'This is a tool for installing extensions in Visual Studio Code. You should provide the list of extension ids to install. The identifier of an extension is \'\${ publisher }.\${ name }\' for example: \'vscode.csharp\'.', -+ modelDescription: 'This is a tool for installing extensions in !!APP_NAME!!. You should provide the list of extension ids to install. The identifier of an extension is \'\${ publisher }.\${ name }\' for example: \'vscode.csharp\'.', ++ modelDescription: 'This is a tool for installing extensions in BradfordCode. You should provide the list of extension ids to install. The identifier of an extension is \'\${ publisher }.\${ name }\' for example: \'vscode.csharp\'.', userDescription: localize('installExtensionsTool.userDescription', 'Tool for installing extensions'), - source: ToolDataSource.Internal, - inputSchema: { diff --git a/src/vs/workbench/contrib/extensions/common/searchExtensionsTool.ts b/src/vs/workbench/contrib/extensions/common/searchExtensionsTool.ts index 91541b6a..3d4d5f05 100644 --- a/src/vs/workbench/contrib/extensions/common/searchExtensionsTool.ts +++ b/src/vs/workbench/contrib/extensions/common/searchExtensionsTool.ts -@@ -20,8 +20,8 @@ export const SearchExtensionsToolData: IToolData = { - legacyToolReferenceFullNames: ['extensions'], - icon: ThemeIcon.fromId(Codicon.extensions.id), +@@ -22,4 +22,4 @@ export const SearchExtensionsToolData: IToolData = { displayName: localize('searchExtensionsTool.displayName', 'Search Extensions'), - modelDescription: 'This is a tool for browsing Visual Studio Code Extensions Marketplace. It allows the model to search for extensions and retrieve detailed information about them. The model should use this tool whenever it needs to discover extensions or resolve information about known ones. To use the tool, the model has to provide the category of the extensions, relevant search keywords, or known extension IDs. Note that search results may include false positives, so reviewing and filtering is recommended.', - userDescription: localize('searchExtensionsTool.userDescription', 'Search for VS Code extensions'), + modelDescription: 'This is a tool for browsing OpenVSX. It allows the model to search for extensions and retrieve detailed information about them. The model should use this tool whenever it needs to discover extensions or resolve information about known ones. To use the tool, the model has to provide the category of the extensions, relevant search keywords, or known extension IDs. Note that search results may include false positives, so reviewing and filtering is recommended.', -+ userDescription: localize('searchExtensionsTool.userDescription', 'Search for !!APP_NAME!! extensions'), ++ userDescription: localize('searchExtensionsTool.userDescription', 'Search for BradfordCode extensions'), source: ToolDataSource.Internal, - inputSchema: { - type: 'object', diff --git a/src/vs/workbench/contrib/externalUriOpener/common/configuration.ts b/src/vs/workbench/contrib/externalUriOpener/common/configuration.ts index f54ddfe2..2c524b07 100644 --- a/src/vs/workbench/contrib/externalUriOpener/common/configuration.ts +++ b/src/vs/workbench/contrib/externalUriOpener/common/configuration.ts -@@ -55,7 +55,7 @@ export const externalUriOpenersConfigurationNode: IConfigurationNode = { - type: 'string', - markdownDescription: nls.localize('externalUriOpeners.uri', "Map URI pattern to an opener id.\nExample patterns: \n{0}", exampleUriPatterns), +@@ -57,3 +57,3 @@ export const externalUriOpenersConfigurationNode: IConfigurationNode = { enum: [defaultExternalUriOpenerId], - enumDescriptions: [nls.localize('externalUriOpeners.defaultId', "Open using VS Code's standard opener.")], -+ enumDescriptions: [nls.localize('externalUriOpeners.defaultId', "Open using !!APP_NAME!!'s standard opener.")], ++ enumDescriptions: [nls.localize('externalUriOpeners.defaultId', "Open using BradfordCode's standard opener.")], }, - externalUriOpenerIdSchemaAddition - ] diff --git a/src/vs/workbench/contrib/localization/common/localization.contribution.ts b/src/vs/workbench/contrib/localization/common/localization.contribution.ts index bd739953..bae23895 100644 --- a/src/vs/workbench/contrib/localization/common/localization.contribution.ts +++ b/src/vs/workbench/contrib/localization/common/localization.contribution.ts -@@ -56,9 +56,9 @@ export class BaseLocalizationWorkbenchContribution extends Disposable implements - properties: { - id: { +@@ -58,5 +58,5 @@ export class BaseLocalizationWorkbenchContribution extends Disposable implements type: 'string', - description: localize('vscode.extension.contributes.localizations.translations.id', "Id of VS Code or Extension for which this translation is contributed to. Id of VS Code is always `vscode` and of extension should be in format `publisherId.extensionName`."), -+ description: localize('vscode.extension.contributes.localizations.translations.id', "Id of !!APP_NAME!! or Extension for which this translation is contributed to. Id of !!APP_NAME!! is always `vscode` and of extension should be in format `publisherId.extensionName`."), ++ description: localize('vscode.extension.contributes.localizations.translations.id', "Id of BradfordCode or Extension for which this translation is contributed to. Id of BradfordCode is always `vscode` and of extension should be in format `publisherId.extensionName`."), pattern: '^((vscode)|([a-z0-9A-Z][a-z0-9A-Z-]*)\\.([a-z0-9A-Z][a-z0-9A-Z-]*))$', - patternErrorMessage: localize('vscode.extension.contributes.localizations.translations.id.pattern', "Id should be `vscode` or in format `publisherId.extensionName` for translating VS code or an extension respectively.") -+ patternErrorMessage: localize('vscode.extension.contributes.localizations.translations.id.pattern', "Id should be `vscode` or in format `publisherId.extensionName` for translating !!APP_NAME!! or an extension respectively.") ++ patternErrorMessage: localize('vscode.extension.contributes.localizations.translations.id.pattern', "Id should be `vscode` or in format `publisherId.extensionName` for translating BradfordCode or an extension respectively.") }, - path: { - type: 'string', diff --git a/src/vs/workbench/contrib/localization/common/localizationsActions.ts b/src/vs/workbench/contrib/localization/common/localizationsActions.ts index 050dde41..edf24a48 100644 --- a/src/vs/workbench/contrib/localization/common/localizationsActions.ts +++ b/src/vs/workbench/contrib/localization/common/localizationsActions.ts -@@ -23,7 +23,7 @@ export class ConfigureDisplayLanguageAction extends Action2 { - id: MenuId.CommandPalette - }, +@@ -25,3 +25,3 @@ export class ConfigureDisplayLanguageAction extends Action2 { metadata: { - description: localize2('configureLocaleDescription', "Changes the locale of VS Code based on installed language packs. Common languages include French, Chinese, Spanish, Japanese, German, Korean, and more.") -+ description: localize2('configureLocaleDescription', "Changes the locale of !!APP_NAME!! based on installed language packs. Common languages include French, Chinese, Spanish, Japanese, German, Korean, and more.") ++ description: localize2('configureLocaleDescription', "Changes the locale of BradfordCode based on installed language packs. Common languages include French, Chinese, Spanish, Japanese, German, Korean, and more.") } - }); - } diff --git a/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts b/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts index dc231e44..c983379d 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts -@@ -274,7 +274,7 @@ export class McpServersListView extends AbstractExtensionsListView this.configurationService.updateValue(TerminalSettingId.FontFamily, 'monospace'), +@@ -210,3 +210,3 @@ export class TerminalViewPane extends ViewPane { }]; - this._notificationService.prompt(Severity.Warning, nls.localize('terminal.monospaceOnly', "The terminal only supports monospace fonts. Be sure to restart VS Code if this is a newly installed font."), choices); -+ this._notificationService.prompt(Severity.Warning, nls.localize('terminal.monospaceOnly', "The terminal only supports monospace fonts. Be sure to restart !!APP_NAME!! if this is a newly installed font."), choices); ++ this._notificationService.prompt(Severity.Warning, nls.localize('terminal.monospaceOnly', "The terminal only supports monospace fonts. Be sure to restart BradfordCode if this is a newly installed font."), choices); } - } - })); diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh index c5729c39..ccd535ba 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh -@@ -105,7 +105,7 @@ if [ -z "${VSCODE_PYTHON_AUTOACTIVATE_GUARD:-}" ]; then - # Prevent crashing by negating exit code - if ! builtin eval "$VSCODE_PYTHON_BASH_ACTIVATE"; then +@@ -107,3 +107,3 @@ if [ -z "${VSCODE_PYTHON_AUTOACTIVATE_GUARD:-}" ]; then __vsc_activation_status=$? - builtin printf '\x1b[0m\x1b[7m * \x1b[0;103m VS Code Python bash activation failed with exit code %d \x1b[0m' "$__vsc_activation_status" -+ builtin printf '\x1b[0m\x1b[7m * \x1b[0;103m !!APP_NAME!! Python bash activation failed with exit code %d \x1b[0m' "$__vsc_activation_status" ++ builtin printf '\x1b[0m\x1b[7m * \x1b[0;103m BradfordCode Python bash activation failed with exit code %d \x1b[0m' "$__vsc_activation_status" fi - fi - # Remove any leftover Python activation env vars. diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh index c43718e3..ca17a601 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh -@@ -76,7 +76,7 @@ if [ -z "${VSCODE_PYTHON_AUTOACTIVATE_GUARD:-}" ]; then - # Prevent crashing by negating exit code - if ! builtin eval "$VSCODE_PYTHON_ZSH_ACTIVATE"; then +@@ -78,3 +78,3 @@ if [ -z "${VSCODE_PYTHON_AUTOACTIVATE_GUARD:-}" ]; then __vsc_activation_status=$? - builtin printf '\x1b[0m\x1b[7m * \x1b[0;103m VS Code Python zsh activation failed with exit code %d \x1b[0m' "$__vsc_activation_status" -+ builtin printf '\x1b[0m\x1b[7m * \x1b[0;103m !!APP_NAME!! Python zsh activation failed with exit code %d \x1b[0m' "$__vsc_activation_status" ++ builtin printf '\x1b[0m\x1b[7m * \x1b[0;103m BradfordCode Python zsh activation failed with exit code %d \x1b[0m' "$__vsc_activation_status" fi - fi - # Remove any leftover Python activation env vars. diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish index f87fc762..15ffe678 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish -@@ -92,7 +92,7 @@ if not set -q VSCODE_PYTHON_AUTOACTIVATE_GUARD - set __vsc_activation_status $status - +@@ -94,3 +94,3 @@ if not set -q VSCODE_PYTHON_AUTOACTIVATE_GUARD if test $__vsc_activation_status -ne 0 - builtin printf '\x1b[0m\x1b[7m * \x1b[0;103m VS Code Python fish activation failed with exit code %d \x1b[0m \n' "$__vsc_activation_status" -+ builtin printf '\x1b[0m\x1b[7m * \x1b[0;103m !!APP_NAME!! Python fish activation failed with exit code %d \x1b[0m \n' "$__vsc_activation_status" ++ builtin printf '\x1b[0m\x1b[7m * \x1b[0;103m BradfordCode Python fish activation failed with exit code %d \x1b[0m \n' "$__vsc_activation_status" end - end - # Remove any leftover Python activation env vars. -@@ -108,7 +108,7 @@ if set -q VSCODE_NONCE - end - +@@ -110,3 +110,3 @@ end # Helper function -function __vsc_esc -d "Emit escape sequences for VS Code shell integration" -+function __vsc_esc -d "Emit escape sequences for !!APP_NAME!! shell integration" ++function __vsc_esc -d "Emit escape sequences for BradfordCode shell integration" builtin printf "\e]633;%s\a" (string join ";" -- $argv) - end - diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 index 72a329b8..2399d553 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 -@@ -85,7 +85,7 @@ if (-not $env:VSCODE_PYTHON_AUTOACTIVATE_GUARD) { - } - catch { +@@ -87,3 +87,3 @@ if (-not $env:VSCODE_PYTHON_AUTOACTIVATE_GUARD) { $activationError = $_ - Write-Host "`e[0m`e[7m * `e[0;103m VS Code Python powershell activation failed with exit code $($activationError.Exception.Message) `e[0m" -+ Write-Host "`e[0m`e[7m * `e[0;103m !!APP_NAME!! Python powershell activation failed with exit code $($activationError.Exception.Message) `e[0m" ++ Write-Host "`e[0m`e[7m * `e[0;103m BradfordCode Python powershell activation failed with exit code $($activationError.Exception.Message) `e[0m" } - } - # Remove any leftover Python activation env vars. diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts -index 3d3e8f2d..5cbfc9ad 100644 +index 1486e2b2..3cdf07cc 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts -@@ -320,7 +320,7 @@ const terminalConfiguration: IStringDictionary = { - default: 1000 - }, +@@ -323,3 +323,3 @@ const terminalConfiguration: IStringDictionary = { [TerminalSettingId.DetectLocale]: { - markdownDescription: localize('terminal.integrated.detectLocale', "Controls whether to detect and set the `$LANG` environment variable to a UTF-8 compliant option since VS Code's terminal only supports UTF-8 encoded data coming from the shell."), -+ markdownDescription: localize('terminal.integrated.detectLocale', "Controls whether to detect and set the `$LANG` environment variable to a UTF-8 compliant option since !!APP_NAME!!'s terminal only supports UTF-8 encoded data coming from the shell."), ++ markdownDescription: localize('terminal.integrated.detectLocale', "Controls whether to detect and set the `$LANG` environment variable to a UTF-8 compliant option since BradfordCode's terminal only supports UTF-8 encoded data coming from the shell."), type: 'string', - enum: ['auto', 'off', 'on'], - markdownEnumDescriptions: [ -@@ -334,7 +334,7 @@ const terminalConfiguration: IStringDictionary = { - type: 'string', - enum: ['auto', 'on', 'off'], +@@ -337,3 +337,3 @@ const terminalConfiguration: IStringDictionary = { markdownEnumDescriptions: [ - localize('terminal.integrated.gpuAcceleration.auto', "Let VS Code detect which renderer will give the best experience."), -+ localize('terminal.integrated.gpuAcceleration.auto', "Let !!APP_NAME!! detect which renderer will give the best experience."), ++ localize('terminal.integrated.gpuAcceleration.auto', "Let BradfordCode detect which renderer will give the best experience."), localize('terminal.integrated.gpuAcceleration.on', "Enable GPU acceleration within the terminal."), - localize('terminal.integrated.gpuAcceleration.off', "Disable GPU acceleration within the terminal. The terminal will render much slower when GPU acceleration is off but it should reliably work on all systems."), - ], -@@ -422,7 +422,7 @@ const terminalConfiguration: IStringDictionary = { - [TerminalSettingId.CommandsToSkipShell]: { - markdownDescription: localize( +@@ -425,3 +425,3 @@ const terminalConfiguration: IStringDictionary = { 'terminal.integrated.commandsToSkipShell', - "A set of command IDs whose keybindings will not be sent to the shell but instead always be handled by VS Code. This allows keybindings that would normally be consumed by the shell to act instead the same as when the terminal is not focused, for example `Ctrl+P` to launch Quick Open.\n\n \n\nMany commands are skipped by default. To override a default and pass that command's keybinding to the shell instead, add the command prefixed with the `-` character. For example add `-workbench.action.quickOpen` to allow `Ctrl+P` to reach the shell.\n\n \n\nThe following list of default skipped commands is truncated when viewed in Settings Editor. To see the full list, {1} and search for the first command from the list below.\n\n \n\nDefault Skipped Commands:\n\n{0}", -+ "A set of command IDs whose keybindings will not be sent to the shell but instead always be handled by !!APP_NAME!!. This allows keybindings that would normally be consumed by the shell to act instead the same as when the terminal is not focused, for example `Ctrl+P` to launch Quick Open.\n\n \n\nMany commands are skipped by default. To override a default and pass that command's keybinding to the shell instead, add the command prefixed with the `-` character. For example add `-workbench.action.quickOpen` to allow `Ctrl+P` to reach the shell.\n\n \n\nThe following list of default skipped commands is truncated when viewed in Settings Editor. To see the full list, {1} and search for the first command from the list below.\n\n \n\nDefault Skipped Commands:\n\n{0}", ++ "A set of command IDs whose keybindings will not be sent to the shell but instead always be handled by BradfordCode. This allows keybindings that would normally be consumed by the shell to act instead the same as when the terminal is not focused, for example `Ctrl+P` to launch Quick Open.\n\n \n\nMany commands are skipped by default. To override a default and pass that command's keybinding to the shell instead, add the command prefixed with the `-` character. For example add `-workbench.action.quickOpen` to allow `Ctrl+P` to reach the shell.\n\n \n\nThe following list of default skipped commands is truncated when viewed in Settings Editor. To see the full list, {1} and search for the first command from the list below.\n\n \n\nDefault Skipped Commands:\n\n{0}", DEFAULT_COMMANDS_TO_SKIP_SHELL.sort().map(command => `- ${command}`).join('\n'), - `[${localize('openDefaultSettingsJson', "open the default settings JSON")}](command:workbench.action.openRawDefaultSettings '${localize('openDefaultSettingsJson.capitalized', "Open Default Settings (JSON)")}')`, - -@@ -434,7 +434,7 @@ const terminalConfiguration: IStringDictionary = { - default: [] - }, +@@ -437,3 +437,3 @@ const terminalConfiguration: IStringDictionary = { [TerminalSettingId.AllowChords]: { - markdownDescription: localize('terminal.integrated.allowChords', "Whether or not to allow chord keybindings in the terminal. Note that when this is true and the keystroke results in a chord it will bypass {0}, setting this to false is particularly useful when you want ctrl+k to go to your shell (not VS Code).", '`#terminal.integrated.commandsToSkipShell#`'), -+ markdownDescription: localize('terminal.integrated.allowChords', "Whether or not to allow chord keybindings in the terminal. Note that when this is true and the keystroke results in a chord it will bypass {0}, setting this to false is particularly useful when you want ctrl+k to go to your shell (not !!APP_NAME!!).", '`#terminal.integrated.commandsToSkipShell#`'), ++ markdownDescription: localize('terminal.integrated.allowChords', "Whether or not to allow chord keybindings in the terminal. Note that when this is true and the keystroke results in a chord it will bypass {0}, setting this to false is particularly useful when you want ctrl+k to go to your shell (not BradfordCode).", '`#terminal.integrated.commandsToSkipShell#`'), type: 'boolean', - default: true - }, -@@ -445,7 +445,7 @@ const terminalConfiguration: IStringDictionary = { - }, - [TerminalSettingId.EnvMacOs]: { +@@ -448,3 +448,3 @@ const terminalConfiguration: IStringDictionary = { restricted: true, - markdownDescription: localize('terminal.integrated.env.osx', "Object with environment variables that will be added to the VS Code process to be used by the terminal on macOS. Set to `null` to delete the environment variable."), -+ markdownDescription: localize('terminal.integrated.env.osx', "Object with environment variables that will be added to the !!APP_NAME!! process to be used by the terminal on macOS. Set to `null` to delete the environment variable."), ++ markdownDescription: localize('terminal.integrated.env.osx', "Object with environment variables that will be added to the BradfordCode process to be used by the terminal on macOS. Set to `null` to delete the environment variable."), type: 'object', - additionalProperties: { - type: ['string', 'null'] -@@ -454,7 +454,7 @@ const terminalConfiguration: IStringDictionary = { - }, - [TerminalSettingId.EnvLinux]: { +@@ -457,3 +457,3 @@ const terminalConfiguration: IStringDictionary = { restricted: true, - markdownDescription: localize('terminal.integrated.env.linux', "Object with environment variables that will be added to the VS Code process to be used by the terminal on Linux. Set to `null` to delete the environment variable."), -+ markdownDescription: localize('terminal.integrated.env.linux', "Object with environment variables that will be added to the !!APP_NAME!! process to be used by the terminal on Linux. Set to `null` to delete the environment variable."), ++ markdownDescription: localize('terminal.integrated.env.linux', "Object with environment variables that will be added to the BradfordCode process to be used by the terminal on Linux. Set to `null` to delete the environment variable."), type: 'object', - additionalProperties: { - type: ['string', 'null'] -@@ -463,7 +463,7 @@ const terminalConfiguration: IStringDictionary = { - }, - [TerminalSettingId.EnvWindows]: { +@@ -466,3 +466,3 @@ const terminalConfiguration: IStringDictionary = { restricted: true, - markdownDescription: localize('terminal.integrated.env.windows', "Object with environment variables that will be added to the VS Code process to be used by the terminal on Windows. Set to `null` to delete the environment variable."), -+ markdownDescription: localize('terminal.integrated.env.windows', "Object with environment variables that will be added to the !!APP_NAME!! process to be used by the terminal on Windows. Set to `null` to delete the environment variable."), ++ markdownDescription: localize('terminal.integrated.env.windows', "Object with environment variables that will be added to the BradfordCode process to be used by the terminal on Windows. Set to `null` to delete the environment variable."), type: 'object', - additionalProperties: { - type: ['string', 'null'] -@@ -482,7 +482,7 @@ const terminalConfiguration: IStringDictionary = { - }, - [TerminalSettingId.WindowsUseConptyDll]: { +@@ -485,3 +485,3 @@ const terminalConfiguration: IStringDictionary = { restricted: true, - markdownDescription: localize('terminal.integrated.windowsUseConptyDll', "Whether to use the conpty.dll (v1.25.260303002) shipped with VS Code, instead of the one bundled with Windows."), -+ markdownDescription: localize('terminal.integrated.windowsUseConptyDll', "Whether to use the conpty.dll (v1.25.260303002) shipped with !!APP_NAME!!, instead of the one bundled with Windows."), ++ markdownDescription: localize('terminal.integrated.windowsUseConptyDll', "Whether to use the conpty.dll (v1.25.260303002) shipped with BradfordCode, instead of the one bundled with Windows."), type: 'boolean', - default: true, - }, -@@ -608,7 +608,7 @@ const terminalConfiguration: IStringDictionary = { - }, - [TerminalSettingId.ShellIntegrationEnabled]: { +@@ -611,3 +611,3 @@ const terminalConfiguration: IStringDictionary = { restricted: true, - markdownDescription: localize('terminal.integrated.shellIntegration.enabled', "Determines whether or not shell integration is auto-injected to support features like enhanced command tracking and current working directory detection. \n\nShell integration works by injecting the shell with a startup script. The script gives VS Code insight into what is happening within the terminal.\n\nSupported shells:\n\n- Linux/macOS: bash, fish, pwsh, zsh\n - Windows: pwsh, git bash\n\nThis setting applies only when terminals are created, so you will need to restart your terminals for it to take effect.\n\n Note that the script injection may not work if you have custom arguments defined in the terminal profile, have enabled {1}, have a [complex bash `PROMPT_COMMAND`](https://code.visualstudio.com/docs/editor/integrated-terminal#_complex-bash-promptcommand), or other unsupported setup. To disable decorations, see {0}", '`#terminal.integrated.shellIntegration.decorationsEnabled#`', '`#editor.accessibilitySupport#`'), -+ markdownDescription: localize('terminal.integrated.shellIntegration.enabled', "Determines whether or not shell integration is auto-injected to support features like enhanced command tracking and current working directory detection. \n\nShell integration works by injecting the shell with a startup script. The script gives !!APP_NAME!! insight into what is happening within the terminal.\n\nSupported shells:\n\n- Linux/macOS: bash, fish, pwsh, zsh\n - Windows: pwsh, git bash\n\nThis setting applies only when terminals are created, so you will need to restart your terminals for it to take effect.\n\n Note that the script injection may not work if you have custom arguments defined in the terminal profile, have enabled {1}, have a [complex bash `PROMPT_COMMAND`](https://code.visualstudio.com/docs/editor/integrated-terminal#_complex-bash-promptcommand), or other unsupported setup. To disable decorations, see {0}", '`#terminal.integrated.shellIntegration.decorationsEnabled#`', '`#editor.accessibilitySupport#`'), ++ markdownDescription: localize('terminal.integrated.shellIntegration.enabled', "Determines whether or not shell integration is auto-injected to support features like enhanced command tracking and current working directory detection. \n\nShell integration works by injecting the shell with a startup script. The script gives BradfordCode insight into what is happening within the terminal.\n\nSupported shells:\n\n- Linux/macOS: bash, fish, pwsh, zsh\n - Windows: pwsh, git bash\n\nThis setting applies only when terminals are created, so you will need to restart your terminals for it to take effect.\n\n Note that the script injection may not work if you have custom arguments defined in the terminal profile, have enabled {1}, have a [complex bash `PROMPT_COMMAND`](https://code.visualstudio.com/docs/editor/integrated-terminal#_complex-bash-promptcommand), or other unsupported setup. To disable decorations, see {0}", '`#terminal.integrated.shellIntegration.decorationsEnabled#`', '`#editor.accessibilitySupport#`'), type: 'boolean', - default: true - }, diff --git a/src/vs/workbench/contrib/terminalContrib/autoReplies/common/terminalAutoRepliesConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/autoReplies/common/terminalAutoRepliesConfiguration.ts index dc20533b..b97acfc7 100644 --- a/src/vs/workbench/contrib/terminalContrib/autoReplies/common/terminalAutoRepliesConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/autoReplies/common/terminalAutoRepliesConfiguration.ts -@@ -17,7 +17,7 @@ export interface ITerminalAutoRepliesConfiguration { - - export const terminalAutoRepliesConfiguration: IStringDictionary = { +@@ -19,3 +19,3 @@ export const terminalAutoRepliesConfiguration: IStringDictionary workbenchExtensionEnablementService.setEnablement([extension], EnablementState.EnabledWorkspace); +@@ -65,3 +65,3 @@ export function registerTerminalVoiceActions() { } else { - message = localize('terminal.voice.installSpeechExtension', "Would you like to install 'VS Code Speech' extension from 'Microsoft'?"); -+ message = localize('terminal.voice.installSpeechExtension', "Would you like to install '!!APP_NAME!! Speech' extension from 'Microsoft'?"); ++ message = localize('terminal.voice.installSpeechExtension', "Would you like to install 'BradfordCode Speech' extension from 'Microsoft'?"); run = () => commandService.executeCommand('workbench.extensions.installExtension', 'ms-vscode.vscode-speech'); - primaryButton = localize('installExtension', "Install Extension"); - } diff --git a/src/vs/workbench/contrib/update/browser/update.ts b/src/vs/workbench/contrib/update/browser/update.ts -index c182b9c2..34bfb4d7 100644 +index 02d318ec..fc91df7c 100644 --- a/src/vs/workbench/contrib/update/browser/update.ts +++ b/src/vs/workbench/contrib/update/browser/update.ts -@@ -383,8 +383,8 @@ export class SwitchProductQualityContribution extends Disposable implements IWor - type: 'info', - message: nls.localize('relaunchMessage', "Changing the version requires a reload to take effect"), +@@ -407,4 +407,4 @@ export class SwitchProductQualityContribution extends Disposable implements IWor detail: newQuality === 'insider' ? - nls.localize('relaunchDetailInsiders', "Press the reload button to switch to the Insiders version of VS Code.") : - nls.localize('relaunchDetailStable', "Press the reload button to switch to the Stable version of VS Code."), -+ nls.localize('relaunchDetailInsiders', "Press the reload button to switch to the Insiders version of !!APP_NAME!!.") : -+ nls.localize('relaunchDetailStable', "Press the reload button to switch to the Stable version of !!APP_NAME!!."), ++ nls.localize('relaunchDetailInsiders', "Press the reload button to switch to the Insiders version of BradfordCode.") : ++ nls.localize('relaunchDetailStable', "Press the reload button to switch to the Stable version of BradfordCode."), primaryButton: nls.localize({ key: 'reload', comment: ['&& denotes a mnemonic'] }, "&&Reload") - }); - -@@ -419,7 +419,7 @@ export class SwitchProductQualityContribution extends Disposable implements IWor - const { result } = await dialogService.prompt({ - type: Severity.Info, +@@ -443,3 +443,3 @@ export class SwitchProductQualityContribution extends Disposable implements IWor message: nls.localize('selectSyncService.message', "Choose the settings sync service to use after changing the version"), - detail: nls.localize('selectSyncService.detail', "The Insiders version of VS Code will synchronize your settings, keybindings, extensions, snippets and UI State using separate insiders settings sync service by default."), -+ detail: nls.localize('selectSyncService.detail', "The Insiders version of !!APP_NAME!! will synchronize your settings, keybindings, extensions, snippets and UI State using separate insiders settings sync service by default."), ++ detail: nls.localize('selectSyncService.detail', "The Insiders version of BradfordCode will synchronize your settings, keybindings, extensions, snippets and UI State using separate insiders settings sync service by default."), buttons: [ - { - label: nls.localize({ key: 'use insiders', comment: ['&& denotes a mnemonic'] }, "&&Insiders"), diff --git a/src/vs/workbench/contrib/url/browser/trustedDomainsFileSystemProvider.ts b/src/vs/workbench/contrib/url/browser/trustedDomainsFileSystemProvider.ts index 393c8c36..a8e359db 100644 --- a/src/vs/workbench/contrib/url/browser/trustedDomainsFileSystemProvider.ts +++ b/src/vs/workbench/contrib/url/browser/trustedDomainsFileSystemProvider.ts -@@ -53,12 +53,12 @@ function computeTrustedDomainContent(defaultTrustedDomains: string[], trustedDom - let content = CONFIG_HELP_TEXT_PRE; - +@@ -55,3 +55,3 @@ function computeTrustedDomainContent(defaultTrustedDomains: string[], trustedDom if (defaultTrustedDomains.length > 0) { - content += `// By default, VS Code trusts "localhost" as well as the following domains:\n`; -+ content += `// By default, !!APP_NAME!! trusts "localhost" as well as the following domains:\n`; ++ content += `// By default, BradfordCode trusts "localhost" as well as the following domains:\n`; defaultTrustedDomains.forEach(d => { - content += `// - "${d}"\n`; - }); +@@ -60,3 +60,3 @@ function computeTrustedDomainContent(defaultTrustedDomains: string[], trustedDom } else { - content += `// By default, VS Code trusts "localhost".\n`; -+ content += `// By default, !!APP_NAME!! trusts "localhost".\n`; ++ content += `// By default, BradfordCode trusts "localhost".\n`; } - - content += CONFIG_HELP_TEXT_AFTER; diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.ts b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.ts index 067580a4..0982e9d6 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.ts -@@ -50,7 +50,7 @@ registerAction2(class extends Action2 { - order: 1, - }, +@@ -52,3 +52,3 @@ registerAction2(class extends Action2 { metadata: { - description: localize2('minWelcomeDescription', 'Opens a Walkthrough to help you get started in VS Code.') -+ description: localize2('minWelcomeDescription', 'Opens a Walkthrough to help you get started in !!APP_NAME!!.') ++ description: localize2('minWelcomeDescription', 'Opens a Walkthrough to help you get started in BradfordCode.') } - }); - } -@@ -320,7 +320,7 @@ configurationRegistry.registerConfiguration({ - 'enum': ['none', 'welcomePage', 'readme', 'newUntitledFile', 'welcomePageInEmptyWorkbench', 'terminal', 'agentSessionsWelcomePage'], - 'enumDescriptions': [ +@@ -322,3 +322,3 @@ configurationRegistry.registerConfiguration({ localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'workbench.startupEditor.none' }, "Start without an editor."), - localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'workbench.startupEditor.welcomePage' }, "Open the Welcome page, with content to aid in getting started with VS Code and extensions."), -+ localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'workbench.startupEditor.welcomePage' }, "Open the Welcome page, with content to aid in getting started with !!APP_NAME!! and extensions."), ++ localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'workbench.startupEditor.welcomePage' }, "Open the Welcome page, with content to aid in getting started with BradfordCode and extensions."), localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'workbench.startupEditor.readme' }, "Open the README when opening a folder that contains one, fallback to 'welcomePage' otherwise. Note: This is only observed as a global configuration, it will be ignored if set in a workspace or folder configuration."), - localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'workbench.startupEditor.newUntitledFile' }, "Open a new untitled text file (only applies when opening an empty window)."), - localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'workbench.startupEditor.welcomePageInEmptyWorkbench' }, "Open the Welcome page when opening an empty workbench."), diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedExtensionPoint.ts b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedExtensionPoint.ts index 297598ef..45282b79 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedExtensionPoint.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedExtensionPoint.ts -@@ -159,7 +159,7 @@ export const walkthroughsExtensionPoint = ExtensionsRegistry.registerExtensionPo - defaultSnippets: [ - { +@@ -161,3 +161,3 @@ export const walkthroughsExtensionPoint = ExtensionsRegistry.registerExtensionPo label: 'onCommand', - description: localize('walkthroughs.steps.completionEvents.onCommand', 'Check off step when a given command is executed anywhere in VS Code.'), -+ description: localize('walkthroughs.steps.completionEvents.onCommand', 'Check off step when a given command is executed anywhere in !!APP_NAME!!.'), ++ 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 --- a/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts -@@ -199,73 +199,23 @@ export const startEntries: GettingStartedStartEntryContent = [ - command: 'command:workbench.action.remote.showMenu', - } +@@ -201,24 +201,2 @@ export const startEntries: GettingStartedStartEntryContent = [ }, - { - id: 'topLevelOpenTunnel', @@ -1842,8 +1277,7 @@ index 168a3d01..fd76dda2 100644 - } - }, ]; - - const Button = (title: string, href: string) => `[${title}](${href})`; +@@ -227,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 ?? ''); @@ -1870,70 +1304,50 @@ index 168a3d01..fd76dda2 100644 -} - export const walkthroughs: GettingStartedWalkthroughContent = [ - { +@@ -254,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 !!APP_NAME!!"), ++ title: localize('gettingStarted.setup.title', "Get started with BradfordCode"), description: localize('gettingStarted.setup.description', "Customize your editor, learn the basics, and start coding"), - isFeatured: true, - icon: setupIcon, +@@ -259,3 +213,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ when: '!isWeb', - walkthroughPageTitle: localize('gettingStarted.setup.walkthroughPageTitle', 'Setup VS Code'), -+ walkthroughPageTitle: localize('gettingStarted.setup.walkthroughPageTitle', 'Setup !!APP_NAME!!'), ++ walkthroughPageTitle: localize('gettingStarted.setup.walkthroughPageTitle', 'Setup BradfordCode'), next: 'Beginner', - content: { - type: 'steps', +@@ -264,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), { - id: 'pickColorTheme', - title: localize('gettingStarted.pickColor.title', "Choose your theme"), -@@ -279,8 +229,8 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ - { - id: 'videoTutorial', +@@ -281,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 !!APP_NAME!!'s key features.\n{0}", Button(localize('watch', "Watch Tutorial"), 'https://aka.ms/vscode-getting-started-video')), -+ media: { type: 'svg', altText: '!!APP_NAME!! 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' }, } - ] - } -@@ -288,13 +238,13 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ - - { +@@ -290,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 !!APP_NAME!! 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"), - isFeatured: true, - icon: setupIcon, - when: 'isWeb', +@@ -296,3 +246,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ next: 'Beginner', - walkthroughPageTitle: localize('gettingStarted.setupWeb.walkthroughPageTitle', 'Setup VS Code Web'), -+ walkthroughPageTitle: localize('gettingStarted.setupWeb.walkthroughPageTitle', 'Setup !!APP_NAME!! Web'), ++ walkthroughPageTitle: localize('gettingStarted.setupWeb.walkthroughPageTitle', 'Setup BradfordCode Web'), content: { - type: 'steps', - steps: [ -@@ -320,10 +270,10 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ - { - id: 'extensionsWebWeb', +@@ -322,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 !!APP_NAME!!'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')), when: 'workspacePlatform == \'webworker\'', media: { - type: 'svg', altText: 'VS Code extension marketplace with featured language extensions', path: 'extensions-web.svg' -+ type: 'svg', altText: '!!APP_NAME!! extension marketplace with featured language extensions', path: 'extensions-web.svg' - }, - }, - { -@@ -335,26 +285,16 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ - type: 'svg', altText: 'Language extensions', path: 'languages.svg' ++ type: 'svg', altText: 'BradfordCode extension marketplace with featured language extensions', path: 'extensions-web.svg' }, +@@ -337,12 +287,2 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ }, - { - id: 'settingsSyncWeb', @@ -1946,282 +1360,193 @@ index 168a3d01..fd76dda2 100644 - }, - }, { - id: 'commandPaletteTaskWeb', +@@ -350,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 !!APP_NAME!!.\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' }, - }, - { - id: 'pickAFolderTask-WebWeb', +@@ -356,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 !!APP_NAME!!.\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', - media: { - type: 'svg', altText: 'Explorer view showing buttons for opening folder and cloning repository.', path: 'openFolder.svg' -@@ -375,12 +315,12 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ - { - id: 'SetupAccessibility', +@@ -377,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 !!APP_NAME!! 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, - icon: setupIcon, - when: CONTEXT_ACCESSIBILITY_MODE_ENABLED.key, +@@ -382,3 +322,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ next: 'Setup', - walkthroughPageTitle: localize('gettingStarted.setupAccessibility.walkthroughPageTitle', 'Setup VS Code Accessibility'), -+ walkthroughPageTitle: localize('gettingStarted.setupAccessibility.walkthroughPageTitle', 'Setup !!APP_NAME!! Accessibility'), ++ walkthroughPageTitle: localize('gettingStarted.setupAccessibility.walkthroughPageTitle', 'Setup BradfordCode Accessibility'), content: { - type: 'steps', - steps: [ -@@ -411,7 +351,7 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ - { - id: 'commandPaletteTaskAccessibility', +@@ -413,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 !!APP_NAME!!.\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' }, - }, - { -@@ -491,20 +431,20 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ - { - id: 'settingsAndSync', +@@ -493,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 !!APP_NAME!! 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', - completionEvents: ['onEvent:sync-enabled'], +@@ -497,3 +437,3 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ media: { - type: 'svg', altText: 'VS Code Settings', path: 'settings.svg' -+ type: 'svg', altText: '!!APP_NAME!! Settings', path: 'settings.svg' ++ type: 'svg', altText: 'BradfordCode Settings', path: 'settings.svg' }, - }, - { - id: 'extensions', +@@ -503,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 !!APP_NAME!!'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')), when: 'workspacePlatform != \'webworker\'', media: { - type: 'svg', altText: 'VS Code extension marketplace with featured language extensions', path: 'extensions.svg' -+ type: 'svg', altText: '!!APP_NAME!! extension marketplace with featured language extensions', path: 'extensions.svg' ++ type: 'svg', altText: 'BradfordCode extension marketplace with featured language extensions', path: 'extensions.svg' }, - }, - { diff --git a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts index c1fb3e12..4bbf4eb3 100644 --- a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts +++ b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts -@@ -180,7 +180,7 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi - this.overlay = append(container, $('.onboarding-a-overlay')); - this.overlay.setAttribute('role', 'dialog'); +@@ -182,3 +182,3 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi this.overlay.setAttribute('aria-modal', 'true'); - this.overlay.setAttribute('aria-label', localize('onboarding.a.aria', "Welcome to Visual Studio Code")); -+ this.overlay.setAttribute('aria-label', localize('onboarding.a.aria', "Welcome to !!APP_NAME!!")); ++ this.overlay.setAttribute('aria-label', localize('onboarding.a.aria', "Welcome to BradfordCode")); - // Card - this.card = append(this.overlay, $('.onboarding-a-card')); diff --git a/src/vs/workbench/contrib/welcomeWalkthrough/browser/editor/vs_code_editor_walkthrough.ts b/src/vs/workbench/contrib/welcomeWalkthrough/browser/editor/vs_code_editor_walkthrough.ts index bdd30bf2..cc397e9c 100644 --- a/src/vs/workbench/contrib/welcomeWalkthrough/browser/editor/vs_code_editor_walkthrough.ts +++ b/src/vs/workbench/contrib/welcomeWalkthrough/browser/editor/vs_code_editor_walkthrough.ts -@@ -11,7 +11,7 @@ export default function content(accessor: ServicesAccessor) { - const isServerless = platform.isWeb && !accessor.get(IWorkbenchEnvironmentService).remoteAuthority; - return ` +@@ -13,3 +13,3 @@ export default function content(accessor: ServicesAccessor) { ## Interactive Editor Playground -The core editor in VS Code is packed with features. This page highlights a number of them and lets you interactively try them out through the use of a number of embedded editors. For full details on the editor features for VS Code and more head over to our [documentation](https://code.visualstudio.com/docs). -+The core editor in !!APP_NAME!! is packed with features. This page highlights a number of them and lets you interactively try them out through the use of a number of embedded editors. For full details on the editor features for !!APP_NAME!! and more head over to our [documentation](https://code.visualstudio.com/docs). ++The core editor in BradfordCode is packed with features. This page highlights a number of them and lets you interactively try them out through the use of a number of embedded editors. For full details on the editor features for BradfordCode and more head over to our [documentation](https://code.visualstudio.com/docs). - * [Multi-cursor Editing](#multi-cursor-editing) - block selection, select all occurrences, add additional cursors and more. - * [IntelliSense](#intellisense) - get code assistance and parameter suggestions for your code and external modules. -@@ -44,7 +44,7 @@ That is the tip of the iceberg for multi-cursor editing. Have a look at the sele - - ### IntelliSense +@@ -46,3 +46,3 @@ That is the tip of the iceberg for multi-cursor editing. Have a look at the sele -Visual Studio Code comes with the powerful IntelliSense for JavaScript and TypeScript pre-installed. In the below example, position the text cursor right after the dot and press kb(editor.action.triggerSuggest) to invoke IntelliSense. Notice how the suggestions come from the Canvas API. -+!!APP_NAME!! comes with the powerful IntelliSense for JavaScript and TypeScript pre-installed. In the below example, position the text cursor right after the dot and press kb(editor.action.triggerSuggest) to invoke IntelliSense. Notice how the suggestions come from the Canvas API. ++BradfordCode comes with the powerful IntelliSense for JavaScript and TypeScript pre-installed. In the below example, position the text cursor right after the dot and press kb(editor.action.triggerSuggest) to invoke IntelliSense. Notice how the suggestions come from the Canvas API. - |||js - const canvas = document.querySelector('canvas'); -@@ -95,7 +95,7 @@ function Book(title, author) { - } - ||| +@@ -97,3 +97,3 @@ function Book(title, author) { -> **JSDoc Tip:** VS Code's IntelliSense uses JSDoc comments to provide richer suggestions. The types and documentation from JSDoc comments show up when you hover over a reference to |Book| or in IntelliSense when you create a new instance of |Book|. -+> **JSDoc Tip:** !!APP_NAME!!'s IntelliSense uses JSDoc comments to provide richer suggestions. The types and documentation from JSDoc comments show up when you hover over a reference to |Book| or in IntelliSense when you create a new instance of |Book|. - - ` : ''} - ### Formatting -@@ -181,7 +181,7 @@ easy = 42; - ++> **JSDoc Tip:** BradfordCode's IntelliSense uses JSDoc comments to provide richer suggestions. The types and documentation from JSDoc comments show up when you hover over a reference to |Book| or in IntelliSense when you create a new instance of |Book|. +@@ -183,3 +183,3 @@ easy = 42; ## Thanks! -Well if you have got this far then you will have touched on some of the editing features in Visual Studio Code. But don't stop now :) We have lots of additional [documentation](https://code.visualstudio.com/docs), [introductory videos](https://code.visualstudio.com/docs/getstarted/introvideos) and [tips and tricks](https://go.microsoft.com/fwlink/?linkid=852118) for the product that will help you learn how to use it. And while you are here, here are a few additional things you can try: -+Well if you have got this far then you will have touched on some of the editing features in !!APP_NAME!!. But don't stop now :) We have lots of additional [documentation](https://code.visualstudio.com/docs), [introductory videos](https://code.visualstudio.com/docs/getstarted/introvideos) and [tips and tricks](https://go.microsoft.com/fwlink/?linkid=852118) for the product that will help you learn how to use it. And while you are here, here are a few additional things you can try: ++Well if you have got this far then you will have touched on some of the editing features in BradfordCode. But don't stop now :) We have lots of additional [documentation](https://code.visualstudio.com/docs), [introductory videos](https://code.visualstudio.com/docs/getstarted/introvideos) and [tips and tricks](https://go.microsoft.com/fwlink/?linkid=852118) for the product that will help you learn how to use it. And while you are here, here are a few additional things you can try: - Open the Integrated Terminal by pressing kb(workbench.action.terminal.toggleTerminal), then see what's possible by [reviewing the terminal documentation](https://code.visualstudio.com/docs/editor/integrated-terminal) - - Work with version control by pressing kb(workbench.view.scm). Understand how to stage, commit, change branches, and view diffs and more by reviewing the [version control documentation](https://code.visualstudio.com/docs/editor/versioncontrol) - - Browse thousands of extensions in our integrated gallery by pressing kb(workbench.view.extensions). The [documentation](https://code.visualstudio.com/docs/editor/extension-gallery) will show you how to see the most popular extensions, disable installed ones and more. diff --git a/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts b/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts index 6cccbced..1e404b92 100644 --- a/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts +++ b/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts -@@ -799,7 +799,7 @@ Registry.as(ConfigurationExtensions.Configuration) - [WORKSPACE_TRUST_ENABLED]: { - type: 'boolean', +@@ -801,3 +801,3 @@ Registry.as(ConfigurationExtensions.Configuration) default: true, - description: localize('workspace.trust.description', "Controls whether or not Workspace Trust is enabled within VS Code."), -+ description: localize('workspace.trust.description', "Controls whether or not Workspace Trust is enabled within !!APP_NAME!!."), ++ description: localize('workspace.trust.description', "Controls whether or not Workspace Trust is enabled within BradfordCode."), tags: [WORKSPACE_TRUST_SETTING_TAG], - scope: ConfigurationScope.APPLICATION, - }, -@@ -845,7 +845,7 @@ Registry.as(ConfigurationExtensions.Configuration) - [WORKSPACE_TRUST_EMPTY_WINDOW]: { - type: 'boolean', +@@ -847,3 +847,3 @@ Registry.as(ConfigurationExtensions.Configuration) default: true, - markdownDescription: localize('workspace.trust.emptyWindow.description', "Controls whether or not the empty window is trusted by default within VS Code. When used with `#{0}#`, you can enable the full functionality of VS Code without prompting in an empty window.", WORKSPACE_TRUST_UNTRUSTED_FILES), -+ markdownDescription: localize('workspace.trust.emptyWindow.description', "Controls whether or not the empty window is trusted by default within !!APP_NAME!!. When used with `#{0}#`, you can enable the full functionality of !!APP_NAME!! without prompting in an empty window.", WORKSPACE_TRUST_UNTRUSTED_FILES), ++ markdownDescription: localize('workspace.trust.emptyWindow.description', "Controls whether or not the empty window is trusted by default within BradfordCode. When used with `#{0}#`, you can enable the full functionality of BradfordCode without prompting in an empty window.", WORKSPACE_TRUST_UNTRUSTED_FILES), tags: [WORKSPACE_TRUST_SETTING_TAG], - scope: ConfigurationScope.APPLICATION - } diff --git a/src/vs/workbench/electron-browser/desktop.contribution.ts b/src/vs/workbench/electron-browser/desktop.contribution.ts -index 9db62d8f..26208123 100644 +index 41250d7b..5019ced6 100644 --- a/src/vs/workbench/electron-browser/desktop.contribution.ts +++ b/src/vs/workbench/electron-browser/desktop.contribution.ts -@@ -451,11 +451,11 @@ import product from '../../platform/product/common/product.js'; - }, - 'disable-chromium-sandbox': { +@@ -454,3 +454,3 @@ import product from '../../platform/product/common/product.js'; type: 'boolean', - description: localize('argv.disableChromiumSandbox', "Disables the Chromium sandbox. This is useful when running VS Code as elevated on Linux and running under Applocker on Windows.") -+ description: localize('argv.disableChromiumSandbox', "Disables the Chromium sandbox. This is useful when running !!APP_NAME!! as elevated on Linux and running under Applocker on Windows.") ++ description: localize('argv.disableChromiumSandbox', "Disables the Chromium sandbox. This is useful when running BradfordCode as elevated on Linux and running under Applocker on Windows.") }, - 'use-inmemory-secretstorage': { +@@ -458,3 +458,3 @@ import product from '../../platform/product/common/product.js'; type: 'boolean', - description: localize('argv.useInMemorySecretStorage', "Ensures that an in-memory store will be used for secret storage instead of using the OS's credential store. This is often used when running VS Code extension tests or when you're experiencing difficulties with the credential store.") -+ description: localize('argv.useInMemorySecretStorage', "Ensures that an in-memory store will be used for secret storage instead of using the OS's credential store. This is often used when running !!APP_NAME!! extension tests or when you're experiencing difficulties with the credential store.") ++ description: localize('argv.useInMemorySecretStorage', "Ensures that an in-memory store will be used for secret storage instead of using the OS's credential store. This is often used when running BradfordCode extension tests or when you're experiencing difficulties with the credential store.") }, - 'remote-debugging-port': { - type: 'string', diff --git a/src/vs/workbench/services/extensionManagement/common/extensionManagementService.ts b/src/vs/workbench/services/extensionManagement/common/extensionManagementService.ts index e60991aa..f7e95209 100644 --- a/src/vs/workbench/services/extensionManagement/common/extensionManagementService.ts +++ b/src/vs/workbench/services/extensionManagement/common/extensionManagementService.ts -@@ -1042,7 +1042,7 @@ export class ExtensionManagementService extends CommontExtensionManagementServic - } - } +@@ -1044,3 +1044,3 @@ export class ExtensionManagementService extends CommontExtensionManagementServic - const productName = localize('VS Code for Web', "{0} for the Web", this.productService.nameLong); -+ const productName = localize('!!APP_NAME!! for Web', "{0} for the Web", this.productService.nameLong); ++ const productName = localize('BradfordCode for Web', "{0} for the Web", this.productService.nameLong); const virtualWorkspaceSupport = this.extensionManifestPropertiesService.getExtensionVirtualWorkspaceSupportType(manifest); - const virtualWorkspaceSupportReason = getWorkspaceSupportTypeMessage(manifest.capabilities?.virtualWorkspaces); - const hasLimitedSupport = virtualWorkspaceSupport === 'limited' || !!virtualWorkspaceSupportReason; diff --git a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts index 09dd590e..d3fc08d7 100644 --- a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts +++ b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts -@@ -179,21 +179,21 @@ export const schema: IJSONSchema = { - properties: { - 'vscode': { +@@ -181,3 +181,3 @@ export const schema: IJSONSchema = { type: 'string', - description: nls.localize('vscode.extension.engines.vscode', 'For VS Code extensions, specifies the VS Code version that the extension is compatible with. Cannot be *. For example: ^1.105.0 indicates compatibility with a minimum VS Code version of 1.105.0.'), -+ description: nls.localize('vscode.extension.engines.vscode', 'For !!APP_NAME!! extensions, specifies the !!APP_NAME!! version that the extension is compatible with. Cannot be *. For example: ^1.105.0 indicates compatibility with a minimum !!APP_NAME!! version of 1.105.0.'), ++ description: nls.localize('vscode.extension.engines.vscode', 'For BradfordCode extensions, specifies the BradfordCode version that the extension is compatible with. Cannot be *. For example: ^1.105.0 indicates compatibility with a minimum BradfordCode version of 1.105.0.'), default: '^1.105.0', - } - } - }, +@@ -187,3 +187,3 @@ export const schema: IJSONSchema = { publisher: { - description: nls.localize('vscode.extension.publisher', 'The publisher of the VS Code extension.'), -+ description: nls.localize('vscode.extension.publisher', 'The publisher of the !!APP_NAME!! extension.'), ++ description: nls.localize('vscode.extension.publisher', 'The publisher of the BradfordCode extension.'), type: 'string' - }, +@@ -191,3 +191,3 @@ export const schema: IJSONSchema = { displayName: { - description: nls.localize('vscode.extension.displayName', 'The display name for the extension used in the VS Code gallery.'), -+ description: nls.localize('vscode.extension.displayName', 'The display name for the extension used in the !!APP_NAME!! gallery.'), ++ description: nls.localize('vscode.extension.displayName', 'The display name for the extension used in the BradfordCode gallery.'), type: 'string' - }, +@@ -195,3 +195,3 @@ export const schema: IJSONSchema = { categories: { - description: nls.localize('vscode.extension.categories', 'The categories used by the VS Code gallery to categorize the extension.'), -+ description: nls.localize('vscode.extension.categories', 'The categories used by the !!APP_NAME!! gallery to categorize the extension.'), ++ description: nls.localize('vscode.extension.categories', 'The categories used by the BradfordCode gallery to categorize the extension.'), type: 'array', - uniqueItems: true, - items: { -@@ -210,10 +210,10 @@ export const schema: IJSONSchema = { - }, - galleryBanner: { +@@ -212,6 +212,6 @@ export const schema: IJSONSchema = { type: 'object', - description: nls.localize('vscode.extension.galleryBanner', 'Banner used in the VS Code marketplace.'), -+ description: nls.localize('vscode.extension.galleryBanner', 'Banner used in the !!APP_NAME!! marketplace.'), ++ description: nls.localize('vscode.extension.galleryBanner', 'Banner used in the BradfordCode marketplace.'), properties: { color: { - description: nls.localize('vscode.extension.galleryBanner.color', 'The banner color on the VS Code marketplace page header.'), -+ description: nls.localize('vscode.extension.galleryBanner.color', 'The banner color on the !!APP_NAME!! marketplace page header.'), ++ description: nls.localize('vscode.extension.galleryBanner.color', 'The banner color on the BradfordCode marketplace page header.'), type: 'string' - }, - theme: { -@@ -224,7 +224,7 @@ export const schema: IJSONSchema = { - } - }, +@@ -226,3 +226,3 @@ export const schema: IJSONSchema = { contributes: { - description: nls.localize('vscode.extension.contributes', 'All contributions of the VS Code extension represented by this package.'), -+ description: nls.localize('vscode.extension.contributes', 'All contributions of the !!APP_NAME!! extension represented by this package.'), ++ description: nls.localize('vscode.extension.contributes', 'All contributions of the BradfordCode extension represented by this package.'), type: 'object', - // eslint-disable-next-line local/code-no-any-casts - properties: { -@@ -259,7 +259,7 @@ export const schema: IJSONSchema = { - ] - }, +@@ -261,3 +261,3 @@ export const schema: IJSONSchema = { activationEvents: { - description: nls.localize('vscode.extension.activationEvents', 'Activation events for the VS Code extension.'), -+ description: nls.localize('vscode.extension.activationEvents', 'Activation events for the !!APP_NAME!! extension.'), ++ description: nls.localize('vscode.extension.activationEvents', 'Activation events for the BradfordCode extension.'), type: 'array', - items: { - type: 'string', -@@ -426,7 +426,7 @@ export const schema: IJSONSchema = { - }, - { +@@ -428,3 +428,3 @@ export const schema: IJSONSchema = { label: '*', - description: nls.localize('vscode.extension.activationEvents.star', 'An activation event emitted on VS Code startup. To ensure a great end user experience, please use this activation event in your extension only when no other activation events combination works in your use-case.'), -+ description: nls.localize('vscode.extension.activationEvents.star', 'An activation event emitted on !!APP_NAME!! startup. To ensure a great end user experience, please use this activation event in your extension only when no other activation events combination works in your use-case.'), ++ description: nls.localize('vscode.extension.activationEvents.star', 'An activation event emitted on BradfordCode startup. To ensure a great end user experience, please use this activation event in your extension only when no other activation events combination works in your use-case.'), body: '*' - } - ], -@@ -607,11 +607,11 @@ export const schema: IJSONSchema = { - type: 'object', - properties: { +@@ -609,3 +609,3 @@ export const schema: IJSONSchema = { 'vscode:prepublish': { - description: nls.localize('vscode.extension.scripts.prepublish', 'Script executed before the package is published as a VS Code extension.'), -+ description: nls.localize('vscode.extension.scripts.prepublish', 'Script executed before the package is published as a !!APP_NAME!! extension.'), ++ description: nls.localize('vscode.extension.scripts.prepublish', 'Script executed before the package is published as a BradfordCode extension.'), type: 'string' - }, +@@ -613,3 +613,3 @@ export const schema: IJSONSchema = { 'vscode:uninstall': { - description: nls.localize('vscode.extension.scripts.uninstall', 'Uninstall hook for VS Code extension. Script that gets executed when the extension is completely uninstalled from VS Code which is when VS Code is restarted (shutdown and start) after the extension is uninstalled. Only Node scripts are supported.'), -+ description: nls.localize('vscode.extension.scripts.uninstall', 'Uninstall hook for !!APP_NAME!! extension. Script that gets executed when the extension is completely uninstalled from !!APP_NAME!! which is when !!APP_NAME!! is restarted (shutdown and start) after the extension is uninstalled. Only Node scripts are supported.'), ++ description: nls.localize('vscode.extension.scripts.uninstall', 'Uninstall hook for BradfordCode extension. Script that gets executed when the extension is completely uninstalled from BradfordCode which is when BradfordCode is restarted (shutdown and start) after the extension is uninstalled. Only Node scripts are supported.'), type: 'string' - } - } diff --git a/src/vs/workbench/services/extensions/electron-browser/nativeExtensionService.ts b/src/vs/workbench/services/extensions/electron-browser/nativeExtensionService.ts index 4340604b..9ea3a495 100644 --- a/src/vs/workbench/services/extensions/electron-browser/nativeExtensionService.ts +++ b/src/vs/workbench/services/extensions/electron-browser/nativeExtensionService.ts -@@ -165,7 +165,7 @@ export class NativeExtensionService extends AbstractExtensionService implements - Severity.Error, - nls.localize('extensionService.versionMismatchCrash', "Extension host cannot start: version mismatch."), +@@ -167,3 +167,3 @@ export class NativeExtensionService extends AbstractExtensionService implements [{ - label: nls.localize('relaunch', "Relaunch VS Code"), -+ label: nls.localize('relaunch', "Relaunch !!APP_NAME!!"), ++ label: nls.localize('relaunch', "Relaunch BradfordCode"), run: () => { - this._instantiationService.invokeFunction((accessor) => { - const hostService = accessor.get(IHostService); diff --git a/src/vs/workbench/services/userDataProfile/browser/userDataProfileManagement.ts b/src/vs/workbench/services/userDataProfile/browser/userDataProfileManagement.ts index 43da4619..3a58c158 100644 --- a/src/vs/workbench/services/userDataProfile/browser/userDataProfileManagement.ts +++ b/src/vs/workbench/services/userDataProfile/browser/userDataProfileManagement.ts -@@ -197,7 +197,7 @@ export class UserDataProfileManagementService extends Disposable implements IUse - if (shouldRestartExtensionHosts) { - if (isRemoteWindow) { +@@ -199,3 +199,3 @@ export class UserDataProfileManagementService extends Disposable implements IUse const { confirmed } = await this.dialogService.confirm({ - message: reloadMessage ?? localize('reload message', "Switching a profile requires reloading VS Code."), -+ message: reloadMessage ?? localize('reload message', "Switching a profile requires reloading !!APP_NAME!!."), ++ message: reloadMessage ?? localize('reload message', "Switching a profile requires reloading BradfordCode."), primaryButton: localize('reload button', "&&Reload"), - }); - if (confirmed) { diff --git a/patches/00-build-download-extensions-from-gh.patch b/patches/00-build-download-extensions-from-gh.patch index e5bf77d..a13f851 100644 --- a/patches/00-build-download-extensions-from-gh.patch +++ b/patches/00-build-download-extensions-from-gh.patch @@ -1,9 +1,9 @@ diff --git a/build/lib/builtInExtensions.ts b/build/lib/builtInExtensions.ts -index d52567b..fe5b988 100644 +index de687da4..2436a1fc 100644 --- a/build/lib/builtInExtensions.ts +++ b/build/lib/builtInExtensions.ts -@@ -75,4 +75,2 @@ function getExtensionDownloadStream(extension: IExtensionDefinition) { - input = ext.fromVsix(path.join(root, extension.vsix), extension); +@@ -95,4 +95,2 @@ function getExtensionDownloadStream(extension: IExtensionDefinition) { + input = ext.fromGithub(extension, { asset, latest: isInsiders() }); - } else if (productjson.extensionsGallery?.serviceUrl) { - input = ext.fromMarketplace(productjson.extensionsGallery.serviceUrl, extension); } else { diff --git a/patches/00-copilot-fix-action-condition.patch b/patches/00-copilot-fix-action-condition.patch index 11a3d54..b3e9553 100644 --- a/patches/00-copilot-fix-action-condition.patch +++ b/patches/00-copilot-fix-action-condition.patch @@ -1,62 +1,44 @@ diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts -index 7f7d1479..87b739be 100644 +index 5a064adc..14e66e95 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts -@@ -1202,7 +1202,7 @@ export function registerChatActions() { - id: 'workbench.action.chat.configureCodeCompletions', - title: localize2('configureCompletions', "Configure Inline Suggestions..."), +@@ -1224,3 +1224,3 @@ export function registerChatActions() { precondition: ContextKeyExpr.and( - ChatContextKeys.Setup.installed, + ContextKeyExpr.has('config.chat.disableAIFeatures').negate(), ChatContextKeys.Setup.disabled.negate(), - ChatContextKeys.Setup.untrusted.negate() - ), diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts -index 00c445ab..f1e87408 100644 +index 5c0d324b..c3ba615a 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts -@@ -317,7 +317,8 @@ class AttachSelectionToChatAction extends Action2 { - ResourceContextKey.Scheme.isEqualTo(Schemas.vscodeRemote), - ResourceContextKey.Scheme.isEqualTo(Schemas.untitled), +@@ -319,3 +319,4 @@ class AttachSelectionToChatAction extends Action2 { ResourceContextKey.Scheme.isEqualTo(Schemas.vscodeUserData) - ) + ), + ContextKeyExpr.has('config.chat.disableAIFeatures').negate(), ) - }, { - id: MenuId.InlineChatEditorAffordance, diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts -index 3fcf5140..82d4066f 100644 +index b0348dbb..3f3b81aa 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts -@@ -1757,7 +1757,7 @@ configurationRegistry.registerConfiguration({ - [ChatConfiguration.AIDisabled]: { - type: 'boolean', +@@ -2100,3 +2100,3 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.disableAIFeatures', "Disable and hide built-in AI features provided by GitHub Copilot, including chat and inline suggestions."), - default: false, + default: true, scope: ConfigurationScope.WINDOW, - }, - [ChatConfiguration.TitleBarSignInEnabled]: { diff --git a/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts index 8fb472b3..16cff2b1 100644 --- a/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts -@@ -72,6 +72,7 @@ const chatViewDescriptor: IViewDescriptor = { - ChatContextKeys.accountPolicyGateActive.negate(), - ContextKeyExpr.or( +@@ -74,2 +74,3 @@ const chatViewDescriptor: IViewDescriptor = { ContextKeyExpr.and( + ContextKeyExpr.has('config.chat.disableAIFeatures').negate(), ChatContextKeys.Setup.hidden.negate(), - ChatContextKeys.Setup.disabledInWorkspace.negate(), - ), diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts -index ac91175c..8fd319a7 100644 +index ce20f7bb..1152196d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts -@@ -230,12 +230,13 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr - title: ChatSetupTriggerAction.CHAT_SETUP_ACTION_LABEL, - category: CHAT_CATEGORY, +@@ -233,8 +233,9 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr f1: true, - precondition: ContextKeyExpr.or( - ChatContextKeys.Setup.hidden, @@ -72,51 +54,27 @@ index ac91175c..8fd319a7 100644 + ChatContextKeys.Setup.completed, + ChatContextKeys.Entitlement.canSignUp.negate() ) - }); - } -@@ -368,6 +369,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr - id: MenuId.AccountsContext, - group: '2_copilot', +@@ -375,2 +376,3 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr when: ContextKeyExpr.and( + ContextKeyExpr.has('config.chat.disableAIFeatures').negate(), ChatContextKeys.Setup.hidden.negate(), - ChatContextKeys.Setup.disabledInWorkspace.negate(), - ChatContextKeys.Setup.completed.negate(), -@@ -400,6 +402,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr - id: MenuId.TitleBarAdjacentCenter, - order: 0, // same position as the update button +@@ -408,2 +410,3 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr when: ContextKeyExpr.and( + ContextKeyExpr.has('config.chat.disableAIFeatures').negate(), IsWebContext.negate(), - ChatContextKeys.Entitlement.signedOut, - ChatEntitlementContextKeys.hasByokModels.negate(), -@@ -449,6 +452,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr - category: localize2('chat.category', 'Chat'), - f1: true, +@@ -458,2 +461,3 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr precondition: ContextKeyExpr.and( + ContextKeyExpr.has('config.chat.disableAIFeatures').negate(), ChatContextKeys.Setup.hidden.negate(), - ChatContextKeys.Setup.disabledInWorkspace.negate(), - ContextKeyExpr.or( -@@ -514,6 +518,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr - category: localize2('chat.category', 'Chat'), - f1: true, +@@ -523,2 +527,3 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr precondition: ContextKeyExpr.and( + ContextKeyExpr.has('config.chat.disableAIFeatures').negate(), ChatContextKeys.Setup.hidden.negate(), - ChatContextKeys.Setup.disabledInWorkspace.negate(), - ContextKeyExpr.or( -@@ -608,6 +613,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr - registerGenerateCodeCommand('chat.internal.review', 'github.copilot.chat.review'); - +@@ -617,2 +622,3 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr const internalGenerateCodeContext = ContextKeyExpr.and( + ContextKeyExpr.has('config.chat.disableAIFeatures').negate(), ChatContextKeys.Setup.hidden.negate(), - ChatContextKeys.Setup.disabledInWorkspace.negate(), - ChatContextKeys.Setup.completed.negate(), -@@ -873,7 +879,11 @@ export class ChatTeardownContribution extends Disposable implements IWorkbenchCo - title: ChatSetupHideAction.TITLE, - f1: true, +@@ -882,3 +888,7 @@ export class ChatTeardownContribution extends Disposable implements IWorkbenchCo category: CHAT_CATEGORY, - precondition: ContextKeyExpr.and(ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabledInWorkspace.negate()), + precondition: ContextKeyExpr.and( @@ -125,90 +83,58 @@ index ac91175c..8fd319a7 100644 + ChatContextKeys.Setup.disabledInWorkspace.negate() + ), menu: { - id: MenuId.ChatTitleBarMenu, - group: 'z_hide', diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts index 7e5d8f63..e949b389 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts -@@ -42,7 +42,8 @@ const inlineChatContextKey = ContextKeyExpr.and( - ContextKeyExpr.or(inlineChatNotebooksOldEnabled, CTX_INLINE_CHAT_V2_ENABLED), - CTX_INLINE_CHAT_POSSIBLE, +@@ -44,3 +44,4 @@ const inlineChatContextKey = ContextKeyExpr.and( EditorContextKeys.writable, - EditorContextKeys.editorSimpleInput.negate() + EditorContextKeys.editorSimpleInput.negate(), + ContextKeyExpr.has('config.chat.disableAIFeatures').negate(), ); - - export class StartSessionAction extends Action2 { diff --git a/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts b/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts -index c983379d..bc16fdc5 100644 +index dc231e44..633de730 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts -@@ -543,7 +543,7 @@ export class McpServersViewsContribution extends Disposable implements IWorkbenc - id: InstalledMcpServersViewId, - name: localize2('mcp-installed', "MCP Servers - Installed"), +@@ -545,3 +545,3 @@ export class McpServersViewsContribution extends Disposable implements IWorkbenc ctorDescriptor: new SyncDescriptor(McpServersListView, [{}]), - when: ContextKeyExpr.and(DefaultViewsContext, HasInstalledMcpServersContext, ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabledInWorkspace.negate()), + when: ContextKeyExpr.and(DefaultViewsContext, HasInstalledMcpServersContext, ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabledInWorkspace.negate(), ContextKeyExpr.has('config.chat.disableAIFeatures').negate()), weight: 40, - order: 4, - canToggleVisibility: true -@@ -552,7 +552,7 @@ export class McpServersViewsContribution extends Disposable implements IWorkbenc - id: 'workbench.views.mcp.default.marketplace', - name: localize2('mcp', "MCP Servers"), +@@ -554,3 +554,3 @@ export class McpServersViewsContribution extends Disposable implements IWorkbenc ctorDescriptor: new SyncDescriptor(DefaultBrowseMcpServersView, [{}]), - when: ContextKeyExpr.and(DefaultViewsContext, HasInstalledMcpServersContext.toNegated(), ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabledInWorkspace.negate(), McpServersGalleryStatusContext.isEqualTo(McpGalleryManifestStatus.Available), ContextKeyExpr.or(ContextKeyDefinedExpr.create(`config.${mcpGalleryServiceUrlConfig}`), ProductQualityContext.notEqualsTo('stable'), ContextKeyDefinedExpr.create(`config.${mcpGalleryServiceEnablementConfig}`))), + when: ContextKeyExpr.and(DefaultViewsContext, HasInstalledMcpServersContext.toNegated(), ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabledInWorkspace.negate(), McpServersGalleryStatusContext.isEqualTo(McpGalleryManifestStatus.Available), ContextKeyExpr.or(ContextKeyDefinedExpr.create(`config.${mcpGalleryServiceUrlConfig}`), ProductQualityContext.notEqualsTo('stable'), ContextKeyDefinedExpr.create(`config.${mcpGalleryServiceEnablementConfig}`)), ContextKeyExpr.has('config.chat.disableAIFeatures').negate()), weight: 40, - order: 4, - canToggleVisibility: true -@@ -561,13 +561,13 @@ export class McpServersViewsContribution extends Disposable implements IWorkbenc - id: 'workbench.views.mcp.marketplace', - name: localize2('mcp', "MCP Servers"), +@@ -563,3 +563,3 @@ export class McpServersViewsContribution extends Disposable implements IWorkbenc ctorDescriptor: new SyncDescriptor(McpServersListView, [{}]), - when: ContextKeyExpr.and(SearchMcpServersContext, ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabledInWorkspace.negate(), McpServersGalleryStatusContext.isEqualTo(McpGalleryManifestStatus.Available), ContextKeyExpr.or(ContextKeyDefinedExpr.create(`config.${mcpGalleryServiceUrlConfig}`), ProductQualityContext.notEqualsTo('stable'), ContextKeyDefinedExpr.create(`config.${mcpGalleryServiceEnablementConfig}`))), + when: ContextKeyExpr.and(SearchMcpServersContext, ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabledInWorkspace.negate(), McpServersGalleryStatusContext.isEqualTo(McpGalleryManifestStatus.Available), ContextKeyExpr.or(ContextKeyDefinedExpr.create(`config.${mcpGalleryServiceUrlConfig}`), ProductQualityContext.notEqualsTo('stable'), ContextKeyDefinedExpr.create(`config.${mcpGalleryServiceEnablementConfig}`)), ContextKeyExpr.has('config.chat.disableAIFeatures').negate()), }, - { - id: 'workbench.views.mcp.default.welcomeView', - name: localize2('mcp', "MCP Servers"), +@@ -569,3 +569,3 @@ export class McpServersViewsContribution extends Disposable implements IWorkbenc ctorDescriptor: new SyncDescriptor(DefaultBrowseMcpServersView, [{ showWelcome: true }]), - when: ContextKeyExpr.and(DefaultViewsContext, HasInstalledMcpServersContext.toNegated(), ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabledInWorkspace.negate(), McpServersGalleryStatusContext.isEqualTo(McpGalleryManifestStatus.Available), ContextKeyDefinedExpr.create(`config.${mcpGalleryServiceUrlConfig}`).negate(), ProductQualityContext.isEqualTo('stable'), ContextKeyDefinedExpr.create(`config.${mcpGalleryServiceEnablementConfig}`).negate()), + when: ContextKeyExpr.and(DefaultViewsContext, HasInstalledMcpServersContext.toNegated(), ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabledInWorkspace.negate(), McpServersGalleryStatusContext.isEqualTo(McpGalleryManifestStatus.Available), ContextKeyDefinedExpr.create(`config.${mcpGalleryServiceUrlConfig}`).negate(), ProductQualityContext.isEqualTo('stable'), ContextKeyDefinedExpr.create(`config.${mcpGalleryServiceEnablementConfig}`).negate(), ContextKeyExpr.has('config.chat.disableAIFeatures').negate()), weight: 40, - order: 4, - canToggleVisibility: true -@@ -576,7 +576,7 @@ export class McpServersViewsContribution extends Disposable implements IWorkbenc - id: 'workbench.views.mcp.welcomeView', - name: localize2('mcp', "MCP Servers"), +@@ -578,3 +578,3 @@ export class McpServersViewsContribution extends Disposable implements IWorkbenc ctorDescriptor: new SyncDescriptor(McpServersListView, [{ showWelcome: true }]), - when: ContextKeyExpr.and(SearchMcpServersContext, ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabledInWorkspace.negate(), McpServersGalleryStatusContext.isEqualTo(McpGalleryManifestStatus.Available), ContextKeyDefinedExpr.create(`config.${mcpGalleryServiceUrlConfig}`).negate(), ProductQualityContext.isEqualTo('stable'), ContextKeyDefinedExpr.create(`config.${mcpGalleryServiceEnablementConfig}`).negate()), + when: ContextKeyExpr.and(SearchMcpServersContext, ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabledInWorkspace.negate(), McpServersGalleryStatusContext.isEqualTo(McpGalleryManifestStatus.Available), ContextKeyDefinedExpr.create(`config.${mcpGalleryServiceUrlConfig}`).negate(), ProductQualityContext.isEqualTo('stable'), ContextKeyDefinedExpr.create(`config.${mcpGalleryServiceEnablementConfig}`).negate(), ContextKeyExpr.has('config.chat.disableAIFeatures').negate()), } - ], VIEW_CONTAINER); - } diff --git a/src/vs/workbench/contrib/scm/browser/scm.contribution.ts b/src/vs/workbench/contrib/scm/browser/scm.contribution.ts index 6ee2a32c..c5cdc792 100644 --- a/src/vs/workbench/contrib/scm/browser/scm.contribution.ts +++ b/src/vs/workbench/contrib/scm/browser/scm.contribution.ts -@@ -678,6 +678,7 @@ registerAction2(class extends Action2 { - menu: { - id: MenuId.EditorContent, +@@ -680,2 +680,3 @@ registerAction2(class extends Action2 { when: ContextKeyExpr.and( + ContextKeyExpr.has('config.chat.disableAIFeatures').negate(), ChatContextKeys.Setup.hidden.negate(), - ChatContextKeys.Setup.disabledInWorkspace.negate(), - ChatContextKeys.Setup.completed.negate(), diff --git a/src/vs/workbench/contrib/scm/browser/scmInput.ts b/src/vs/workbench/contrib/scm/browser/scmInput.ts index 63642985..5dee8dca 100644 --- a/src/vs/workbench/contrib/scm/browser/scmInput.ts +++ b/src/vs/workbench/contrib/scm/browser/scmInput.ts -@@ -847,6 +847,7 @@ registerAction2(class extends Action2 { - menu: { - id: MenuId.SCMInputBox, +@@ -849,2 +849,3 @@ registerAction2(class extends Action2 { when: ContextKeyExpr.and( + ContextKeyExpr.has('config.chat.disableAIFeatures').negate(), ChatContextKeys.Setup.hidden.negate(), - ChatContextKeys.Setup.disabledInWorkspace.negate(), - ChatContextKeys.Setup.completed.negate(), diff --git a/patches/00-vsce-use-custom-lib.patch b/patches/00-vsce-use-custom-lib.patch index 152160f..89ad905 100644 --- a/patches/00-vsce-use-custom-lib.patch +++ b/patches/00-vsce-use-custom-lib.patch @@ -1,8 +1,8 @@ diff --git a/build/.moduleignore b/build/.moduleignore -index f83624f8..00779f06 100644 +index ed8de136..6149b1ac 100644 --- a/build/.moduleignore +++ b/build/.moduleignore -@@ -82,7 +82,7 @@ native-is-elevated/deps/** +@@ -86,7 +86,7 @@ native-is-elevated/deps/** -@vscode/vsce-sign/** -!@vscode/vsce-sign/src/main.d.ts @@ -16,30 +16,30 @@ index f83624f8..00779f06 100644 +!@vscodium/vsce-sign/bin/** diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts -index 080d97f3..180177c5 100644 +index 66514015..3323376d 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts -@@ -460,3 +460,3 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d +@@ -410,3 +410,3 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d '**/*.wasm', - '**/@vscode/vsce-sign/bin/*', + '**/@vscodium/vsce-sign/bin/*', ], [ diff --git a/build/lib/extensions.ts b/build/lib/extensions.ts -index a25395c9..d87ff9aa 100644 +index 6a54fbe9..842f0e62 100644 --- a/build/lib/extensions.ts +++ b/build/lib/extensions.ts -@@ -126,3 +126,3 @@ export function typeCheckExtensionStream(extensionPath: string, forWeb: boolean) +@@ -128,3 +128,3 @@ export function typeCheckExtensionStream(extensionPath: string, forWeb: boolean) function fromLocalNormal(extensionPath: string): Stream { - const vsce = require('@vscode/vsce') as typeof import('@vscode/vsce'); + const vsce = require('@vscodium/vsce') as typeof import('@vscodium/vsce'); const result = es.through(); -@@ -148,3 +148,3 @@ function fromLocalNormal(extensionPath: string): Stream { +@@ -150,3 +150,3 @@ function fromLocalNormal(extensionPath: string): Stream { function fromLocalEsbuild(extensionPath: string, esbuildConfigFileName: string): Stream { - const vsce = require('@vscode/vsce') as typeof import('@vscode/vsce'); + const vsce = require('@vscodium/vsce') as typeof import('@vscodium/vsce'); const result = es.through(); diff --git a/build/package-lock.json b/build/package-lock.json -index 92f3b6a4..420464b0 100644 +index e9178ced..1b8d8a0b 100644 --- a/build/package-lock.json +++ b/build/package-lock.json @@ -51,3 +51,3 @@ @@ -47,7 +47,7 @@ index 92f3b6a4..420464b0 100644 - "@vscode/vsce": "3.6.1", + "@vscodium/vsce": "3.6.1-258428", "ansi-colors": "^3.2.3", -@@ -1941,6 +1941,45 @@ +@@ -1928,6 +1928,32 @@ }, - "node_modules/@vscode/vsce": { - "version": "3.6.1", @@ -63,19 +63,6 @@ index 92f3b6a4..420464b0 100644 + "node": "18 || 20 || >=22" + } + }, -+ "node_modules/@vscode/vsce/node_modules/brace-expansion": { -+ "version": "5.0.5", -+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", -+ "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", -+ "extraneous": true, -+ "license": "MIT", -+ "dependencies": { -+ "balanced-match": "^4.0.2" -+ }, -+ "engines": { -+ "node": "18 || 20 || >=22" -+ } -+ }, + "node_modules/@vscode/vsce/node_modules/glob/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -97,11 +84,11 @@ index 92f3b6a4..420464b0 100644 + "resolved": "https://registry.npmjs.org/@vscodium/vsce/-/vsce-3.6.1-258428.tgz", + "integrity": "sha512-B2uys5YU5MGNmzQPHsMsRXxzlQvZanhxE8Sfkd4Sr/LBN9BaV5yhMCppV7jyLUOQajj9K3YZThLqUYm4CCBy3A==", "dev": true, -@@ -1953,3 +1992,2 @@ +@@ -1940,3 +1966,2 @@ "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", - "@vscode/vsce-sign": "^2.0.0", "azure-devops-node-api": "^12.5.0", -@@ -1988,148 +2026,3 @@ +@@ -1975,148 +2000,3 @@ }, - "node_modules/@vscode/vsce-sign": { - "version": "2.0.7", @@ -251,74 +238,75 @@ index 92f3b6a4..420464b0 100644 - "node_modules/@vscode/vsce/node_modules/ansi-styles": { + "node_modules/@vscodium/vsce/node_modules/ansi-styles": { "version": "4.3.0", -@@ -2149,3 +2042,3 @@ +@@ -2136,3 +2016,3 @@ }, - "node_modules/@vscode/vsce/node_modules/balanced-match": { + "node_modules/@vscodium/vsce/node_modules/balanced-match": { "version": "4.0.4", -@@ -2159,3 +2052,3 @@ +@@ -2146,3 +2026,3 @@ }, - "node_modules/@vscode/vsce/node_modules/brace-expansion": { + "node_modules/@vscodium/vsce/node_modules/brace-expansion": { - "version": "5.0.6", -@@ -2172,3 +2065,3 @@ + "version": "5.0.7", +@@ -2159,3 +2039,3 @@ }, - "node_modules/@vscode/vsce/node_modules/chalk": { + "node_modules/@vscodium/vsce/node_modules/chalk": { "version": "4.1.2", -@@ -2189,3 +2082,3 @@ +@@ -2176,3 +2056,3 @@ }, - "node_modules/@vscode/vsce/node_modules/color-convert": { + "node_modules/@vscodium/vsce/node_modules/color-convert": { "version": "2.0.1", -@@ -2202,3 +2095,3 @@ +@@ -2189,3 +2069,3 @@ }, - "node_modules/@vscode/vsce/node_modules/color-name": { + "node_modules/@vscodium/vsce/node_modules/color-name": { "version": "1.1.4", -@@ -2209,3 +2102,3 @@ +@@ -2196,3 +2076,3 @@ }, - "node_modules/@vscode/vsce/node_modules/glob": { + "node_modules/@vscodium/vsce/node_modules/glob": { "version": "11.1.0", -@@ -2233,3 +2126,3 @@ +@@ -2220,3 +2100,3 @@ }, - "node_modules/@vscode/vsce/node_modules/glob/node_modules/minimatch": { + "node_modules/@vscodium/vsce/node_modules/glob/node_modules/minimatch": { "version": "10.2.4", -@@ -2249,3 +2142,3 @@ +@@ -2236,3 +2116,3 @@ }, - "node_modules/@vscode/vsce/node_modules/has-flag": { + "node_modules/@vscodium/vsce/node_modules/has-flag": { "version": "4.0.0", -@@ -2259,3 +2152,3 @@ +@@ -2246,3 +2126,3 @@ }, - "node_modules/@vscode/vsce/node_modules/jsonc-parser": { + "node_modules/@vscodium/vsce/node_modules/jsonc-parser": { "version": "3.3.1", -@@ -2263,9 +2156,11 @@ +@@ -2250,9 +2130,11 @@ "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", - "dev": true -+ "dev": true, -+ "license": "MIT" - }, +- }, - "node_modules/@vscode/vsce/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, ++ "license": "MIT" ++ }, + "node_modules/@vscodium/vsce/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, ++ "dev": true, + "license": "ISC", "bin": { -@@ -2277,3 +2172,3 @@ +@@ -2264,3 +2146,3 @@ }, - "node_modules/@vscode/vsce/node_modules/supports-color": { + "node_modules/@vscodium/vsce/node_modules/supports-color": { "version": "7.2.0", -@@ -2290,12 +2185,2 @@ +@@ -2277,12 +2159,2 @@ }, - "node_modules/@vscode/vsce/node_modules/yazl": { - "version": "2.5.1", @@ -331,7 +319,7 @@ index 92f3b6a4..420464b0 100644 - } - }, "node_modules/@xmldom/xmldom": { -@@ -7022,2 +6907,12 @@ +@@ -7077,2 +6949,12 @@ }, + "node_modules/yazl": { + "version": "2.5.1", @@ -345,7 +333,7 @@ index 92f3b6a4..420464b0 100644 + }, "node_modules/yocto-queue": { diff --git a/build/package.json b/build/package.json -index 4746ea2f..49867df9 100644 +index 84db71fd..ecb927d9 100644 --- a/build/package.json +++ b/build/package.json @@ -45,3 +45,3 @@ diff --git a/patches/11-update-use-github-release.patch b/patches/11-update-use-github-release.patch index 3e17460..4622142 100644 --- a/patches/11-update-use-github-release.patch +++ b/patches/11-update-use-github-release.patch @@ -1,14 +1,14 @@ diff --git a/src/vs/platform/update/common/update.ts b/src/vs/platform/update/common/update.ts -index 92ae7b96..c3fe6214 100644 +index 1afbb0d3..87c877fd 100644 --- a/src/vs/platform/update/common/update.ts +++ b/src/vs/platform/update/common/update.ts -@@ -55,3 +55,4 @@ export const enum UpdateType { +@@ -57,3 +57,4 @@ export const enum UpdateType { Archive, - Snap + Snap, + WindowsInstaller, } -@@ -123 +124,38 @@ export interface IUpdateService { +@@ -127 +128,38 @@ export interface IUpdateService { } + +export type Architecture = @@ -49,21 +49,21 @@ index 92ae7b96..c3fe6214 100644 + | "user"; \ No newline at end of file diff --git a/src/vs/platform/update/electron-main/abstractUpdateService.ts b/src/vs/platform/update/electron-main/abstractUpdateService.ts -index e13d1ba5..3767c907 100644 +index 09971caf..92552866 100644 --- a/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/src/vs/platform/update/electron-main/abstractUpdateService.ts -@@ -17,3 +17,3 @@ import { ILogService } from '../../log/common/log.js'; +@@ -19,3 +19,3 @@ import { ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; -import { IRequestService } from '../../request/common/request.js'; +import { asJson, IRequestService, NO_FETCH_TELEMETRY } from '../../request/common/request.js'; import { StorageScope, StorageTarget } from '../../storage/common/storage.js'; -@@ -21,3 +21,4 @@ import { IApplicationStorageMainService } from '../../storage/electron-main/stor +@@ -23,3 +23,4 @@ import { IApplicationStorageMainService } from '../../storage/electron-main/stor import { ITelemetryService } from '../../telemetry/common/telemetry.js'; -import { AvailableForDownload, DisablementReason, IUpdateService, State, StateType, UpdateType } from '../common/update.js'; +import { Architecture, AvailableForDownload, DisablementReason, IUpdate, IUpdateService, Platform, State, StateType, Target, UpdateType } from '../common/update.js'; +import * as semver from 'semver'; -@@ -30,12 +31,8 @@ export interface IUpdateURLOptions { +@@ -32,12 +33,8 @@ export interface IUpdateURLOptions { -export function createUpdateURL(baseUpdateUrl: string, platform: string, quality: string, commit: string, options?: IUpdateURLOptions): string { - const url = new URL(`${baseUpdateUrl}/api/update/${platform}/${quality}/${commit}`); @@ -81,12 +81,12 @@ index e13d1ba5..3767c907 100644 - - return url.toString(); } -@@ -434,3 +431,3 @@ export abstract class AbstractUpdateService implements IUpdateService { +@@ -535,3 +532,3 @@ export abstract class AbstractUpdateService extends Disposable implements IUpdat - if (mode === 'none') { + if (mode === 'none' || mode === 'manual') { return undefined; -@@ -444,18 +441,37 @@ export abstract class AbstractUpdateService implements IUpdateService { +@@ -545,17 +542,37 @@ export abstract class AbstractUpdateService extends Disposable implements IUpdat + return this._isLatestVersion(url, false) + .then((result) => { @@ -108,8 +108,7 @@ index e13d1ba5..3767c907 100644 - const context = await this.requestService.request({ url, headers, callSite: 'updateService.isLatestVersion' }, token); - const statusCode = context.res.statusCode; - this.logService.trace('update#isLatestVersion() - response', { statusCode }); -- // The update server replies with 204 (No Content) when no -- // update is available - that's all we want to know. +- // The update server replies with 204 (No Content) when no update is available. - return statusCode === 204; + this.logService.info('update#isLatestVersion() - checking update server', { url, headers }); @@ -138,15 +137,15 @@ index e13d1ba5..3767c907 100644 + }) } diff --git a/src/vs/platform/update/electron-main/updateService.darwin.ts b/src/vs/platform/update/electron-main/updateService.darwin.ts -index 3ddb310e..26738a64 100644 +index 90634472..7c10813c 100644 --- a/src/vs/platform/update/electron-main/updateService.darwin.ts +++ b/src/vs/platform/update/electron-main/updateService.darwin.ts -@@ -16,3 +16,3 @@ import { ILogService } from '../../log/common/log.js'; +@@ -15,3 +15,3 @@ import { ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; -import { asJson, IRequestService } from '../../request/common/request.js'; +import { asJson, IRequestService, NO_FETCH_TELEMETRY } from '../../request/common/request.js'; import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; -@@ -101,15 +101,4 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau +@@ -95,15 +95,4 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau - protected buildUpdateFeedUrl(quality: string, commit: string, options?: IUpdateURLOptions): string | undefined { - const assetID = this.productService.darwinUniversalAssetId ?? (process.arch === 'x64' ? 'darwin' : 'darwin-arm64'); @@ -164,7 +163,7 @@ index 3ddb310e..26738a64 100644 + protected buildUpdateFeedUrl(quality: string, _commit: string, _options?: IUpdateURLOptions): string | undefined { + return createUpdateURL(this.productService, quality, process.platform, process.arch); } -@@ -145,4 +134,30 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau +@@ -133,4 +122,30 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau - this.logService.trace('update#doCheckForUpdates - using Electron autoUpdater', { url, explicit, background }); - electron.autoUpdater.checkForUpdates(); @@ -197,13 +196,13 @@ index 3ddb310e..26738a64 100644 + this.setState(State.Idle(UpdateType.Setup, message)); + }); } -@@ -159,3 +174,3 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau +@@ -147,3 +162,3 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau try { - const context = await this.requestService.request({ url, headers, callSite: 'updateService.darwin.checkForUpdates' }, CancellationToken.None); + const context = await this.requestService.request({ url, headers, callSite: NO_FETCH_TELEMETRY }, CancellationToken.None); const statusCode = context.res.statusCode; diff --git a/src/vs/platform/update/electron-main/updateService.linux.ts b/src/vs/platform/update/electron-main/updateService.linux.ts -index 2be53f61..8776a7f6 100644 +index dbb08664..68ce1182 100644 --- a/src/vs/platform/update/electron-main/updateService.linux.ts +++ b/src/vs/platform/update/electron-main/updateService.linux.ts @@ -5,3 +5,2 @@ @@ -216,8 +215,8 @@ index 2be53f61..8776a7f6 100644 +import { IRequestService } from '../../request/common/request.js'; import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; --import { AvailableForDownload, IUpdate, State, UpdateType } from '../common/update.js'; -+import { AvailableForDownload, State, UpdateType } from '../common/update.js'; +-import { AvailableForDownload, IUpdate, State, StateType, UpdateType } from '../common/update.js'; ++import { AvailableForDownload, State, StateType, UpdateType } from '../common/update.js'; import { AbstractUpdateService, createUpdateURL, IUpdateURLOptions } from './abstractUpdateService.js'; @@ -36,4 +35,4 @@ export class LinuxUpdateService extends AbstractUpdateService { @@ -231,27 +230,34 @@ index 2be53f61..8776a7f6 100644 + this.setState(State.CheckingForUpdates(explicit)); + const internalOrg = this.getInternalOrg(); -@@ -48,17 +49,26 @@ export class LinuxUpdateService extends AbstractUpdateService { +@@ -48,19 +49,26 @@ export class LinuxUpdateService extends AbstractUpdateService { const url = this.buildUpdateFeedUrl(this.quality, this.productService.commit!, { background, internalOrg }); - this.setState(State.CheckingForUpdates(explicit)); - this.requestService.request({ url, callSite: 'updateService.linux.checkForUpdates' }, CancellationToken.None) - .then(asJson) - .then(update => { -- if (!update || !update.url || !update.version || !update.productVersion) { + this.logService.info('update#doCheckForUpdates', { url, explicit, background }); + + this._isLatestVersion(url, explicit) + .then((result) => { -+ if(!result) { + // If updates were disabled mid-check, ignore the result so we don't leave the Disabled state. + if (this.state.type !== StateType.CheckingForUpdates) { +- return; + return Promise.resolve(null); -+ } -+ -+ if(result.lastest) { - this.setState(State.Idle(UpdateType.Archive, undefined, explicit || undefined)); + } + +- if (!update || !update.url || !update.version || !update.productVersion) { +- this.setState(State.Idle(UpdateType.Archive, undefined, explicit || undefined)); - } else { - this.setState(State.AvailableForDownload(update)); ++ if(!result) { ++ return Promise.resolve(null); } ++ ++ if(result.lastest) { ++ this.setState(State.Idle(UpdateType.Archive, undefined, explicit || undefined)); ++ } + else { + this.setState(State.AvailableForDownload(result.update)); + } @@ -259,8 +265,11 @@ index 2be53f61..8776a7f6 100644 + return Promise.resolve(null); }) - .then(undefined, err => { -- this.logService.error(err); + .then(undefined, (error) => { + if (this.state.type !== StateType.CheckingForUpdates) { +@@ -69,5 +77,7 @@ export class LinuxUpdateService extends AbstractUpdateService { + +- this.logService.error(err); + this.logService.error(error); + // only show message when explicitly checking for updates @@ -269,20 +278,14 @@ index 2be53f61..8776a7f6 100644 + this.setState(State.Idle(UpdateType.Archive, message)); diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts -index 1911ac0a6bc..8fe1d1e1cf0 100644 +index edf04ae3..87b741c6 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts -@@ -12,7 +12,6 @@ import { Delayer, ProcessTimeRunOnceScheduler, timeout } from '../../../base/com - import { VSBuffer } from '../../../base/common/buffer.js'; - import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; - import { memoize } from '../../../base/common/decorators.js'; +@@ -15,3 +15,2 @@ import { memoize } from '../../../base/common/decorators.js'; + import { isCancellationError } from '../../../base/common/errors.js'; -import { hash } from '../../../base/common/hash.js'; import * as path from '../../../base/common/path.js'; - import { basename } from '../../../base/common/path.js'; - import { transform } from '../../../base/common/stream.js'; -@@ -29,11 +28,11 @@ import { ILogService } from '../../log/common/log.js'; - import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; - import { INativeHostMainService } from '../../native/electron-main/nativeHostMainService.js'; +@@ -32,7 +31,7 @@ import { INativeHostMainService } from '../../native/electron-main/nativeHostMai import { IProductService } from '../../product/common/productService.js'; -import { asJson, IRequestService } from '../../request/common/request.js'; +import { IRequestService, NO_FETCH_TELEMETRY } from '../../request/common/request.js'; @@ -292,17 +295,11 @@ index 1911ac0a6bc..8fe1d1e1cf0 100644 -import { AbstractUpdateService, createUpdateURL, getUpdateRequestHeaders, IUpdateURLOptions, UpdateErrorClassification } from './abstractUpdateService.js'; +import { AvailableForDownload, DisablementReason, IUpdate, State, StateType, Target, UpdateType } from '../common/update.js'; +import { AbstractUpdateService, createUpdateURL, IUpdateURLOptions } from './abstractUpdateService.js'; - - interface IAvailableUpdate { - packagePath: string; -@@ -47,9 +46,13 @@ interface IAvailableUpdate { - let _updateType: UpdateType | undefined = undefined; - function getUpdateType(): UpdateType { + import { getWin32UpdateType } from './win32UpdateType.js'; +@@ -51,3 +50,9 @@ function getUpdateType(): UpdateType { if (typeof _updateType === 'undefined') { -- _updateType = existsSync(path.join(path.dirname(process.execPath), 'unins000.exe')) -- ? UpdateType.Setup -- : UpdateType.Archive; -+ if (existsSync(path.join(path.dirname(process.execPath), 'unins000.exe'))) { +- _updateType = getWin32UpdateType(); ++ if (getWin32UpdateType() === UpdateType.Setup) { + _updateType = UpdateType.Setup; + } else if (path.basename(path.normalize(path.join(process.execPath, '..', '..'))) === 'Program Files') { + _updateType = UpdateType.WindowsInstaller; @@ -310,31 +307,22 @@ index 1911ac0a6bc..8fe1d1e1cf0 100644 + _updateType = UpdateType.Archive; + } } +@@ -183,3 +188,3 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun - return _updateType; -@@ -168,7 +171,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun - // updatingVersionPath will be deleted by inno setup. - } - } else { -- const fastUpdatesEnabled = this.configurationService.getValue('update.enableWindowsBackgroundUpdates'); -+ const fastUpdatesEnabled = getUpdateType() === UpdateType.Setup && this.configurationService.getValue('update.enableWindowsBackgroundUpdates'); - // GC for background updates in system setup happens via inno_setup since it requires - // elevated permissions. - if (fastUpdatesEnabled && this.productService.target === 'user' && this.productService.commit) { -@@ -187,16 +190,26 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun - } - } +- const fastUpdatesEnabled = this.configurationService.getValue('update.enableWindowsBackgroundUpdates'); ++ const fastUpdatesEnabled = getUpdateType() === UpdateType.Setup && this.configurationService.getValue('update.enableWindowsBackgroundUpdates'); + // GC for background updates in system setup happens via inno_setup since it requires elevated permissions. +@@ -209,12 +214,22 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun - protected buildUpdateFeedUrl(quality: string, commit: string, options?: IUpdateURLOptions): string | undefined { - let platform = `win32-${process.arch}`; -- ++ protected buildUpdateFeedUrl(quality: string, _commit: string, _options?: IUpdateURLOptions): string { ++ let target: Target; + - if (getUpdateType() === UpdateType.Archive) { - platform += '-archive'; - } else if (this.productService.target === 'user') { - platform += '-user'; -+ protected buildUpdateFeedUrl(quality: string, _commit: string, _options?: IUpdateURLOptions): string { -+ let target: Target; -+ + switch (getUpdateType()) { + case UpdateType.Archive: + target = "archive" @@ -354,23 +342,17 @@ index 1911ac0a6bc..8fe1d1e1cf0 100644 - return createUpdateURL(this.productService.updateUrl!, platform, quality, commit, options); + return createUpdateURL(this.productService, quality, process.platform, process.arch, target); } - - protected doCheckForUpdates(explicit: boolean, pendingCommit?: string): void { -@@ -204,22 +217,22 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun - return; - } +@@ -226,6 +241,2 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun - const internalOrg = this.getInternalOrg(); - const background = !explicit && !internalOrg; - const url = this.buildUpdateFeedUrl(this.quality, pendingCommit ?? this.productService.commit!, { background, internalOrg }); - // Only set CheckingForUpdates if we're not already in Overwriting state - if (this.state.type !== StateType.Overwriting) { - this.setState(State.CheckingForUpdates(explicit)); - } +@@ -240,6 +251,10 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun - const headers = getUpdateRequestHeaders(this.productService.version); -- this.requestService.request({ url, headers, callSite: 'updateService.win32.checkForUpdates' }, CancellationToken.None) +- const promise = this.requestService.request({ url, headers, callSite: 'updateService.win32.checkForUpdates' }, token) - .then(asJson) - .then(update => { + const internalOrg = this.getInternalOrg(); @@ -379,18 +361,15 @@ index 1911ac0a6bc..8fe1d1e1cf0 100644 + + this.logService.info('update#doCheckForUpdates', { url, explicit, background }); + -+ this._isLatestVersion(url, explicit) ++ const promise = this._isLatestVersion(url, explicit) + .then((result) => { const updateType = getUpdateType(); +@@ -250,3 +265,3 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun - if (!update || !update.url || !update.version || !update.productVersion) { + if(!result) { // If we were checking for an overwrite update and found nothing newer, - // restore the Ready state with the pending update - if (this.state.type === StateType.Overwriting) { -@@ -231,6 +244,13 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun - return Promise.resolve(null); - } +@@ -262,2 +277,9 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun + const { lastest, update } = result; + @@ -400,36 +379,30 @@ index 1911ac0a6bc..8fe1d1e1cf0 100644 + } + if (updateType === UpdateType.Archive) { - this.setState(State.AvailableForDownload(update)); - return Promise.resolve(null); -@@ -256,7 +276,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun +@@ -287,3 +309,3 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun - const downloadPath = `${updatePackagePath}.tmp`; - -- return this.requestService.request({ url: update.url, callSite: 'updateService.win32.downloadUpdate' }, CancellationToken.None) -+ return this.requestService.request({ url: update.url, callSite: NO_FETCH_TELEMETRY }, CancellationToken.None) +- return this.requestService.request({ url: update.url, callSite: 'updateService.win32.downloadUpdate' }, token) ++ return this.requestService.request({ url: update.url, callSite: NO_FETCH_TELEMETRY }, token) .then(context => { - // Get total size from Content-Length header - const contentLengthHeader = context.res.headers['content-length']; -@@ -301,12 +321,11 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun - }); - }); +@@ -336,5 +358,5 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun }) - .then(undefined, err => { ++ .then(undefined, (error) => { + // The chain was cancelled because updates are being disabled; leave state to the disable flow. +- if (token.isCancellationRequested || isCancellationError(err)) { ++ if (token.isCancellationRequested || isCancellationError(error)) { + return; +@@ -342,7 +364,6 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun + - this.telemetryService.publicLog2<{ messageHash: string }, UpdateErrorClassification>('update:error', { messageHash: String(hash(String(err))) }); - this.logService.error(err); -+ .then(undefined, (error) => { + this.logService.error(error); // only show message when explicitly checking for updates - const message: string | undefined = explicit ? (err.message || err) : undefined; + const message: string | undefined = explicit ? (error.message || error) : undefined; - // If we were checking for an overwrite update and it failed, - // restore the Ready state with the pending update -@@ -373,24 +392,34 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun - await this.unlink(progressFilePath); - await pfs.Promises.writeFile(this.availableUpdate.updateFilePath, 'flag'); +@@ -425,20 +446,30 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun - const child = spawn(this.availableUpdate.packagePath, - [ @@ -477,5 +450,3 @@ index 1911ac0a6bc..8fe1d1e1cf0 100644 + ); + } - // Track the process so we can cancel it if needed - this.availableUpdate.updateProcess = child; diff --git a/patches/20-keymap-use-custom-lib.patch b/patches/20-keymap-use-custom-lib.patch index bd3adeb..d0c423d 100644 --- a/patches/20-keymap-use-custom-lib.patch +++ b/patches/20-keymap-use-custom-lib.patch @@ -1,21 +1,16 @@ diff --git a/.npmrc b/.npmrc -index 8c21e58e..25a93f40 100644 +index 02f65e85..e2530b3a 100644 --- a/.npmrc +++ b/.npmrc -@@ -4,5 +4,6 @@ ms_build_id="14159160" - runtime="electron" - ignore-scripts=false +@@ -6,2 +6,3 @@ ignore-scripts=false build_from_source="true" +build_from_source_native_keymap="no" legacy-peer-deps="true" - timeout=180000 diff --git a/build/.moduleignore b/build/.moduleignore -index 8eb8da15..da57545b 100644 +index ed8de136..e148aa55 100644 --- a/build/.moduleignore +++ b/build/.moduleignore -@@ -63,11 +63,11 @@ fsevents/test/** - !@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm - !@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +@@ -69,7 +69,7 @@ fsevents/test/** -native-keymap/binding.gyp -native-keymap/build/** @@ -28,44 +23,28 @@ index 8eb8da15..da57545b 100644 +@vscodium/native-keymap/deps/** +!@vscodium/native-keymap/build/Release/*.node - native-is-elevated/binding.gyp - native-is-elevated/build/** diff --git a/eslint.config.js b/eslint.config.js -index 51b085ee..938419e2 100644 +index 0e48d130..ec666735 100644 --- a/eslint.config.js +++ b/eslint.config.js -@@ -1561,7 +1561,7 @@ export default defineConfig( - 'inspector', - 'minimist', +@@ -1564,3 +1564,3 @@ export default defineConfig( 'node:module', - 'native-keymap', + '@vscodium/native-keymap', 'net', - 'node-pty', - 'os', diff --git a/package-lock.json b/package-lock.json -index 81d315f0..1d99e154 100644 +index 7c4b3c2f..d3cffeca 100644 --- a/package-lock.json +++ b/package-lock.json -@@ -41,6 +41,7 @@ - "@vscode/windows-mutex": "^0.5.0", - "@vscode/windows-process-tree": "^0.7.0", +@@ -45,2 +45,3 @@ "@vscode/windows-registry": "^1.2.0", + "@vscodium/native-keymap": "3.3.7-258424", - "@xterm/addon-clipboard": "^0.3.0-beta.285", - "@xterm/addon-image": "^0.10.0-beta.285", - "@xterm/addon-ligatures": "^0.11.0-beta.285", -@@ -60,7 +61,6 @@ - "kerberos": "2.1.1", - "minimist": "^1.2.8", + "@xterm/addon-clipboard": "^0.3.0-beta.291", +@@ -65,3 +66,2 @@ "native-is-elevated": "0.9.0", - "native-keymap": "^3.3.5", "node-addon-api": "^6.0.0", - "node-pty": "^1.2.0-beta.13", - "open": "^10.1.2", -@@ -4496,6 +4496,13 @@ - "hasInstallScript": true, - "license": "MIT" +@@ -4981,2 +4981,9 @@ }, + "node_modules/@vscodium/native-keymap": { + "version": "3.3.7-258424", @@ -75,11 +54,7 @@ index 81d315f0..1d99e154 100644 + "license": "MIT" + }, "node_modules/@webgpu/types": { - "version": "0.1.66", - "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.66.tgz", -@@ -14157,9 +14164,10 @@ - } - }, +@@ -14854,5 +14861,6 @@ "node_modules/napi-build-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", @@ -89,11 +64,7 @@ index 81d315f0..1d99e154 100644 + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" }, - "node_modules/native-is-elevated": { - "version": "0.9.0", -@@ -14168,13 +14176,6 @@ - "hasInstallScript": true, - "license": "MIT" +@@ -14865,9 +14873,2 @@ }, - "node_modules/native-keymap": { - "version": "3.3.9", @@ -103,11 +74,7 @@ index 81d315f0..1d99e154 100644 - "license": "MIT" - }, "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", -@@ -15580,16 +15581,17 @@ - "license": "ISC" - }, +@@ -16279,5 +16280,6 @@ "node_modules/prebuild-install": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.2.tgz", @@ -117,36 +84,25 @@ index 81d315f0..1d99e154 100644 + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", +@@ -16288,3 +16290,3 @@ "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^1.0.1", + "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", diff --git a/package.json b/package.json -index 36485330..2a5830cd 100644 +index c80e65a6..6b0ff734 100644 --- a/package.json +++ b/package.json -@@ -144,7 +144,7 @@ - "kerberos": "2.1.1", - "minimist": "^1.2.8", +@@ -151,3 +151,3 @@ "native-is-elevated": "0.9.0", - "native-keymap": "^3.3.5", + "@vscodium/native-keymap": "3.3.7-258424", "node-addon-api": "^6.0.0", - "node-pty": "^1.2.0-beta.13", - "open": "^10.1.2", diff --git a/src/vs/platform/environment/test/node/nativeModules.integrationTest.ts b/src/vs/platform/environment/test/node/nativeModules.integrationTest.ts -index 9b11a712..975c3127 100644 +index 4b0ef7f2..90667682 100644 --- a/src/vs/platform/environment/test/node/nativeModules.integrationTest.ts +++ b/src/vs/platform/environment/test/node/nativeModules.integrationTest.ts -@@ -42,12 +42,12 @@ flakySuite('Native Modules (all platforms)', () => { - }); - +@@ -44,8 +44,8 @@ flakySuite('Native Modules (all platforms)', () => { test('native-keymap', async () => { - const keyMap = await import('native-keymap'); - assert.ok(typeof keyMap.onDidChangeKeyboardLayout === 'function', testErrorMessage('native-keymap')); @@ -159,27 +115,17 @@ index 9b11a712..975c3127 100644 - assert.ok(result, testErrorMessage('native-keymap')); + assert.ok(result, testErrorMessage('@vscodium/native-keymap')); }); - - test('@vscode/native-watchdog', async () => { diff --git a/src/vs/platform/keyboardLayout/electron-main/keyboardLayoutMainService.ts b/src/vs/platform/keyboardLayout/electron-main/keyboardLayoutMainService.ts index 8950ce21..f31cea62 100644 --- a/src/vs/platform/keyboardLayout/electron-main/keyboardLayoutMainService.ts +++ b/src/vs/platform/keyboardLayout/electron-main/keyboardLayoutMainService.ts -@@ -3,7 +3,7 @@ - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ +@@ -5,3 +5,3 @@ -import type * as nativeKeymap from 'native-keymap'; +import type * as nativeKeymap from '@vscodium/native-keymap'; import * as platform from '../../../base/common/platform.js'; - import { Emitter } from '../../../base/common/event.js'; - import { Disposable } from '../../../base/common/lifecycle.js'; -@@ -46,7 +46,7 @@ export class KeyboardLayoutMainService extends Disposable implements INativeKeyb - } - +@@ -48,3 +48,3 @@ export class KeyboardLayoutMainService extends Disposable implements INativeKeyb private async _doInitialize(): Promise { - const nativeKeymapMod = await import('native-keymap'); + const nativeKeymapMod = await import('@vscodium/native-keymap'); - this._keyboardLayoutData = readKeyboardLayoutData(nativeKeymapMod); - if (!platform.isCI) { diff --git a/patches/21-policy-use-custom-lib.patch b/patches/21-policy-use-custom-lib.patch index 88a25bb..7addc80 100644 --- a/patches/21-policy-use-custom-lib.patch +++ b/patches/21-policy-use-custom-lib.patch @@ -1,10 +1,8 @@ diff --git a/build/.moduleignore b/build/.moduleignore -index da57545b..5056155d 100644 +index e148aa55..a74fe613 100644 --- a/build/.moduleignore +++ b/build/.moduleignore -@@ -151,13 +151,15 @@ vsda/** - !vsda/rust/web/** - !vsda/rust/bundler/** +@@ -157,9 +157,11 @@ vsda/** -@vscode/policy-watcher/build/** -@vscode/policy-watcher/.husky/** @@ -23,111 +21,73 @@ index da57545b..5056155d 100644 +@vscodium/policy-watcher/index.d.ts +!@vscodium/policy-watcher/build/Release/vscodium-policy-watcher.node - @vscode/windows-ca-certs/**/* - !@vscode/windows-ca-certs/package.json diff --git a/build/lib/policies/basePolicy.ts b/build/lib/policies/basePolicy.ts index 7f650ba7..3e3521fd 100644 --- a/build/lib/policies/basePolicy.ts +++ b/build/lib/policies/basePolicy.ts -@@ -36,7 +36,7 @@ export abstract class BasePolicy implements Policy { - - renderADMX(regKey: string) { +@@ -38,3 +38,3 @@ export abstract class BasePolicy implements Policy { return [ - ``, + ``, ` `, - ` `, - ` `, diff --git a/build/lib/policies/render.ts b/build/lib/policies/render.ts index bd5958cb..b45f99b9 100644 --- a/build/lib/policies/render.ts +++ b/build/lib/policies/render.ts -@@ -54,7 +54,7 @@ export function renderADMX(regKey: string, versions: string[], categories: Categ - return ` - +@@ -56,3 +56,3 @@ export function renderADMX(regKey: string, versions: string[], categories: Categ - + - - -@@ -172,7 +172,7 @@ export function renderProfileManifest(appName: string, bundleIdentifier: string, - - +@@ -174,3 +174,3 @@ export function renderProfileManifest(appName: string, bundleIdentifier: string, pfm_default - Microsoft + bradford-tech pfm_name - PayloadOrganization - pfm_title -@@ -190,11 +190,11 @@ export function renderProfileManifest(appName: string, bundleIdentifier: string, - - +@@ -192,3 +192,3 @@ export function renderProfileManifest(appName: string, bundleIdentifier: string, pfm_app_url - https://code.visualstudio.com/ + https://github.com/bradford-tech/code pfm_description - ${appName} Managed Settings +@@ -196,3 +196,3 @@ export function renderProfileManifest(appName: string, bundleIdentifier: string, pfm_documentation_url - https://code.visualstudio.com/docs/setup/enterprise + https://github.com/bradford-tech/code pfm_domain - ${bundleIdentifier} - pfm_format_version -@@ -260,13 +260,13 @@ ${policyEntries} - - +@@ -262,3 +262,3 @@ ${policyEntries} PayloadDescription - This profile manages ${appName}. For more information see https://code.visualstudio.com/docs/setup/enterprise + This profile manages ${appName}. For more information see https://github.com/bradford-tech/code PayloadDisplayName - ${appName} - PayloadIdentifier - ${bundleIdentifier} +@@ -268,3 +268,3 @@ ${policyEntries} PayloadOrganization - Microsoft + bradford-tech PayloadType - Configuration - PayloadUUID diff --git a/eslint.config.js b/eslint.config.js -index 938419e2..f3a103ca 100644 +index ec666735..433db425 100644 --- a/eslint.config.js +++ b/eslint.config.js -@@ -1542,7 +1542,7 @@ export default defineConfig( - '@vscode/ripgrep-universal', - '@vscode/iconv-lite-umd', +@@ -1545,3 +1545,3 @@ export default defineConfig( '@vscode/native-watchdog', - '@vscode/policy-watcher', + '@vscodium/policy-watcher', '@vscode/proxy-agent', - '@vscode/spdlog', - '@vscode/windows-process-tree', diff --git a/package-lock.json b/package-lock.json -index 1d99e154..4acb64a7 100644 +index d3cffeca..ffabf71b 100644 --- a/package-lock.json +++ b/package-lock.json -@@ -29,7 +29,6 @@ - "@vscode/diff": "0.0.2-7", - "@vscode/iconv-lite-umd": "0.7.1", - "@vscode/native-watchdog": "^1.4.6", +@@ -33,3 +33,2 @@ + "@vscode/os-proxy-resolver": "^0.3.0", - "@vscode/policy-watcher": "^1.4.0", - "@vscode/proxy-agent": "^0.42.0", - "@vscode/ripgrep-universal": "^1.18.0", - "@vscode/sandbox-runtime": "0.0.1", -@@ -74,7 +73,8 @@ - "ws": "^8.19.0", - "yauzl": "^3.3.1", + "@vscode/proxy-agent": "^0.44.0", +@@ -80,3 +79,4 @@ "yazl": "^2.4.3", - "zod": "^3.25.76" + "zod": "^3.25.76", + "@vscodium/policy-watcher": "^1.3.2-252465" }, - "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.169", -@@ -4120,26 +4120,6 @@ - "hasInstallScript": true, - "license": "MIT" +@@ -4565,22 +4565,2 @@ }, - "node_modules/@vscode/policy-watcher": { - "version": "1.4.0", @@ -150,11 +110,7 @@ index 1d99e154..4acb64a7 100644 - } - }, "node_modules/@vscode/proxy-agent": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.42.0.tgz", -@@ -4503,6 +4483,26 @@ - "hasInstallScript": true, - "license": "MIT" +@@ -4988,2 +4968,22 @@ }, + "node_modules/@vscodium/policy-watcher": { + "version": "1.3.2-255408", @@ -177,28 +133,20 @@ index 1d99e154..4acb64a7 100644 + } + }, "node_modules/@webgpu/types": { - "version": "0.1.66", - "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.66.tgz", diff --git a/package.json b/package.json -index 2a5830cd..9a425766 100644 +index 6b0ff734..7ae109ca 100644 --- a/package.json +++ b/package.json -@@ -113,7 +113,7 @@ - "@vscode/diff": "0.0.2-7", - "@vscode/iconv-lite-umd": "0.7.1", - "@vscode/native-watchdog": "^1.4.6", +@@ -119,3 +119,3 @@ + "@vscode/os-proxy-resolver": "^0.3.0", - "@vscode/policy-watcher": "^1.4.0", + "@vscodium/policy-watcher": "^1.3.2-252465", - "@vscode/proxy-agent": "^0.42.0", - "@vscode/ripgrep-universal": "^1.18.0", - "@vscode/sandbox-runtime": "0.0.1", + "@vscode/proxy-agent": "^0.44.0", diff --git a/src/vs/base/test/node/uri.perf.data.txt b/src/vs/base/test/node/uri.perf.data.txt index ee0a24b5..881ce36a 100644 --- a/src/vs/base/test/node/uri.perf.data.txt +++ b/src/vs/base/test/node/uri.perf.data.txt -@@ -14696,52 +14696,52 @@ - /Users/example/node_modules/@vscode/node-addon-api/node_addon_api_except.target.mk - /Users/example/node_modules/@vscode/node-addon-api/node_addon_api.target.mk +@@ -14698,48 +14698,48 @@ /Users/example/node_modules/@vscode/node-addon-api/node_addon_api.Makefile -/Users/example/node_modules/@vscode/policy-watcher/LICENSE -/Users/example/node_modules/@vscode/policy-watcher/binding.gyp @@ -293,15 +241,11 @@ index ee0a24b5..881ce36a 100644 +/Users/example/node_modules/@vscodium/policy-watcher/src/windows/StringPolicy.cc +/Users/example/node_modules/@vscodium/policy-watcher/src/windows/NumberPolicy.hh /Users/example/node_modules/@vscode/vscode-languagedetection/CODE_OF_CONDUCT.md - /Users/example/node_modules/@vscode/vscode-languagedetection/LICENSE - /Users/example/node_modules/@vscode/vscode-languagedetection/dist/lib/index.js diff --git a/src/vs/platform/environment/test/node/nativeModules.integrationTest.ts b/src/vs/platform/environment/test/node/nativeModules.integrationTest.ts -index 975c3127..7ff873da 100644 +index 90667682..de250c8d 100644 --- a/src/vs/platform/environment/test/node/nativeModules.integrationTest.ts +++ b/src/vs/platform/environment/test/node/nativeModules.integrationTest.ts -@@ -60,9 +60,9 @@ flakySuite('Native Modules (all platforms)', () => { - assert.ok(typeof prompt.exec === 'function', testErrorMessage('@vscode/sudo-prompt')); - }); +@@ -62,5 +62,5 @@ flakySuite('Native Modules (all platforms)', () => { - test('@vscode/policy-watcher', async () => { - const watcher = await import('@vscode/policy-watcher'); @@ -310,62 +254,26 @@ index 975c3127..7ff873da 100644 + const watcher = await import('@vscodium/policy-watcher'); + assert.ok(typeof watcher.createWatcher === 'function', testErrorMessage('@vscodium/policy-watcher')); }); - - test('node-pty', async () => { -diff --git a/src/vs/platform/policy/node/nativePolicyService.ts b/src/vs/platform/policy/node/nativePolicyService.ts -index 6039dbb7..6fe3fe47 100644 ---- a/src/vs/platform/policy/node/nativePolicyService.ts -+++ b/src/vs/platform/policy/node/nativePolicyService.ts -@@ -6,7 +6,7 @@ - import { AbstractPolicyService, IPolicyService, PolicyDefinition, PolicyValue } from '../common/policy.js'; - import { IStringDictionary } from '../../../base/common/collections.js'; - import { Throttler } from '../../../base/common/async.js'; --import type { PolicyUpdate, Watcher } from '@vscode/policy-watcher'; -+import type { PolicyUpdate, Watcher } from '@vscodium/policy-watcher'; - import { MutableDisposable } from '../../../base/common/lifecycle.js'; - import { ILogService } from '../../log/common/log.js'; - -@@ -25,12 +25,12 @@ export class NativePolicyService extends AbstractPolicyService implements IPolic - protected async _updatePolicyDefinitions(policyDefinitions: IStringDictionary): Promise { - this.logService.trace(`NativePolicyService#_updatePolicyDefinitions - Found ${Object.keys(policyDefinitions).length} policy definitions`); - -- const { createWatcher } = await import('@vscode/policy-watcher'); -+ const { createWatcher } = await import('@vscodium/policy-watcher'); - - await this.throttler.queue(() => new Promise((c, e) => { - try { - this.logService.trace(`Creating watcher for productName ${this.productName}`); -- this.watcher.value = createWatcher(this.productName, policyDefinitions, update => { -+ this.watcher.value = createWatcher('BradfordCode', this.productName, policyDefinitions, update => { - this._onDidPolicyChange(update); - c(); - }); -diff --git a/src/vs/platform/policy/node/copilotManagedSettingsService.ts b/src/vs/platform/policy/node/copilotManagedSettingsService.ts -index 5b76979d..d8223ab9 100644 ---- a/src/vs/platform/policy/node/copilotManagedSettingsService.ts -+++ b/src/vs/platform/policy/node/copilotManagedSettingsService.ts -@@ -11,7 +11,7 @@ import { IManagedSettingsPolicyDefinitions, ManagedSettingsData } from '../../.. - import { ILogService } from '../../log/common/log.js'; - import { collectManagedSettingsDefinitions, ICopilotManagedSettingsService } from '../common/copilotManagedSettings.js'; +diff --git a/src/vs/platform/policy/node/nativeManagedSettingsService.ts b/src/vs/platform/policy/node/nativeManagedSettingsService.ts +index 29d621df..3a03fc32 100644 +--- a/src/vs/platform/policy/node/nativeManagedSettingsService.ts ++++ b/src/vs/platform/policy/node/nativeManagedSettingsService.ts +@@ -14,3 +14,3 @@ import { collectManagedSettingsDefinitions, INativeManagedSettingsService } from import { PolicyDefinition, PolicyValue } from '../common/policy.js'; -import type { Watcher } from '@vscode/policy-watcher'; +import type { Watcher } from '@vscodium/policy-watcher'; - export interface ICopilotPolicyWatcherOptions { - readonly registryPath?: string; -@@ -89,7 +89,22 @@ export class CopilotManagedSettingsService extends Disposable implements ICopilo - return; - } +@@ -92,3 +92,18 @@ export class NativeManagedSettingsService extends Disposable implements INativeM -- const { createWatcher } = this.watcherFactory ? { createWatcher: this.watcherFactory } : (await import('@vscode/policy-watcher') as { createWatcher: CopilotPolicyWatcherFactory }); -+ let createWatcher: CopilotPolicyWatcherFactory; +- const { createWatcher } = this.watcherFactory ? { createWatcher: this.watcherFactory } : (await import('@vscode/policy-watcher') as { createWatcher: NativePolicyWatcherFactory }); ++ let createWatcher: NativePolicyWatcherFactory; + if (this.watcherFactory) { + createWatcher = this.watcherFactory; + } else { + const policyWatcher = await import('@vscodium/policy-watcher'); + // `@vscodium/policy-watcher`'s `createWatcher` takes a leading vendor name + // and no options argument, unlike upstream `@vscode/policy-watcher`; adapt -+ // it to this service's CopilotPolicyWatcherFactory shape. ++ // it to this service's NativePolicyWatcherFactory shape. + const createNativeWatcher = policyWatcher.createWatcher as unknown as ( + vendorName: string, + productName: string, @@ -375,5 +283,22 @@ index 5b76979d..d8223ab9 100644 + createWatcher = (productName, policies, onDidChange) => createNativeWatcher('BradfordCode', productName, policies, onDidChange); + } await this.throttler.queue(() => new Promise((c, e) => { - try { - this.logService.trace(`Creating Copilot managed-settings watcher for productName ${this.productName}`); +diff --git a/src/vs/platform/policy/node/nativePolicyService.ts b/src/vs/platform/policy/node/nativePolicyService.ts +index 6039dbb7..6fe3fe47 100644 +--- a/src/vs/platform/policy/node/nativePolicyService.ts ++++ b/src/vs/platform/policy/node/nativePolicyService.ts +@@ -8,3 +8,3 @@ import { IStringDictionary } from '../../../base/common/collections.js'; + import { Throttler } from '../../../base/common/async.js'; +-import type { PolicyUpdate, Watcher } from '@vscode/policy-watcher'; ++import type { PolicyUpdate, Watcher } from '@vscodium/policy-watcher'; + import { MutableDisposable } from '../../../base/common/lifecycle.js'; +@@ -27,3 +27,3 @@ export class NativePolicyService extends AbstractPolicyService implements IPolic + +- const { createWatcher } = await import('@vscode/policy-watcher'); ++ const { createWatcher } = await import('@vscodium/policy-watcher'); + +@@ -32,3 +32,3 @@ export class NativePolicyService extends AbstractPolicyService implements IPolic + this.logService.trace(`Creating watcher for productName ${this.productName}`); +- this.watcher.value = createWatcher(this.productName, policyDefinitions, update => { ++ this.watcher.value = createWatcher('BradfordCode', this.productName, policyDefinitions, update => { + this._onDidPolicyChange(update); diff --git a/patches/50-build-improve-gulp-tasks.patch b/patches/50-build-improve-gulp-tasks.patch index 4806970..f95a769 100644 --- a/patches/50-build-improve-gulp-tasks.patch +++ b/patches/50-build-improve-gulp-tasks.patch @@ -1,34 +1,25 @@ diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts -index 88c66de6..386bbb0b 100644 +index 66514015..009e1d4d 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts -@@ -25,7 +25,7 @@ import { getProductionDependencies } from './lib/dependencies.ts'; - import { config } from './lib/electron.ts'; - import { createAsar } from './lib/asar.ts'; +@@ -27,3 +27,3 @@ import { createAsar } from './lib/asar.ts'; import minimist from 'minimist'; -import { compileBuildWithoutManglingTask, compileBuildWithManglingTask } from './gulpfile.compile.ts'; +import { compileBuildWithoutManglingTask } from './gulpfile.compile.ts'; import { compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileAllExtensionsBuildTask, compileExtensionMediaBuildTask, cleanExtensionsBuildTask, compileCopilotExtensionBuildTask } from './gulpfile.extensions.ts'; - import { copyCodiconsTask } from './lib/compilation.ts'; - import { getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getCopilotTgrepExcludeFilter, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; -@@ -655,26 +655,46 @@ BUILD_TARGETS.forEach(buildTarget => { - minified && useCdnSourceMapsForPackagingTasks ? `${sourceMappingURLBase}/core` : undefined - ) +@@ -724,3 +724,3 @@ BUILD_TARGETS.forEach(buildTarget => { ); - vscodeTask = task.define(`vscode${dashed(platform)}${dashed(arch)}${dashed(minified)}`, task.series( + const prepackTask = task.define(`vscode${dashed(minified)}-prepack`, task.series( copyCodiconsTask, - cleanExtensionsBuildTask, - compileNonNativeExtensionsBuildTask, - compileCopilotExtensionBuildTask, +@@ -730,2 +730,6 @@ BUILD_TARGETS.forEach(buildTarget => { compileExtensionMediaBuildTask, + )); + task.task(prepackTask); + + const packingTask = task.define(`vscode${dashed(platform)}${dashed(arch)}${dashed(minified)}-packing`, task.series( writeISODate('out-build'), - esbuildBundleTask, - vscodeTaskCI +@@ -734,5 +738,11 @@ BUILD_TARGETS.forEach(buildTarget => { )); - } else { + task.task(packingTask); @@ -42,9 +33,7 @@ index 88c66de6..386bbb0b 100644 + const prepackTask = task.define(`vscode${dashed(minified)}-prepack`, task.series( + compileBuildWithoutManglingTask, cleanExtensionsBuildTask, - compileNonNativeExtensionsBuildTask, - compileCopilotExtensionBuildTask, - compileExtensionMediaBuildTask, +@@ -742,4 +752,14 @@ BUILD_TARGETS.forEach(buildTarget => { minified ? minifyVSCodeTask : bundleVSCodeTask, + )); + task.task(prepackTask); @@ -59,5 +48,3 @@ index 88c66de6..386bbb0b 100644 + packingTask, + )); } - task.task(vscodeTask); - diff --git a/patches/51-ext-copilot-remove-it.patch b/patches/51-ext-copilot-remove-it.patch index 904b0f3..dbbd3c0 100644 --- a/patches/51-ext-copilot-remove-it.patch +++ b/patches/51-ext-copilot-remove-it.patch @@ -1,38 +1,28 @@ diff --git a/build/gulpfile.reh.ts b/build/gulpfile.reh.ts -index 62c30da5..cac6a2e4 100644 +index a40ff6f6..241d3dc3 100644 --- a/build/gulpfile.reh.ts +++ b/build/gulpfile.reh.ts -@@ -23,13 +23,12 @@ import glob from 'glob'; - import { promisify } from 'util'; - import rceditCallback from 'rcedit'; +@@ -25,3 +25,3 @@ import rceditCallback from 'rcedit'; import { compileBuildWithManglingTask } from './gulpfile.compile.ts'; -import { cleanExtensionsBuildTask, compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileExtensionMediaBuildTask, compileCopilotExtensionBuildTask } from './gulpfile.extensions.ts'; +import { cleanExtensionsBuildTask, compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileExtensionMediaBuildTask } from './gulpfile.extensions.ts'; import { vscodeWebResourceIncludes, createVSCodeWebFileContentMapper } from './gulpfile.vscode.web.ts'; - import * as cp from 'child_process'; - import log from 'fancy-log'; - import buildfile from './buildfile.ts'; - import { fetchUrls, fetchGithub } from './lib/fetch.ts'; --import { getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getCopilotTgrepExcludeFilter, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; +@@ -33,3 +33,2 @@ import { fetchUrls } from './lib/fetch.ts'; + import { downloadFeedPackage } from './lib/azureFeed.ts'; +-import { ensureCopilotPlatformPackage, getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getCopilotTgrepExcludeFilter, getMxcExcludeFilter, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; import { readAgentSdkResults } from './agent-sdk/common.ts'; - - -@@ -395,11 +394,7 @@ function packageTask(type: string, platform: string, arch: string, sourceFolderN - .pipe(filter(['**', '!**/package-lock.json', '!**/*.{js,css}.map'])) - .pipe(util.cleanNodeModules(path.join(import.meta.dirname, '.moduleignore'))) +@@ -444,9 +443,3 @@ function packageTask(type: string, platform: string, arch: string, sourceFolderN .pipe(util.cleanNodeModules(path.join(import.meta.dirname, `.moduleignore.${process.platform}`))); +- ensureCopilotPlatformPackage(platform, arch, 'remote/node_modules'); - const copilotRuntimePrebuilds = gulp.src(getCopilotRuntimePrebuildFiles(platform, arch, 'remote/node_modules'), { base: 'remote', dot: true, allowEmpty: true }); - const deps = es.merge(cleanedDeps, copilotRuntimePrebuilds) - .pipe(filter(getCopilotExcludeFilter(platform, arch))) - .pipe(filter(getCopilotTgrepExcludeFilter(platform, arch))) - .pipe(filter(getRipgrepExcludeFilter(platform, arch))) +- .pipe(filter(getMxcExcludeFilter(arch))) + const deps = cleanedDeps .pipe(jsFilter) - .pipe(util.stripSourceMappingURL()) - .pipe(jsFilter.restore); -@@ -553,16 +548,6 @@ function patchWin32DependenciesTask(destinationFolderName: string) { - }; - } +@@ -604,12 +597,2 @@ function patchWin32DependenciesTask(destinationFolderName: string) { -function prepareCopilotRipgrepShimTaskREH(platform: string, arch: string, destinationFolderName: string) { - return async () => { @@ -45,55 +35,39 @@ index 62c30da5..cac6a2e4 100644 -} - /** - * @param product The parsed product.json file contents - */ -@@ -612,7 +597,6 @@ function tweakProductForServerWeb(product: typeof import('../product.json')) { - task.task(`node-${platform}-${arch}`) as task.Task, - util.rimraf(path.join(BUILD_ROOT, destinationFolderName)), +@@ -663,3 +646,2 @@ function tweakProductForServerWeb(product: typeof import('../product.json')) { packageTask(type, platform, arch, sourceFolderName, destinationFolderName), - prepareCopilotRipgrepShimTaskREH(platform, arch, destinationFolderName) ]; - - if (platform === 'win32') { -@@ -626,7 +610,6 @@ function tweakProductForServerWeb(product: typeof import('../product.json')) { - compileBuildWithManglingTask, - cleanExtensionsBuildTask, +@@ -677,3 +659,2 @@ function tweakProductForServerWeb(product: typeof import('../product.json')) { compileNonNativeExtensionsBuildTask, - compileCopilotExtensionBuildTask, compileExtensionMediaBuildTask, - minified ? minifyTask : bundleTask, - serverTaskCI diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts -index db372863..c5af9fda 100644 +index 009e1d4d..222f54b0 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts -@@ -26,9 +26,8 @@ import { config } from './lib/electron.ts'; - import { createAsar } from './lib/asar.ts'; - import minimist from 'minimist'; +@@ -28,5 +28,4 @@ import minimist from 'minimist'; import { compileBuildWithoutManglingTask } from './gulpfile.compile.ts'; -import { compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileAllExtensionsBuildTask, compileExtensionMediaBuildTask, cleanExtensionsBuildTask, compileCopilotExtensionBuildTask } from './gulpfile.extensions.ts'; +import { compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileAllExtensionsBuildTask, compileExtensionMediaBuildTask, cleanExtensionsBuildTask } from './gulpfile.extensions.ts'; import { copyCodiconsTask } from './lib/compilation.ts'; --import { getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getCopilotTgrepExcludeFilter, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; - import { readAgentSdkResults } from './agent-sdk/common.ts'; - import { useEsbuildTranspile } from './buildConfig.ts'; - import { promisify } from 'util'; -@@ -340,11 +339,7 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d - .pipe(filter(depFilterPattern)) - .pipe(util.cleanNodeModules(path.join(import.meta.dirname, '.moduleignore'))) +-import { ensureCopilotPlatformPackage, getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getCopilotTgrepExcludeFilter, getMxcExcludeFilter, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; + import { ensureOSProxyResolverPlatformPackage, getOSProxyResolverExcludeFilter, getOSProxyResolverPlatformFiles } from './lib/osProxyResolver.ts'; +@@ -371,11 +370,5 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d .pipe(util.cleanNodeModules(path.join(import.meta.dirname, `.moduleignore.${process.platform}`))); +- ensureCopilotPlatformPackage(platform, arch); - const copilotRuntimePrebuilds = gulp.src(getCopilotRuntimePrebuildFiles(platform, arch), { base: '.', dot: true, allowEmpty: true }); -- const deps = es.merge(cleanedDeps, copilotRuntimePrebuilds) + ensureOSProxyResolverPlatformPackage(platform, arch); + const osProxyResolverPlatformPackage = gulp.src(getOSProxyResolverPlatformFiles(platform, arch), { base: '.', dot: true, allowEmpty: true }); +- const deps = es.merge(cleanedDeps, copilotRuntimePrebuilds, osProxyResolverPlatformPackage) - .pipe(filter(getCopilotExcludeFilter(platform, arch))) - .pipe(filter(getCopilotTgrepExcludeFilter(platform, arch))) - .pipe(filter(getRipgrepExcludeFilter(platform, arch))) -+ const deps = cleanedDeps - .pipe(jsFilter) - .pipe(util.rewriteSourceMappingURL(sourceMappingURLBase)) - .pipe(jsFilter.restore) -@@ -601,23 +596,6 @@ function patchWin32DependenciesTask(destinationFolderName: string) { - }; - } +- .pipe(filter(getMxcExcludeFilter(arch))) ++ const deps = es.merge(cleanedDeps, osProxyResolverPlatformPackage) + .pipe(filter(getFoundryLocalExcludeFilter())) +@@ -660,19 +653,2 @@ function patchWin32DependenciesTask(destinationFolderName: string) { -function prepareCopilotRipgrepShimTask(platform: string, arch: string, destinationFolderName: string) { - const outputDir = path.join(path.dirname(root), destinationFolderName); @@ -105,7 +79,7 @@ index db372863..c5af9fda 100644 - const appBase = platform === 'darwin' - ? path.join(outputDir, `${product.nameLong}.app`, 'Contents', 'Resources', 'app') - : path.join(outputDir, versionedResourcesFolder, 'resources', 'app'); -- const appNodeModulesDir = path.join(appBase, 'node_modules'); +- const appNodeModulesDir = path.join(appBase, 'node_modules.asar.unpacked'); - - const builtInCopilotExtensionDir = path.join(appBase, 'extensions', 'copilot'); - prepareBuiltInCopilotRipgrepShim(platform, arch, builtInCopilotExtensionDir, appNodeModulesDir); @@ -113,53 +87,32 @@ index db372863..c5af9fda 100644 -} - const buildRoot = path.dirname(root); - - const BUILD_TARGETS = [ -@@ -643,7 +621,6 @@ BUILD_TARGETS.forEach(buildTarget => { - compileNativeExtensionsBuildTask, - util.rimraf(path.join(buildRoot, destinationFolderName)), +@@ -702,3 +678,2 @@ BUILD_TARGETS.forEach(buildTarget => { packageTask(platform, arch, sourceFolderName, destinationFolderName, opts), - prepareCopilotRipgrepShimTask(platform, arch, destinationFolderName) ]; - - if (platform === 'win32') { -@@ -669,7 +646,6 @@ BUILD_TARGETS.forEach(buildTarget => { - copyCodiconsTask, - cleanExtensionsBuildTask, +@@ -728,3 +703,2 @@ BUILD_TARGETS.forEach(buildTarget => { compileNonNativeExtensionsBuildTask, - compileCopilotExtensionBuildTask, compileExtensionMediaBuildTask, - )); - task.task(prepackTask); -@@ -690,7 +666,6 @@ BUILD_TARGETS.forEach(buildTarget => { - compileBuildWithoutManglingTask, - cleanExtensionsBuildTask, +@@ -749,3 +723,2 @@ BUILD_TARGETS.forEach(buildTarget => { compileNonNativeExtensionsBuildTask, - compileCopilotExtensionBuildTask, compileExtensionMediaBuildTask, - minified ? minifyVSCodeTask : bundleVSCodeTask, - )); diff --git a/build/npm/dirs.ts b/build/npm/dirs.ts index 289a4697..8c6df6f7 100644 --- a/build/npm/dirs.ts +++ b/build/npm/dirs.ts -@@ -15,7 +15,6 @@ export const dirs = [ - 'build/vite', - 'extensions', +@@ -17,3 +17,2 @@ export const dirs = [ 'extensions/configuration-editing', - 'extensions/copilot', 'extensions/css-language-features', - 'extensions/css-language-features/server', - 'extensions/debug-auto-launch', diff --git a/build/npm/postinstall.ts b/build/npm/postinstall.ts -index 0d00ac39..7e2ef16c 100644 +index 0dd0b514..f111993a 100644 --- a/build/npm/postinstall.ts +++ b/build/npm/postinstall.ts -@@ -334,21 +334,6 @@ async function main() { - if (claudeSkillsLinkType !== 'existing') { - log('.', `Created ${claudeSkillsLinkType} .claude/skills -> .agents/skills`); - } -- +@@ -337,17 +337,2 @@ async function main() { + - // Temporary: patch @github/copilot-sdk session.js to fix ESM import - // (missing .js extension on vscode-jsonrpc/node). Fixed upstream in v0.1.32. - // TODO: Remove once @github/copilot-sdk is updated to >=0.1.32 @@ -174,53 +127,45 @@ index 0d00ac39..7e2ef16c 100644 - } - } - } - } - - main().catch(err => { +- + // foundry-local-sdk (on-device chat dictation) resolves its prebuilt N-API diff --git a/package-lock.json b/package-lock.json -index 4acb64a7..2ec27af6 100644 +index 7c4b3c2f..d7c29e27 100644 --- a/package-lock.json +++ b/package-lock.json -@@ -11,8 +11,6 @@ - "license": "MIT", - "dependencies": { +@@ -13,4 +13,2 @@ "@anthropic-ai/sdk": "^0.82.0", -- "@github/copilot": "^1.0.64-0", -- "@github/copilot-sdk": "^1.0.2", +- "@github/copilot": "^1.0.77", +- "@github/copilot-sdk": "^1.0.9-preview.1", "@microsoft/1ds-core-js": "^3.2.13", - "@microsoft/1ds-post-js": "^3.2.13", - "@microsoft/dev-tunnels-connections": "^1.3.41", -@@ -1084,192 +1082,6 @@ - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } +@@ -1114,188 +1112,2 @@ }, - "node_modules/@github/copilot": { -- "version": "1.0.64-0", -- "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.64-0.tgz", -- "integrity": "sha512-PlH7ByBHjmPLqLXS4CE2q8hN6CFEfkCMV6ScBEzW/u73+KYQB4fGNouo8Lr8okL6D5CW5rzPJbsXyISyJqVOZg==", +- "version": "1.0.77", +- "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.77.tgz", +- "integrity": "sha512-nkTtDPKvsClAByPPqnD/57vK7YIBK1dgiv7aVc9uO3rxKCyqiqYaBqwi8pMzesvGP3yl+//+iMzaBXNWEcZVWQ==", - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { -- "detect-libc": "^2.1.2", -- "os-theme": "^0.0.8" +- "detect-libc": "^2.1.2" - }, - "bin": { - "copilot": "npm-loader.js" - }, - "optionalDependencies": { -- "@github/copilot-darwin-arm64": "1.0.64-0", -- "@github/copilot-darwin-x64": "1.0.64-0", -- "@github/copilot-linux-arm64": "1.0.64-0", -- "@github/copilot-linux-x64": "1.0.64-0", -- "@github/copilot-linuxmusl-arm64": "1.0.64-0", -- "@github/copilot-linuxmusl-x64": "1.0.64-0", -- "@github/copilot-win32-arm64": "1.0.64-0", -- "@github/copilot-win32-x64": "1.0.64-0" +- "@github/copilot-darwin-arm64": "1.0.77", +- "@github/copilot-darwin-x64": "1.0.77", +- "@github/copilot-linux-arm64": "1.0.77", +- "@github/copilot-linux-x64": "1.0.77", +- "@github/copilot-linuxmusl-arm64": "1.0.77", +- "@github/copilot-linuxmusl-x64": "1.0.77", +- "@github/copilot-win32-arm64": "1.0.77", +- "@github/copilot-win32-x64": "1.0.77" - } - }, - "node_modules/@github/copilot-darwin-arm64": { -- "version": "1.0.64-0", -- "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.64-0.tgz", -- "integrity": "sha512-97DUGiuYrkCYOlSSLWMmr+K0uGzAxz1JOL/GyO/7mNl6V/1xgs6Van1Jj+Dpj4ly96iHE8lUIW8cQNCG66644g==", +- "version": "1.0.77", +- "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.77.tgz", +- "integrity": "sha512-sCWSH5+Flm/OxFe7dzsBfyj7ADBkzkR54Sz5NGw7dtcVVEOnVUkZLjEtNmZ1t5QRD4Sf1+g/DiwgJEbsR9xR1w==", - "cpu": [ - "arm64" - ], @@ -234,9 +179,9 @@ index 4acb64a7..2ec27af6 100644 - } - }, - "node_modules/@github/copilot-darwin-x64": { -- "version": "1.0.64-0", -- "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.64-0.tgz", -- "integrity": "sha512-2PXY4mSFtIjFdRaAt8PakegRgGtf6Sz9z6U/dIgVygNfctVNzaL5FH65PNPm8Y80jaDvEcz1/XY5MiQtxnlzZQ==", +- "version": "1.0.77", +- "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.77.tgz", +- "integrity": "sha512-ReNlB+g+OBiqHwmY5leJBIyvHZQcjyWL/OY8aVimHyESn2ToPKP3eUNTzSUJvvbPM6+0LXwEpijLedkRd2Cn1g==", - "cpu": [ - "x64" - ], @@ -250,9 +195,9 @@ index 4acb64a7..2ec27af6 100644 - } - }, - "node_modules/@github/copilot-linux-arm64": { -- "version": "1.0.64-0", -- "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.64-0.tgz", -- "integrity": "sha512-PLP+vR508fOTlCr9CSZiXi9geicHKXuX9jLGdwNqK2TMZO5TqCLz8wP7dBEmkdkeXcFKovMb8nQVB1Toc6xutw==", +- "version": "1.0.77", +- "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.77.tgz", +- "integrity": "sha512-A8j/WBPFvV5WfLbgnIIQLUVuFRAR7kLyc5WgId6XLCu1ARbkRM7353zz9mEXXwjc6LqotHVg80ooANJjNtmSPg==", - "cpu": [ - "arm64" - ], @@ -269,9 +214,9 @@ index 4acb64a7..2ec27af6 100644 - } - }, - "node_modules/@github/copilot-linux-x64": { -- "version": "1.0.64-0", -- "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.64-0.tgz", -- "integrity": "sha512-NvVjQ69zr390ijzo2f75+v0DHm6xnvPbi67ugnKDk7ZPbx8P3vSxVdAnrzrrL4T3T8ng3pJANcC4p+eGbx+UDw==", +- "version": "1.0.77", +- "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.77.tgz", +- "integrity": "sha512-2eefKkdUnQ1Y8oxyRyexHBXVpuSmrfEM8XJauquVjPc0JqF5nab9axwpFPzrRSF1GB+25F9tUK2sDQRyp08wag==", - "cpu": [ - "x64" - ], @@ -288,9 +233,9 @@ index 4acb64a7..2ec27af6 100644 - } - }, - "node_modules/@github/copilot-linuxmusl-arm64": { -- "version": "1.0.64-0", -- "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.64-0.tgz", -- "integrity": "sha512-qCnVF5vIcTO74CukAENZo8e5nqXm4QUshuKN69aiZb5GOhVvyyIKsf5Jo7ikZt54jJBHycAMUKlTA8L3/nK+KA==", +- "version": "1.0.77", +- "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.77.tgz", +- "integrity": "sha512-YtltOZQp8plytSKSGTWWKbOx3QD8iZH04sLtKTrYs6nu5UalIgFPoMkwamy1gh7h5EBkeVxD2s3epVrlvP4X4w==", - "cpu": [ - "arm64" - ], @@ -307,9 +252,9 @@ index 4acb64a7..2ec27af6 100644 - } - }, - "node_modules/@github/copilot-linuxmusl-x64": { -- "version": "1.0.64-0", -- "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.64-0.tgz", -- "integrity": "sha512-WDBEmkBk1RulTfdLK5IuttNBadjLOBpvQyonGQ/aLeaetRNNdapoygrSjFU7q1QBSenmCyanXH6D+TS7tP3Qsw==", +- "version": "1.0.77", +- "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.77.tgz", +- "integrity": "sha512-owINwPgHU/ZZBwFhVPgkgGjLkF6e4QbdofADvKMdKJWV2+7oWjXUIlPA4/PwraD2Gkuu583l7m0XLL27TN8oUA==", - "cpu": [ - "x64" - ], @@ -326,12 +271,13 @@ index 4acb64a7..2ec27af6 100644 - } - }, - "node_modules/@github/copilot-sdk": { -- "version": "1.0.2", -- "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.2.tgz", -- "integrity": "sha512-JJDsGM/bA1LGy1Ro/8iC8RLpKsLmuiFdQ67oFAVfi0Hfxyx289teHwmM70ehK76DXBkrPqqcxcliJi56k1ggFA==", +- "version": "1.0.9-preview.1", +- "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.9-preview.1.tgz", +- "integrity": "sha512-/hUYdxpa4HL57uKmRCLmcg31wWjZPKGBbBfIhVqJdWXkp4/E6lbtHoiIbT5SLCPpf4c8Ka5zw356z+k4Nn/diA==", - "license": "MIT", - "dependencies": { -- "@github/copilot": "^1.0.64-0", +- "@github/copilot": "^1.0.76-5", +- "koffi": "^3.1.0", - "vscode-jsonrpc": "^8.2.1", - "zod": "^4.3.6" - }, @@ -349,9 +295,9 @@ index 4acb64a7..2ec27af6 100644 - } - }, - "node_modules/@github/copilot-win32-arm64": { -- "version": "1.0.64-0", -- "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.64-0.tgz", -- "integrity": "sha512-PC7yuUKcVbhli4bpzWFVT3juxj+v/iONazetNe3tMpHWza3W7MeFRifzAseSErKQCt2fHJth3m8bQAwFN2jfrA==", +- "version": "1.0.77", +- "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.77.tgz", +- "integrity": "sha512-l5oQaMLCRup0nmmpbqOAYEAJ5YWgNlaoO0psNaKDzvTbdzEJRZqib2t7+p3bgoDpK7SB/m8m1uxFC4XT3hlprg==", - "cpu": [ - "arm64" - ], @@ -365,9 +311,9 @@ index 4acb64a7..2ec27af6 100644 - } - }, - "node_modules/@github/copilot-win32-x64": { -- "version": "1.0.64-0", -- "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.64-0.tgz", -- "integrity": "sha512-d2fnUTIlqNxCqS2PuV+FD99ZOYBaX72OLtAmphbKyz36KyZ6D4ssiu8M4vHVTKWWdyc3TWiLsnIB+ryWdv1gGw==", +- "version": "1.0.77", +- "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.77.tgz", +- "integrity": "sha512-8Mo9y3/8CVU2w35WqwSiRMTGH1kKHR3URPSJYF4J4OG8L7NOEy2fafXR9Tuq3H21Srg3OzFkl/A+Taunqz9KcA==", - "cpu": [ - "x64" - ], @@ -381,28 +327,20 @@ index 4acb64a7..2ec27af6 100644 - } - }, "node_modules/@gulp-sourcemaps/identity-map": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@gulp-sourcemaps/identity-map/-/identity-map-2.0.1.tgz", diff --git a/package.json b/package.json -index 9a425766..594fa86b 100644 +index c80e65a6..0815ec65 100644 --- a/package.json +++ b/package.json -@@ -95,8 +95,6 @@ - }, - "dependencies": { +@@ -99,4 +99,2 @@ "@anthropic-ai/sdk": "^0.82.0", -- "@github/copilot": "^1.0.64-0", -- "@github/copilot-sdk": "^1.0.2", +- "@github/copilot": "^1.0.77", +- "@github/copilot-sdk": "^1.0.9-preview.1", "@microsoft/1ds-core-js": "^3.2.13", - "@microsoft/1ds-post-js": "^3.2.13", - "@microsoft/dev-tunnels-connections": "^1.3.41", diff --git a/src/vs/platform/agentHost/common/otel/agentHostOTelService.ts b/src/vs/platform/agentHost/common/otel/agentHostOTelService.ts -index 667e3bc8..634bca98 100644 +index d116b231..f98ea668 100644 --- a/src/vs/platform/agentHost/common/otel/agentHostOTelService.ts +++ b/src/vs/platform/agentHost/common/otel/agentHostOTelService.ts -@@ -3,7 +3,17 @@ - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ +@@ -5,3 +5,13 @@ -import type { TelemetryConfig } from '@github/copilot-sdk'; +// `TelemetryConfig` was originally imported from '@github/copilot-sdk'; the SDK @@ -417,56 +355,50 @@ index 667e3bc8..634bca98 100644 + captureContent?: boolean; +}; import type { URI } from '../../../../base/common/uri.js'; - import { createDecorator } from '../../../instantiation/common/instantiation.js'; +diff --git a/src/vs/platform/agentHost/node/agentHostGitHubTelemetryRouter.ts b/src/vs/platform/agentHost/node/agentHostGitHubTelemetryRouter.ts +index ab773491..074f8b17 100644 +--- a/src/vs/platform/agentHost/node/agentHostGitHubTelemetryRouter.ts ++++ b/src/vs/platform/agentHost/node/agentHostGitHubTelemetryRouter.ts +@@ -5,3 +5,6 @@ +-import type { GitHubTelemetryNotification } from '@github/copilot-sdk'; ++// `GitHubTelemetryNotification` 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 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 0dd36914..6440c830 100644 +index bb684cc8..6a5c83c2 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts -@@ -20,7 +20,6 @@ import { AgentService } from './agentService.js'; - import { IAgentConfigurationService } from './agentConfigurationService.js'; - import { IAgentHostCompletions } from './agentHostCompletions.js'; +@@ -25,3 +25,2 @@ import { IAgentHostCompletions } from './agentHostCompletions.js'; import { IAgentHostTerminalManager } from './agentHostTerminalManager.js'; -import { CopilotAgent } from './copilot/copilotAgent.js'; - import { CopilotBranchNameGenerator, ICopilotBranchNameGenerator } from './copilot/copilotBranchNameGenerator.js'; - import { CopilotApiService, ICopilotApiService } from './shared/copilotApiService.js'; - import { ClaudeAgent } from './claude/claudeAgent.js'; -@@ -184,7 +183,6 @@ async function startAgentHost(): Promise { - diServices.set(IAgentHostTerminalManager, agentService.terminalManager); - diServices.set(IAgentConfigurationService, agentService.configurationService); - diServices.set(IAgentHostCompletions, agentService.completionsService); + import { WorktreeIsolation } from './shared/worktreeIsolation.js'; +@@ -240,3 +239,2 @@ async function startAgentHost(): Promise { + diServices.set(ICodexProxyService, codexProxyService); - agentService.registerProvider(instantiationService.createInstance(CopilotAgent)); // Claude and Codex providers are gated on two things: - // 1. The user-facing enable toggle (`chat.agentHost.Agent.enabled`, - // forwarded as an env var by the starters). Claude defaults to on, diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts -index 5c428d75..a23b5047 100644 +index 1c40a1ce..a6499444 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts -@@ -33,7 +33,6 @@ import { IProductService } from '../../product/common/productService.js'; - import { InstantiationService } from '../../instantiation/common/instantiationService.js'; - import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; - import { registerAgentHostNetworkServices } from './agentHostBootstrap.js'; +@@ -37,3 +37,2 @@ import { registerAgentHostNetworkServices } from './agentHostBootstrap.js'; + import { BANG_COMMAND_PREFIX } from './agentHostBangCommand.js'; -import { CopilotAgent } from './copilot/copilotAgent.js'; - import { CopilotBranchNameGenerator, ICopilotBranchNameGenerator } from './copilot/copilotBranchNameGenerator.js'; - import { CopilotApiService, ICopilotApiService } from './shared/copilotApiService.js'; - import { ClaudeAgent } from './claude/claudeAgent.js'; -@@ -282,9 +281,6 @@ async function main(): Promise { - diServices.set(ICodexProxyService, codexProxyService); - const agentHostOTelService = disposables.add(instantiationService.createInstance(AgentHostOTelService)); - diServices.set(IAgentHostOTelService, agentHostOTelService); + import { INetworkDiagnosticsService, NetworkDiagnosticsService } from './networkDiagnosticsService.js'; +@@ -316,5 +315,2 @@ async function main(): Promise { + diServices.set(IByokLmProxyService, new NullByokLmProxyService()); - const copilotAgent = disposables.add(instantiationService.createInstance(CopilotAgent)); - agentService.registerProvider(copilotAgent); - log('CopilotAgent registered'); // Claude and Codex providers are gated on two things: - // 1. The user-facing enable toggle (`chat.agentHost.Agent.enabled`, - // forwarded as an env var by the renderer-side starters; the remote diff --git a/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts b/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts deleted file mode 100644 -index ec3155d1..00000000 +index 461cf23b..00000000 --- a/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts +++ /dev/null -@@ -1,136 +0,0 @@ +@@ -1,140 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -484,6 +416,7 @@ index ec3155d1..00000000 -import { ISandboxHelperService, type ISandboxDependencyStatus, type IWindowsMxcPolicyContainment, type IWindowsMxcSandboxPolicy } from '../../../sandbox/common/sandboxHelperService.js'; -import { ITerminalSandboxEngineHost, ITerminalSandboxRuntimeInfo, TerminalSandboxEngine } from '../../../sandbox/common/terminalSandboxEngine.js'; -import { IAgentConfigurationService } from '../agentConfigurationService.js'; +-import { getAppNodeModulesDirName } from '../appNodeModules.js'; -import { AgentHostSandboxConfigKey, sandboxConfigSchema, sandboxSettingIdToAgentHostKey } from '../../common/sandboxConfigSchema.js'; - -/** Subdirectory under the user home + product data folder where the engine creates its temp dir. */ @@ -518,7 +451,12 @@ index ec3155d1..00000000 - async getRuntimeInfo(): Promise { - const appRoot = dirname(FileAccess.asFileUri('').path); - const runAsNode = !!process.versions['electron']; -- return { appRoot, execPath: process.execPath, runAsNode }; +- // In the desktop app the native binaries (ripgrep-universal, mxc-sdk) are +- // unpacked from the ASAR archive into `node_modules.asar.unpacked`; in dev +- // and on the server (which has no ASAR) they remain in a plain +- // `node_modules`. +- const nativeModulesDir = getAppNodeModulesDirName(); +- return { appRoot, execPath: process.execPath, runAsNode, nativeModulesDir }; - } - - async getUserHome(): Promise { @@ -572,9 +510,7 @@ index ec3155d1..00000000 - // The agent host stores sandbox settings nested under a single - // top-level `sandbox` object with prefix-free sub-keys (e.g. - // `sandbox.enabled` rather than `chat.agent.sandbox.enabled`). Map -- // from the engine's modern setting ID into that sub-key namespace; -- // unknown IDs (which include all deprecated keys — handled host-side -- // by the workbench client) resolve to undefined. +- // from the engine's setting ID into that sub-key namespace. - const innerKey = sandboxSettingIdToAgentHostKey[settingId]; - if (innerKey === undefined) { - return undefined; @@ -603,94 +539,170 @@ index ec3155d1..00000000 - return instantiationService.createInstance(TerminalSandboxEngine, host); -} - +diff --git a/src/vs/platform/agentHost/node/copilot/buildSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/buildSessionEvents.ts +index ce31c525..dbf25212 100644 +--- a/src/vs/platform/agentHost/node/copilot/buildSessionEvents.ts ++++ b/src/vs/platform/agentHost/node/copilot/buildSessionEvents.ts +@@ -5,3 +5,6 @@ + +-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 { 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 3f37722b..00000000 +index c727ddb9..00000000 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ /dev/null -@@ -1,3187 +0,0 @@ +@@ -1,4574 +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 { CopilotClient, RuntimeConnection, type CopilotClientOptions } from '@github/copilot-sdk'; +-import { CopilotClient, RuntimeConnection, type CopilotClientOptions, type GitHubTelemetryNotification } from '@github/copilot-sdk'; -import * as fs from 'fs/promises'; -import * as os from 'os'; --import { CancelablePromise, createCancelablePromise, Delayer, Limiter, SequencerByKey } from '../../../../base/common/async.js'; +-import { pathToFileURL } from 'url'; +-import { CancelablePromise, createCancelablePromise, Delayer, disposableTimeout, Limiter, SequencerByKey } from '../../../../base/common/async.js'; -import { type CancellationToken } from '../../../../base/common/cancellation.js'; -import { CancellationError } from '../../../../base/common/errors.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; --import { appendEscapedMarkdownInlineCode } from '../../../../base/common/htmlContent.js'; --import { Disposable, DisposableMap, MutableDisposable } from '../../../../base/common/lifecycle.js'; +-import { combinedDisposable, Disposable, DisposableMap, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { ResourceMap } from '../../../../base/common/map.js'; -import { FileAccess } from '../../../../base/common/network.js'; -import { formatTokenCount } from '../../../../base/common/numbers.js'; -import { equals } from '../../../../base/common/objects.js'; --import { observableValue } from '../../../../base/common/observable.js'; --import { basename, delimiter, dirname, join } from '../../../../base/common/path.js'; --import { basename as resourceBasename } from '../../../../base/common/resources.js'; +-import { autorun, observableValue, type ISettableObservable } from '../../../../base/common/observable.js'; +-import { delimiter, dirname, join } from '../../../../base/common/path.js'; +-import { basename as resourceBasename, isEqual, isEqualOrParent, joinPath as resourceJoinPath, relativePath } from '../../../../base/common/resources.js'; -import { URI } from '../../../../base/common/uri.js'; -import { generateUuid } from '../../../../base/common/uuid.js'; -import { rgDiskPath } from '../../../../base/node/ripgrep.js'; -import { localize } from '../../../../nls.js'; --import { IParsedAgent, IParsedPlugin, IParsedRule, IParsedSkill, parseAgentFile, parsePlugin, parseRuleFile, parseSkillFile } from '../../../agentPlugins/common/pluginParsers.js'; +-import { IParsedAgent, IParsedPlugin, IParsedRule, IParsedSkill, parseAgentFile, parsePlugin, parseRuleFile, parseSkillFile, PluginFormat } from '../../../agentPlugins/common/pluginParsers.js'; +-import { AiAgentEnvValue, AiAgentEnvVar } from '../../../chat/common/aiAgentEnv.js'; -import { IFileService } from '../../../files/common/files.js'; -import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; -import { ILogService, LogLevel } from '../../../log/common/log.js'; +-import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; +-import { INativeEnvironmentService } from '../../../../platform/environment/common/environment.js'; +-import { workspacelessScratchDir } from '../workspacelessScratchDir.js'; -import { IAgentHostCheckpointService } from '../../common/agentHostCheckpointService.js'; --import { createAgentModelPricingMeta } from '../../common/agentModelPricing.js'; --import { AgentHostConfigKey, agentHostCustomizationConfigSchema, toContainerCustomization } from '../../common/agentHostCustomizationConfig.js'; --import { AgentHostMcpServersConfigKey, AgentHostSessionSyncEnabledConfigKey, AutoApproveLevel, ISchemaProperty, SessionMode, createSchema, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, schemaProperty, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; +-import { IAgentHostReviewService } from '../../common/agentHostReviewService.js'; +-import { createPricingMetaFromBilling, hasLongContextSurcharge, normalizeCAPIBilling, type ICAPIModelBilling } from '../../common/agentModelPricing.js'; +-import { createAgentModelByokMeta } from '../../common/agentModelByokMeta.js'; +-import { AgentHostConfigKey, agentHostCustomizationConfigSchema, DEFAULT_SESSION_CUSTOMIZATION_DISCOVERY_MODE, toContainerCustomization } from '../../common/agentHostCustomizationConfig.js'; +-import { CopilotCliConfigKey, copilotCliConfigSchema, type CopilotSdkLogLevelSetting } from '../../common/copilotCliConfig.js'; +-import { AgentHostMcpServersConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AutoApproveLevel, SessionMode, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; -import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; --import { AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, IAgent, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IMcpNotification } from '../../common/agentService.js'; --import { getEffectiveAgents } from '../../common/customAgents.js'; --import { getReasoningEffortDescription, getReasoningEffortLabel } from '../../common/reasoningEffort.js'; +-import { AgentSessionEntry, decodeProviderData, encodeProviderData, prepareSideChatPrompt, stripSideChatContext, type IPersistedChat } from '../agentPeerChats.js'; +-import { AgentSession, AgentSignal, AuthenticateParams, IActiveClient, IAgent, IAgentChatDataChange, IAgentChats, IAgentLegacyChat, IAgentCreateChatForkSource, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, IRestoredSubagentSession, SubagentChatSignal } from '../../common/agentService.js'; +-import { getReasoningEffortDescription, getReasoningEffortLabel, resolveDefaultReasoningEffort } from '../../common/reasoningEffort.js'; -import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; -import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; -import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +-import { ICopilotConfigSlashCommandState } from '../../common/copilotConfigSlashCommands.js'; +-import { getCopilotHomePath } from '../../common/copilotHome.js'; -import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js'; +-import { IAgentHostProxyResolver } from '../agentHostProxyResolver.js'; -import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; -import { ProtectedResourceMetadata, type AgentSelection, type ChildCustomizationType, type ConfigPropertySchema, type ConfigSchema, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; -import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; --import { AgentCustomization, CustomizationLoadStatus, CustomizationType, ResponsePartKind, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, isDefaultChatUri, parseChatUri, parseSubagentSessionUri, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ResponsePart, type ChatInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; --import { ActiveClientState } from '../activeClientState.js'; +-import { AgentCustomization, CustomizationLoadStatus, CustomizationType, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, buildDefaultChatUri, isDefaultChatUri, parseChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, AH_META_WORKSPACELESS_DB_KEY, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ChatInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; +-import { ActiveClientToolSet } from '../activeClientState.js'; -import { IAgentConfigurationService } from '../agentConfigurationService.js'; +-import { IAgentHostGitHubEndpointService } from '../agentHostGitHubEndpointService.js'; -import { IAgentHostCompletions } from '../agentHostCompletions.js'; --import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from '../agentHostGitService.js'; --import { findMcpChildId } from '../shared/mcpCustomizationController.js'; --import { ICopilotBranchNameGenerator } from './copilotBranchNameGenerator.js'; +-import { IAgentHostGitService } from '../../common/agentHostGitService.js'; +-import { applyMcpServerEnablement, findMcpChildId, type IMcpServerRuntimeState } from '../shared/mcpCustomizationController.js'; +-import { AgentHostStateManager, IAgentHostStateManager } from '../agentHostStateManager.js'; +- +-interface ICopilotRuntimeManagedSettingsSdk { +- getManagedSettings(input?: { token?: string; host?: string }): Promise<{ account?: string; resolved: IAgentHostManagedSettingsSnapshot }>; +-} +- +-function isCopilotRuntimeManagedSettingsSdk(value: unknown): value is ICopilotRuntimeManagedSettingsSdk { +- return typeof value === 'object' && value !== null && 'getManagedSettings' in value +- && typeof (value as { getManagedSettings?: unknown }).getManagedSettings === 'function'; +-} +-import { IByokLmBridgeRegistry } from '../byokLmBridgeRegistry.js'; +-import { SessionWorkingDirectoryMissingError } from '../shared/worktreeIsolation.js'; +-import { buildSessionEventLogFromTurns } from './buildSessionEvents.js'; -import { CopilotAgentSession, type CopilotSdkMode } from './copilotAgentSession.js'; -import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; -import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js'; --import { CopilotSessionLauncher, ContextTierConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; +-import { CopilotGitHubTelemetryForwarder } from './copilotGitHubTelemetryForwarder.js'; +-import { CopilotSessionLauncher, ContextSizeConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, isCopilotReasoningEffort, resolveCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; -import { ShellManager } from './copilotShellTools.js'; --import { isRestrictedTelemetryEnabled } from './copilotTokenFields.js'; --import { CopilotSlashCommandCompletionProvider } from './copilotSlashCommandCompletionProvider.js'; +-import { isAgentHostTelemetryService } from '../agentHostTelemetryService.js'; +-import { ICopilotApiService, type IRestrictedTelemetryContext } from '../shared/copilotApiService.js'; +-import { AgentHostGitHubTelemetryRouter } from '../agentHostGitHubTelemetryRouter.js'; +-import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; +-import { CopilotSlashCommandCompletionProvider, ICopilotRuntimeSlashCommandQueryOptions } from './copilotSlashCommandCompletionProvider.js'; -import { DiscoveredType, SessionCustomizationDiscovery, areDiscoveredDirectoriesEqual, type IDiscoveredDirectory } from './sessionCustomizationDiscovery.js'; -import { COPILOT_INTEGRATION_ID } from '../../../endpoint/common/licenseAgreement.js'; +-import { getAppNodeModulesPath } from '../appNodeModules.js'; +-import { CopilotSlashCommandProvider } from './copilotSlashCommandProvider.js'; - +-const RUNTIME_SLASH_COMMAND_COMPLETION_WAIT_MS = 300; +-const COPILOT_CAPI_URL = 'https://api.githubcopilot.com'; +-/** +- * Proxy env vars that indicate the environment already configures a proxy. +- */ +-const COPILOT_PROXY_ENV_KEYS = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'] as const; -/** -- * Maps a VS Code {@link LogLevel} to the Copilot CLI runtime's `logLevel` -- * option so the spawned CLI logs (written to `~/.copilot/logs/process-*.log`) -- * match the agent host's configured verbosity. `Trace` maps to the CLI's most -- * verbose `'all'` level so renderer-side trace logging surfaces the CLI's -- * internal diagnostics. +- * Proxy env vars we set when injecting the resolved CAPI proxy. - */ --function copilotCliLogLevelFor(level: LogLevel): NonNullable { -- switch (level) { -- case LogLevel.Off: return 'none'; -- case LogLevel.Trace: return 'all'; -- case LogLevel.Debug: return 'debug'; -- case LogLevel.Info: return 'info'; -- case LogLevel.Warning: return 'warning'; -- case LogLevel.Error: return 'error'; +-const COPILOT_PROXY_SET_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY'] as const; +- +-async function fileExists(filePath: string): Promise { +- try { +- await fs.access(filePath); +- return true; +- } catch { +- return false; +- } +-} +- +-function isLinuxMuslRuntime(): boolean { +- if (process.platform !== 'linux') { +- return false; - } +- +- const report = process.report?.getReport() as { header?: { glibcVersionRuntime?: string } } | undefined; +- return !report?.header?.glibcVersionRuntime; +-} +- +-function getCopilotPlatformPackageCandidates(): string[] { +- const platformArch = `${process.platform}-${process.arch}`; +- if (process.platform !== 'linux') { +- return [platformArch]; +- } +- +- const linuxCandidates = [`linux-${process.arch}`, `linuxmusl-${process.arch}`]; +- return isLinuxMuslRuntime() ? linuxCandidates.reverse() : linuxCandidates; -} - --interface ICreatedWorktree { -- readonly repositoryRoot: URI; -- readonly worktree: URI; +-async function resolveCopilotCliPath(nodeModulesUri: URI): Promise { +- const tried: string[] = []; +- for (const platformPackage of getCopilotPlatformPackageCandidates()) { +- const cliPath = URI.joinPath(nodeModulesUri, '@github', `copilot-${platformPackage}`, 'index.js').fsPath; +- tried.push(cliPath); +- if (await fileExists(cliPath)) { +- return cliPath; +- } +- } +- +- const oldTopLevelPath = URI.joinPath(nodeModulesUri, '@github', 'copilot', 'index.js').fsPath; +- tried.push(oldTopLevelPath); +- if (await fileExists(oldTopLevelPath)) { +- return oldTopLevelPath; +- } +- +- throw new Error(`Unable to resolve @github/copilot CLI path. Tried: ${tried.join(', ')}`); -} - -export type ICopilotPluginInfo = IParsedPlugin & { readonly pluginDir?: URI }; @@ -708,8 +720,8 @@ index 3f37722b..00000000 - * worktree to remove and no on-disk state to delete. - * - * `model` absorbs {@link CopilotAgent.changeModel} updates that arrive -- * before the first message. The latest session config (isolation / branch / -- * etc.) is read straight from the state manager via +- * before the first message. The latest provider-owned session config is read +- * straight from the state manager via - * {@link IAgentConfigurationService.getSessionConfigValues} at - * materialization time, so no bespoke forwarding is required for it. - */ @@ -723,12 +735,31 @@ index 3f37722b..00000000 - * worktree path that may not exist yet). - */ - readonly workingDirectory: URI; +- /** +- * The full ordered working-directory set as sent by the client at create +- * time (index 0 = primary === {@link workingDirectory}), for a multi-root +- * workspace. Undefined for single-folder / legacy clients. The non-primary +- * roots are attached to customization discovery immediately (they are stable +- * workspace folders, unlike the worktree that resolves only at send). +- */ +- readonly workingDirectories?: readonly URI[]; - /** Most recent model selection. Updated by `changeModel` while provisional. */ - model: ModelSelection | undefined; - /** Most recent custom agent selection. Updated by `changeAgent` while provisional. */ - agent: AgentSelection | undefined; - /** Project info eagerly resolved at create time so the summary renders. */ - readonly project: IAgentSessionProjectInfo | undefined; +- /** Whether this session is workspace-less (surfaced in the sessions UI as a "Quick Chat"). */ +- readonly workspaceless?: boolean; +-} +- +-interface ICopilotAgentSessionIdentity { +- readonly sessionUri: URI; +- readonly chatChannelUri: URI; +-} +- +-function toRestrictedTelemetryEndpoint(endpoint: string | undefined): string | undefined { +- return endpoint ? `${endpoint.replace(/\/+$/, '')}/telemetry` : undefined; -} - -export { COPILOT_AGENT_HOST_SYSTEM_MESSAGE } from './prompts/systemMessage.js'; @@ -741,41 +772,6 @@ index 3f37722b..00000000 -} - -/** -- * A persisted additional (non-default) peer chat. Records the SDK conversation -- * id that backs the chat so it can be resumed after a process restart, along -- * with any model override chosen at creation time. -- */ --interface IPersistedChat { -- readonly sdkSessionId: string; -- readonly model?: ModelSelection; --} -- --/** -- * Augments the published `@vscode/copilot-api` `ModelBilling` with the `tokenPrices` field the runtime CAPI `/models` -- * payload already carries but the SDK type doesn't yet declare. Mirror of `IClaudeModelSupports` in `claudeAgent.ts`. -- */ --interface ICopilotModelBilling { -- readonly multiplier?: number; -- /** Coarse price bucket surfaced as a tag in the model picker hover. */ -- readonly priceCategory?: string; -- readonly tokenPrices?: { -- /** Default-tier prices, expressed as credits per 1M tokens. */ -- readonly contextMax?: number; -- readonly inputPrice?: number; -- readonly cachePrice?: number; -- readonly cacheWritePrice?: number; -- readonly outputPrice?: number; -- readonly longContext?: { -- readonly contextMax?: number; -- readonly inputPrice?: number; -- readonly cachePrice?: number; -- readonly cacheWritePrice?: number; -- readonly outputPrice?: number; -- }; -- }; --} -- --/** - * Subset of the JSON-RPC `MessageConnection` we reach into via the SDK's private `connection` field to wire plan mode. - * See {@link CopilotAgent._enablePlanModeOnClient}. - */ @@ -809,70 +805,50 @@ index 3f37722b..00000000 - readonly feedback?: string; -} - --export function getCopilotWorktreesRoot(repositoryRoot: URI): URI { -- return URI.joinPath(repositoryRoot, '..', `${basename(repositoryRoot.fsPath)}.worktrees`); --} -- --export function getCopilotWorktreeName(branchName: string): string { -- return branchName.replace(/\//g, '-'); --} -- -/** -- * Builds the localized "Created isolated worktree for branch X" markdown -- * shown at the top of the first response in worktree-isolated sessions. -- * The branch name is wrapped as inline code so the localized template -- * doesn't have to embed markdown punctuation. The trailing blank line -- * keeps the announcement visually separated when it gets merged into the -- * same markdown part as the model's reply. +- * Rebases `uri` from under `fromDir` onto `toDir`, preserving the relative path. +- * Returns `undefined` when `uri` is not equal to or under `fromDir`. - */ --function buildWorktreeAnnouncementText(branchName: string): string { -- return localize( -- 'copilotAgent.worktreeCreated', -- "Created isolated worktree for branch {0}", -- appendEscapedMarkdownInlineCode(branchName) -- ) + '\n\n'; +-export function rebaseUnder(uri: URI, fromDir: URI, toDir: URI): URI | undefined { +- if (!isEqualOrParent(uri, fromDir)) { +- return undefined; +- } +- const rel = relativePath(fromDir, uri); +- if (rel === undefined) { +- return undefined; +- } +- return rel.length === 0 ? toDir : resourceJoinPath(toDir, rel); -} - -/** -- * Returns a copy of `turns` where `announcement` has been prepended to the -- * first top-level assistant turn's first markdown response part. Used on -- * session restore so the worktree announcement remains visible after the -- * session is reopened. If no assistant content exists yet, a fresh -- * markdown part is inserted at the top of the first turn. +- * Per-session container. Owns the session's default (main) chat and any +- * additional peer chats, keeping all chats of a session together in a single +- * {@link CopilotAgent._sessions} map (no parallel maps). The default chat is +- * optional because a Copilot session can exist as a provisional record (in +- * {@link CopilotAgent._provisionalSessions}) whose SDK-backed default chat has +- * not materialized yet — a peer chat may still be created on it. Disposing the +- * entry disposes the default chat and every peer chat. +- * +- * Exported for tests, which inject fake sessions into the container. - */ --function prependAnnouncementToFirstTurn( -- turns: readonly Turn[], -- announcement: string, --): readonly Turn[] { -- if (turns.length === 0) { -- return turns; -- } -- const result = turns.slice(); -- const first = result[0]; -- const part = first.responseParts[0]; -- if (part?.kind === ResponsePartKind.Markdown) { -- const responseParts = first.responseParts.slice(); -- responseParts[0] = { ...part, content: announcement + part.content }; -- result[0] = { ...first, responseParts }; -- } else { -- const responseParts: ResponsePart[] = [ -- { kind: ResponsePartKind.Markdown, id: generateUuid(), content: announcement }, -- ...first.responseParts, -- ]; -- result[0] = { ...first, responseParts }; -- } -- return result; --} +-export class CopilotSessionEntry extends AgentSessionEntry { } - -/** - * Agent provider backed by the Copilot SDK {@link CopilotClient}. - */ -export class CopilotAgent extends Disposable implements IAgent { - readonly id = 'copilotcli' as const; -- private static readonly _BRANCH_COMPLETION_LIMIT = 25; - - private readonly _onDidSessionProgress = this._register(new Emitter()); - readonly onDidSessionProgress = this._onDidSessionProgress.event; +- /** +- * Membership channel for chats the agent spawns itself — sub-agents +- * delegated by a tool call (the same fan-out the `subagent_started` / +- * `subagent_completed` signals drive). The orchestrator routes these into +- * the chat catalog so harness-spawned and user-driven chats share one path. +- */ +- private readonly _onDidSpawnChat = this._register(new Emitter()); +- readonly onDidSpawnChat = this._onDidSpawnChat.event; - private readonly _onDidMaterializeSession = this._register(new Emitter()); - readonly onDidMaterializeSession = this._onDidMaterializeSession.event; - /** @@ -884,9 +860,46 @@ index 3f37722b..00000000 - readonly onMcpNotification = this._onMcpNotification.event; - private readonly _models = observableValue(this, []); - readonly models = this._models; +- /** +- * The two sources merged into {@link _models}: CAPI models from the CLI's +- * `models.list` and BYOK models from the renderer bridge registry's serving +- * window. Tracked separately so each can refresh independently without +- * clobbering the other; {@link _publishModels} concatenates them for the +- * picker. +- */ +- private _capiModels: readonly IAgentModelInfo[] = []; +- private _byokModels: readonly IAgentModelInfo[] = []; +- +- /** Model IDs whose long-context tier costs the same as the default tier. */ +- private readonly _freeLongContextModels = new Set(); +- +- /** +- * Bounded exponential-backoff retry for {@link _refreshModels}. The SDK's +- * `models.list` RPC can fail transiently (e.g. a `429 "too many requests"` +- * right after startup). Without a retry the model picker would stay empty +- * until the GitHub token next changes — the only other trigger for a +- * refresh — so we retry a few times before giving up. Overridable in tests +- * to avoid real delays. +- */ +- protected readonly _modelRefreshMaxAttempts: number = 5; +- protected readonly _modelRefreshBaseDelayMs: number = 1_000; +- protected readonly _modelRefreshMaxDelayMs: number = 30_000; +- /** Pending model-refresh retry timer; cleared on a fresh refresh, shutdown, or dispose. */ +- private readonly _modelRefreshRetry = this._register(new MutableDisposable()); - - private _client: CopilotClient | undefined; - private _clientStarting: Promise | undefined; +- /** +- * Proxy URL injected into the running client's subprocess env (`undefined` +- * when none was injected). Used to detect when a token change alters the +- * token-discovered CAPI endpoint's proxy so we can restart the client. +- */ +- private _appliedProxy: string | undefined; +- /** +- * Reasons for a client restart that is parked until every chat is idle. See +- * {@link _requestClientRestart}; drained by {@link _applyPendingClientRestart}. +- */ +- private readonly _pendingClientRestartReasons = new Set(); - private _githubToken: string | undefined; - private _serverToolHost: IAgentServerToolHost | undefined; - @@ -903,14 +916,27 @@ index 3f37722b..00000000 - return this._restrictedTelemetryEnabled; - } - -- private readonly _sessions = this._register(new DisposableMap()); +- /** Root AHP session id -> container that owns the default chat and all peer chats. */ +- private readonly _sessions = this._register(new DisposableMap()); +- /** SDK session id -> individual chat session, used to route connection-global SDK telemetry in O(1). */ +- private readonly _sdkSessionsById = new Map(); +- /** +- * Live `chatUri → backing` map for additional (non-default) peer chats, +- * keyed by chat channel URI string. Records the SDK chat id (and +- * optional model override) that backs each peer chat so the agent can +- * resume it without consulting on-disk persistence. Populated by +- * {@link createChat} on creation and by {@link materializeChat} on +- * restore; the orchestrator now owns the durable peer-chat catalog (the +- * agent no longer writes `copilot.chats`). +- */ +- private readonly _chatBackings = new Map(); - /** -- * Additional (non-default) chats within a session, keyed by chat channel -- * URI string. Each entry is its own Copilot SDK conversation sharing the -- * owning session's working directory/model scope. The default chat is not -- * tracked here — it maps to the primary {@link _sessions} entry. +- * Fires when a peer chat's opaque `providerData` blob changes after +- * creation (e.g. a per-chat model switch), so the orchestrator re-persists +- * the refreshed token. See {@link IAgent.onDidChangeChatData}. - */ -- private readonly _chatSessions = this._register(new DisposableMap()); +- private readonly _onDidChangeChatData = this._register(new Emitter()); +- readonly onDidChangeChatData: Event = this._onDidChangeChatData.event; - /** - * Per-session MCP-notification subscriptions, keyed by `sessionId`. - * Disposed in lockstep with the matching {@link _sessions} entry so @@ -933,24 +959,16 @@ index 3f37722b..00000000 - * out of this map and into {@link _sessions}. See {@link IProvisionalSession}. - */ - private readonly _provisionalSessions = new Map(); -- private readonly _createdWorktrees = new Map(); -- /** -- * Per-session announcement (markdown string) that should be emitted as -- * a synthetic streaming `delta` event the first time {@link sendMessage} -- * is called for the session. Currently used to surface the "Created -- * isolated worktree for branch X" message live during the first turn. -- * The same announcement is also injected on restore via -- * {@link getSessionMessages} by prepending to the first assistant -- * message's content so it stays visible after the session is reopened. -- */ -- private readonly _pendingFirstTurnAnnouncements = new Map(); - private readonly _sessionSequencer = new SequencerByKey(); - private _shutdownPromise: Promise | undefined; - private readonly _plugins: PluginController; - private readonly _sessionLauncher: CopilotSessionLauncher; +- private readonly _gitHubTelemetryForwarder: CopilotGitHubTelemetryForwarder; +- private readonly _githubTelemetryRouter: AgentHostGitHubTelemetryRouter | undefined; - readonly onDidCustomizationsChange: Event; - /** Per-session active client state for tools + plugin snapshot tracking. */ - private readonly _activeClients = new ResourceMap(); +- private readonly _slashCommandProvider: CopilotSlashCommandProvider; - - constructor( - @ILogService private readonly _logService: ILogService, @@ -958,70 +976,230 @@ index 3f37722b..00000000 - @ISessionDataService private readonly _sessionDataService: ISessionDataService, - @IAgentHostGitService private readonly _gitService: IAgentHostGitService, - @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, +- @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, +- @IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService, - @IAgentHostOTelService private readonly _otelService: IAgentHostOTelService, -- @ICopilotBranchNameGenerator private readonly _branchNameGenerator: ICopilotBranchNameGenerator, - @IAgentHostCompletions completions: IAgentHostCompletions, - @IAgentHostCheckpointService private readonly _checkpointService: IAgentHostCheckpointService, +- @IAgentHostReviewService private readonly _reviewService: IAgentHostReviewService, +- @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService, +- @IByokLmBridgeRegistry private readonly _byokBridgeRegistry: IByokLmBridgeRegistry, +- @ITelemetryService private readonly _telemetryService: ITelemetryService, +- @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, +- @IAgentHostProxyResolver private readonly _proxyResolver: IAgentHostProxyResolver, - ) { - super(); -- this._plugins = this._register(this._instantiationService.createInstance(PluginController)); +- this._plugins = this._register(this._instantiationService.createInstance(PluginController, () => this._ensureClient())); - this._sessionLauncher = this._instantiationService.createInstance(CopilotSessionLauncher); +- this._gitHubTelemetryForwarder = this._instantiationService.createInstance(CopilotGitHubTelemetryForwarder, () => this._restrictedTelemetryEnabled); +- this._slashCommandProvider = new CopilotSlashCommandProvider(() => this._ensureClient().then(c => c.rpc.commands.list().then(c => c.commands)), this._logService); +- this._githubTelemetryRouter = isAgentHostTelemetryService(this._telemetryService) +- ? new AgentHostGitHubTelemetryRouter(this._telemetryService) +- : undefined; - this.onDidCustomizationsChange = this._plugins.onDidChange; -- this._register(completions.registerProvider(new CopilotSlashCommandCompletionProvider(this.id, { -- hasHistory: (sessionId) => !this._provisionalSessions.has(sessionId) && this._sessions.has(sessionId), -- isRubberDuckEnabled: () => this._isRubberDuckEnabled(), -- hasRuntimeSlashCommand: async (sessionId, command) => this._sessions.get(sessionId)?.hasRuntimeSlashCommand(command) ?? false, -- }))); +- this._register(this._stateManager.onDidChangeSessionTitle(({ session, title }) => { +- if (AgentSession.provider(session) === this.id) { +- this._otelService.emitSessionTitleChanged(AgentSession.id(session), session, title); +- } +- })); +- // Mirror the sub-agent fan-out signals onto the first-class spawned- +- // chat channel so the orchestrator manages sub-agent chats +- // through the same membership path as user-driven chats. +- this._register(this._onDidSessionProgress.event(signal => this._emitSpawnedChatForSubagentSignal(signal))); +- this._register(completions.registerProvider(new CopilotSlashCommandCompletionProvider(this.id, +- { +- isRubberDuckEnabled: () => this._isRubberDuckEnabled(), +- getRuntimeSlashCommands: (sessionId, options) => this._getRuntimeSlashCommands(sessionId, options), +- getSessionCustomizations: (sessionId) => this.getSessionCustomizations(AgentSession.uri(this.id, sessionId)), +- getSessionConfigState: (sessionId) => this._getSessionConfigState(sessionId), +- }, +- RUNTIME_SLASH_COMMAND_COMPLETION_WAIT_MS, +- ))); - - // Restart the CLI client when a setting baked into the client/subprocess at -- // startup changes, disposing any active sessions. Both session sync (a client -- // option) and the rubber duck flag (a subprocess env var) are applied in +- // startup changes, disposing any active sessions. These values are applied in - // `_ensureClient`, so they only take effect on the next client start. - this._register(this._configurationService.onDidRootConfigChange(() => { - this._restartClientIfStartupConfigChanged().catch(err => -- this._logService.error('[Copilot] Failed to restart client after config change', err) +- this._logService.error('[Copilot] Failed to apply root config change', err) +- ); +- })); +- +- // Surface renderer BYOK models in the picker: republish them whenever the +- // set of connected renderer bridges, or any renderer's models, change. +- // The registry is only populated when `chat.agentHost.byokModels.enabled` +- // is on, so this stays a no-op (empty list) while the feature is off. +- this._register(this._byokBridgeRegistry.onDidChangeModels(() => { +- this._logService.info('[Copilot] BYOK bridge changed; refreshing models'); +- this._refreshByokModels(); +- })); +- +- // `COPILOT_GH_HOST` is a subprocess env var (applied in `_ensureClient`) the +- // CLI reads only at spawn time. When the configured GitHub Enterprise host +- // changes - notably the startup race where the workbench pushes +- // `githubEnterpriseUri` just after the client's initial spawn - restart the +- // client so it comes up pointed at the right host. Driven off the endpoint +- // service's `onDidChange` (which fires after its endpoints are recomputed) +- // rather than the raw config event, so `getEnterpriseHost()` is current here. +- this._register(this._gitHubEndpointService.onDidChange(() => { +- this._restartClientIfStartupConfigChanged().catch(err => +- this._logService.error('[Copilot] Failed to restart client after endpoint change', err) - ); - })); - } - +- /** +- * Translates the sub-agent fan-out signals into the first-class spawned- +- * chat channel: `subagent_started` -> {@link onDidSpawnChat} +- * (carrying the spawning tool call as the chat's parent edge). A completed +- * subagent chat stays live and subscribable (it is removed only on session +- * teardown), so there is no corresponding end event. The signals themselves +- * are left untouched so the existing sub-agent behavior is preserved. +- */ +- private _emitSpawnedChatForSubagentSignal(signal: AgentSignal): void { +- const spawn = SubagentChatSignal.toSpawnEvent(signal); +- if (spawn) { +- this._onDidSpawnChat.fire(spawn); +- } +- } +- - private _lastSessionSyncEnabled: boolean = this._isSessionSyncEnabled(); - private _lastRubberDuckEnabled: boolean = this._isRubberDuckEnabled(); +- private _lastCopilotSdkLogLevelSetting: CopilotSdkLogLevelSetting = this._getCopilotSdkLogLevelSetting(); +- private _lastEnterpriseHost: string | undefined = this._getEnterpriseHost(); +- private _lastSystemProxyEnabled: boolean = this._isSystemProxyEnabled(); - - private _isSessionSyncEnabled(): boolean { - return this._configurationService.getRootValue(platformRootSchema, AgentHostSessionSyncEnabledConfigKey) === true; - } - - private _isRubberDuckEnabled(): boolean { -- return this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.RubberDuck) === true; +- return this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.RubberDuck) === true; +- } +- +- private _getCopilotSdkLogLevelSetting(): CopilotSdkLogLevelSetting { +- return this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.CopilotSdkLogLevel) ?? 'info'; +- } +- +- private _resolveCopilotSdkLogLevel(configured: CopilotSdkLogLevelSetting): NonNullable { +- return configured === 'trace' || this._logService.getLevel() === LogLevel.Trace ? 'all' : 'info'; +- } +- +- private _getEnterpriseHost(): string | undefined { +- return this._gitHubEndpointService.getEnterpriseHost(); +- } +- +- private _isPreferLongContextEnabled(): boolean { +- return this._configurationService.getRootValue(platformRootSchema, AgentHostPreferLongContextEnabledConfigKey) === true; +- } +- +- private _isSystemProxyEnabled(): boolean { +- return this._configurationService.getRootValue(platformRootSchema, AgentHostSystemProxyEnabledConfigKey) !== false; - } - - /** - * Restarts the CLI client when a config value that is only read at client -- * startup ({@link _isSessionSyncEnabled} client option, {@link _isRubberDuckEnabled} -- * subprocess env var) has changed. Any active sessions are disposed before -- * the client is stopped; the latest values are picked up the next time -- * {@link _ensureClient} runs. If the client is still starting up, the -- * in-flight start detects the change against {@link _lastSessionSyncEnabled} / -- * {@link _lastRubberDuckEnabled} and aborts so it never comes up stale. +- * startup has changed. The restart is deferred while any chat has an +- * in-flight turn — see {@link _requestClientRestart} — so the new values are +- * picked up at the next quiet point rather than by killing live work. +- * An in-flight start aborts if any startup value changes. - */ - private async _restartClientIfStartupConfigChanged(): Promise { - const sessionSync = this._isSessionSyncEnabled(); - const rubberDuck = this._isRubberDuckEnabled(); -- if (this._lastSessionSyncEnabled === sessionSync && this._lastRubberDuckEnabled === rubberDuck) { +- const copilotSdkLogLevelSetting = this._getCopilotSdkLogLevelSetting(); +- const enterpriseHost = this._getEnterpriseHost(); +- const systemProxyEnabled = this._isSystemProxyEnabled(); +- if (this._lastSessionSyncEnabled === sessionSync && this._lastRubberDuckEnabled === rubberDuck && this._lastCopilotSdkLogLevelSetting === copilotSdkLogLevelSetting && this._lastEnterpriseHost === enterpriseHost && this._lastSystemProxyEnabled === systemProxyEnabled) { - return; - } - const changed = [ - this._lastSessionSyncEnabled !== sessionSync ? `sessionSync=${sessionSync}` : undefined, - this._lastRubberDuckEnabled !== rubberDuck ? `rubberDuck=${rubberDuck}` : undefined, +- this._lastCopilotSdkLogLevelSetting !== copilotSdkLogLevelSetting ? `copilotSdkLogLevel=${copilotSdkLogLevelSetting}` : undefined, +- this._lastEnterpriseHost !== enterpriseHost ? `enterpriseHost=${enterpriseHost}` : undefined, +- this._lastSystemProxyEnabled !== systemProxyEnabled ? `systemProxy=${systemProxyEnabled}` : undefined, - ].filter((v): v is string => v !== undefined).join(', '); - this._lastSessionSyncEnabled = sessionSync; - this._lastRubberDuckEnabled = rubberDuck; -- if (this._client) { -- this._logService.info(`[Copilot] Startup config changed (${changed}), restarting CopilotClient`); -- this._sessions.clearAndDisposeAll(); -- this._mcpNotificationSubs.clearAndDisposeAll(); -- await this._stopClient(); +- this._lastCopilotSdkLogLevelSetting = copilotSdkLogLevelSetting; +- this._lastEnterpriseHost = enterpriseHost; +- this._lastSystemProxyEnabled = systemProxyEnabled; +- await this._requestClientRestart(`startup config changed: ${changed}`); +- } +- +- /** +- * Requests a CLI client restart, running it immediately when every chat is +- * idle and otherwise parking it until the last in-flight turn ends. +- * +- * Restarting tears the SDK sessions down, and a torn-down session stops +- * producing the events that finalize its protocol turn — the client would +- * be left with a turn that never completes, cancels, or errors, i.e. a +- * session that spins forever. Startup-only values (session sync, the SDK +- * log level, the enterprise host, the system proxy) can also change without +- * any user action, from an experiment or policy refresh, so this must never +- * be paid for with a running turn. The values are read fresh by +- * {@link _ensureClient} on the next start, so applying the restart late is +- * always correct. +- */ +- private async _requestClientRestart(reason: string): Promise { +- if (this._shutdownPromise || !this._client) { +- // Nothing running to restart: the next `_ensureClient` starts from +- // the current values anyway. +- return; +- } +- this._pendingClientRestartReasons.add(reason); +- const busyChats = this._chatsWithActiveTurn(); +- if (busyChats > 0) { +- this._logService.info(`[Copilot] Deferring CopilotClient restart (${reason}) until ${busyChats} in-flight turn(s) finish`); +- return; - } +- await this._applyPendingClientRestart(); +- } +- +- /** +- * Runs a restart parked by {@link _requestClientRestart} once no chat has +- * an in-flight turn. No-op while any turn is still running; the next chat +- * to go idle drives this again. +- */ +- private async _applyPendingClientRestart(): Promise { +- if (this._pendingClientRestartReasons.size === 0 || this._shutdownPromise || !this._client || this._chatsWithActiveTurn() > 0) { +- return; +- } +- const reason = [...this._pendingClientRestartReasons].join('; '); +- this._logService.info(`[Copilot] Restarting CopilotClient (${reason})`); +- this._sessions.clearAndDisposeAll(); +- this._mcpNotificationSubs.clearAndDisposeAll(); +- await this._stopClient(); +- } +- +- /** +- * Called by a {@link CopilotAgentSession} when its turn ends. Scheduled off +- * the current stack because the callback fires from inside that session's +- * SDK event handling and the restart disposes the session making the call. +- */ +- private _onChatTurnEnded(): void { +- if (this._pendingClientRestartReasons.size === 0) { +- return; +- } +- queueMicrotask(() => { +- this._applyPendingClientRestart().catch(err => +- this._logService.error('[Copilot] Failed to apply deferred client restart', err) +- ); +- }); +- } +- +- /** Number of live chats (default or peer, across all sessions) with an in-flight turn. */ +- private _chatsWithActiveTurn(): number { +- let count = 0; +- for (const [, entry] of this._sessions) { +- for (const chatSession of entry.allChatSessions()) { +- if (chatSession.hasActiveTurn) { +- count++; +- } +- } +- } +- return count; - } - - protected _createCopilotClient(options: CopilotClientOptions): CopilotClient { @@ -1034,59 +1212,165 @@ index 3f37722b..00000000 - return { - provider: 'copilotcli', - displayName: 'Copilot', -- description: 'Copilot SDK agent running in a dedicated process', +- description: localize('copilotAgent.description', "Copilot SDK agent running in the local agent host process"), +- capabilities: { +- multipleChats: { fork: true, sideChat: true }, +- ...(this._isMultiRootEnabled() ? { multipleWorkingDirectories: { immutablePrimary: true } } : {}), +- }, - }; - } - +- private _isMultiRootEnabled(): boolean { +- return this._configurationService.getRootValue(platformRootSchema, AgentHostCopilotMultiRootEnabledConfigKey) === true; +- } +- - getProtectedResources(): ProtectedResourceMetadata[] { - return [ -- GITHUB_COPILOT_PROTECTED_RESOURCE, -- GITHUB_REPO_PROTECTED_RESOURCE +- this._gitHubEndpointService.getCopilotResource(), +- this._gitHubEndpointService.getRepoResource(), +- ]; +- } +- +- async getNetworkDiagnosticsEndpoints(): Promise { +- let capiUrl = process.env['VSCODE_AGENT_HOST_CAPI_URL_OVERRIDE'] || COPILOT_CAPI_URL; +- if (this._githubToken) { +- try { +- capiUrl = await this._copilotApiService.resolveApiEndpoint(this._githubToken) || capiUrl; +- } catch (error) { +- this._logService.debug(`[Copilot] CAPI endpoint discovery for network diagnostics failed; using ${capiUrl}: ${error instanceof Error ? error.message : String(error)}`); +- } +- } +- const capiPingUrl = new URL(capiUrl); +- capiPingUrl.pathname = `${capiPingUrl.pathname.replace(/\/$/, '')}/_ping`; +- return [ +- { name: 'GitHub API', url: this._gitHubEndpointService.getApiBaseUri() }, +- { name: 'Copilot API (CAPI)', url: capiPingUrl.toString() }, - ]; - } - +- async getNetworkDiagnosticsAccount(): Promise { +- return this._githubToken ? this._copilotApiService.resolveUserLogin?.(this._githubToken) : undefined; +- } +- +- async getManagedSettingsDiagnostics(): Promise { +- const nodeModulesUri = FileAccess.asFileUri(getAppNodeModulesPath()); +- const cliPath = await resolveCopilotCliPath(nodeModulesUri); +- const runtimeSdkPath = join(dirname(cliPath), 'sdk', 'index.js'); +- if (!await fileExists(runtimeSdkPath)) { +- throw new Error(`Copilot runtime SDK not found at ${runtimeSdkPath}`); +- } +- const runtimeSdk: unknown = await import(pathToFileURL(runtimeSdkPath).href); +- if (!isCopilotRuntimeManagedSettingsSdk(runtimeSdk)) { +- throw new Error('Copilot runtime SDK does not expose getManagedSettings()'); +- } +- +- const enterpriseHost = this._getEnterpriseHost(); +- const result = await runtimeSdk.getManagedSettings({ +- ...(this._githubToken ? { token: this._githubToken } : {}), +- ...(enterpriseHost ? { host: enterpriseHost } : {}), +- }); +- return { +- ...result.resolved, +- ...(result.account ? { account: result.account } : {}), +- }; +- } +- - getCustomizations(): readonly Customization[] { - return this._plugins.getConfiguredHostCustomizations(); - } - - async getSessionCustomizations(session: URI): Promise { -- const directory = await this._getSessionCustomizationDirectory(session); -- const activeClient = this._getOrCreateActiveClient(session, directory); +- const anchors = await this._getSessionCustomizationAnchors(session); +- const activeClient = this._getOrCreateActiveClient(session, anchors.directory); +- if (anchors.applyAdditional) { +- // Provisional (pre-send) or pre-resume: the anchors carry the full ordered +- // root set, so anchor discovery to every root instead of caching a +- // primary-only entry. Skipped for a live session (its tail is already set +- // by materialize/resume — do not clobber it). +- activeClient.pluginController.setAdditionalDirectories(anchors.additionalDirectories); +- } - const fromPlugins = await activeClient.pluginController.getCustomizationsSettled(); - const sessionId = AgentSession.id(session); -- const entry = this._sessions.get(sessionId); +- const entry = this._findAnySession(sessionId); - const topLevelMcp = entry?.topLevelMcpCustomizations() ?? []; -- if (topLevelMcp.length === 0) { -- return fromPlugins; -- } -- return [...fromPlugins, ...topLevelMcp]; +- const customizations = [...fromPlugins, ...topLevelMcp]; +- const desired = this._stateManager.getSessionState(session.toString())?.customizations ?? []; +- return applyMcpServerEnablement(customizations, desired); - } - - async handleMcpRequest(session: URI, serverName: string, method: string, params: Record | undefined): Promise { - const sessionId = AgentSession.id(session); -- const entry = this._sessions.get(sessionId); +- const entry = this._findAnySession(sessionId); - if (!entry) { - throw new Error(`Method not found: no active session ${sessionId}`); - } - return entry.handleMcpRequest(serverName, method, params); - } - -- private async _getSessionCustomizationDirectory(session: URI): Promise { +- async startMcpServer(session: URI, id: string): Promise { +- const sessionId = AgentSession.id(session); +- await this._findAnySession(sessionId)?.startMcpServer(id); +- } +- +- async stopMcpServer(session: URI, id: string): Promise { +- const sessionId = AgentSession.id(session); +- await this._findAnySession(sessionId)?.stopMcpServer(id); +- } +- +- /** +- * The gated additional (non-primary) customization roots for a session: the +- * tail of the ordered working-directory set when multi-root is enabled, else +- * empty (so single-root / flag-off is byte-identical). +- */ +- private _additionalCustomizationDirectories(workingDirectories: readonly URI[] | undefined): readonly URI[] { +- if (!this._isMultiRootEnabled() || !workingDirectories || workingDirectories.length <= 1) { +- return []; +- } +- return workingDirectories.slice(1); +- } +- +- /** +- * Resolves the customization anchor(s) for a session. `directory` is the +- * primary (index 0) anchor — the worktree for worktree-isolated sessions. +- * `additionalDirectories` are the non-primary roots to attach to discovery, +- * and are applied only when `applyAdditional` is true: +- * - **provisional** (pre-send) sessions carry the client-supplied set, whose +- * non-primary folders are stable workspace folders that can be discovered +- * immediately (the worktree, if any, only affects index 0 at send); +- * - **not-yet-live** sessions carry the persisted set from metadata; +- * - **live** (active) sessions manage their own tail via materialize/resume, +- * so `applyAdditional` is false to avoid clobbering it. +- */ +- private async _getSessionCustomizationAnchors(session: URI): Promise<{ readonly directory: URI | undefined; readonly additionalDirectories: readonly URI[]; readonly applyAdditional: boolean }> { - const sessionId = AgentSession.id(session); - const provisional = this._provisionalSessions.get(sessionId); - if (provisional) { -- return provisional.workingDirectory; +- return { +- directory: provisional.workingDirectory, +- additionalDirectories: this._additionalCustomizationDirectories(provisional.workingDirectories), +- applyAdditional: true, +- }; - } -- const entry = this._sessions.get(sessionId); -- const metadata = entry ? undefined : await this._readSessionMetadata(session); -- return entry?.customizationDirectory ?? metadata?.customizationDirectory ?? metadata?.workingDirectory; +- const entry = this._findAnySession(sessionId); +- if (entry) { +- // For non-provisional sessions the anchor follows the working directory +- // (the worktree). Prefer it over a persisted `customizationDirectory`, +- // which older sessions stored as the original user-picked folder. +- return { directory: entry.customizationDirectory, additionalDirectories: [], applyAdditional: false }; +- } +- const metadata = await this._readSessionMetadata(session); +- return { +- directory: metadata.workingDirectory ?? metadata.customizationDirectory, +- additionalDirectories: this._additionalCustomizationDirectories(metadata.workingDirectories), +- applyAdditional: true, +- }; - } - - async authenticate(resource: string, token: string): Promise { -- if (resource === GITHUB_REPO_PROTECTED_RESOURCE.resource) { +- if (resource === this._gitHubEndpointService.getRepoResource().resource) { - return true; - } -- if (resource !== GITHUB_COPILOT_PROTECTED_RESOURCE.resource) { +- if (resource !== this._gitHubEndpointService.getCopilotResource().resource) { - return false; - } - const tokenChanged = this._githubToken !== token; @@ -1094,44 +1378,222 @@ index 3f37722b..00000000 - this._updateRestrictedTelemetry(token); - this._logService.info(`[Copilot] Auth token ${tokenChanged ? 'updated' : 'unchanged'}`); - if (tokenChanged) { +- await this._restartClientIfProxyChanged(); - void this._refreshModels(); - } - return true; - } - -- private _updateRestrictedTelemetry(token: string | undefined): void { -- const rtEnabled = isRestrictedTelemetryEnabled(token); +- async handleAuthenticationToken(params: AuthenticateParams): Promise { +- let handled = false; +- for (const [, entry] of this._sessions) { +- for (const session of entry.allChatSessions()) { +- const didHandle = await session.resolveMcpAuthentication(params); +- handled ||= didHandle; +- } +- } +- return handled; +- } +- +- private _updateRestrictedTelemetry(githubToken: string | undefined): void { +- // Safe default synchronously: keep restricted/enhanced telemetry disabled until the minted +- // CAPI Copilot session token confirms the `rt=1` opt-in. The GitHub token here carries no +- // `rt`/`tid` claims — those live in the Copilot session token, which the API service mints — +- // so the real values are resolved asynchronously below. Mirrors how the Copilot extension +- // reads `rt`/`tid` off its `CopilotToken` rather than the GitHub token. +- this._applyRestrictedTelemetry(undefined); +- if (githubToken) { +- void this._resolveRestrictedTelemetry(githubToken); +- } +- } +- +- private async _resolveRestrictedTelemetry(githubToken: string): Promise { +- try { +- const ctx = await this._copilotApiService.resolveRestrictedTelemetryContext(githubToken); +- if (this._githubToken !== githubToken) { +- return; // token changed while resolving; a newer call owns the state +- } +- this._applyRestrictedTelemetry({ +- ...ctx, +- telemetryEndpoint: toRestrictedTelemetryEndpoint(ctx.telemetryEndpoint), +- }); +- } catch (err) { +- this._logService.debug(`[Copilot] Restricted telemetry resolution failed: ${err instanceof Error ? err.message : String(err)}`); +- } +- } +- +- private _applyRestrictedTelemetry(context: IRestrictedTelemetryContext | undefined): void { +- const rtEnabled = context?.restrictedTelemetryEnabled === true; - if (rtEnabled !== this._restrictedTelemetryEnabled) { - this._restrictedTelemetryEnabled = rtEnabled; -- this._logService.info(`[Copilot] Restricted telemetry ${rtEnabled ? 'enabled' : 'disabled'}`); +- this._logService.info(`[Copilot] Enhanced (restricted) telemetry ${rtEnabled ? 'enabled for this account' : 'disabled'}`); - this._onDidChangeRestrictedTelemetry.fire(); - } +- // Push the token-derived telemetry policy/identity to the restricted sender: `rt` gates +- // enhanced GH telemetry (kept off for public users), `tid` becomes `copilot_trackingId`, and +- // the endpoint routes at the user's CAPI telemetry host (dotcom, GHE, or proxy). +- if (isAgentHostTelemetryService(this._telemetryService)) { +- this._telemetryService.setRestrictedTelemetryEnabled(rtEnabled); +- this._telemetryService.setCopilotTrackingId(context?.trackingId); +- this._telemetryService.setRestrictedTelemetryEndpoint(context?.telemetryEndpoint); +- } +- } +- +- private async _routeGitHubTelemetry(notification: GitHubTelemetryNotification): Promise { +- const additionalProperties = { initiatorClientType: this._clientTypeForTelemetry(notification.sessionId) }; +- const router = this._githubTelemetryRouter; +- if (!router?.isTarget(notification)) { +- this._gitHubTelemetryForwarder.forward(notification); +- return; +- } +- if (!notification.restricted) { +- await router.route(notification, undefined, additionalProperties); +- return; +- } +- +- const sessionId = notification.sessionId; +- const githubToken = this._githubToken; +- if (!githubToken) { +- await router.route(notification, undefined, additionalProperties); +- return; +- } +- +- try { +- const context = await this._copilotApiService.resolveRestrictedTelemetryContext(githubToken); +- if (this._githubToken !== githubToken) { +- return; +- } +- await router.route(notification, { +- restrictedTelemetryEnabled: context.restrictedTelemetryEnabled, +- trackingId: context.trackingId, +- telemetryEndpoint: toRestrictedTelemetryEndpoint(context.telemetryEndpoint), +- isInternal: context.isInternal === true, +- userName: context.userName, +- isVscodeTeamMember: context.isVscodeTeamMember === true, +- }, additionalProperties); +- } catch (error) { +- this._logService.debug(`[Copilot:${sessionId}] Restricted telemetry context resolution failed; dropping ${notification.event.kind}: ${error instanceof Error ? error.message : String(error)}`); +- } +- } +- +- private _clientTypeForTelemetry(sdkSessionId: string | undefined): AgentHostClientType { +- return sdkSessionId +- ? this._sdkSessionsById.get(sdkSessionId)?.currentTurnClientType ?? AgentHostClientType.Unknown +- : AgentHostClientType.Unknown; - } - -- private async _refreshModels(): Promise { +- private async _refreshModels(attempt = 0): Promise { +- // A fresh refresh (e.g. a token change) supersedes any scheduled retry. +- this._modelRefreshRetry.clear(); +- +- // Once teardown has begun, skip the refresh entirely: a retry timer that +- // fires during the shutdown window would otherwise call `_ensureClient()` +- // and resurrect the SDK subprocess after `shutdown()` tore it down. +- if (this._shutdownPromise) { +- return; +- } +- - const tokenAtRefreshStart = this._githubToken; - if (!tokenAtRefreshStart) { -- this._models.set([], undefined); +- this._capiModels = []; +- this._publishModels(); - return; - } - try { - const models = await this._listModels(tokenAtRefreshStart); - if (this._githubToken === tokenAtRefreshStart) { -- this._models.set(models, undefined); +- this._capiModels = models; +- this._publishModels(); - } - } catch (err) { -- this._logService.error(err, '[Copilot] Failed to refresh models'); -- if (this._githubToken === tokenAtRefreshStart) { -- this._models.set([], undefined); +- // Token rotated mid-flight — a newer refresh owns the result — or +- // teardown began while the request was in flight, in which case a +- // retry would just resurrect the client we are tearing down. +- if (this._githubToken !== tokenAtRefreshStart || this._shutdownPromise) { +- return; +- } +- if (attempt + 1 < this._modelRefreshMaxAttempts) { +- const delay = this._modelRefreshBackoff(attempt); +- this._logService.warn(`[Copilot] Failed to refresh models (attempt ${attempt + 1}), retrying in ${delay}ms`, err); +- this._modelRefreshRetry.value = disposableTimeout(() => { +- void this._refreshModels(attempt + 1); +- }, delay); +- return; - } +- // Retries exhausted: surface the error but keep the last-known CAPI +- // list so a transient failure never wipes a previously loaded, good +- // model list. Republish so a concurrently-updated BYOK list still +- // shows through. +- this._logService.error(err, '[Copilot] Failed to refresh models'); +- this._publishModels(); +- } +- } +- +- /** +- * Re-emit the merged CAPI + BYOK model list to the picker. A fresh array is +- * allocated each call so the observable always notifies its consumers. +- */ +- private _publishModels(): void { +- this._models.set([...this._capiModels, ...this._byokModels], undefined); +- } +- +- /** +- * (Re)publish the renderer BYOK models from the bridge registry's serving +- * window. Triggered when any renderer bridge connects, disconnects, or +- * reports a model change — the registry owns enumeration (with its own +- * connect-time retry) and caches the serving window's models, so this is a +- * cheap synchronous read of that cache. +- * +- * Each model is surfaced under the provider-qualified id `vendor/id` so a +- * selection round-trips to the per-session provider config synthesized by +- * `resolveByokSessionConfig`. +- */ +- private _refreshByokModels(): void { +- if (this._shutdownPromise) { +- return; - } +- this._byokModels = this._byokBridgeRegistry.getModels().map((m): IAgentModelInfo => { +- const byokMeta = createAgentModelByokMeta(m.modelIdentifier); +- const thinkingLevel = this._createThinkingLevelConfigSchemaProperty(m.supportedReasoningEfforts, m.defaultReasoningEffort, m.id); +- return { +- provider: this.id, +- id: `${m.vendor}/${m.id}`, +- name: m.name ?? m.id, +- maxContextWindow: m.maxContextWindowTokens, +- supportsVision: m.supportsVision ?? false, +- ...(thinkingLevel ? { configSchema: { type: 'object', properties: { [ThinkingLevelConfigKey]: thinkingLevel } } satisfies ConfigSchema } : {}), +- ...(byokMeta && { _meta: byokMeta }), +- }; +- }); +- this._logService.trace(`[Copilot] Found ${this._byokModels.length} BYOK models${this._byokModels.length ? ': ' + this._byokModels.map(m => m.name).join(', ') : ''}`); +- this._publishModels(); +- } +- +- /** +- * Equal-jitter exponential backoff for model-refresh retries. Doubles the +- * base delay per attempt (capped at {@link _modelRefreshMaxDelayMs}) and +- * picks a random point in the upper half of that window, so the returned +- * delay lands in `[exp/2, exp]`. The jitter avoids synchronized retries +- * across windows/agents hitting a shared rate limit, while the `exp/2` +- * floor keeps a minimum spacing between attempts. +- */ +- private _modelRefreshBackoff(attempt: number): number { +- const exp = Math.min(this._modelRefreshMaxDelayMs, this._modelRefreshBaseDelayMs * 2 ** attempt); +- return Math.round(exp / 2 + Math.random() * (exp / 2)); - } - - private async _stopClient(): Promise { +- // Any parked restart is satisfied by this stop: the next `_ensureClient` +- // starts from the current config, so nothing is left to re-apply. +- this._pendingClientRestartReasons.clear(); - const client = this._client; - this._client = undefined; - this._clientStarting = undefined; - await client?.stop(); +- // The runtime subprocess is now dead, so it is safe to release the BYOK +- // proxy handle: the next session launch mints a fresh nonce. See the +- // ownership invariant on `CopilotSessionLauncher.disposeByokProxyHandle`. +- await this._sessionLauncher.disposeByokProxyHandle(); - } - - /** @@ -1186,6 +1648,9 @@ index 3f37722b..00000000 - // into the client options / subprocess env below). - const sessionSyncAtStartup = this._isSessionSyncEnabled(); - const rubberDuckAtStartup = this._isRubberDuckEnabled(); +- const copilotSdkLogLevelSettingAtStartup = this._getCopilotSdkLogLevelSetting(); +- const enterpriseHostAtStartup = this._getEnterpriseHost(); +- const systemProxyEnabledAtStartup = this._isSystemProxyEnabled(); - const clientStarting = (async () => { - this._logService.info('[Copilot] Starting CopilotClient...'); - @@ -1200,13 +1665,24 @@ index 3f37722b..00000000 - if (key === 'ELECTRON_RUN_AS_NODE') { - continue; - } -- if (key.startsWith('VSCODE_') || key.startsWith('ELECTRON_')) { +- if (key === 'VSCODE_AGENT_HOST_CAPI_URL_OVERRIDE') { +- // used for running the CLI in a test harness against a mock CAPI server +- continue; +- } +- if (key.startsWith('VSCODE_') || key.startsWith('ELECTRON_')) { - delete env[key]; - } - } - env['COPILOT_CLI_RUN_AS_NODE'] = '1'; - env['USE_BUILTIN_RIPGREP'] = 'false'; - env['COPILOT_MCP_APPS'] = 'true'; +- // Attribute the CLI and everything it spawns (`gh`, …) back to +- // VS Code. Already inherited via `process.env` (the strip loop only +- // removes `VSCODE_*`/`ELECTRON_*`); set here as defense in depth. +- env[AiAgentEnvVar] = AiAgentEnvValue; +- // Required by the currently bundled SDK to enable its experimental auto-approval judge. +- env['AUTO_APPROVAL'] = 'true'; +- await this._configureProxyEnv(env); - - // On Linux the MXC bubblewrap sandbox backend does not forward a PTY into - // the container, so the CLI's default PTY-backed interactive shell can @@ -1229,6 +1705,16 @@ index 3f37722b..00000000 - env['GITHUB_COPILOT_INTEGRATION_ID'] = COPILOT_INTEGRATION_ID; - this._logService.info(`[Copilot] Set CLI env: GITHUB_COPILOT_INTEGRATION_ID=${COPILOT_INTEGRATION_ID}`); - +- // Point the Copilot CLI at a configured GitHub Enterprise host for its +- // authentication and CAPI endpoint discovery. `COPILOT_GH_HOST` is +- // Copilot-CLI-specific (it does not affect the `gh` CLI). Unset for +- // github.com so the CLI uses its default host. +- const enterpriseHost = this._getEnterpriseHost(); +- if (enterpriseHost) { +- env['COPILOT_GH_HOST'] = enterpriseHost; +- this._logService.info(`[Copilot] Set CLI env: COPILOT_GH_HOST=${enterpriseHost}`); +- } +- - // Enable the rubber duck critic subagent in the CLI when the agent host - // config opts in. `RUBBER_DUCK_AGENT` is the SDK's required interface for - // gating this experimental feature @@ -1238,15 +1724,20 @@ index 3f37722b..00000000 - delete env['RUBBER_DUCK_AGENT']; - } - -- // Resolve the CLI entry point from node_modules. We can't use require.resolve() -- // because @github/copilot's exports map blocks direct subpath access. -- // FileAccess.asFileUri('') points to the `out/` directory; node_modules is one level up. -- const nodeModulesUri = URI.joinPath(FileAccess.asFileUri(''), '..', 'node_modules'); -- const cliPath = URI.joinPath(nodeModulesUri, '@github', 'copilot', 'index.js').fsPath; +- // Resolve the CLI entry point and native SDK binaries from node_modules. +- // In the desktop app these live next to the ASAR archive in +- // `node_modules.asar.unpacked` (the `@github/copilot-` CLI and +- // the `@microsoft/mxc-sdk/bin` executables are unpacked so they can be +- // spawned), while in dev and on the server (which has no ASAR) they live +- // in a plain `node_modules`. +- // We can't use require.resolve() because @github/copilot's exports map +- // blocks direct subpath access. +- const nodeModulesUri = FileAccess.asFileUri(getAppNodeModulesPath()); +- const cliPath = await resolveCopilotCliPath(nodeModulesUri); - - // The SDK's sandbox auto-detection looks for `//wxc-exec.exe` - // (and the Linux/macOS equivalents). VS Code core ships the MXC sandbox binaries -- // at `node_modules/@microsoft/mxc-sdk/bin//`, so point `MXC_BIN_DIR` there. +- // at `/@microsoft/mxc-sdk/bin//`, so point `MXC_BIN_DIR` there. - // The @github/copilot package's own `mxc-bin/` is excluded from the product build - // (see build/.moduleignore), mirroring `CopilotCLISDK.getPackage` in the extension. - env['MXC_BIN_DIR'] = URI.joinPath(nodeModulesUri, '@microsoft', 'mxc-sdk', 'bin').fsPath; @@ -1262,18 +1753,20 @@ index 3f37722b..00000000 - this._logService.info(`[Copilot] Resolved CLI path: ${cliPath}`); - - const telemetry = await this._otelService.getSdkTelemetryConfig(); +- const copilotSdkLogLevelAtStartup = this._resolveCopilotSdkLogLevel(copilotSdkLogLevelSettingAtStartup); - - const clientOptions: CopilotClientOptions = { - useLoggedInUser: false, - connection: RuntimeConnection.forStdio({ path: cliPath }), - env, - telemetry, -- logLevel: copilotCliLogLevelFor(this._logService.getLevel()), -- enableRemoteSessions: this._isSessionSyncEnabled(), +- logLevel: copilotSdkLogLevelAtStartup, +- enableRemoteSessions: sessionSyncAtStartup, +- onGitHubTelemetry: notification => { void this._routeGitHubTelemetry(notification).catch(err => this._logService.trace(`[Copilot] GitHub telemetry routing failed: ${err instanceof Error ? err.message : String(err)}`)); }, - }; - const client = this._createCopilotClient(clientOptions); - await client.start(); -- if (this._isSessionSyncEnabled() !== sessionSyncAtStartup || this._isRubberDuckEnabled() !== rubberDuckAtStartup) { +- if (this._isSessionSyncEnabled() !== sessionSyncAtStartup || this._isRubberDuckEnabled() !== rubberDuckAtStartup || this._getCopilotSdkLogLevelSetting() !== copilotSdkLogLevelSettingAtStartup || this._getEnterpriseHost() !== enterpriseHostAtStartup || this._isSystemProxyEnabled() !== systemProxyEnabledAtStartup) { - await client.stop(); - throw new Error('Copilot startup config changed while the client was starting'); - } @@ -1292,7 +1785,10 @@ index 3f37722b..00000000 - - // ---- session management ------------------------------------------------- - -- private _createThinkingLevelConfigSchemaProperty(supportedReasoningEfforts: readonly string[] | undefined, defaultReasoningEffort: string | undefined): ConfigPropertySchema | undefined { +- private _createThinkingLevelConfigSchemaProperty(reasoningEfforts: readonly string[] | undefined, defaultReasoningEffort: string | undefined, modelId: string | undefined): ConfigPropertySchema | undefined { +- // Only advertise efforts the Copilot launcher actually accepts, otherwise the picker would +- // surface a level that is silently dropped when the session is launched. +- const supportedReasoningEfforts = reasoningEfforts?.filter(isCopilotReasoningEffort); - if (!supportedReasoningEfforts?.length) { - return undefined; - } @@ -1301,7 +1797,7 @@ index 3f37722b..00000000 - type: 'string', - title: localize('copilot.modelThinkingLevel.title', "Thinking Level"), - description: localize('copilot.modelThinkingLevel.description', "Controls how much reasoning effort the model uses."), -- default: defaultReasoningEffort, +- default: resolveDefaultReasoningEffort(supportedReasoningEfforts, defaultReasoningEffort, modelId), - enum: [...supportedReasoningEfforts], - enumLabels: supportedReasoningEfforts.map(getReasoningEffortLabel), - enumDescriptions: supportedReasoningEfforts.map(value => getReasoningEffortDescription(value) ?? ''), @@ -1309,14 +1805,16 @@ index 3f37722b..00000000 - } - - /** -- * Synthesize a `contextTier` config property when the model exposes a `long_context` pricing tier with a distinct +- * Synthesize a `contextSize` config property when the model exposes a `long_context` pricing tier with a distinct - * context-max. Picker surfaces this as the "Context Size" button. Mirrors `getContextSizeOptions` in -- * `extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts`. +- * `extensions/copilot/src/extension/chat/vscode-node/languageModelAccess.ts`. - * -- * `billing.tokenPrices` is present on the runtime CAPI `/models` payload but not yet declared on the published SDK -- * `ModelBilling` type — narrow through {@link ICopilotModelBilling} until the SDK catches up. +- * The `enum` values are the two context-window sizes (in tokens), smallest first, so the numeric token counts +- * flow to the client. The chosen value comes back in the model's `config` bag and is mapped to the SDK's +- * two-valued `contextTier` at the SDK boundary by {@link getCopilotContextTier}, using the model's long-context +- * window from {@link _longContextWindowFor}. - */ -- private _createContextTierConfigSchemaProperty(billing: ModelInfo['billing'] | undefined): ConfigPropertySchema | undefined { +- private _createContextSizeConfigSchemaProperty(billing: ICAPIModelBilling | undefined): ConfigPropertySchema | undefined { - const tokenPrices = billing?.tokenPrices; - const defaultMax = tokenPrices?.contextMax; - const longContextMax = tokenPrices?.longContext?.contextMax; @@ -1324,66 +1822,76 @@ index 3f37722b..00000000 - return undefined; - } - -- const hasLongContextSurcharge = typeof tokenPrices?.longContext?.inputPrice === 'number' -- || typeof tokenPrices?.longContext?.outputPrice === 'number'; +- // When both tiers cost the same and the user prefers long context, show only the long-context option as a non-switchable indicator. See microsoft/vscode#322950, microsoft/vscode#323116. +- if (this._isPreferLongContextEnabled() && !hasLongContextSurcharge(billing)) { +- return { +- type: 'number', +- title: localize('copilot.modelContextSize.title', "Context Size"), +- description: localize('copilot.modelContextSize.description', "Selects the context window size for this model."), +- default: longContextMax, +- enum: [longContextMax], +- enumLabels: [formatTokenCount(longContextMax)], +- enumDescriptions: [ +- localize('copilot.modelContextSize.longerSessions', "Longer sessions"), +- ], +- }; +- } - - return { -- type: 'string', -- title: localize('copilot.modelContextTier.title', "Context Size"), -- description: localize('copilot.modelContextTier.description', "Selects the context window size for this model."), -- default: 'default', -- enum: ['default', 'long_context'], +- type: 'number', +- title: localize('copilot.modelContextSize.title', "Context Size"), +- description: localize('copilot.modelContextSize.description', "Selects the context window size for this model."), +- default: defaultMax, +- enum: [defaultMax, longContextMax], - enumLabels: [formatTokenCount(defaultMax), formatTokenCount(longContextMax)], - enumDescriptions: [ -- localize('copilot.modelContextTier.default', "Default"), -- hasLongContextSurcharge -- ? localize('copilot.modelContextTier.longerSessions', "Longer sessions") -- : localize('copilot.modelContextTier.longerSessionsNoCompaction', "Longer sessions without compaction"), +- localize('copilot.modelContextSize.default', "Default"), +- localize('copilot.modelContextSize.longerSessions', "Longer sessions"), - ], - }; - } - - /** -- * Builds the open `_meta` pricing bag for a model from its billing info so the chat model picker can render its -- * cost hover. Cost values are credits per 1M tokens. -- * -- * Long-context costs are only emitted when they differ from the default tier, mirroring `normalizeTokenPrices` in -- * `extensions/copilot/src/extension/conversation/common/languageModelAccess.ts`. -- * -- * `billing.tokenPrices` / `billing.priceCategory` are present on the runtime CAPI `/models` payload but not yet -- * declared on the published SDK `ModelBilling` type — narrow through {@link ICopilotModelBilling}. +- * The model's long-context window (in tokens): the largest size offered by its "Context Size" picker +- * (the max numeric value in the synthesized `contextSize` {@link ConfigPropertySchema.enum}). Used by +- * {@link getCopilotContextTier} to decide whether a numeric selection opts into `long_context`. +- * Returns `undefined` when the model exposes no such picker (or the model list isn't loaded yet), +- * leaving the SDK on its default tier. - */ -- private _createModelPricingMeta(modelInfo: ModelInfo | undefined): Record | undefined { -- const billing = modelInfo?.billing; -- const tokenPrices = billing?.tokenPrices; -- const longContext = tokenPrices?.longContext; +- private _longContextWindowFor(modelId: string | undefined): number | undefined { +- if (!modelId) { +- return undefined; +- } +- const windows = this._models.get().find(m => m.id === modelId)?.configSchema?.properties?.[ContextSizeConfigKey]?.enum; +- const numericWindows = windows?.filter((w): w is number => typeof w === 'number'); +- return numericWindows && numericWindows.length > 0 ? Math.max(...numericWindows) : undefined; +- } - -- const differsFromDefault = (longValue: number | undefined, defaultValue: number | undefined): number | undefined => -- longValue !== undefined && longValue !== defaultValue ? longValue : undefined; +- /** +- * Whether the model has a long-context window available at no additional cost. +- * When true the model should always run in `long_context` tier without showing +- * a context-size picker. +- */ +- private _isFreeLongContext(modelId: string | undefined): boolean { +- return !!modelId && this._freeLongContextModels.has(modelId); +- } - -- return createAgentModelPricingMeta({ -- multiplierNumeric: typeof billing?.multiplier === 'number' ? billing.multiplier : undefined, -- inputCost: tokenPrices?.inputPrice, -- cacheCost: tokenPrices?.cachePrice, -- cacheWriteCost: tokenPrices?.cachePrice, -- outputCost: tokenPrices?.outputPrice, -- longContextInputCost: differsFromDefault(longContext?.inputPrice, tokenPrices?.inputPrice), -- longContextCacheCost: differsFromDefault(longContext?.cachePrice, tokenPrices?.cachePrice), -- longContextCacheWriteCost: differsFromDefault(longContext?.cachePrice, tokenPrices?.cachePrice), -- longContextOutputCost: differsFromDefault(longContext?.outputPrice, tokenPrices?.outputPrice), -- priceCategory: typeof modelInfo?.modelPickerPriceCategory === 'string' ? modelInfo.modelPickerPriceCategory : undefined, -- }); +- /** +- * Builds the open `_meta` model picker bag from the SDK's billing and picker metadata. +- */ +- private _createModelPickerMeta(modelInfo: ModelInfo, billing: ICAPIModelBilling | undefined): Record | undefined { +- return createPricingMetaFromBilling(billing, modelInfo.modelPickerPriceCategory, modelInfo.modelPickerCategory); - } - -- private _createModelConfigSchema(m: ModelInfo): ConfigSchema | undefined { +- private _createModelConfigSchema(m: ModelInfo, billing: ICAPIModelBilling | undefined): ConfigSchema | undefined { - const properties: ConfigSchema['properties'] = {}; -- const thinkingLevel = this._createThinkingLevelConfigSchemaProperty(m.supportedReasoningEfforts, m.defaultReasoningEffort); +- const thinkingLevel = this._createThinkingLevelConfigSchemaProperty(m.supportedReasoningEfforts, m.defaultReasoningEffort, m.id); - if (thinkingLevel) { - properties[ThinkingLevelConfigKey] = thinkingLevel; - } -- const contextTier = this._createContextTierConfigSchemaProperty(m.billing); -- if (contextTier) { -- properties[ContextTierConfigKey] = contextTier; +- const contextSize = this._createContextSizeConfigSchemaProperty(billing); +- if (contextSize) { +- properties[ContextSizeConfigKey] = contextSize; - } - if (Object.keys(properties).length === 0) { - return undefined; @@ -1444,23 +1952,16 @@ index 3f37722b..00000000 - } - - /** -- * Resolves an {@link AgentSelection}'s SDK-facing name by looking it up in -- * the active client snapshot's parsed plugin agents. Falls back to `undefined` -- * when no matching plugin agent is found. +- * Resolves an {@link AgentSelection}'s SDK-facing name from the plugin +- * snapshot that is, or will be, applied to the SDK session. - */ -- private async _resolveAgentName(sessionUri: URI, snapshot: IActiveClientSnapshot, agent: AgentSelection): Promise { +- private _resolveAgentName(snapshot: IActiveClientSnapshot, agent: AgentSelection): string | undefined { - for (const plugin of snapshot.plugins) { - const found = plugin.agents.find(a => a.uri.toString() === agent.uri); - if (found) { - return found.name; - } - } -- const customizations = await this.getSessionCustomizations(sessionUri); -- const agents = getEffectiveAgents(customizations); -- const found = agents.find(a => a.uri.toString() === agent.uri); -- if (found) { -- return found.name; -- } - return undefined; - } - @@ -1481,17 +1982,14 @@ index 3f37722b..00000000 - project = await this._resolveSessionProject(s.context, projectLimiter, projectByContext); - void this._storeSessionProjectResolution(session, project); - } -- const workingDirectory = metadata.workingDirectory ?? (typeof s.context?.workingDirectory === 'string' ? URI.file(s.context.workingDirectory) : undefined); +- const workingDirectories = metadata.workingDirectories ?? (typeof s.context?.workingDirectory === 'string' ? [URI.file(s.context.workingDirectory)] : undefined); - const result: IAgentSessionMetadata = { - session, - startTime: s.startTime.getTime(), - modifiedTime: s.modifiedTime.getTime(), - project, - summary: s.summary, -- model: metadata.model, -- agent: metadata.agent, -- workingDirectory, -- customizationDirectory: metadata.customizationDirectory, +- workingDirectories, - }; - return result; - })); @@ -1520,17 +2018,14 @@ index 3f37722b..00000000 - void this._storeSessionProjectResolution(session, project); - } - -- const workingDirectory = storedMetadata?.workingDirectory ?? (typeof sessionMetadata?.context?.workingDirectory === 'string' ? URI.file(sessionMetadata.context.workingDirectory) : undefined); +- const workingDirectories = storedMetadata?.workingDirectories ?? (typeof sessionMetadata?.context?.workingDirectory === 'string' ? [URI.file(sessionMetadata.context.workingDirectory)] : undefined); - return { - session, - startTime: sessionMetadata?.startTime.getTime() ?? Date.now(), - modifiedTime: sessionMetadata?.modifiedTime.getTime() ?? Date.now(), - project, - summary: sessionMetadata?.summary, -- model: storedMetadata?.model, -- agent: storedMetadata?.agent, -- workingDirectory, -- customizationDirectory: storedMetadata?.customizationDirectory, +- workingDirectories, - }; - } - @@ -1538,44 +2033,221 @@ index 3f37722b..00000000 - this._logService.info('[Copilot] Listing models...'); - const client = await this._ensureClient(); - const { models } = await client.rpc.models.list({ gitHubToken }); -- const result = models.map((m): IAgentModelInfo => ({ -- provider: this.id, -- id: m.id, -- name: m.name, -- // Synthetic SDK entries like `auto` ship with `capabilities: {}` and -- // no fixed context window — surface them with maxContextWindow undefined. -- maxContextWindow: m.capabilities?.limits?.max_context_window_tokens, -- supportsVision: !!m.capabilities?.supports?.vision, -- configSchema: this._createModelConfigSchema(m), -- policyState: m.policy?.state as PolicyState | undefined, -- _meta: this._createModelPricingMeta(m), -- })); +- this._freeLongContextModels.clear(); +- const preferLongContext = this._isPreferLongContextEnabled(); +- const result = models.map((m): IAgentModelInfo => { +- const billing = normalizeCAPIBilling(m.billing); +- const configSchema = this._createModelConfigSchema(m, billing); +- // A model has free long context (larger window, no surcharge), but only treat it as free when the user prefers long context. +- const tokenPrices = billing?.tokenPrices; +- const hasLargerLongContext = !!tokenPrices?.contextMax +- && !!tokenPrices.longContext?.contextMax +- && tokenPrices.longContext.contextMax > tokenPrices.contextMax; +- if (preferLongContext && hasLargerLongContext && !hasLongContextSurcharge(billing)) { +- this._freeLongContextModels.add(m.id); +- } +- return { +- provider: this.id, +- id: m.id, +- name: m.name, +- // Synthetic SDK entries like `auto` ship with `capabilities: {}` and +- // no fixed context window — surface them with maxContextWindow undefined. +- maxContextWindow: m.capabilities?.limits?.max_context_window_tokens, +- maxOutputTokens: m.capabilities?.limits?.max_output_tokens, +- maxPromptTokens: m.capabilities?.limits?.max_prompt_tokens, +- supportsVision: !!m.capabilities?.supports?.vision, +- configSchema, +- policyState: m.policy?.state as PolicyState | undefined, +- _meta: this._createModelPickerMeta(m, billing), +- }; +- }); - this._logService.info(`[Copilot] Found ${result.length} models: ${result.map(m => m.name).join(', ')}`); - return result; - } - - /** - * Resolves the working directory for a {@link createSession} call: the caller-supplied folder, else a -- * still-provisional session's folder for an idempotent re-create, else a freshly created empty directory under the -- * OS temp dir (used when the editor has no workspace open). +- * still-provisional session's folder for an idempotent re-create, else — when the session is workspace-less +- * (no `workingDirectory` supplied) — a stable per-session scratch directory. - */ -- private async _resolveCreateWorkingDirectory(sessionConfig: IAgentCreateSessionConfig, sessionId: string): Promise { -- const existing = sessionConfig.workingDirectory ?? this._provisionalSessions.get(sessionId)?.workingDirectory; +- private async _resolveCreateWorkingDirectory(sessionConfig: IAgentCreateSessionConfig, sessionId: string, isWorkspaceless: boolean): Promise { +- const existing = sessionConfig.workingDirectories?.[0] ?? this._provisionalSessions.get(sessionId)?.workingDirectory; - if (existing) { - return existing; - } +- // A workspace-less session (inferred from an absent input +- // `workingDirectory`) gets a STABLE, deterministic per-session scratch +- // dir (mirroring the GitHub app's `/chats/`) rather than +- // a throwaway `os.tmpdir()` dir, so the cwd survives reloads and isn't +- // lost to OS temp reaping. +- if (isWorkspaceless) { +- const scratchDir = this._workspacelessScratchDir(sessionId); +- await fs.mkdir(scratchDir.fsPath, { recursive: true }); +- return scratchDir; +- } - const tmpPath = await fs.mkdtemp(join(os.tmpdir(), 'agent-host-session-')); - const workingDirectory = URI.file(tmpPath); - this._logService.trace(`[Copilot] No workingDirectory provided, defaulting to temp directory: ${workingDirectory.fsPath}`); - return workingDirectory; - } - +- /** +- * Stable per-session scratch directory for a workspace-less chat: +- * `/.copilot/chats/`. Deterministic, persistent, and +- * cleaned up on session delete (see {@link _cleanupWorkspacelessScratchDir}). +- */ +- private _workspacelessScratchDir(sessionId: string): URI { +- return workspacelessScratchDir(this._environmentService.userHome, sessionId); +- } +- +- /** Ensures a workspace-less chat's scratch dir exists (mkdir -p), recreating it if it was reaped. */ +- private async _ensureWorkspacelessScratchDir(scratchDir: URI, sessionId: string): Promise { +- try { +- await fs.mkdir(scratchDir.fsPath, { recursive: true }); +- this._logService.trace(`[Copilot:${sessionId}] Workspace-less scratch directory ready: ${scratchDir.fsPath}`); +- } catch (error) { +- this._logService.warn(`[Copilot:${sessionId}] Failed to ensure workspace-less scratch directory '${scratchDir.fsPath}': ${error instanceof Error ? error.message : String(error)}`); +- } +- } +- +- /** Removes a workspace-less chat's stable scratch dir on session delete/dispose. */ +- private async _cleanupWorkspacelessScratchDir(scratchDir: URI, sessionId: string): Promise { +- try { +- await fs.rm(scratchDir.fsPath, { recursive: true, force: true }); +- this._logService.trace(`[Copilot:${sessionId}] Removed workspace-less scratch directory: ${scratchDir.fsPath}`); +- } catch (error) { +- this._logService.warn(`[Copilot:${sessionId}] Failed to remove workspace-less scratch directory '${scratchDir.fsPath}': ${error instanceof Error ? error.message : String(error)}`); +- } +- } +- +- // ---- Chat surface ------------------------------------------------------ +- // +- // The chat-addressed operation surface (see +- // {@link IAgent.chats}). The orchestrator owns the feature-level +- // `(session, chat)` mapping and hands these methods a single, +- // concrete chat channel URI: the default chat channel or an additional +- // peer chat channel. Each method re-derives the `(session, chat)` pair +- // the agent's internal SDK storage is keyed by via +- // {@link _resolveChatTarget}. +- +- /** +- * Maps a resolved chat URI to the `(session, chat)` pair the agent's +- * internal storage is keyed by. A peer (`ahp-chat`) chat carries its +- * owning session in its URI. The default chat is addressed by its +- * deterministic chat channel URI. +- */ +- private _resolveChatTarget(chat: URI): { session: URI; chat: URI } { +- const parsed = parseChatUri(chat); +- if (!parsed) { +- throw new Error(`Copilot chat operation requires an AHP chat URI: ${chat.toString()}`); +- } +- return { session: URI.parse(parsed.session), chat: chat }; +- } +- +- private _getChatContext(chatOrSession: URI): { session: URI; sessionId: string; chatKey: string; target: CopilotAgentSession | undefined; isPeerChat: boolean } { +- // Accept either a chat channel URI or a bare session URI: per the AHP +- // convention the default chat's URI equals the session URI, so callers +- // that address the default chat by the session URI resolve here in one +- // place rather than each operational method re-deriving it. +- const chat = parseChatUri(chatOrSession) ? chatOrSession : URI.parse(buildDefaultChatUri(chatOrSession)); +- const session = URI.parse(parseRequiredSessionUriFromChatUri(chat)); +- const sessionId = AgentSession.id(session); +- const chatKey = chat.toString(); +- const resolved = this._sessions.get(sessionId)?.resolveChat(chatKey); +- return { +- session, +- sessionId, +- chatKey, +- target: resolved?.chatSession, +- isPeerChat: resolved ? !resolved.isDefault : chatKey !== buildDefaultChatUri(session), +- }; +- } +- +- /** +- * Resolve the session's materialized default (main) chat by raw session id, +- * or `undefined` when the session is provisional or not in memory. The +- * default chat is the primary {@link CopilotAgentSession} of the owning +- * {@link CopilotSessionEntry}. +- */ +- private _findAnySession(sessionId: string): CopilotAgentSession | undefined { +- return this._sessions.get(sessionId)?.defaultChat; +- } +- +- private _getRuntimeSlashCommands(sessionId: string, options?: ICopilotRuntimeSlashCommandQueryOptions) { +- const session = this._findAnySession(sessionId); +- if (session) { +- return session.getRuntimeSlashCommands(options) ?? []; +- } +- return this._slashCommandProvider.getSlashCommands(options); +- } +- +- /** +- * Resolve a live peer (non-default) chat — its own SDK chat — by +- * looking it up within the owning session's entry. Returns `undefined` when +- * the session (or the peer chat) is not in memory. +- */ +- private _findPeerChat(session: URI, chat: URI): CopilotAgentSession | undefined { +- return this._sessions.get(AgentSession.id(session))?.getPeerChat(chat.toString()); +- } +- +- /** +- * Return the owning session's entry, creating an empty one (no default chat +- * yet) if needed so a peer chat can be hosted on a still-provisional parent. +- */ +- private _ensureEntry(sessionId: string): CopilotSessionEntry { +- let entry = this._sessions.get(sessionId); +- if (!entry) { +- entry = new CopilotSessionEntry(); +- this._sessions.set(sessionId, entry); +- } +- return entry; +- } +- +- /** +- * Chat-addressed surface for the chats within a session. +- */ +- readonly chats: IAgentChats = { +- createChat: (chat: URI, options?: IAgentCreateChatOptions): Promise => { +- return this._createChat(chat, options); +- }, +- fork: (chat: URI, source: IAgentCreateChatForkSource, options?: IAgentCreateChatOptions): Promise => { +- return this._createChat(chat, { ...options, fork: source }); +- }, +- disposeChat: (chatUri: URI): Promise => { +- const { session, chat } = this._resolveChatTarget(chatUri); +- return this._disposeChat(session, chat); +- }, +- sendMessage: (chatUri: URI, prompt: string, workingDirectories: readonly URI[] | undefined, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string, clientType?: AgentHostClientType): Promise => { +- return this._sendMessage(chatUri, prompt, attachments, turnId, senderClientId, clientType, workingDirectories); +- }, +- abort: (chatUri: URI): Promise => { +- return this._abortSession(chatUri); +- }, +- changeModel: (chatUri: URI, model: ModelSelection): Promise => { +- return this._changeModel(chatUri, model); +- }, +- changeAgent: (chatUri: URI, agent: AgentSelection | undefined): Promise => { +- return this._changeAgent(chatUri, agent); +- }, +- getMessages: (chat: URI): Promise => { +- return this.getSessionMessages(chat); +- }, +- }; +- - async createSession(config?: IAgentCreateSessionConfig): Promise { - const sessionConfig = config ?? {}; - - this._logService.info(`[Copilot] Creating session... ${sessionConfig.model ? `model=${sessionConfig.model.id}` : ''}`); - const sessionId = sessionConfig.session ? AgentSession.id(sessionConfig.session) : generateUuid(); -- const workingDirectory = await this._resolveCreateWorkingDirectory(sessionConfig, sessionId); +- // Workspace-less is inferred at create from an absent input +- // `workingDirectory`: such a session is run in a stable scratch dir. The +- // AH service persists the marker centrally (`agentHost.workspaceless`) and +- // hands it back on restore; the agent only reads it (never persists it) to +- // pick the workspace-less system prompt. Forks always inherit the source +- // session's context, so they are never inferred workspace-less even when no +- // `workingDirectory` is passed. +- const isWorkspaceless = !sessionConfig.fork && sessionConfig.workingDirectories === undefined; +- const workingDirectory = await this._resolveCreateWorkingDirectory(sessionConfig, sessionId, isWorkspaceless); - const client = await this._ensureClient(); - // When forking, use the SDK's sessions.fork RPC. Forking from a source - // session that has no turns is equivalent to creating a fresh session; @@ -1589,7 +2261,7 @@ index 3f37722b..00000000 - return this._sessionSequencer.queue(sourceSessionId, async () => { - this._logService.info(`[Copilot] Forking session ${sourceSessionId} at turnId=${sessionConfig.fork!.turnId}`); - -- const sourceEntry = this._sessions.get(sourceSessionId) ?? await this._resumeSession(sourceSessionId); +- const sourceEntry = this._findAnySession(sourceSessionId) ?? await this._resumeSession(sourceSessionId); - - // Look up the SDK event ID for the turn *after* the fork point. - // toEventId is exclusive — events before it are included. @@ -1612,6 +2284,9 @@ index 3f37722b..00000000 - if (sourceDbRef) { - try { - await fs.mkdir(targetDbDir.fsPath, { recursive: true }); +- // VACUUM INTO fails if the target already exists; clear +- // any stale DB left by a previous (e.g. crashed) attempt. +- await fs.rm(targetDbPath.fsPath, { force: true }); - await sourceDbRef.object.vacuumInto(targetDbPath.fsPath); - } finally { - sourceDbRef.dispose(); @@ -1631,15 +2306,29 @@ index 3f37722b..00000000 - - const session = agentSession.sessionUri; - this._logService.info(`[Copilot] Forked session created: ${session.toString()}`); +- +- // Copy the source session's reviewed ref so the fork starts with +- // the parent's review progress (best-effort; a failure just means +- // the fork starts unreviewed). +- try { +- await this._reviewService.copyReviewedRef(sessionConfig.fork!.session.toString(), session.toString(), workingDirectory); +- } catch (err) { +- this._logService.warn(`[Copilot] Failed to copy reviewed ref for fork: ${err instanceof Error ? err.message : String(err)}`); +- } +- - const project = await projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService); -- await this._storeSessionMetadata(session, sessionConfig.model, workingDirectory, workingDirectory, project, true); +- await this._storeSessionMetadata(session, sessionConfig.model, workingDirectory, sessionConfig.workingDirectories ?? (workingDirectory ? [workingDirectory] : undefined), workingDirectory, project, true); - if (sessionConfig.agent !== undefined) { - await this._storeSessionAgentMetadata(session, sessionConfig.agent); - } -- return { session, workingDirectory, ...(project ? { project } : {}) }; +- return { session, resolvedWorkingDirectory: workingDirectory, ...(project ? { project } : {}) }; - }); - } - +- if (sessionConfig.importConversation) { +- return this._importConversation(sessionConfig, sessionId, workingDirectory); +- } +- - // Non-fork path: create a *provisional* session. The Copilot SDK - // session, the worktree (if any), and the on-disk metadata are all - // deferred until the first {@link sendMessage} via @@ -1654,10 +2343,10 @@ index 3f37722b..00000000 - // non-provisional result so the caller doesn't re-fire `SessionAdded`. - // This guards against client retries that race a successful first - // message. -- if (this._sessions.has(sessionId)) { +- if (this._findAnySession(sessionId)) { - this._logService.info(`[Copilot] createSession is a no-op: session already materialized: ${sessionUri.toString()}`); - const project = await projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService); -- return { session: sessionUri, workingDirectory, ...(project ? { project } : {}) }; +- return { session: sessionUri, resolvedWorkingDirectory: workingDirectory, ...(project ? { project } : {}) }; - } - - // Idempotent: a duplicate `createSession` for a still-provisional URI @@ -1675,13 +2364,19 @@ index 3f37722b..00000000 - // of activeClient state isn't engaged until materialization. - if (sessionConfig.activeClient) { - const ac = this._getOrCreateActiveClient(sessionUri, workingDirectory); -- ac.updateTools(sessionConfig.activeClient.clientId, sessionConfig.activeClient.tools); -- if (sessionConfig.activeClient.customizations !== undefined) { +- // Multi-root: anchor discovery to the additional (non-primary) roots too, so a +- // still-provisional (pre-send) chat surfaces customizations from every folder — not +- // just the primary. Empty when single-root / gated off (byte-identical). +- ac.pluginController.setAdditionalDirectories(this._additionalCustomizationDirectories(sessionConfig.workingDirectories)); +- const seeded = sessionConfig.activeClient; +- ac.toolSet.set(seeded.clientId, seeded.tools); +- ac.getOrCreateHandle(seeded.clientId, seeded.displayName); +- if (seeded.customizations !== undefined) { - // Provisional eager-create: no session-state listener is - // hooked up yet, so suppress action events. The session - // reads the final view via its initial snapshot once it - // materializes. -- await ac.pluginController.sync(sessionConfig.activeClient.clientId, sessionConfig.activeClient.customizations, { quiet: true }); +- await ac.pluginController.sync(seeded.clientId, seeded.customizations, { quiet: true }); - } - } - @@ -1695,14 +2390,61 @@ index 3f37722b..00000000 - sessionId, - sessionUri, - workingDirectory, +- workingDirectories: sessionConfig.workingDirectories, - model: sessionConfig.model, - agent: sessionConfig.agent, - project, +- workspaceless: isWorkspaceless, - }); - } - - this._logService.info(`[Copilot] Session created (provisional): ${sessionUri.toString()}`); -- return { session: sessionUri, workingDirectory, provisional: true, ...(project ? { project } : {}) }; +- return { session: sessionUri, resolvedWorkingDirectory: workingDirectory, provisional: true, ...(project ? { project } : {}) }; +- } +- +- /** +- * Materializes an imported conversation into a real, editable Copilot +- * session. Translates the supplied turns into a Copilot event log, seeds it +- * at the CLI's native per-session store, then resumes the session so the +- * SDK reconstitutes the turns as genuine backend events (editable / forkable +- * / truncatable). The turns arrive with fresh UUID ids assigned by the +- * service layer, so the seeded event ids and the seeded protocol turns stay +- * aligned. Mirrors the immediate-materialization shape of the fork path. +- */ +- private async _importConversation(sessionConfig: IAgentCreateSessionConfig, sessionId: string, workingDirectory: URI): Promise { +- const importConfig = sessionConfig.importConversation!; +- const sessionUri = AgentSession.uri(this.id, sessionId); +- return this._sessionSequencer.queue(sessionId, async () => { +- this._logService.info(`[Copilot] Importing conversation into session ${sessionId} (${importConfig.turns.length} turns)`); +- const model = importConfig.model ?? sessionConfig.model; +- +- // Translate the conversation and seed it at the CLI's native +- // per-session store so a normal resume reconstitutes editable turns. +- // Detect the project concurrently with the (independent) event-log write +- // so the git probe and file I/O overlap on the session-creation path. +- const projectPromise = projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService); +- const eventsPath = join(getCopilotHomePath(this._environmentService.userHome.fsPath, process.env), 'session-state', sessionId, 'events.jsonl'); +- const jsonl = buildSessionEventLogFromTurns(importConfig.turns, { +- sessionId, +- workingDirectory: workingDirectory.fsPath, +- model: model?.id, +- }); +- await fs.mkdir(dirname(eventsPath), { recursive: true }); +- await fs.writeFile(eventsPath, jsonl, 'utf8'); +- +- // Persist metadata before resume so `_resumeSession` can resolve the +- // working directory and model. +- const project = await projectPromise; +- await this._storeSessionMetadata(sessionUri, model, workingDirectory, sessionConfig.workingDirectories ?? (workingDirectory ? [workingDirectory] : undefined), workingDirectory, project); +- if (sessionConfig.agent !== undefined) { +- await this._storeSessionAgentMetadata(sessionUri, sessionConfig.agent); +- } +- +- // Resume so the SDK loads the seeded history as editable turns. +- await this._resumeSession(sessionId); +- this._logService.info(`[Copilot] Imported session created: ${sessionUri.toString()}`); +- return { session: sessionUri, resolvedWorkingDirectory: workingDirectory, ...(project ? { project } : {}) }; +- }); - } - - /** @@ -1719,68 +2461,83 @@ index 3f37722b..00000000 - * naturally. - * - * The latest model lives on the provisional record (kept in sync via -- * {@link changeModel}). The latest session config (isolation / branch / -- * etc.) is read straight from the state manager via +- * {@link changeModel}). The latest provider-owned session config is read +- * straight from the state manager via - * {@link IAgentConfigurationService.getSessionConfigValues} so any - * `SessionConfigChanged` actions that arrived after `createSession` are - * honoured without bespoke forwarding. - */ -- private async _materializeProvisional(sessionId: string, prompt: string): Promise { +- private async _materializeProvisional(sessionId: string, resolvedWorkingDirectories?: readonly URI[]): Promise { - const provisional = this._provisionalSessions.get(sessionId); - if (!provisional) { - throw new Error(`Cannot materialize unknown provisional session: ${sessionId}`); - } - const client = await this._ensureClient(); - const sessionUri = provisional.sessionUri; -- const liveSessionConfig = this._configurationService.getSessionConfigValues(sessionUri.toString()); -- -- const materializedConfig: IAgentCreateSessionConfig = { -- provider: this.id, -- session: sessionUri, -- workingDirectory: provisional.workingDirectory, -- model: provisional.model, -- config: liveSessionConfig, -- }; - -- const customizationDirectory = provisional.workingDirectory; +- // The host hands us the resolved working directories (an isolated worktree for +- // worktree isolation) on the first send; use index 0 (the process root) so the +- // SDK subprocess spawns in it. Falls back to the folder / scratch dir captured +- // at create time for folder / workspace-less sessions. +- const workingDirectory = resolvedWorkingDirectories?.[0] ?? provisional.workingDirectory; +- // The customization anchor follows the working directory: once a worktree +- // is created the agent must discover skills/instructions/agents from the +- // worktree (not the user-picked folder) so the model reads and edits files +- // in the worktree it actually runs in. +- const customizationDirectory = workingDirectory ?? provisional.workingDirectory; - // Always create an ActiveClient so the snapshot includes host + -- // session-discovered customizations, even when no client has called -- // `setClientCustomizations` / `setClientTools` yet. +- // session-discovered customizations, even when no client has +- // registered an active-client handle yet. - const activeClient = this._getOrCreateActiveClient(sessionUri, customizationDirectory); +- // Re-anchor in case the provisional active client was already bound to the +- // user-picked folder before the worktree existed. +- activeClient.pluginController.reanchor(customizationDirectory); +- // Multi-root: anchor customization discovery to the additional workspace +- // roots (index 1..N of the resolved set). Empty when single-root / gated off. +- activeClient.pluginController.setAdditionalDirectories(this._additionalCustomizationDirectories(resolvedWorkingDirectories)); - const snapshot = await activeClient.snapshot(); -- const workingDirectory = await this._resolveSessionWorkingDirectory(materializedConfig, sessionId, prompt); - const shellManager = this._instantiationService.createInstance(ShellManager, sessionUri, workingDirectory); - - let agentSession: CopilotAgentSession | undefined; +- let agent: AgentSelection | undefined; - try { -- const resolvedAgentName = provisional.agent ? await this._resolveAgentName(provisional.sessionUri, snapshot, provisional.agent) : undefined; +- const resolvedAgent = await this._resolveAgentWhenMaterializing(provisional, snapshot, workingDirectory); +- agent = resolvedAgent?.agent; - const launchPlan: CopilotSessionLaunchPlan = { - kind: 'create', - client, - sessionId, - workingDirectory, -- resolvedAgentName, +- additionalDirectories: resolvedWorkingDirectories?.slice(1), +- resolvedAgentName: resolvedAgent?.name, - snapshot, -- activeClientState: activeClient.state, +- activeClientToolSet: activeClient.toolSet, - shellManager, - githubToken: this._githubToken, - model: provisional.model, +- longContextWindow: this._longContextWindowFor(provisional.model?.id), +- freeLongContext: this._isFreeLongContext(provisional.model?.id), +- workspaceless: provisional.workspaceless, - }; - agentSession = this._createAgentSession(launchPlan, customizationDirectory, activeClient); - await agentSession.initializeSession(); - this._registerInitializedSession(sessionId, agentSession); - } catch (error) { - agentSession?.dispose(); -- await this._removeCreatedWorktree(sessionId); - throw error; - } - - const project = await projectFromCopilotContext({ cwd: workingDirectory?.fsPath }, this._gitService); - +- // The resolved root set (index 0 = process root, e.g. a worktree). +- // Shared by the persisted metadata, the baseline checkpoint and the +- // materialize receipt so all three agree on the same directories. +- const materializedWorkingDirectories = resolvedWorkingDirectories ?? (workingDirectory ? [workingDirectory] : undefined); +- - this._provisionalSessions.delete(sessionId); -- await this._storeSessionMetadata(sessionUri, provisional.model, workingDirectory, customizationDirectory, project, true); -- if (provisional.agent !== undefined) { -- await this._storeSessionAgentMetadata(sessionUri, provisional.agent); +- await this._storeSessionMetadata(sessionUri, provisional.model, workingDirectory, materializedWorkingDirectories, customizationDirectory, project, true); +- if (agent !== undefined) { +- await this._storeSessionAgentMetadata(sessionUri, agent); - } - - // Capture the per-session baseline (turn/0) git checkpoint so @@ -1789,195 +2546,194 @@ index 3f37722b..00000000 - // invisible to the FileEditTracker pipeline. Best-effort: a - // non-git folder or capture failure leaves the session running - // with the legacy `file_edits`-based per-turn diff path. -- this._checkpointService.captureBaseline(sessionUri, workingDirectory).catch(err => { +- // +- // The resolved directories are passed explicitly: the state manager +- // does not learn about them until it observes the materialize event +- // fired below, so a lookup here would still see the pre-worktree set. +- this._checkpointService.captureBaselineCheckpoint(sessionUri, materializedWorkingDirectories).catch(err => { - this._logService.warn(`[Copilot:${sessionId}] Baseline checkpoint capture failed: ${err instanceof Error ? err.message : String(err)}`); - }); - - this._logService.info(`[Copilot] Session materialized: ${sessionUri.toString()}`); -- this._onDidMaterializeSession.fire({ session: sessionUri, workingDirectory, project }); +- // Emit the resolved working-directory set (index 0 = process root). The host +- // replaces index 0 of the session set with it, preserving the tail. +- this._onDidMaterializeSession.fire({ session: sessionUri, project, workingDirectories: materializedWorkingDirectories }); - return agentSession; - } - -- async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { -- const gitInfo = params.workingDirectory ? await this._getGitInfo(params.workingDirectory) : undefined; +- private async _resolveAgentWhenMaterializing(provisional: IProvisionalSession, snapshot: IActiveClientSnapshot, workingDirectory: URI | undefined): Promise<{ agent: AgentSelection; name: string } | undefined> { +- const agent = provisional.agent; +- if (!agent) { +- return undefined; +- } +- const alternativeAgent = this._getAlternativeAgentForWorktree(provisional, workingDirectory); - -- const isolationProperty = schemaProperty<'folder' | 'worktree'>({ -- type: 'string', -- title: localize('agentHost.sessionConfig.isolation', "Isolation"), -- description: localize('agentHost.sessionConfig.isolationDescription', "Where the agent should make changes"), -- enum: gitInfo ? ['folder', 'worktree'] : ['folder'], -- enumLabels: gitInfo ? [localize('agentHost.sessionConfig.isolation.folder', "Folder"), localize('agentHost.sessionConfig.isolation.worktree', "Worktree")] : [localize('agentHost.sessionConfig.isolation.folder', "Folder")], -- enumDescriptions: gitInfo ? [localize('agentHost.sessionConfig.isolation.folderDescription', "Work directly in the folder"), localize('agentHost.sessionConfig.isolation.worktreeDescription', "Create a Git worktree for isolation")] : [localize('agentHost.sessionConfig.isolation.folderDescription', "Work directly in the folder")], -- default: gitInfo ? 'worktree' : 'folder', -- readOnly: !gitInfo, -- sessionMutable: false, -- }); -- -- // Resolve isolation first — downstream schema shapes (branch's -- // read-only mode + enum restriction) depend on the effective value. -- const isolationDefault: 'folder' | 'worktree' = gitInfo ? 'worktree' : 'folder'; -- const isolationValue = isolationProperty.validate(params.config?.[SessionConfigKey.Isolation]) -- ? params.config[SessionConfigKey.Isolation] as 'folder' | 'worktree' -- : isolationDefault; -- -- let branchProperty: ISchemaProperty | undefined; -- let branchDefault: string | undefined; -- if (gitInfo) { -- const branchReadOnly = isolationValue === 'folder'; -- branchDefault = isolationValue === 'worktree' ? gitInfo.defaultBranch : gitInfo.currentBranch; -- branchProperty = schemaProperty({ -- type: 'string', -- title: localize('agentHost.sessionConfig.branch', "Branch"), -- description: localize('agentHost.sessionConfig.branchDescription', "Base branch to work from"), -- enum: [branchDefault], -- enumLabels: [branchDefault], -- default: branchDefault, -- enumDynamic: !branchReadOnly, -- readOnly: branchReadOnly, -- sessionMutable: false, -- }); -- } -- -- const sessionSchema = createSchema({ -- [SessionConfigKey.Isolation]: isolationProperty, -- ...platformSessionSchema.definition, -- ...(branchProperty ? { [SessionConfigKey.Branch]: branchProperty } : {}), -- }); -- -- const values = sessionSchema.validateOrDefault(migrateLegacyAutopilotConfig(params.config), { -- [SessionConfigKey.Isolation]: isolationValue, +- const originalAgentName = this._resolveAgentName(snapshot, agent); +- const alternativeAgentName = alternativeAgent ? this._resolveAgentName(snapshot, alternativeAgent) : undefined; +- +- if (originalAgentName) { +- return { agent: 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; +- } +- private _getAlternativeAgentForWorktree(provisional: IProvisionalSession, workingDirectory: URI | undefined): AgentSelection | undefined { +- const agent = provisional.agent; +- if (!agent) { +- return undefined; +- } +- if (!provisional.workingDirectory || !workingDirectory) { +- return undefined; +- } +- if (isEqual(provisional.workingDirectory, workingDirectory)) { +- return undefined; +- } +- const agentUri = URI.parse(agent.uri); +- const alternativeAgentUri = rebaseUnder(agentUri, provisional.workingDirectory, workingDirectory); +- return alternativeAgentUri ? { uri: alternativeAgentUri.toString() } : undefined; +- } +- +- async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { +- // Isolation / branch are contributed by the host (see +- // AgentService._withIsolationSchema); this agent only owns its platform +- // session config (auto-approve / mode / permissions). +- const values = platformSessionSchema.validateOrDefault(migrateLegacyAutopilotConfig(params.config), { - [SessionConfigKey.AutoApprove]: 'default' satisfies AutoApproveLevel, - [SessionConfigKey.Mode]: 'interactive' satisfies SessionMode, - // Permissions intentionally omitted — leave unset so auto-approval - // falls through to the host-level `permissions` default, and only - // materializes on the session once the user hits "Allow in this - // Session". -- ...(branchDefault !== undefined ? { [SessionConfigKey.Branch]: branchDefault } : {}), - }); - - return { -- schema: sessionSchema.toProtocol(), +- schema: platformSessionSchema.toProtocol(), - values, - }; - } - -- async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { -- if (params.property !== 'branch' || !params.workingDirectory) { -- return { items: [] }; -- } -- -- const branches = await this._getBranches(params.workingDirectory, params.query); -- return { items: branches.map(branch => ({ value: branch, label: branch })) }; -- } -- -- async setClientCustomizations(session: URI, clientId: string, customizations: ClientPluginCustomization[]): Promise { -- const directory = await this._getSessionCustomizationDirectory(session); -- const activeClient = this._getOrCreateActiveClient(session, directory); -- return activeClient.pluginController.sync(clientId, customizations); +- async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { +- // Branch completions (the only dynamic Copilot property) are owned by the +- // host now; no provider-specific completions remain. +- return { items: [] }; - } - -- setClientTools(session: URI, clientId: string | undefined, tools: ToolDefinition[]): void { -- const sessionId = AgentSession.id(session); +- getOrCreateActiveClient(session: URI, client: { readonly clientId: string; readonly displayName?: string }): IActiveClient { - const activeClient = this._getOrCreateActiveClient(session, undefined); -- const hasCachedEntry = this._sessions.has(sessionId); -- this._logService.info(`[Copilot:${sessionId}] setClientTools: clientId=${clientId ?? '(none)'}, tools=[${tools.map(t => t.name).join(', ') || '(none)'}], hasCachedSdkSession=${hasCachedEntry}`); -- activeClient.updateTools(clientId, tools); +- // Anchor the customization directory (best-effort, idempotent) so +- // session-discovered customizations surface alongside this client's, +- // mirroring the previous eager resolution in `setClientCustomizations`. +- if (!activeClient.pluginController.directory) { +- this._getSessionCustomizationAnchors(session).then( +- anchors => { +- activeClient.pluginController.setDirectory(anchors.directory); +- if (anchors.applyAdditional) { +- activeClient.pluginController.setAdditionalDirectories(anchors.additionalDirectories); +- } +- }, +- () => { /* best-effort anchoring */ }, +- ); +- } +- return activeClient.getOrCreateHandle(client.clientId, client.displayName); - } - -- onClientToolCallComplete(session: URI, toolCallId: string, result: ToolCallResult): void { -- // Walk up the subagent chain to reach the root SDK session entry; -- // _sessions is keyed by root session IDs only. -- let target = session; -- let parsed; -- while ((parsed = parseSubagentSessionUri(target))) { -- target = parsed.parentSession; -- } -- // The completion may belong to a peer chat (tracked in `_chatSessions` -- // keyed by chat URI) rather than the default/parent session. -- const entry = this._sessions.get(AgentSession.id(target)) -- ?? this._chatSessions.get(target.toString()); -- entry?.handleClientToolCallComplete(toolCallId, result); +- removeActiveClient(session: URI, clientId: string): void { +- const sessionId = AgentSession.id(session); +- this._logService.info(`[Copilot:${sessionId}] removeActiveClient: clientId=${clientId}`); +- this._activeClients.get(session)?.removeClient(clientId); - } - -- setCustomizationEnabled(uri: string, enabled: boolean): void { -- // Enablement is per-session: fan out to every existing session -- // controller (provisional + materialized). New sessions start with -- // the default value baked into their customizations. -- for (const activeClient of this._activeClients.values()) { -- activeClient.pluginController.setEnabled(uri, enabled); +- onClientToolCallComplete(session: URI, chat: URI, toolCallId: string, result: ToolCallResult): void { +- const sessionId = AgentSession.id(session); +- // Peer (non-default) chats own their SDK chat within the owning +- // session entry, keyed by the chat URI. Mirrors the routing in `sendMessage`. +- if (!isDefaultChatUri(chat)) { +- const peerChat = this._findPeerChat(session, chat); +- if (!peerChat) { +- this._logService.warn(`[Copilot:${sessionId}] Dropping client tool completion for missing peer chat: chat=${chat.toString()}, toolCallId=${toolCallId}, success=${result.success}`); +- return; +- } +- this._logService.info(`[Copilot:${sessionId}] Routing client tool completion to peer chat: chat=${chat.toString()}, toolCallId=${toolCallId}, success=${result.success}`); +- peerChat.handleClientToolCallComplete(toolCallId, result); +- } else { +- const entry = this._findAnySession(sessionId); +- if (!entry) { +- this._logService.warn(`[Copilot:${sessionId}] Dropping client tool completion for missing default chat: chat=${chat.toString()}, toolCallId=${toolCallId}, success=${result.success}`); +- return; +- } +- this._logService.info(`[Copilot:${sessionId}] Routing client tool completion to default chat: chat=${chat.toString()}, toolCallId=${toolCallId}, success=${result.success}`); +- entry.handleClientToolCallComplete(toolCallId, result); - } - } - -- async sendMessage(session: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, chat?: URI): Promise { +- private async _sendMessage(chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string, clientType = AgentHostClientType.Unknown, workingDirectories?: readonly URI[]): Promise { +- const context = this._getChatContext(chat); - // Additional (non-default) chats are backed by their own SDK -- // conversation tracked in `_chatSessions`, keyed by the chat URI. -- if (chat && !isDefaultChatUri(chat)) { -- const entry = await this._ensureChatSession(session, chat); +- // chat hosted on the owning session entry, keyed by the chat URI. +- if (context.isPeerChat) { +- const entry = await this._ensureChatSession(context.session, chat); - if (!entry) { - throw new Error(`[Copilot] sendMessage for unknown chat: ${chat.toString()}`); - } - if (turnId) { -- entry.resetTurnState(turnId); +- entry.resetTurnState(turnId, senderClientId, clientType); - } -- await entry.send(prompt, attachments, turnId, this._resolveSdkMode(session)); +- const sideChat = this._chatBackings.get(chat.toString())?.sideChat; +- const existingTurns = sideChat ? await entry.getMessages() : []; +- const sdkPrompt = prepareSideChatPrompt(prompt, existingTurns, sideChat); +- await entry.send(sdkPrompt, attachments, turnId, this._resolveSdkMode(context.session), senderClientId, clientType); - return; - } -- const sessionId = AgentSession.id(session); -- await this._sessionSequencer.queue(sessionId, async () => { -- await this._activeClients.get(session)?.pluginController.retryFailedClientSyncIfNeeded(); +- await this._sessionSequencer.queue(context.sessionId, async () => { +- await this._activeClients.get(context.session)?.pluginController.retryFailedClientSyncIfNeeded(); - - // First message on a provisional session: materialize the SDK - // session, worktree, and on-disk metadata before continuing. The - // prompt is forwarded so a worktree-isolated session can derive - // its branch-name hint from the user's first message. - let entry: CopilotAgentSession | undefined; -- if (this._provisionalSessions.has(sessionId)) { -- entry = await this._materializeProvisional(sessionId, prompt); +- if (this._provisionalSessions.has(context.sessionId)) { +- entry = await this._materializeProvisional(context.sessionId, workingDirectories); - } else { -- entry = this._sessions.get(sessionId); +- entry = this._getChatContext(chat).target; - } - - // If the active client's config changed (tools or plugins), - // dispose this session so it gets resumed with the updated config. -- const activeClient = this._activeClients.get(session); +- const activeClient = this._activeClients.get(context.session); - const hadCachedEntry = !!entry; -- this._logService.info(`[Copilot:${sessionId}] sendMessage: cachedEntry=${hadCachedEntry}, hasActiveClient=${!!activeClient}, activeClientId=${activeClient ? '(set)' : '(none)'}`); +- this._logService.info(`[Copilot:${context.sessionId}] sendMessage: cachedEntry=${hadCachedEntry}, hasActiveClient=${!!activeClient}, activeClientId=${activeClient ? '(set)' : '(none)'}`); - if (entry && activeClient && await activeClient.requiresRestart(entry.appliedSnapshot)) { -- this._logService.info(`[Copilot:${sessionId}] Session config changed (requiresRestart=true), refreshing session. clientId=${activeClient.state.clientId ?? '(none)'}`); -- this._sessions.deleteAndDispose(sessionId); +- this._logService.info(`[Copilot:${context.sessionId}] Session config changed (requiresRestart=true), refreshing session. clients=[${[...activeClient.toolSet.clientIds()].join(', ') || '(none)'}]`); +- // Finish disconnecting before resuming the same SDK session id. +- this._sdkSessionsById.delete(entry.sessionId); +- await entry.destroySession(); +- this._sessions.get(context.sessionId)?.clearDefaultChat(); - entry = undefined; - } - - if (!entry) { -- this._logService.info(`[Copilot:${sessionId}] No cached entry${hadCachedEntry ? ' (was evicted by requiresRestart)' : ''}, calling _resumeSession`); +- this._logService.info(`[Copilot:${context.sessionId}] No cached entry${hadCachedEntry ? ' (was evicted by requiresRestart)' : ''}, calling _resumeSession`); - } -- entry ??= await this._resumeSession(sessionId); +- entry ??= await this._resumeSession(context.sessionId); - - // Reset per-turn streaming state on the session so that the - // next text/reasoning chunk (and any host-emitted announcement) - // allocates a fresh response part. - if (turnId) { -- entry.resetTurnState(turnId); -- } -- -- // Emit any pending first-turn announcement (e.g. worktree -- // created) as a synthetic markdown response part before -- // delegating to the SDK. The SDK's subsequent deltas append to -- // the same markdown part because the session has already -- // allocated `_currentMarkdownPartId`. -- const announcement = this._pendingFirstTurnAnnouncements.get(sessionId); -- if (announcement !== undefined) { -- this._pendingFirstTurnAnnouncements.delete(sessionId); -- entry.emitInitialMarkdown(announcement); +- entry.resetTurnState(turnId, senderClientId, clientType); - } - - try { -- const sdkMode = this._resolveSdkMode(session); -- await entry.send(prompt, attachments, turnId, sdkMode); +- const sdkMode = this._resolveSdkMode(context.session); +- await entry.send(prompt, attachments, turnId, sdkMode, senderClientId, clientType); - } catch (err) { - const errCode = (err as { code?: number })?.code; - const errMsg = err instanceof Error ? err.message : String(err); -- this._logService.error(`[Copilot:${sessionId}] entry.send() failed: code=${errCode}, message=${errMsg}, hadCachedEntry=${hadCachedEntry}, errorType=${err?.constructor?.name}`); +- this._logService.error(`[Copilot:${context.sessionId}] entry.send() failed: code=${errCode}, message=${errMsg}, hadCachedEntry=${hadCachedEntry}, errorType=${err?.constructor?.name}`); - throw err; - } - }); @@ -2015,17 +2771,29 @@ index 3f37722b..00000000 - } - } - -- setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, _queuedMessages: readonly PendingMessage[]): void { -- const sessionId = AgentSession.id(session); -- const entry = this._sessions.get(sessionId); -- if (!entry) { -- this._logService.warn(`[Copilot:${sessionId}] setPendingMessages: session not found`); +- /** +- * Reads the session's current `mode` and `autoApprove` axis values so the +- * slash-command completion provider can hide config-action toggles that would +- * be a no-op (e.g. `/autopilot on` while already in autopilot). +- */ +- private _getSessionConfigState(sessionId: string): ICopilotConfigSlashCommandState { +- const sessionKey = AgentSession.uri(this.id, sessionId).toString(); +- return { +- mode: this._configurationService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.Mode), +- autoApprove: this._configurationService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.AutoApprove), +- }; +- } +- +- setPendingMessages(chat: URI, steeringMessage: PendingMessage | undefined, _queuedMessages: readonly PendingMessage[]): void { +- const context = this._getChatContext(chat); +- if (!context.target) { +- this._logService.warn(`[Copilot:${context.sessionId}] setPendingMessages: chat not found for ${chat.toString()}`); - return; - } - - // Steering: send with mode 'immediate' so the SDK injects it mid-turn - if (steeringMessage) { -- entry.sendSteering(steeringMessage); +- context.target.sendSteering(steeringMessage); - } - - // Queued messages are consumed by the server (AgentSideEffects) @@ -2034,15 +2802,6 @@ index 3f37722b..00000000 - } - - async getSessionMessages(session: URI): Promise { -- // An additional (non-default) peer chat is addressed by its `ahp-chat` -- // channel URI. Resume its backing SDK conversation and return its turns. -- const chatInfo = parseChatUri(session); -- if (chatInfo && !isDefaultChatUri(session)) { -- const parentSession = URI.parse(chatInfo.session); -- const entry = await this._ensureChatSession(parentSession, session); -- return entry ? entry.getMessages() : []; -- } -- - // If the URI describes a subagent child session (`/subagent/`), - // load the parent's events once and extract the child's filtered turns. - const subagentInfo = parseSubagentSessionUri(session); @@ -2055,7 +2814,7 @@ index 3f37722b..00000000 - rootSession = parentParsed.parentSession; - } - const rootSessionId = AgentSession.id(rootSession); -- const parentEntry = this._sessions.get(rootSessionId) ?? await this._resumeSession(rootSessionId).catch(err => { +- const parentEntry = this._findAnySession(rootSessionId) ?? await this._resumeSession(rootSessionId).catch(err => { - this._logService.warn(`[Copilot:${rootSessionId}] Failed to resume root for subagent restore`, err); - return undefined; - }); @@ -2065,40 +2824,69 @@ index 3f37722b..00000000 - return parentEntry.getSubagentMessages(subagentInfo.toolCallId); - } - -- const sessionId = AgentSession.id(session); +- const chat = parseChatUri(session) ? session : URI.parse(buildDefaultChatUri(session)); +- const context = this._getChatContext(chat); +- if (context.isPeerChat) { +- const entry = await this._ensureChatSession(context.session, chat); +- const turns = entry ? await entry.getMessages() : []; +- const sideChat = this._chatBackings.get(chat.toString())?.sideChat; +- return stripSideChatContext(turns.slice(sideChat?.inheritedTurnCount ?? 0), sideChat); +- } +- +- const sessionId = context.sessionId; - // Provisional sessions have no SDK history yet. - if (this._provisionalSessions.has(sessionId)) { - return []; - } -- const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId).catch(err => { +- const entry = context.target ?? await this._resumeSession(sessionId).catch(err => { +- if (err instanceof SessionWorkingDirectoryMissingError) { +- // Unrecoverable: surface to the restore/subscribe path so the +- // client shows a clear error instead of a silently empty chat. +- throw err; +- } - this._logService.warn(`[Copilot:${sessionId}] Failed to resume session for message lookup`, err); - return undefined; - }); - if (!entry) { - return []; - } -- const rawTurns = await entry.getMessages(); -- -- // If a worktree was created for this session at create-time, prepend -- // If a worktree was created for this session at create-time, prepend -- // the announcement to the first turn so it appears at the top of the -- // first response when the session is reopened. The live path -- // (sendMessage) handles the very first turn when the session is fresh; -- // this path takes over on subsequent loads, where -- // _pendingFirstTurnAnnouncements is empty. -- const worktreeMeta = await this._readWorktreeMetadata(session).catch(err => { -- this._logService.warn(`[Copilot:${sessionId}] Failed to read worktree branch metadata`, err); +- // The host prepends the worktree "created" announcement on restore (see +- // AgentService._getChatMessages); this agent just returns its turns. +- return entry.getMessages(); +- } +- +- async getSubagentSessions(session: URI): Promise { +- // Only the root SDK session entry owns the event log; peer-chat and +- // subagent URIs are derived from it and have no subagents of their own. +- const chatInfo = parseChatUri(session); +- if (chatInfo && !isDefaultChatUri(session)) { +- return []; +- } +- if (parseSubagentSessionUri(session)) { +- return []; +- } +- const sessionId = AgentSession.id(session); +- // Provisional sessions have no SDK history (and thus no subagents) yet. +- if (this._provisionalSessions.has(sessionId)) { +- return []; +- } +- const entry = this._findAnySession(sessionId) ?? await this._resumeSession(sessionId).catch(err => { +- this._logService.warn(`[Copilot:${sessionId}] Failed to resume session for subagent lookup`, err); - return undefined; - }); -- if (!worktreeMeta?.branchName) { -- return rawTurns; -- } -- return prependAnnouncementToFirstTurn(rawTurns, buildWorktreeAnnouncementText(worktreeMeta.branchName)); +- return entry ? entry.getSubagentSessions() : []; - } - - async disposeSession(session: URI): Promise { - const sessionId = AgentSession.id(session); - await this._sessionSequencer.queue(sessionId, async () => { +- // Resolve the workspace-less scratch dir (if any) before deleting, so we +- // can reap it afterwards. A provisional workspace-less chat carries its state +- // in memory; a materialized/restored one persists `workspaceless` metadata. +- const provisional = this._provisionalSessions.get(sessionId); +- const isWorkspaceless = provisional +- ? provisional.workspaceless === true +- : (await this._readSessionMetadata(session).catch(() => undefined))?.workspaceless === true; - // Remove the session from the SDK's on-disk store first so it doesn't reappear in `listSessions()` after a - // restart, and so that any final persist triggered by in-memory teardown can't recreate it. Provisional - // sessions were never persisted, so there is nothing to delete on the SDK side. @@ -2107,194 +2895,289 @@ index 3f37722b..00000000 - await client.deleteSession(sessionId); - } - await this._destroyAndDisposeSession(sessionId); +- if (isWorkspaceless) { +- await this._cleanupWorkspacelessScratchDir(this._workspacelessScratchDir(sessionId), sessionId); +- } - }); - } - -- async onArchivedChanged(session: URI, isArchived: boolean): Promise { +- /** +- * Non-destructive counterpart to {@link disposeSession}: releases the +- * session's in-memory resources (SDK session/connection, cached entry, +- * active clients, MCP subscriptions) but preserves all durable data — the +- * SDK session log, session database, and worktree stay on disk. The session +- * transparently resumes on the next access via {@link _resumeSession}. +- * +- * No-ops for sessions that have nothing durable to resume from (provisional +- * sessions) or that aren't currently held in memory, and for sessions with a +- * running turn — disconnecting mid-turn would strand the SDK session. +- */ +- async releaseSession(session: URI): Promise { - const sessionId = AgentSession.id(session); - await this._sessionSequencer.queue(sessionId, async () => { -- if (isArchived) { -- await this._cleanupWorktreeOnArchive(session, sessionId); -- } else { -- await this._recreateWorktreeOnUnarchive(session, sessionId); +- // Provisional sessions were never persisted, so releasing them would +- // lose state with no way to resume. Leave them in memory. +- if (this._provisionalSessions.has(sessionId)) { +- return; +- } +- const entry = this._sessions.get(sessionId); +- if (!entry) { +- return; +- } +- // Defensive active-turn guard: the orchestrator already skips +- // eviction while a turn is active, but a turn could have started +- // between that check and this sequenced callback. +- if (entry.allChatSessions().some(chatSession => chatSession.hasActiveTurn)) { +- return; - } +- this._logService.info(`[Copilot:${sessionId}] Releasing idle session from memory (durable state preserved)`); +- await this._releaseSessionResources(sessionId); - }); - } - -- private async _cleanupWorktreeOnArchive(session: URI, sessionId: string): Promise { -- const meta = await this._readWorktreeMetadata(session).catch(() => undefined); -- if (!meta?.worktreePath || !meta.repositoryRoot) { -- return; -- } -- const { branchName, worktreePath, repositoryRoot } = meta; -- -- // Skip if the worktree directory is already gone — nothing to clean. -- try { -- await fs.access(worktreePath.fsPath); -- } catch { -- this._createdWorktrees.delete(sessionId); -- return; -- } -- -- // Skip if the branch is missing — without it we can't safely recreate -- // the worktree on unarchive, so leave the working tree intact. -- const branchPresent = await this._gitService.branchExists(repositoryRoot, branchName).catch(() => false); -- if (!branchPresent) { -- this._logService.info(`[Copilot:${sessionId}] Skipping worktree cleanup: branch '${branchName}' is missing`); -- return; -- } -- -- // Skip if there are uncommitted changes — don't silently destroy work. -- const dirty = await this._gitService.hasUncommittedChanges(worktreePath).catch(() => true); -- if (dirty) { -- this._logService.info(`[Copilot:${sessionId}] Skipping worktree cleanup: '${worktreePath.fsPath}' has uncommitted changes`); -- return; -- } -- -- try { -- await this._gitService.removeWorktree(repositoryRoot, worktreePath); -- this._logService.info(`[Copilot:${sessionId}] Removed worktree '${worktreePath.fsPath}' on archive`); -- } catch (error) { -- this._logService.warn(`[Copilot:${sessionId}] Failed to remove worktree '${worktreePath.fsPath}' on archive: ${error instanceof Error ? error.message : String(error)}`); -- } finally { -- this._createdWorktrees.delete(sessionId); -- } -- } -- -- private async _recreateWorktreeOnUnarchive(session: URI, sessionId: string): Promise { -- const meta = await this._readWorktreeMetadata(session).catch(() => undefined); -- if (!meta?.worktreePath || !meta.repositoryRoot) { -- return; -- } -- const { branchName, worktreePath, repositoryRoot } = meta; -- -- // Skip if the worktree directory already exists — nothing to do. -- try { -- await fs.access(worktreePath.fsPath); -- return; -- } catch { -- // expected when the worktree was cleaned up on archive -- } -- -- // Skip if the branch is missing — we have no commit to attach the -- // recreated worktree to. -- const branchPresent = await this._gitService.branchExists(repositoryRoot, branchName).catch(() => false); -- if (!branchPresent) { -- this._logService.info(`[Copilot:${sessionId}] Skipping worktree recreation: branch '${branchName}' is missing`); -- return; -- } -- -- try { -- await fs.mkdir(URI.joinPath(worktreePath, '..').fsPath, { recursive: true }); -- await this._gitService.addExistingWorktree(repositoryRoot, worktreePath, branchName); -- this._createdWorktrees.set(sessionId, { repositoryRoot, worktree: worktreePath }); -- this._logService.info(`[Copilot:${sessionId}] Recreated worktree '${worktreePath.fsPath}' on unarchive`); -- } catch (error) { -- this._logService.warn(`[Copilot:${sessionId}] Failed to recreate worktree '${worktreePath.fsPath}' on unarchive: ${error instanceof Error ? error.message : String(error)}`); -- } -- } -- -- async abortSession(session: URI, chat?: URI): Promise { -- if (chat && !isDefaultChatUri(chat)) { -- await this._chatSessions.get(chat.toString())?.abort(); +- private async _abortSession(chat: URI): Promise { +- const context = this._getChatContext(chat); +- if (context.isPeerChat) { +- await context.target?.abort(); - return; - } -- const sessionId = AgentSession.id(session); -- await this._sessionSequencer.queue(sessionId, async () => { -- const entry = this._sessions.get(sessionId); -- if (entry) { -- await entry.abort(); -- } +- await this._sessionSequencer.queue(context.sessionId, async () => { +- await this._getChatContext(chat).target?.abort(); - }); - } - -- async createChat(session: URI, chat: URI, options?: IAgentCreateChatOptions): Promise { +- private async _createChat(chat: URI, options?: IAgentCreateChatOptions): Promise { - if (isDefaultChatUri(chat)) { - return; - } +- const parsed = parseChatUri(chat); +- if (!parsed) { +- throw new Error(`[Copilot] createChat: malformed chat URI ${chat.toString()}`); +- } +- const session = URI.parse(parsed.session); - const chatKey = chat.toString(); -- if (this._chatSessions.has(chatKey)) { -- return; +- if (this._sessions.get(AgentSession.id(session))?.hasPeerChat(chatKey)) { +- // Already live: hand back the existing backing so the orchestrator +- // re-persists a consistent blob for an idempotent create. +- const existing = this._chatBackings.get(chatKey); +- return existing ? { providerData: encodeProviderData(existing), backingSession: AgentSession.uri(this.id, existing.sdkSessionId) } : undefined; - } - const sessionId = AgentSession.id(session); -- await this._sessionSequencer.queue(sessionId, async () => { +- let result: IAgentCreateChatResult | undefined; +- const queueKey = options?.sideChat ? chatKey : sessionId; +- await this._sessionSequencer.queue(queueKey, async () => { - // Re-check inside the per-session sequencer: the outer `has` check - // above is only a fast early-out. If two `createChat` calls for the - // same chat URI race, both can pass that outer check; the sequencer - // serializes them, so the second task must re-check here to avoid -- // overwriting (and disposing) the conversation the first one set. -- if (this._chatSessions.has(chatKey)) { +- // overwriting (and disposing) the chat the first one set. +- if (this._sessions.get(sessionId)?.hasPeerChat(chatKey)) { +- const existing = this._chatBackings.get(chatKey); +- result = existing ? { providerData: encodeProviderData(existing), backingSession: AgentSession.uri(this.id, existing.sdkSessionId) } : undefined; - return; - } +- const model = options?.model; - // Resolve the owning session so the new chat inherits its working - // directory scope. The parent may be provisional (no SDK session - // yet); in that case use its provisional working directory. -- const parentEntry = this._sessions.get(sessionId); +- const parentEntry = this._findAnySession(sessionId); - const workingDirectory = parentEntry?.workingDirectory - ?? this._provisionalSessions.get(sessionId)?.workingDirectory; - const client = await this._ensureClient(); - const chatSdkId = generateUuid(); - // Peer chats share the owning session's ActiveClient so that - // client tool / customization updates (which are keyed by the -- // session URI via `setClientTools` / `setClientCustomizations`) -- // reach the additional chat's SDK conversation. Keying it by the -- // chat URI instead would snapshot empty/stale tools and never see -- // subsequent updates, and would also leak (nothing disposes a -- // chat-keyed ActiveClient). +- // session URI via the active-client handles) reach the additional +- // chat's SDK chat. Keying it by the chat URI instead would +- // snapshot empty/stale tools and never see subsequent updates, and +- // would also leak (nothing disposes a chat-keyed ActiveClient). - const activeClient = this._getOrCreateActiveClient(session, workingDirectory); - const snapshot = await activeClient.snapshot(); - const shellManager = this._instantiationService.createInstance(ShellManager, chat, workingDirectory); -- const launchPlan: CopilotSessionLaunchPlan = { -- kind: 'create', -- client, -- sessionId: chatSdkId, -- workingDirectory, -- resolvedAgentName: undefined, -- snapshot, -- activeClientState: activeClient.state, -- shellManager, -- githubToken: this._githubToken, -- model: options?.model, -- }; -- let agentSession: CopilotAgentSession | undefined; -- try { -- agentSession = this._createAgentSession(launchPlan, workingDirectory, activeClient, chat); -- await agentSession.initializeSession(); -- this._chatSessions.set(chatKey, agentSession); -- const parsed = parseChatUri(chat); -- if (parsed) { -- const persisted = await this._readPersistedChats(session); -- persisted.set(parsed.chatId, { sdkSessionId: chatSdkId, ...(options?.model ? { model: options.model } : {}) }); -- await this._writePersistedChats(session, persisted); +- +- // Forking: mint the new chat's backing chat by forking the +- // source chat's SDK session at the requested turn (copying its +- // database into the new chat's data dir), then resume it. Otherwise +- // spin up a fresh empty chat. +- let launchPlan: CopilotSessionLaunchPlan; +- let sdkSessionId: string; +- let sideChat: IPersistedChat['sideChat']; +- if (options?.fork) { +- if (!workingDirectory) { +- throw new Error(`[Copilot] createChat fork: missing working directory for session ${session.toString()}`); - } -- this._logService.info(`[Copilot] Created additional chat ${chatKey} in session ${session.toString()}`); -- } catch (error) { +- const sourceEntry = await this._resolveChatEntry(session, options.fork.source); +- if (!sourceEntry) { +- throw new Error(`[Copilot] createChat fork: source chat ${options.fork.source.toString()} not found`); +- } +- const forked = await this._forkSdkChat(client, sourceEntry, options.fork.turnId, this._sessionDataService.getSessionDataDir(chat)); +- sdkSessionId = forked.sessionId; +- launchPlan = { +- kind: 'resume', +- client, +- sessionId: sdkSessionId, +- workingDirectory, +- resolvedAgentName: undefined, +- snapshot, +- activeClientToolSet: activeClient.toolSet, +- shellManager, +- githubToken: this._githubToken, +- fallback: { model, longContextWindow: this._longContextWindowFor(model?.id), freeLongContext: this._isFreeLongContext(model?.id) }, +- }; +- } else if (options?.sideChat) { +- if (!workingDirectory) { +- throw new Error(`[Copilot] createChat side chat: missing working directory for session ${session.toString()}`); +- } +- const sourceEntry = await this._resolveChatEntry(session, options.sideChat.source); +- if (!sourceEntry) { +- throw new Error(`[Copilot] createChat side chat: source chat ${options.sideChat.source.toString()} not found`); +- } +- const forked = await this._forkSdkChat(client, sourceEntry, options.sideChat.providerAnchorTurnId ?? options.sideChat.turnId, this._sessionDataService.getSessionDataDir(chat)); +- sdkSessionId = forked.sessionId; +- sideChat = { +- source: options.sideChat.source.toString(), +- turnId: options.sideChat.turnId, +- ...(options.sideChat.selection ? { selection: options.sideChat.selection } : {}), +- ...(options.sideChat.providerAnchorTurnId ? { providerAnchorTurnId: options.sideChat.providerAnchorTurnId } : {}), +- inheritedTurnCount: forked.inheritedTurnCount, +- ...(options.sideChat.sourceContext ? { context: options.sideChat.sourceContext } : {}), +- ...(options.sideChat.partialResponse ? { partialResponse: options.sideChat.partialResponse } : {}), +- }; +- launchPlan = { +- kind: 'resume', +- client, +- sessionId: sdkSessionId, +- workingDirectory, +- resolvedAgentName: undefined, +- snapshot, +- activeClientToolSet: activeClient.toolSet, +- shellManager, +- githubToken: this._githubToken, +- fallback: { model, longContextWindow: this._longContextWindowFor(model?.id), freeLongContext: this._isFreeLongContext(model?.id) }, +- }; +- } else { +- sdkSessionId = chatSdkId; +- launchPlan = { +- kind: 'create', +- client, +- sessionId: chatSdkId, +- workingDirectory, +- resolvedAgentName: undefined, +- snapshot, +- activeClientToolSet: activeClient.toolSet, +- shellManager, +- githubToken: this._githubToken, +- model, +- longContextWindow: this._longContextWindowFor(model?.id), +- freeLongContext: this._isFreeLongContext(model?.id), +- }; +- } +- let agentSession: CopilotAgentSession | undefined; +- try { +- agentSession = this._createAgentSession(launchPlan, workingDirectory, activeClient, { sessionUri: session, chatChannelUri: chat }); +- await agentSession.initializeSession(); +- if (sideChat) { +- sideChat = { ...sideChat, inheritedTurnCount: (await agentSession.getMessages()).length }; +- } +- if (options?.fork?.turnIdMapping) { +- await agentSession.remapTurnIds(options.fork.turnIdMapping); +- } +- this._ensureEntry(sessionId).registerPeerChat(chatKey, new CopilotSessionEntry(agentSession)); +- this._sdkSessionsById.set(agentSession.sessionId, agentSession); +- // Record the live backing and hand the opaque blob back to the +- // orchestrator to persist. The agent no longer owns a durable +- // peer-chat catalog (`copilot.chats` is no longer written). +- const backing: IPersistedChat = { sdkSessionId, ...(model ? { model } : {}), ...(sideChat ? { sideChat } : {}) }; +- this._chatBackings.set(chatKey, backing); +- result = { providerData: encodeProviderData(backing), backingSession: AgentSession.uri(this.id, sdkSessionId) }; +- this._logService.info(`[Copilot] Created additional chat ${chatKey} in session ${session.toString()}${options?.fork ? ' (forked)' : ''}`); +- } catch (error) { - agentSession?.dispose(); - throw error; - } - }); +- return result; +- } +- +- /** +- * Resolves the {@link CopilotAgentSession} backing a chat URI — the +- * session's default chat (keyed by session id) or an additional peer chat +- * (keyed by the chat URI) — resuming it from disk if necessary. +- */ +- private async _resolveChatEntry(session: URI, chatUri: URI): Promise { +- const sessionId = AgentSession.id(session); +- if (isDefaultChatUri(chatUri) || isEqual(chatUri, session)) { +- return this._findAnySession(sessionId) ?? await this._resumeSession(sessionId).catch(() => undefined); +- } +- return this._ensureChatSession(session, chatUri); +- } +- +- /** +- * Forks {@link sourceEntry}'s SDK chat at {@link turnId} via the +- * SDK `sessions.fork` RPC and copies its database into {@link targetDbDir} +- * so the forked chat inherits turn event IDs and file-edit +- * snapshots. Returns the new SDK session id. +- */ +- private async _forkSdkChat(client: CopilotClient, sourceEntry: CopilotAgentSession, turnId: string, targetDbDir: URI): Promise<{ sessionId: string; inheritedTurnCount: number }> { +- const sourceTurns = await sourceEntry.getMessages(); +- const sourceTurnIndex = sourceTurns.findIndex(turn => turn.id === turnId); +- const inheritedTurnCount = sourceTurnIndex === -1 ? sourceTurns.length : sourceTurnIndex + 1; +- // toEventId is exclusive — events before it are included. If there's no +- // next turn, omit it to include all events. +- const toEventId = await sourceEntry.getNextTurnEventId(turnId); +- const forkResult = await client.rpc.sessions.fork({ +- sessionId: sourceEntry.sessionId, +- ...(toEventId ? { toEventId } : {}), +- }); +- const newSessionId = forkResult.sessionId; +- +- // VACUUM INTO is safe even while the source DB is open. +- const targetDbPath = URI.joinPath(targetDbDir, SESSION_DB_FILENAME); +- try { +- const sourceDbRef = await this._sessionDataService.tryOpenDatabase(sourceEntry.sessionUri); +- if (sourceDbRef) { +- try { +- await fs.mkdir(targetDbDir.fsPath, { recursive: true }); +- // VACUUM INTO fails if the target already exists; clear any +- // stale DB left by a previous (e.g. crashed) attempt. +- await fs.rm(targetDbPath.fsPath, { force: true }); +- await sourceDbRef.object.vacuumInto(targetDbPath.fsPath); +- } finally { +- sourceDbRef.dispose(); +- } +- } +- } catch (err) { +- this._logService.warn(`[Copilot] Failed to copy session database for chat fork: ${err instanceof Error ? err.message : String(err)}`); +- } +- return { sessionId: newSessionId, inheritedTurnCount }; - } - -- async disposeChat(session: URI, chat: URI): Promise { +- private async _disposeChat(session: URI, chat: URI): Promise { - if (isDefaultChatUri(chat)) { - return; - } - const chatKey = chat.toString(); -- // Resolve the chat's backing SDK conversation id — from the in-memory -- // session if present, otherwise from the persisted catalog — so we can -- // delete it from the SDK's on-disk store. Without this a fresh process -- // could re-resume an orphaned conversation that no longer has a catalog -- // entry. Best-effort: a missing id still drops the catalog entry below. -- const parsed = parseChatUri(chat); -- let sdkSessionId = this._chatSessions.get(chatKey)?.sessionId; -- if (parsed) { -- const persisted = await this._readPersistedChats(session); -- sdkSessionId ??= persisted.get(parsed.chatId)?.sdkSessionId; -- if (persisted.delete(parsed.chatId)) { -- await this._writePersistedChats(session, persisted); -- } +- // Resolve the chat's backing SDK chat id — from the in-memory +- // session, the live backing map, or (for legacy sessions) a one-time +- // read of the agent's pre-orchestrator catalog — so we can delete it +- // from the SDK's on-disk store. Without this a fresh process could +- // re-resume an orphaned chat. The durable peer-chat catalog is +- // owned by the orchestrator now, so this no longer rewrites +- // `copilot.chats`; it only drops the live backing and SDK chat. +- let sdkSessionId = this._findPeerChat(session, chat)?.sessionId +- ?? this._chatBackings.get(chatKey)?.sdkSessionId; +- if (!sdkSessionId) { +- const parsed = parseChatUri(chat); +- if (parsed) { +- const persisted = await this._readPersistedChats(session); +- sdkSessionId = persisted.get(parsed.chatId)?.sdkSessionId; +- } +- } +- this._chatBackings.delete(chatKey); +- if (sdkSessionId) { +- this._sdkSessionsById.delete(sdkSessionId); - } -- this._chatSessions.deleteAndDispose(chatKey); +- this._sessions.get(AgentSession.id(session))?.disposePeerChat(chatKey); - if (sdkSessionId) { - try { - const client = await this._ensureClient(); @@ -2306,29 +3189,93 @@ index 3f37722b..00000000 - } - - /** -- * Returns the catalog of additional (non-default) peer chats persisted for a -- * session, as `ahp-chat` channel URIs. Used by the agent service to -- * re-register peer chats (and seed their history) when a session is restored -- * after a process restart. +- * Re-attaches the in-memory backing for a peer chat on session restore, +- * decoding the opaque `providerData` the orchestrator persisted at creation +- * (or the latest {@link onDidChangeChatData}). After this resolves +- * the chat's backing SDK chat can be resumed lazily via +- * {@link _ensureChatSession}. When `providerData` is `undefined` (a legacy +- * session persisted before the orchestrator owned the catalog) the agent +- * falls back to a one-time read of its own `copilot.chats` blob. Best-effort +- * — a corrupt/unknown blob is logged and dropped rather than thrown. +- */ +- async materializeChat(chat: URI, providerData: string | undefined): Promise { +- if (isDefaultChatUri(chat)) { +- return; +- } +- const chatInfo = parseChatUri(chat); +- if (!chatInfo) { +- return; +- } +- const chatKey = chat.toString(); +- let backing: IPersistedChat | undefined; +- if (providerData !== undefined) { +- backing = decodeProviderData(providerData); +- if (!backing) { +- this._logService.warn(`[Copilot] materializeChat: dropping corrupt providerData for ${chatKey}`); +- return; +- } +- } else { +- // Legacy fallback: consult the agent's own pre-orchestrator catalog +- // once to recover the backing for sessions persisted before +- // `providerData` existed. +- const persisted = await this._readPersistedChats(URI.parse(chatInfo.session)); +- backing = persisted.get(chatInfo.chatId); +- if (!backing) { +- return; +- } +- } +- this._chatBackings.set(chatKey, backing); +- } +- +- /** +- * Migration-only enumeration of the session's peer chats from the agent's +- * legacy `copilot.chats` catalog, mapping each entry to its channel URI and +- * the same opaque `providerData` blob {@link materializeChat} +- * decodes. The orchestrator calls this once to drain legacy chats into its +- * own catalog. - */ -- async getChats(session: URI): Promise { +- async listLegacyChats(session: URI): Promise { - const persisted = await this._readPersistedChats(session); -- const result: URI[] = []; -- for (const chatId of persisted.keys()) { -- result.push(URI.parse(buildChatUri(session.toString(), chatId))); +- const result: IAgentLegacyChat[] = []; +- for (const [chatId, info] of persisted) { +- result.push({ uri: URI.parse(buildChatUri(session, chatId)), providerData: encodeProviderData(info) }); - } - return result; - } - - /** +- * Resolves the live backing for a peer chat from the in-memory +- * {@link _chatBackings} map, falling back once to the agent's legacy +- * `copilot.chats` catalog (seeding the live map) for sessions that have not +- * been materialized via {@link materializeChat}. +- */ +- private async _resolveChatBacking(session: URI, chat: URI): Promise { +- const chatKey = chat.toString(); +- const live = this._chatBackings.get(chatKey); +- if (live) { +- return live; +- } +- const parsed = parseChatUri(chat); +- if (!parsed) { +- return undefined; +- } +- const persisted = await this._readPersistedChats(session); +- const info = persisted.get(parsed.chatId); +- if (info) { +- this._chatBackings.set(chatKey, info); +- } +- return info; +- } +- +- /** - * Returns the SDK-backed {@link CopilotAgentSession} for an additional peer -- * chat, resuming its persisted SDK conversation if it is not already in +- * chat, resuming its backing SDK chat if it is not already in - * memory (e.g. after a process restart). Returns `undefined` when the chat -- * has no persisted backing conversation. +- * has no known backing chat. - */ - private async _ensureChatSession(session: URI, chat: URI): Promise { - const chatKey = chat.toString(); -- const existing = this._chatSessions.get(chatKey); +- const existing = this._findPeerChat(session, chat); - if (existing) { - return existing; - } @@ -2338,16 +3285,15 @@ index 3f37722b..00000000 - } - const sessionId = AgentSession.id(session); - return this._sessionSequencer.queue(sessionId, async () => { -- const again = this._chatSessions.get(chatKey); +- const again = this._findPeerChat(session, chat); - if (again) { - return again; - } -- const persisted = await this._readPersistedChats(session); -- const info = persisted.get(parsed.chatId); +- const info = await this._resolveChatBacking(session, chat); - if (!info) { - return undefined; - } -- const parentEntry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId).catch(() => undefined); +- const parentEntry = this._findAnySession(sessionId) ?? await this._resumeSession(sessionId).catch(() => undefined); - const workingDirectory = parentEntry?.workingDirectory - ?? this._provisionalSessions.get(sessionId)?.workingDirectory; - if (!workingDirectory) { @@ -2365,16 +3311,17 @@ index 3f37722b..00000000 - workingDirectory, - resolvedAgentName: undefined, - snapshot, -- activeClientState: activeClient.state, +- activeClientToolSet: activeClient.toolSet, - shellManager, - githubToken: this._githubToken, -- fallback: { model: info.model }, +- fallback: { model: info.model, longContextWindow: this._longContextWindowFor(info.model?.id), freeLongContext: this._isFreeLongContext(info.model?.id) }, - }; - let agentSession: CopilotAgentSession | undefined; - try { -- agentSession = this._createAgentSession(launchPlan, workingDirectory, activeClient, chat); +- agentSession = this._createAgentSession(launchPlan, workingDirectory, activeClient, { sessionUri: session, chatChannelUri: chat }); - await agentSession.initializeSession(); -- this._chatSessions.set(chatKey, agentSession); +- this._ensureEntry(sessionId).registerPeerChat(chatKey, new CopilotSessionEntry(agentSession)); +- this._sdkSessionsById.set(agentSession.sessionId, agentSession); - this._logService.info(`[Copilot] Resumed additional chat ${chatKey} in session ${session.toString()}`); - return agentSession; - } catch (error) { @@ -2385,16 +3332,25 @@ index 3f37722b..00000000 - }); - } - -- async truncateSession(session: URI, turnId?: string): Promise { +- async truncateSession(session: URI, turnId: string | undefined, chat: URI): Promise { - const sessionId = AgentSession.id(session); - if (this._provisionalSessions.has(sessionId)) { - return; - } +- const isPeerChat = !isDefaultChatUri(chat); - await this._sessionSequencer.queue(sessionId, async () => { -- this._logService.info(`[Copilot:${sessionId}] Truncating session${turnId !== undefined ? ` at turnId=${turnId}` : ' (all turns)'}`); -- -- // Ensure the session is loaded so we can use the SDK RPC -- const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId); +- this._logService.info(`[Copilot:${sessionId}] Truncating ${isPeerChat ? `peer chat ${chat.toString()}` : 'session'}${turnId !== undefined ? ` at turnId=${turnId}` : ' (all turns)'}`); +- +- // Resolve the entry whose history is being truncated: a peer chat has +- // its own backing SDK session, so route to it rather than the default +- // chat. `_resolveChatEntry` resumes/materializes the chat if needed. +- const entry = isPeerChat +- ? await this._resolveChatEntry(session, chat) +- : (this._findAnySession(sessionId) ?? await this._resumeSession(sessionId)); +- if (!entry) { +- this._logService.info(`[Copilot:${sessionId}] No chat entry resolved for truncation; nothing to truncate`); +- return; +- } - - // Look up the SDK event ID for the truncation boundary. - // The protocol semantics: turnId is the last turn to KEEP. @@ -2418,94 +3374,96 @@ index 3f37722b..00000000 - }); - } - -- async changeModel(session: URI, model: ModelSelection, chat?: URI): Promise { -- // Additional (non-default) chats are backed by their own SDK -- // conversation tracked in `_chatSessions`; apply the change there and -- // skip the session-level metadata store (peer chats are not persisted -- // per-chat). -- if (chat && !isDefaultChatUri(chat)) { -- await this._chatSessions.get(chat.toString())?.setModel(model.id, getCopilotReasoningEffort(model), getCopilotContextTier(model)); +- private async _changeModel(chat: URI, model: ModelSelection): Promise { +- const longContextWindow = this._longContextWindowFor(model.id); +- const freeLongContext = this._isFreeLongContext(model.id); +- const context = this._getChatContext(chat); +- // Same override the launcher applies at create (validated + logged by +- // resolveCopilotReasoningEffort); computed at the point of use so the +- // provisional-session path doesn't resolve or log it prematurely. +- if (context.isPeerChat) { +- await context.target?.setModel(model.id, resolveCopilotReasoningEffort(model, this._configurationService, this._logService, context.sessionId), getCopilotContextTier(model, longContextWindow, freeLongContext)); +- const backing = this._chatBackings.get(context.chatKey); +- if (backing) { +- const updated: IPersistedChat = { ...backing, model }; +- this._chatBackings.set(context.chatKey, updated); +- this._onDidChangeChatData.fire({ chat: chat, providerData: encodeProviderData(updated) }); +- } - return; - } -- const sessionId = AgentSession.id(session); -- const provisional = this._provisionalSessions.get(sessionId); +- const provisional = this._provisionalSessions.get(context.sessionId); - if (provisional) { - provisional.model = model; - return; - } -- const entry = this._sessions.get(sessionId); +- const entry = context.target; - if (entry) { -- await entry.setModel(model.id, getCopilotReasoningEffort(model), getCopilotContextTier(model)); +- await entry.setModel(model.id, resolveCopilotReasoningEffort(model, this._configurationService, this._logService, context.sessionId), getCopilotContextTier(model, longContextWindow, freeLongContext)); - } -- await this._storeSessionMetadata(session, model, undefined, undefined, undefined); +- await this._storeSessionMetadata(context.session, model, undefined, undefined, undefined, undefined); - } - -- async changeAgent(session: URI, agent: AgentSelection | undefined, chat?: URI): Promise { -- // Additional (non-default) chats own their SDK conversation in -- // `_chatSessions`. Apply the agent to that conversation (resolving the -- // URI → SDK name against its own applied snapshot) and skip the -- // session-level metadata store. -- if (chat && !isDefaultChatUri(chat)) { -- const chatEntry = this._chatSessions.get(chat.toString()); -- if (chatEntry) { -- const resolvedAgentName = agent ? await this._resolveAgentName(session, chatEntry.appliedSnapshot, agent) : undefined; -- await chatEntry.setAgent(resolvedAgentName); +- private async _changeAgent(chat: URI, agent: AgentSelection | undefined): Promise { +- const context = this._getChatContext(chat); +- if (context.isPeerChat) { +- if (context.target) { +- const resolvedAgentName = agent ? this._resolveAgentName(context.target.appliedSnapshot, agent) : undefined; +- await context.target.setAgent(resolvedAgentName); - } - return; - } -- const sessionId = AgentSession.id(session); -- const provisional = this._provisionalSessions.get(sessionId); +- const provisional = this._provisionalSessions.get(context.sessionId); - if (provisional) { - provisional.agent = agent; - return; - } -- const entry = this._sessions.get(sessionId); +- const entry = context.target; - if (entry) { - // Resolve the URI → SDK name from the session's currently-applied - // plugin snapshot. If the agent is no longer present (plugin - // removed, never loaded), pass `undefined` so the SDK clears its - // selection rather than silently keeping the previous one. -- const resolvedAgentName = agent ? await this._resolveAgentName(session, entry.appliedSnapshot, agent) : undefined; +- const resolvedAgentName = agent ? this._resolveAgentName(entry.appliedSnapshot, agent) : undefined; - await entry.setAgent(resolvedAgentName); - } -- await this._storeSessionAgentMetadata(session, agent); +- await this._storeSessionAgentMetadata(context.session, agent); - } - - async shutdown(): Promise { - this._shutdownPromise ??= (async () => { +- // Cancel any pending model-refresh retry so its timer cannot fire +- // after teardown and resurrect the client. +- this._modelRefreshRetry.clear(); - this._logService.info('[Copilot] Shutting down...'); -- const sessionIds = new Set([...this._sessions.keys(), ...this._createdWorktrees.keys()]); +- const sessionIds = new Set([...this._sessions.keys()]); - for (const sessionId of sessionIds) { - await this._sessionSequencer.queue(sessionId, () => this._destroyAndDisposeSession(sessionId)); - } - await this._client?.stop(); - this._client = undefined; +- // Release the BYOK proxy handle only after the runtime subprocess is +- // gone, mirroring `_stopClient` and the proxy ownership invariant. +- await this._sessionLauncher.disposeByokProxyHandle(); - })(); - return this._shutdownPromise; - } - - respondToPermissionRequest(requestId: string, approved: boolean): void { -- for (const [, session] of this._sessions) { -- if (session.respondToPermissionRequest(requestId, approved)) { -- return; -- } -- } -- for (const [, chat] of this._chatSessions) { -- if (chat.respondToPermissionRequest(requestId, approved)) { -- return; +- for (const entry of this._sessions.values()) { +- for (const chat of entry.allChatSessions()) { +- if (chat.respondToPermissionRequest(requestId, approved)) { +- return; +- } - } - } - } - - respondToUserInputRequest(requestId: string, response: ChatInputResponseKind, answers?: Record): void { -- for (const [, session] of this._sessions) { -- if (session.respondToUserInputRequest(requestId, response, answers)) { -- return; -- } -- } -- for (const [, chat] of this._chatSessions) { -- if (chat.respondToUserInputRequest(requestId, response, answers)) { -- return; +- for (const entry of this._sessions.values()) { +- for (const chat of entry.allChatSessions()) { +- if (chat.respondToUserInputRequest(requestId, response, answers)) { +- return; +- } - } - } - } @@ -2521,16 +3479,92 @@ index 3f37722b..00000000 - - // ---- helpers ------------------------------------------------------------ - +- private async _configureProxyEnv(env: Record): Promise { +- const proxy = await this._resolveProxyForSdk(env); +- this._appliedProxy = proxy; +- if (proxy) { +- for (const key of COPILOT_PROXY_SET_ENV_KEYS) { +- env[key] = proxy; +- } +- this._logService.info('[Copilot] Resolved CAPI proxy and forwarded HTTP_PROXY/HTTPS_PROXY to Copilot SDK'); +- } +- } +- +- private async _resolveProxyForSdk(env: Record = process.env): Promise { +- if (!this._isSystemProxyEnabled()) { +- return undefined; +- } +- if (COPILOT_PROXY_ENV_KEYS.some(key => env[key])) { +- this._logService.debug('[Copilot] Proxy env var already set; leaving Copilot SDK proxy configuration to the environment'); +- return undefined; +- } +- +- let capiUrl = env['VSCODE_AGENT_HOST_CAPI_URL_OVERRIDE'] || COPILOT_CAPI_URL; +- if (this._githubToken) { +- try { +- const discovered = await this._copilotApiService.resolveApiEndpoint(this._githubToken); +- if (discovered) { +- capiUrl = discovered; +- } +- } catch (error) { +- this._logService.debug(`[Copilot] CAPI endpoint discovery for proxy resolution failed; using ${capiUrl}: ${error instanceof Error ? error.message : String(error)}`); +- } +- } +- +- try { +- return await this._proxyResolver.resolveProxy(capiUrl); +- } catch (error) { +- this._logService.warn(`[Copilot] Failed to resolve CAPI proxy for ${capiUrl}: ${error instanceof Error ? error.message : String(error)}`); +- return undefined; +- } +- } +- +- /** +- * When the GitHub token changes, the token-discovered CAPI endpoint (and so +- * the resolved proxy) can change. The proxy is baked into the SDK subprocess +- * env at client start, so if it would now differ we restart the running +- * client here (deferred while a turn is in flight, see +- * {@link _requestClientRestart}); the next `_ensureClient` re-resolves it +- * against the new token. No-op when no client is running/starting or the +- * proxy is unchanged. +- */ +- private async _restartClientIfProxyChanged(): Promise { +- if (!this._client && !this._clientStarting) { +- return; +- } +- const oldProxy = this._appliedProxy; +- const newProxy = await this._resolveProxyForSdk(); +- if (newProxy === oldProxy) { +- return; +- } +- // Let any in-flight start finish so we stop a live client rather than +- // racing it (the start would otherwise come up with the stale proxy). +- if (this._clientStarting) { +- try { +- await this._clientStarting; +- } catch { +- // Start failed; nothing running to restart. +- } +- } +- await this._requestClientRestart(`CAPI proxy changed after token update (${oldProxy ?? '(none)'} -> ${newProxy ?? '(none)'})`); +- } +- - /** -- * Disposes every peer chat (tracked in {@link _chatSessions}) whose -- * owning session matches `sessionId`. The chat URI encodes its parent -- * session, so we recover it via {@link parseChatUri}. +- * Disposes every peer chat hosted on the owning session's entry and drops +- * their live backings from {@link _chatBackings}. The chat URI encodes its +- * parent session, so we recover it via {@link parseChatUri}. - */ - private _disposeChildChats(sessionId: string): void { -- for (const chatKey of [...this._chatSessions.keys()]) { +- const entry = this._sessions.get(sessionId); +- if (entry) { +- for (const chatKey of entry.peerChatKeys()) { +- entry.disposePeerChat(chatKey); +- } +- } +- for (const chatKey of [...this._chatBackings.keys()]) { - const parsed = parseChatUri(URI.parse(chatKey)); - if (parsed && AgentSession.id(parsed.session) === sessionId) { -- this._chatSessions.deleteAndDispose(chatKey); +- this._chatBackings.delete(chatKey); - } - } - } @@ -2538,7 +3572,7 @@ index 3f37722b..00000000 - private _getOrCreateActiveClient(session: URI, directory: URI | undefined): ActiveClient { - let client = this._activeClients.get(session); - if (!client) { -- const pluginController = this._plugins.createSessionController(directory); +- const pluginController = this._plugins.createSessionController(session, directory); - client = this._instantiationService.createInstance(ActiveClient, session, pluginController, this._onDidSessionProgress); - this._activeClients.set(session, client); - } else if (directory) { @@ -2555,13 +3589,15 @@ index 3f37722b..00000000 - * {@link _resumeSession} for the same id cannot dispose this entry mid-init - * via {@link DisposableMap.set}. - */ -- private _createAgentSession(launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: ActiveClient, channelUri?: URI): CopilotAgentSession { -- const sessionUri = channelUri ?? AgentSession.uri(this.id, launchPlan.sessionId); +- private _createAgentSession(launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: ActiveClient, identity?: ICopilotAgentSessionIdentity): CopilotAgentSession { +- const sessionUri = identity?.sessionUri ?? AgentSession.uri(this.id, launchPlan.sessionId); +- const chatChannelUri = identity?.chatChannelUri ?? URI.parse(buildDefaultChatUri(sessionUri)); - - const agentSession = this._instantiationService.createInstance( - CopilotAgentSession, - { - sessionUri, +- chatChannelUri, - rawSessionId: launchPlan.sessionId, - onDidSessionProgress: this._onDidSessionProgress, - sessionLauncher: this._sessionLauncher, @@ -2570,13 +3606,18 @@ index 3f37722b..00000000 - workingDirectory: launchPlan.workingDirectory, - customizationDirectory, - clientSnapshot: launchPlan.snapshot, -- activeClientState: launchPlan.activeClientState, +- activeClientToolSet: launchPlan.activeClientToolSet, - resolveMcpChildId: name => findMcpChildId(activeClient.pluginController.getCustomizations(), name), - serverToolHost: this._serverToolHost, +- isLaunchTokenCurrent: () => this._githubToken === launchPlan.githubToken, +- onTurnEnded: () => this._onChatTurnEnded(), - }, - ); - -- this._mcpNotificationSubs.set(launchPlan.sessionId, agentSession.onMcpNotification(n => this._onMcpNotification.fire(n))); +- this._mcpNotificationSubs.set(launchPlan.sessionId, combinedDisposable( +- agentSession.onMcpNotification(n => this._onMcpNotification.fire(n)), +- autorun(r => activeClient.pluginController.mcpServerStates.set(agentSession.mcpServerStates.read(r), undefined)), +- )); - - return agentSession; - } @@ -2597,12 +3638,40 @@ index 3f37722b..00000000 - agentSession.dispose(); - throw new CancellationError(); - } -- this._sessions.set(sessionId, agentSession); +- // Reuse an existing entry (which may already host peer chats created +- // while the default chat was still provisional) rather than replacing +- // it, which would dispose those peers. The default chat is seeded into +- // the entry's uniform chat map keyed by its default-chat URI. +- const defaultChatKey = buildDefaultChatUri(agentSession.sessionUri.toString()); +- let entry = this._sessions.get(sessionId); +- if (!entry) { +- entry = new CopilotSessionEntry(); +- this._sessions.set(sessionId, entry); +- } +- entry.setDefaultChat(defaultChatKey, new CopilotSessionEntry(agentSession)); +- this._sdkSessionsById.set(agentSession.sessionId, agentSession); - } - - private async _destroyAndDisposeSession(sessionId: string): Promise { +- await this._releaseSessionResources(sessionId); +- } +- +- /** +- * Tears down a session's in-memory resources without deleting any durable +- * data: the SDK session is disconnected, peer chats and MCP subscriptions +- * are disposed, the `_sessions` entry is dropped, and active clients are +- * released. The on-disk SDK session log, session database, and worktree are +- * left untouched, so the session can be resumed later via +- * {@link _resumeSession}. Shared by the non-destructive {@link releaseSession} +- * path and the destructive {@link _destroyAndDisposeSession} path (the +- * latter reaps the worktree afterwards). +- */ +- private async _releaseSessionResources(sessionId: string): Promise { +- for (const chat of this._sessions.get(sessionId)?.allChatSessions() ?? []) { +- this._sdkSessionsById.delete(chat.sessionId); +- } - // Tear down any peer chats owned by this session first so their SDK -- // conversations don't leak when the parent is deleted/disposed +- // chats don't leak when the parent is deleted/disposed - // without each chat being individually disposed via `disposeChat`. - this._disposeChildChats(sessionId); - // Provisional sessions have no SDK session, no worktree, and no @@ -2612,11 +3681,14 @@ index 3f37722b..00000000 - const provisional = this._provisionalSessions.get(sessionId); - if (provisional) { - this._provisionalSessions.delete(sessionId); +- // Drop any peer-host entry created for this still-provisional +- // session (its peers were disposed by `_disposeChildChats` above). +- this._sessions.deleteAndDispose(sessionId); - this._activeClients.get(provisional.sessionUri)?.dispose(); - this._activeClients.delete(provisional.sessionUri); - return; - } -- const entry = this._sessions.get(sessionId); +- const entry = this._findAnySession(sessionId); - const sessionUri = AgentSession.uri(this.id, sessionId); - if (entry) { - try { @@ -2629,7 +3701,10 @@ index 3f37722b..00000000 - this._mcpNotificationSubs.deleteAndDispose(sessionId); - this._activeClients.get(sessionUri)?.dispose(); - this._activeClients.delete(sessionUri); -- await this._removeCreatedWorktree(sessionId); +- // Disposing a session with a running turn removes the last thing a +- // parked restart could be waiting on, and a disposed session never +- // reports its turn ending. +- await this._applyPendingClientRestart(); - } - - protected _resumeSession(sessionId: string): Promise { @@ -2654,12 +3729,6 @@ index 3f37722b..00000000 - - const sessionUri = AgentSession.uri(this.id, sessionId); - const storedMetadata = await this._readSessionMetadata(sessionUri); -- const customizationDirectory = storedMetadata.customizationDirectory ?? storedMetadata.workingDirectory; -- // Always create an ActiveClient so the snapshot includes host + -- // session-discovered customizations, even when no client has called -- // `setClientCustomizations` / `setClientTools` yet. -- const activeClient = this._getOrCreateActiveClient(sessionUri, customizationDirectory); -- const snapshot = await activeClient.snapshot(); - const sessionMetadata = await client.getSessionMetadata(sessionId).catch(err => { - this._logService.warn(`[Copilot:${sessionId}] getSessionMetadata failed`, err); - return undefined; @@ -2668,116 +3737,88 @@ index 3f37722b..00000000 - if (!workingDirectory) { - throw new Error(`workingDirectory is required to resume Copilot session '${sessionId}'`); - } +- // A workspace-less chat's working directory is a stable per-session scratch dir +- // that may have been reaped (OS temp cleanup, reboot) while the session +- // persisted. Recreate it (mkdir -p) so shell/git/scratch ops don't fail. +- let resolvedWorkingDirectory = workingDirectory; +- if (storedMetadata.workspaceless) { +- await this._ensureWorkspacelessScratchDir(workingDirectory, sessionId); +- } else { +- resolvedWorkingDirectory = await this._configurationService.resolveWorkingDirectoryForResume(sessionUri.toString(), workingDirectory); +- } +- // Anchor customization discovery to the working directory (the worktree for +- // worktree-isolated sessions), matching how the session was materialized. +- // Older sessions persisted `customizationDirectory` as the user-picked +- // folder; preferring the working directory corrects them on resume. +- const customizationDirectory = resolvedWorkingDirectory; +- // Always create an ActiveClient so the snapshot includes host + +- // session-discovered customizations, even when no client has +- // registered an active-client handle yet. +- const activeClient = this._getOrCreateActiveClient(sessionUri, customizationDirectory); +- activeClient.pluginController.reanchor(customizationDirectory); +- // Multi-root: re-attach the persisted non-primary roots so discovery spans +- // every root on resume. Empty when single-root / gated off. +- activeClient.pluginController.setAdditionalDirectories(this._additionalCustomizationDirectories(storedMetadata.workingDirectories)); +- const snapshot = await activeClient.snapshot(); - -- const shellManager = this._instantiationService.createInstance(ShellManager, sessionUri, workingDirectory); -- const resolvedAgentName = storedMetadata.agent ? await this._resolveAgentName(sessionUri, snapshot, storedMetadata.agent) : undefined; +- const shellManager = this._instantiationService.createInstance(ShellManager, sessionUri, resolvedWorkingDirectory); +- const resolvedAgentName = storedMetadata.agent ? this._resolveAgentName(snapshot, storedMetadata.agent) : undefined; +- 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`); +- } - const launchPlan: CopilotSessionLaunchPlan = { - kind: 'resume', - client, - sessionId, -- workingDirectory, +- workingDirectory: resolvedWorkingDirectory, +- additionalDirectories: storedMetadata.workingDirectories?.slice(1), - resolvedAgentName, - snapshot, -- activeClientState: activeClient.state, +- activeClientToolSet: activeClient.toolSet, - shellManager, - githubToken: this._githubToken, +- workspaceless: storedMetadata.workspaceless, - fallback: { - model: storedMetadata.model, +- longContextWindow: this._longContextWindowFor(storedMetadata.model?.id), +- freeLongContext: this._isFreeLongContext(storedMetadata.model?.id), - }, - }; - - const agentSession = this._createAgentSession(launchPlan, customizationDirectory, activeClient); - try { - await agentSession.initializeSession(); +- this._registerInitializedSession(sessionId, agentSession); - } catch (err) { - agentSession.dispose(); - throw err; - } -- this._registerInitializedSession(sessionId, agentSession); - - return agentSession; - } - -- private async _getGitInfo(workingDirectory: URI): Promise<{ currentBranch: string; defaultBranch: string } | undefined> { -- if (!await this._gitService.isInsideWorkTree(workingDirectory)) { -- return undefined; -- } -- -- const currentBranch = await this._gitService.getCurrentBranch(workingDirectory) ?? 'HEAD'; -- const defaultBranch = await this._gitService.getDefaultBranch(workingDirectory) ?? currentBranch; -- return { currentBranch, defaultBranch }; -- } -- -- private async _getBranches(workingDirectory: URI, query?: string): Promise { -- return this._gitService.getBranches(workingDirectory, { query, limit: CopilotAgent._BRANCH_COMPLETION_LIMIT }); -- } -- -- protected async _resolveSessionWorkingDirectory(config: IAgentCreateSessionConfig | undefined, sessionId: string, prompt?: string): Promise { -- if (config?.config?.isolation !== 'worktree' || !config.workingDirectory || typeof config.config.branch !== 'string') { -- return config?.workingDirectory; -- } -- -- const repositoryRoot = await this._gitService.getRepositoryRoot(config.workingDirectory); -- if (!repositoryRoot) { -- return config.workingDirectory; -- } -- -- const worktreesRoot = getCopilotWorktreesRoot(repositoryRoot); -- const branchName = await this._branchNameGenerator.generateBranchName({ sessionId, message: prompt, githubToken: this._githubToken }); -- const worktree = URI.joinPath(worktreesRoot, getCopilotWorktreeName(branchName)); -- await fs.mkdir(worktreesRoot.fsPath, { recursive: true }); -- const baseBranch = typeof config.config[SessionConfigKey.Branch] === 'string' ? config.config[SessionConfigKey.Branch] as string : undefined; -- // `addWorktree`'s signature requires a startPoint, but historically the -- // runtime accepted undefined when `branch` was not set in config. Preserve -- // that behavior by passing through whatever value (or undefined) was set. -- await this._gitService.addWorktree(repositoryRoot, worktree, branchName, baseBranch as string); -- this._createdWorktrees.set(sessionId, { repositoryRoot, worktree }); -- // Queue the worktree announcement so the first turn (live) and any -- // subsequent restore (history) both surface the message in the chat. -- this._pendingFirstTurnAnnouncements.set(sessionId, buildWorktreeAnnouncementText(branchName)); -- const sessionUri = AgentSession.uri(this.id, sessionId); -- try { -- await this._writeWorktreeMetadata(sessionUri, { branchName, baseBranch, worktreePath: worktree, repositoryRoot }); -- } catch (error) { -- this._logService.warn(`[Copilot:${sessionId}] Failed to persist worktree branch metadata: ${error instanceof Error ? error.message : String(error)}`); -- } -- return worktree; -- } -- -- private async _removeCreatedWorktree(sessionId: string): Promise { -- const worktree = this._createdWorktrees.get(sessionId); -- if (!worktree) { -- return; -- } -- try { -- await this._gitService.removeWorktree(worktree.repositoryRoot, worktree.worktree); -- } catch (error) { -- this._logService.warn(`[Copilot:${sessionId}] Failed to remove worktree '${worktree.worktree.fsPath}': ${error instanceof Error ? error.message : String(error)}`); -- } finally { -- this._createdWorktrees.delete(sessionId); -- } -- } -- - // ---- session metadata persistence -------------------------------------- - - private static readonly _META_MODEL = 'copilot.model'; - private static readonly _META_AGENT = 'copilot.agent'; - private static readonly _META_CWD = 'copilot.workingDirectory'; +- /** Persisted ordered working-directory set (JSON array of URI strings; index 0 = primary). */ +- private static readonly _META_CWDS = 'copilot.workingDirectories'; - private static readonly _META_CUSTOMIZATION_DIRECTORY = 'copilot.customizationDirectory'; - private static readonly _META_PROJECT_RESOLVED = 'copilot.project.resolved'; - private static readonly _META_PROJECT_URI = 'copilot.project.uri'; - private static readonly _META_PROJECT_DISPLAY_NAME = 'copilot.project.displayName'; -- private static readonly _META_WORKTREE_BRANCH = 'copilot.worktree.branchName'; -- private static readonly _META_WORKTREE_PATH = 'copilot.worktree.path'; -- private static readonly _META_WORKTREE_REPOSITORY_ROOT = 'copilot.worktree.repositoryRoot'; - /** Persisted catalog of additional (non-default) peer chats, keyed by chatId. */ - private static readonly _META_CHATS = 'copilot.chats'; - - /** -- * Reads the persisted peer-chat catalog for a session. Each entry maps a -- * chatId (the `ahp-chat` authority) to the SDK conversation that backs it -- * (and its optional model override), so the chat can be resumed after a -- * restart even though {@link _chatSessions} is empty in a fresh process. +- * Reads the agent's legacy peer-chat catalog (`copilot.chats`) for a +- * session. Each entry maps a chatId (the `ahp-chat` authority) to the SDK +- * chat that backs it (and its optional model override). The agent +- * no longer *writes* this catalog — the orchestrator owns the durable +- * peer-chat catalog via `providerData` — but the read is retained for one +- * release to drain sessions persisted before that migration (see +- * {@link getChats} and {@link materializeChat}). - */ - private async _readPersistedChats(session: URI): Promise> { - const ref = await this._sessionDataService.tryOpenDatabase(session); @@ -2813,64 +3854,8 @@ index 3f37722b..00000000 - } - } - -- /** Writes the persisted peer-chat catalog for a session. */ -- private async _writePersistedChats(session: URI, chats: Map): Promise { -- const dbRef = this._sessionDataService.openDatabase(session); -- try { -- // Use a null-prototype object: chatIds derive from a client-chosen -- // chat URI authority, so a value like `__proto__` would otherwise -- // pollute the prototype / corrupt the serialized payload. -- const obj: Record = Object.create(null); -- for (const [chatId, info] of chats) { -- obj[chatId] = info; -- } -- await dbRef.object.setMetadata(CopilotAgent._META_CHATS, JSON.stringify(obj)); -- } finally { -- dbRef.dispose(); -- } -- } -- -- -- private async _writeWorktreeMetadata(session: URI, metadata: { branchName: string; baseBranch: string | undefined; worktreePath: URI; repositoryRoot: URI }): Promise { -- const dbRef = this._sessionDataService.openDatabase(session); -- try { -- const work: Promise[] = [ -- dbRef.object.setMetadata(CopilotAgent._META_WORKTREE_BRANCH, metadata.branchName), -- dbRef.object.setMetadata(CopilotAgent._META_WORKTREE_PATH, metadata.worktreePath.toString()), -- dbRef.object.setMetadata(CopilotAgent._META_WORKTREE_REPOSITORY_ROOT, metadata.repositoryRoot.toString()), -- ]; -- if (metadata.baseBranch) { -- work.push(dbRef.object.setMetadata(META_DIFF_BASE_BRANCH, metadata.baseBranch)); -- } -- await Promise.all(work); -- } finally { -- dbRef.dispose(); -- } -- } -- -- private async _readWorktreeMetadata(session: URI): Promise<{ branchName: string; worktreePath?: URI; repositoryRoot?: URI } | undefined> { -- const ref = await this._sessionDataService.tryOpenDatabase(session); -- if (!ref) { -- return undefined; -- } -- try { -- const [branchName, worktreePathRaw, repositoryRootRaw] = await Promise.all([ -- ref.object.getMetadata(CopilotAgent._META_WORKTREE_BRANCH), -- ref.object.getMetadata(CopilotAgent._META_WORKTREE_PATH), -- ref.object.getMetadata(CopilotAgent._META_WORKTREE_REPOSITORY_ROOT), -- ]); -- if (!branchName) { -- return undefined; -- } -- const worktreePath = worktreePathRaw ? URI.parse(worktreePathRaw) : undefined; -- const repositoryRoot = repositoryRootRaw ? URI.parse(repositoryRootRaw) : undefined; -- return { branchName, worktreePath, repositoryRoot }; -- } finally { -- ref.dispose(); -- } -- } - -- private async _storeSessionMetadata(session: URI, model: ModelSelection | undefined, workingDirectory: URI | undefined, customizationDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined): Promise { +- private async _storeSessionMetadata(session: URI, model: ModelSelection | undefined, workingDirectory: URI | undefined, workingDirectories: readonly URI[] | undefined, customizationDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined): Promise { - const dbRef = this._sessionDataService.openDatabase(session); - const db = dbRef.object; - try { @@ -2881,6 +3866,14 @@ index 3f37722b..00000000 - if (workingDirectory) { - work.push(db.setMetadata(CopilotAgent._META_CWD, workingDirectory.toString())); - } +- // Persist the ordered set alongside the legacy single cwd so a +- // multi-root session restores every directory on reload. Reads prefer +- // this key; `_META_CWD` remains the fallback for sessions persisted +- // before this key existed. Written together with `_META_CWD` from the +- // same source so index 0 stays consistent across both keys. +- if (workingDirectories) { +- work.push(db.setMetadata(CopilotAgent._META_CWDS, JSON.stringify(workingDirectories.map(d => d.toString())))); +- } - if (customizationDirectory) { - work.push(db.setMetadata(CopilotAgent._META_CUSTOMIZATION_DIRECTORY, customizationDirectory.toString())); - } @@ -2897,43 +3890,74 @@ index 3f37722b..00000000 - } - } - -- private async _readSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; customizationDirectory?: URI }> { +- /** +- * Parses the persisted ordered working-directory set. Prefers the JSON +- * `_META_CWDS` array when present and valid, otherwise falls back to the +- * single legacy `_META_CWD` value. A malformed blob (the metadata store is +- * client-influenced and may be corrupt) is ignored in favour of the legacy +- * fallback so it can never reject the caller. +- */ +- private _parseWorkingDirectories(rawSet: string | undefined, fallback: URI | undefined): readonly URI[] | undefined { +- if (rawSet) { +- try { +- const parsed = JSON.parse(rawSet); +- if (Array.isArray(parsed)) { +- const dirs = parsed.filter((d): d is string => typeof d === 'string' && d.length > 0).map(d => URI.parse(d)); +- if (dirs.length > 0) { +- return dirs; +- } +- } +- } catch { +- // Malformed metadata blob: fall through to the legacy fallback. +- } +- } +- return fallback ? [fallback] : undefined; +- } +- +- private async _readSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; workingDirectories?: readonly URI[]; customizationDirectory?: URI; workspaceless?: boolean }> { - const ref = await this._sessionDataService.tryOpenDatabase(session); - if (!ref) { - return {}; - } - try { -- const [model, agent, cwd, customizationDirectory] = await Promise.all([ +- const [model, agent, cwd, cwds, customizationDirectory, workspaceless] = await Promise.all([ - ref.object.getMetadata(CopilotAgent._META_MODEL), - ref.object.getMetadata(CopilotAgent._META_AGENT), - ref.object.getMetadata(CopilotAgent._META_CWD), +- ref.object.getMetadata(CopilotAgent._META_CWDS), - ref.object.getMetadata(CopilotAgent._META_CUSTOMIZATION_DIRECTORY), +- ref.object.getMetadata(AH_META_WORKSPACELESS_DB_KEY), - ]); +- const workingDirectory = cwd ? URI.parse(cwd) : undefined; - return { - model: this._parseModelSelection(model), - agent: this._parseAgentSelection(agent), -- workingDirectory: cwd ? URI.parse(cwd) : undefined, +- workingDirectory, +- workingDirectories: this._parseWorkingDirectories(cwds, workingDirectory), - customizationDirectory: customizationDirectory ? URI.parse(customizationDirectory) : undefined, +- workspaceless: workspaceless === 'true', - }; - } finally { - ref.dispose(); - } - } - -- private async _readStoredSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; customizationDirectory?: URI; project?: IAgentSessionProjectInfo; resolved: boolean } | undefined> { +- private async _readStoredSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; workingDirectories?: readonly URI[]; customizationDirectory?: URI; project?: IAgentSessionProjectInfo; resolved: boolean; workspaceless?: boolean } | undefined> { - const ref = await this._sessionDataService.tryOpenDatabase(session); - if (!ref) { - return undefined; - } - try { -- const [model, agent, cwd, customizationDirectory, resolved, uri, displayName] = await Promise.all([ +- const [model, agent, cwd, cwds, customizationDirectory, resolved, uri, displayName, workspaceless] = await Promise.all([ - ref.object.getMetadata(CopilotAgent._META_MODEL), - ref.object.getMetadata(CopilotAgent._META_AGENT), - ref.object.getMetadata(CopilotAgent._META_CWD), +- ref.object.getMetadata(CopilotAgent._META_CWDS), - ref.object.getMetadata(CopilotAgent._META_CUSTOMIZATION_DIRECTORY), - ref.object.getMetadata(CopilotAgent._META_PROJECT_RESOLVED), - ref.object.getMetadata(CopilotAgent._META_PROJECT_URI), - ref.object.getMetadata(CopilotAgent._META_PROJECT_DISPLAY_NAME), +- ref.object.getMetadata(AH_META_WORKSPACELESS_DB_KEY), - ]); - const workingDirectory = cwd ? URI.parse(cwd) : undefined; - const project = uri && displayName ? { uri: URI.parse(uri), displayName } : undefined; @@ -2941,9 +3965,11 @@ index 3f37722b..00000000 - model: this._parseModelSelection(model), - agent: this._parseAgentSelection(agent), - workingDirectory, +- workingDirectories: this._parseWorkingDirectories(cwds, workingDirectory), - customizationDirectory: customizationDirectory ? URI.parse(customizationDirectory) : undefined, - project, - resolved: resolved === 'true' || project !== undefined, +- workspaceless: workspaceless === 'true', - }; - } finally { - ref.dispose(); @@ -2969,7 +3995,7 @@ index 3f37722b..00000000 - } - - private async _storeSessionProjectResolution(session: URI, project: IAgentSessionProjectInfo | undefined): Promise { -- await this._storeSessionMetadata(session, undefined, undefined, undefined, project, true); +- await this._storeSessionMetadata(session, undefined, undefined, undefined, undefined, project, true); - } - - private _resolveSessionProject(context: ICopilotSessionContext | undefined, limiter: Limiter, projectByContext: Map>): Promise { @@ -3023,7 +4049,7 @@ index 3f37722b..00000000 - readonly input?: ClientPluginCustomization; -} - --const REFRESH_DEBOUNCE_MS = 100; +-export const REFRESH_DEBOUNCE_MS = 100; - -/** - * A per-working-directory bundle of customizations the agent host @@ -3050,22 +4076,26 @@ index 3f37722b..00000000 - private _customizations: readonly DirectoryCustomization[] = []; - private _directories: readonly IDiscoveredDirectory[] | undefined; - private _settled: Promise; -- private readonly _fileService: IFileService; - - constructor( -- workingDirectory: URI, +- workingDirectories: readonly URI[], - userHome: URI, +- private readonly _getClient: () => Promise, - private readonly _onDidRefresh: () => void, -- private readonly _logService: ILogService, -- instantiationService: IInstantiationService, +- @IFileService private readonly _fileService: IFileService, +- @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, +- @ILogService private readonly _logService: ILogService, +- @IInstantiationService instantiationService: IInstantiationService, - ) { - super(); -- this._discovery = this._register(instantiationService.createInstance(SessionCustomizationDiscovery, workingDirectory, userHome)); -- this._fileService = instantiationService.invokeFunction(accessor => accessor.get(IFileService)); -- this._settled = this._queueRefresh(false); +- this._discovery = this._register(instantiationService.createInstance(SessionCustomizationDiscovery, workingDirectories, userHome, URI.file)); +- this._settled = this._queueRefresh(false, 0); - this._register(this._discovery.onDidChange(() => { - this._settled = this._queueRefresh(true); - })); +- this._register(this._configurationService.onDidRootConfigChange(() => { +- this._settled = this._queueRefresh(true); +- })); - } - - override dispose(): void { @@ -3082,7 +4112,7 @@ index 3f37722b..00000000 - return this._customizations; - } - -- private _queueRefresh(notify: boolean): Promise { +- private _queueRefresh(notify: boolean, delay = REFRESH_DEBOUNCE_MS): Promise { - this._refreshPromise?.cancel(); - this._refreshPromise = null; - this._pendingRefreshNotify = this._pendingRefreshNotify || notify; @@ -3090,7 +4120,6 @@ index 3f37722b..00000000 - return this._refreshDelayer.trigger(() => { - const shouldNotify = this._pendingRefreshNotify; - this._pendingRefreshNotify = false; -- - const refreshPromise = this._refreshPromise = createCancelablePromise(async token => { - const didRefresh = await this._refresh(token); - if (didRefresh && shouldNotify) { @@ -3111,11 +4140,28 @@ index 3f37722b..00000000 - } - throw err; - }); -- }); +- }, delay); - } - - private async _refresh(token: CancellationToken): Promise { - try { +- const mode = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.SessionCustomizationDiscoveryMode) +- ?? DEFAULT_SESSION_CUSTOMIZATION_DISCOVERY_MODE; +- if (mode === 'discover') { +- const customizations = await this._discovery.discover(await this._getClient(), token); +- if (token.isCancellationRequested) { +- return false; +- } +- +- if (equals(this._customizations, customizations)) { +- return false; +- } +- +- this._customizations = customizations; +- this._directories = undefined; +- return true; +- } +- - const directories = await this._discovery.scan(token); - if (token.isCancellationRequested) { - return false; @@ -3157,10 +4203,10 @@ index 3f37722b..00000000 - type: CustomizationType.Directory, - id: customizationId(protocolUri), - uri: protocolUri, -- name: resourceBasename(directory.uri), +- name: directory.name, - enabled: true, - contents: toDirectoryContentsType(directory.type), -- writable: false, +- writable: directory.writable, // whether the new customization can be created in this directory - load: { kind: CustomizationLoadStatus.Loaded }, - children: await Promise.all(directory.files.map(file => toDiscoveredChildCustomization(file.uri, directory.type, fileService))), - }; @@ -3296,6 +4342,7 @@ index 3f37722b..00000000 - } - - return { +- format: PluginFormat.Copilot, - hooks: [], - mcpServers: [], - skills: skills, @@ -3315,9 +4362,8 @@ index 3f37722b..00000000 - * - parsing + resolution helpers used by both host- and client-side - * customizations - * -- * Per-session state (client-published customizations, on-disk -- * customization discovery for the session's working directory, -- * enablement overrides) lives on {@link SessionPluginController}, +- * Per-session state (client-published customizations and on-disk +- * customization discovery for the session's working directory) lives on {@link SessionPluginController}, - * one per {@link CopilotAgentSession}. Each session controller holds - * a reference back to this shared controller for the resolve/sync - * helpers it needs. @@ -3333,11 +4379,13 @@ index 3f37722b..00000000 - private _lastAppliedRefs: readonly Customization[] = []; - - constructor( +- private readonly _getClient: () => Promise, - @IAgentPluginManager public readonly pluginManager: IAgentPluginManager, -- @ILogService public readonly logService: ILogService, -- @IFileService public readonly fileService: IFileService, +- @ILogService private readonly _logService: ILogService, +- @IFileService private readonly _fileService: IFileService, - @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, -- @IInstantiationService public readonly instantiationService: IInstantiationService, +- @IInstantiationService private readonly _instantiationService: IInstantiationService, +- @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService, - ) { - super(); - @@ -3352,6 +4400,10 @@ index 3f37722b..00000000 - return this._hostCustomizations.map(item => item.customization); - } - +- public get configurationService(): IAgentConfigurationService { +- return this._configurationService; +- } +- - /** - * Snapshot the resolved host customizations (loading or loaded). Used by - * {@link SessionPluginController} to compose its per-session view. @@ -3365,8 +4417,12 @@ index 3f37722b..00000000 - return this._hostSync; - } - -- public getUserHome(): string { -- return process.env['HOME'] ?? process.env['USERPROFILE'] ?? ''; +- public getUserHome(): URI { +- return this._environmentService.userHome; +- } +- +- public async getClient(): Promise { +- return this._getClient(); - } - - /** @@ -3375,8 +4431,8 @@ index 3f37722b..00000000 - * the caller; disposing it releases the session's disk-discovery - * watchers and detaches from this controller's change event. - */ -- public createSessionController(directory: URI | undefined): SessionPluginController { -- return new SessionPluginController(this, directory); +- public createSessionController(session: URI, directory: URI | undefined): SessionPluginController { +- return this._instantiationService.createInstance(SessionPluginController, this, session, directory); - } - - /** @@ -3465,21 +4521,36 @@ index 3f37722b..00000000 - - public async tryParsePlugin(pluginDir: URI): Promise { - try { -- return await parsePlugin(pluginDir, this.fileService, undefined, this.getUserHome()); +- return await parsePlugin(pluginDir, this._fileService, undefined, this.getUserHome(), pluginDir); - } catch (error) { -- this.logService.warn(`[Copilot:PluginController] Error parsing plugin '${pluginDir.toString()}': ${error instanceof Error ? error.message : String(error)}`); +- this._logService.warn(`[Copilot:PluginController] Error parsing plugin '${pluginDir.toString()}': ${error instanceof Error ? error.message : String(error)}`); - return undefined; - } - } -} - -/** +- * Per-client slice of {@link SessionPluginController} customization state. +- * One entry exists per active client that has contributed customizations to +- * the session. +- */ +-interface IClientCustomizationState { +- /** Monotonic revision used to detect and ignore stale in-flight syncs for this client. */ +- revision: number; +- /** This client's resolved customizations (Loading/Loaded/Error per item). */ +- customizations: readonly IResolvedCustomization[]; +- /** This client's in-flight (or settled) sync promise. */ +- sync: Promise; +- /** The raw inputs last passed to {@link SessionPluginController.sync} for this client. */ +- inputs: readonly ClientPluginCustomization[]; +-} +- +-/** - * Per-session view over {@link PluginController}. - * - * Owns the session-scoped slice of plugin state — published client -- * customizations, on-disk-discovered customizations under the session's -- * customization directory, and the user's per-session enablement -- * overrides — and exposes a {@link onDidPublish} stream of +- * customizations and on-disk-discovered customizations under the session's +- * customization directory — and exposes a {@link onDidPublish} stream of - * {@link SessionAction}s targeted at *this* session (no cross-session - * routing). - * @@ -3492,18 +4563,45 @@ index 3f37722b..00000000 - /** Per-session action stream (reset + per-item updates). */ - readonly onDidPublish = this._onDidPublish.event; - -- private readonly _enablement = new Map(); -- private _clientCustomizations: readonly IResolvedCustomization[] = []; -- private _clientSync: Promise = Promise.resolve([]); -- private _clientRevision = 0; -- /** Last clientId seen via {@link sync}; used by {@link retryFailedClientSyncIfNeeded}. */ -- private _clientId: string | undefined; +- private readonly _previousDirectories: URI[] = []; +- private _indexedDesiredCustomizations: readonly Customization[] | undefined; +- private readonly _desiredCustomizationById = new Map(); +- /** +- * Live runtime state (`state`/`channel`) per MCP server customization id, +- * kept up to date by the owning session from its MCP controller. Overlaid +- * onto published customizations by {@link _overlayMcpState} so a re-sync +- * preserves the live state of otherwise-unchanged MCP servers instead of +- * resetting them to the `Stopped` default baked into +- * `makeMcpServerCustomization`. Exposed (not injected) so the session can +- * write to it once it holds this controller. +- */ +- public readonly mcpServerStates: ISettableObservable> = observableValue(this, new Map()); +- /** +- * Per-client customization state, keyed by `clientId`. Each active client +- * contributing customizations to this session has one entry; the published +- * customization list is the union across all entries (deduplicated by URI, +- * first-inserted client wins). Insertion order is preserved so the merged +- * order stays stable across updates. +- */ +- private readonly _clients = new Map(); - - private readonly _sessionDiscovered: MutableDisposable = this._register(new MutableDisposable()); - +- /** +- * The additional (non-primary) workspace roots for a multi-root session. +- * Index 0 (the process root / worktree) is tracked separately by +- * {@link _directory}; this holds roots 1..N, which are stable workspace +- * folders that are never worktree-remapped. Empty for single-root sessions. +- */ +- private _additionalDirectories: readonly URI[] = []; +- - constructor( - private readonly _parent: PluginController, +- private readonly _session: URI, - private _directory: URI | undefined, +- @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, +- @ILogService private readonly _logService: ILogService, +- @IInstantiationService private readonly _instantiationService: IInstantiationService - ) { - super(); - } @@ -3512,6 +4610,11 @@ index 3f37722b..00000000 - return this._directory; - } - +- /** The additional (non-primary) roots attached to customization discovery. */ +- public get additionalDirectories(): readonly URI[] { +- return this._additionalDirectories; +- } +- - /** - * Anchor (or re-anchor) the session's customization directory. - * Only ever transitions from `undefined` → set; once a directory has @@ -3525,22 +4628,74 @@ index 3f37722b..00000000 - this._directory = directory; - } - -- public getCustomizations(): readonly Customization[] { -- const result: Customization[] = [ -- ...this._parent.hostCustomizations().map(item => this._applyEnablement(item.customization)), -- ...this._clientCustomizations.map(item => this._applyEnablement(item.customization)), -- ]; -- const entry = this._discoveredEntry(); -- const discovered = entry?.currentCustomizations() ?? []; -- for (const customization of discovered) { -- result.push(this._applyEnablement(customization)); +- /** +- * Set the additional (non-primary) workspace roots. Recreates the discovered +- * entry when the set actually changes so discovery re-scans every root — +- * important when this is set after a primary-only entry was already created +- * (e.g. on resume). A no-op for the single-root case (empty tail). +- */ +- public setAdditionalDirectories(directories: readonly URI[]): void { +- if (this._additionalDirectories.length === directories.length +- && this._additionalDirectories.every((d, i) => isEqual(d, directories[i]))) { +- return; +- } +- this._additionalDirectories = directories; +- this._sessionDiscovered.clear(); +- } +- +- /** +- * Move the session's customization anchor to a new directory (e.g. from the +- * user-picked folder to the worktree at materialization). Recreates the +- * discovered entry so discovery/watchers re-scan the new directory. +- */ +- public reanchor(directory: URI): void { +- if (this._directory && isEqual(this._directory, directory)) { +- return; +- } +- const previous = this._directory; +- this._directory = directory; +- this._sessionDiscovered.clear(); +- if (previous && !this._previousDirectories.some(candidate => isEqual(candidate, previous))) { +- this._previousDirectories.push(previous); +- } +- } +- +- public getCustomizations(): readonly Customization[] { +- const result: Customization[] = [ +- ...this._parent.hostCustomizations().map(item => this._projectForPublish(item.customization)), +- ...this._flattenClientCustomizations().map(item => this._projectForPublish(item.customization)), +- ]; +- const entry = this._discoveredEntry(); +- const discovered = entry?.currentCustomizations() ?? []; +- for (const customization of discovered) { +- result.push(this._projectForPublish(customization)); +- } +- return result; +- } +- +- /** +- * The union of every active client's resolved customizations, +- * deduplicated by URI with the first-inserted client winning. Order +- * follows client insertion order, then per-client order. +- */ +- private _flattenClientCustomizations(): readonly IResolvedCustomization[] { +- const seen = new Set(); +- const result: IResolvedCustomization[] = []; +- for (const client of this._clients.values()) { +- for (const item of client.customizations) { +- if (seen.has(item.customization.uri)) { +- continue; +- } +- seen.add(item.customization.uri); +- result.push(item); +- } - } - return result; - } - - /** - * Settled variant of {@link getCustomizations}: awaits the in-flight -- * host sync, the in-flight client sync, and the discovered entry's +- * host sync, every in-flight client sync, and the discovered entry's - * initial scan + parse before snapshotting the list. Callers that - * publish customizations into session state at session creation time - * MUST use this — the synchronous variant can return an empty list @@ -3550,8 +4705,8 @@ index 3f37722b..00000000 - public async getCustomizationsSettled(): Promise { - const entry = this._discoveredEntry(); - await Promise.all([ -- this._parent.hostSync().catch(err => this._parent.logService.warn('[Copilot:SessionPluginController] Host customization update failed', err)), -- this._clientSync.catch(err => this._parent.logService.warn('[Copilot:SessionPluginController] Client customization sync failed', err)), +- this._parent.hostSync().catch(err => this._logService.warn('[Copilot:SessionPluginController] Host customization update failed', err)), +- ...[...this._clients.values()].map(client => client.sync.catch(err => this._logService.warn('[Copilot:SessionPluginController] Client customization sync failed', err))), - entry?.whenSettled(), - ]); - return this.getCustomizations(); @@ -3560,15 +4715,15 @@ index 3f37722b..00000000 - /** Returns the parsed plugins currently enabled for this session, awaiting any pending sync. */ - public async getAppliedPlugins(): Promise { - const entry = this._discoveredEntry(); -- const [host, client] = await Promise.all([ +- const [host] = await Promise.all([ - this._parent.hostSync().catch(err => { -- this._parent.logService.warn('[Copilot:SessionPluginController] Host customization update failed', err); +- this._logService.warn('[Copilot:SessionPluginController] Host customization update failed', err); - return this._parent.hostCustomizations(); - }), -- this._clientSync.catch(err => { -- this._parent.logService.warn('[Copilot:SessionPluginController] Client customization sync failed', err); -- return this._clientCustomizations; -- }), +- ...[...this._clients.values()].map(client => client.sync.catch(err => { +- this._logService.warn('[Copilot:SessionPluginController] Client customization sync failed', err); +- return client.customizations; +- })), - entry?.whenSettled(), - ]); - @@ -3579,25 +4734,17 @@ index 3f37722b..00000000 - return [ - ...host.filter(item => !!item.plugin && this._isEnabled(item.customization)) - .map(item => ({ ...item.plugin!, pluginDir: item.pluginDir })), -- ...client.filter(item => !!item.plugin && this._isEnabled(item.customization)) +- ...this._flattenClientCustomizations().filter(item => !!item.plugin && this._isEnabled(item.customization)) - .map(item => ({ ...item.plugin!, pluginDir: item.pluginDir })), - ...sessionPlugins, - ]; - } - - /** -- * Set per-session enablement for a customization (by protocol URI). -- */ -- public setEnabled(pluginProtocolUri: string, enabled: boolean): void { -- const prev = this._enablement.get(pluginProtocolUri); -- if (prev === enabled) { -- return; -- } -- this._enablement.set(pluginProtocolUri, enabled); -- } -- -- /** -- * Sync the published client customizations for this session. +- * Sync the published customizations for a single client of this session, +- * keyed by `clientId`. Replaces only that client's slice; other clients' +- * customizations are untouched. The published session-state list is the +- * union across all clients. - * - * @param quiet when `true`, suppress {@link onDidPublish} events for - * this sync. Used during eager-create paths where there is no @@ -3606,9 +4753,25 @@ index 3f37722b..00000000 - */ - public sync(clientId: string, customizations: ClientPluginCustomization[], options?: { quiet?: boolean }) { - const quiet = options?.quiet === true; -- this._clientId = clientId; -- const revision = ++this._clientRevision; -- this._clientCustomizations = customizations.map(customization => ({ +- let client = this._clients.get(clientId); +- if (!client) { +- client = { revision: 0, customizations: [], sync: Promise.resolve([]), inputs: [] }; +- this._clients.set(clientId, client); +- } else if (equals(client.inputs, customizations)) { +- // No-op re-sync: a window re-subscribing (e.g. navigating away from +- // and back to a session) re-publishes the same customizations. Skip +- // the revision bump, the `SessionCustomizationsChanged` emit, and the +- // redundant plugin-manager re-sync (which otherwise re-parses plugins +- // from disk on every navigation). Genuine changes still publish, and +- // `_projectForPublish` keeps live MCP state intact across those. +- return client.sync.then(results => results.map(item => ({ +- customization: this._projectForPublish(item.customization), +- ...(item.pluginDir ? { pluginDir: item.pluginDir } : {}), +- }))); +- } +- const revision = ++client.revision; +- client.inputs = customizations; +- client.customizations = customizations.map(customization => ({ - customization: { - ...customization, - clientId, @@ -3623,12 +4786,12 @@ index 3f37722b..00000000 - }); - } - const published = new Map(); -- for (const customization of this._clientCustomizations) { -- const enabled = this._applyEnablement(customization.customization); +- for (const customization of client.customizations) { +- const enabled = this._projectForPublish(customization.customization); - published.set(enabled.uri, enabled); - } - const publishUpdate = (item: IResolvedCustomization) => { -- const customization = this._applyEnablement(item.customization); +- const customization = this._projectForPublish(item.customization); - if (equals(published.get(customization.uri), customization)) { - return; - } @@ -3641,13 +4804,13 @@ index 3f37722b..00000000 - } - }; - -- const prev = this._clientSync; -- const promise = this._clientSync = prev.catch(err => { -- this._parent.logService.warn('[Copilot:SessionPluginController] Previous customization sync failed', err); +- const prev = client.sync; +- const promise = client.sync = prev.catch(err => { +- this._logService.warn('[Copilot:SessionPluginController] Previous customization sync failed', err); - }).then(async () => { - const inputByUri = new Map(customizations.map(c => [c.uri, c])); - const result = await this._parent.pluginManager.syncCustomizations(clientId, customizations, status => { -- if (revision !== this._clientRevision) { +- if (revision !== client.revision) { - return; - } - publishUpdate({ @@ -3657,8 +4820,8 @@ index 3f37722b..00000000 - }); - - const resolved = await Promise.all(result.map(item => this._parent.resolveSyncedCustomization(item, clientId, inputByUri.get(item.customization.uri)))); -- if (revision === this._clientRevision) { -- this._clientCustomizations = resolved; +- if (revision === client.revision) { +- client.customizations = resolved; - for (const item of resolved) { - publishUpdate(item); - } @@ -3667,84 +4830,243 @@ index 3f37722b..00000000 - }); - - return promise.then(results => results.map(item => ({ -- customization: this._applyEnablement(item.customization), +- customization: this._overlayMcpState(this._applyEnablement(item.customization)), - ...(item.pluginDir ? { pluginDir: item.pluginDir } : {}), - }))); - } - - /** -- * Re-issue the last client sync if any previously-synced customization -- * is currently in an error state. Used to recover from transient -- * sync failures (e.g. a `vscode-agent-host://` connection drop during -- * reconnection) at message boundaries. Re-syncs **only** the errored -- * items and always non-quiet so listeners observe recovery. +- * Remove a client's customization contribution from this session, +- * publishing the updated (union) customization list so the removed +- * client's plugins disappear from session state. - */ -- public async retryFailedClientSyncIfNeeded(): Promise { -- await this._clientSync.catch(() => { }); -- if (!this._clientId) { -- return; -- } -- const errored = this._clientCustomizations.filter(item => -- item.customization.load?.kind === CustomizationLoadStatus.Error -- && item.input !== undefined -- ); -- if (errored.length === 0) { +- public removeClient(clientId: string): void { +- const client = this._clients.get(clientId); +- if (!client) { - return; - } -- const inputs = errored.map(item => item.input!); -- this._parent.logService.info(`[Copilot:SessionPluginController] Retrying ${inputs.length} previously-failed client customization(s)`); -- await this.sync(this._clientId, inputs).catch(err => { -- this._parent.logService.warn('[Copilot:SessionPluginController] Retried client customization sync failed', err); +- // Invalidate any in-flight sync for this client by bumping its +- // revision so the late continuation's `revision === client.revision` +- // guards fail and it does not re-publish the removed client's +- // customizations. +- client.revision++; +- this._clients.delete(clientId); +- this._onDidPublish.fire({ +- type: ActionType.SessionCustomizationsChanged, +- customizations: [...this.getCustomizations()], - }); - } - +- /** The raw input customizations last synced for `clientId` (empty when absent). */ +- public clientInputs(clientId: string): readonly ClientPluginCustomization[] { +- return this._clients.get(clientId)?.inputs ?? []; +- } +- +- /** +- * Re-issue each client's last sync if any of its previously-synced +- * customizations is currently in an error state. Used to recover from +- * transient sync failures (e.g. a `vscode-agent-host://` connection drop +- * during reconnection) at message boundaries. Re-syncs **only** the +- * errored items and always non-quiet so listeners observe recovery. +- */ +- public async retryFailedClientSyncIfNeeded(): Promise { +- await Promise.all([...this._clients.values()].map(client => client.sync.catch(() => { }))); +- for (const [clientId, client] of [...this._clients]) { +- const errored = client.customizations.filter(item => +- item.customization.load?.kind === CustomizationLoadStatus.Error +- && item.input !== undefined +- ); +- if (errored.length === 0) { +- continue; +- } +- const inputs = errored.map(item => item.input!); +- this._logService.info(`[Copilot:SessionPluginController] Retrying ${inputs.length} previously-failed client customization(s) for ${clientId}`); +- await this.sync(clientId, inputs).catch(err => { +- this._logService.warn('[Copilot:SessionPluginController] Retried client customization sync failed', err); +- }); +- } +- } +- - private _discoveredEntry(): SessionDiscoveredEntry | undefined { - if (!this._directory) { - return undefined; - } - if (!this._sessionDiscovered.value) { -- this._sessionDiscovered.value = new SessionDiscoveredEntry( -- this._directory, -- URI.file(this._parent.getUserHome()), +- this._sessionDiscovered.value = this._instantiationService.createInstance(SessionDiscoveredEntry, +- [this._directory, ...this._additionalDirectories], +- this._parent.getUserHome(), +- () => this._parent.getClient(), - () => this._onDidPublish.fire({ - type: ActionType.SessionCustomizationsChanged, - customizations: [...this.getCustomizations()], -- }), -- this._parent.logService, -- this._parent.instantiationService, +- }) - ); - } - return this._sessionDiscovered.value; - } - - private _isEnabled(customization: Customization): boolean { -- return this._enablement.get(customization.uri) ?? customization.enabled; +- return this._desiredEnabled(customization) ?? customization.enabled !== false; - } - - private _applyEnablement(customization: T): T { - const enabled = this._isEnabled(customization); -- return customization.enabled === enabled ? customization : { ...customization, enabled }; +- if (customization.type === CustomizationType.McpServer) { +- return customization.enabled === enabled ? customization : { ...customization, enabled }; +- } +- let changed = customization.enabled !== enabled; +- const children = customization.children?.map(child => { +- const desiredEnabled = this._desiredEnabled(child); +- if (desiredEnabled === undefined || desiredEnabled === child.enabled) { +- return child; +- } +- changed = true; +- return { ...child, enabled: desiredEnabled }; +- }); +- return changed ? { ...customization, enabled, children } : customization; +- } +- +- private _desiredEnabled(customization: Customization | ChildCustomization): boolean | undefined { +- const exact = this._getDesiredCustomization(customization.id); +- if (exact) { +- return exact.enabled; +- } +- if (!this._directory) { +- return undefined; +- } +- for (const previousDirectory of this._previousDirectories) { +- const previousUri = rebaseUnder(URI.parse(customization.uri), this._directory, previousDirectory); +- if (!previousUri) { +- continue; +- } +- const previousId = customizationId(previousUri.toString(), customization.range); +- const previous = this._getDesiredCustomization(previousId); +- if (previous) { +- return previous.enabled; +- } +- } +- return undefined; +- } +- +- private _getDesiredCustomization(id: string): Customization | ChildCustomization | undefined { +- const customizations = this._stateManager.getSessionState(this._session.toString())?.customizations; +- if (customizations !== this._indexedDesiredCustomizations) { +- this._indexedDesiredCustomizations = customizations; +- this._desiredCustomizationById.clear(); +- for (const customization of customizations ?? []) { +- this._desiredCustomizationById.set(customization.id, customization); +- if (customization.type !== CustomizationType.McpServer) { +- for (const child of customization.children ?? []) { +- this._desiredCustomizationById.set(child.id, child); +- } +- } +- } +- } +- return this._desiredCustomizationById.get(id); +- } +- +- /** +- * Projects a raw customization into its published form: applies reducer-backed +- * per-session enablement, then overlays the latest +- * known MCP runtime `state`/`channel` (see {@link mcpServerStates}). +- * Every publish path runs customizations through this so enablement and +- * live MCP state stay consistent. Object identity is preserved when +- * neither step changes anything, keeping downstream equality checks +- * stable. +- */ +- private _projectForPublish(customization: T): T { +- return this._overlayMcpState(this._applyEnablement(customization)); +- } +- +- /** +- * Overlays the latest known MCP runtime `state`/`channel` (see +- * {@link mcpServerStates}) onto a customization and its children, +- * preserving object identity when nothing is overlaid so downstream +- * equality checks stay stable. +- */ +- private _overlayMcpState(customization: T): T { +- const overlays = this.mcpServerStates.get(); +- if (overlays.size === 0) { +- return customization; +- } +- if (customization.type === CustomizationType.McpServer) { +- const overlay = overlays.get(customization.id); +- return overlay ? { ...customization, state: overlay.state, channel: overlay.channel } : customization; +- } +- const children = customization.children; +- if (!children || children.length === 0) { +- return customization; +- } +- let changed = false; +- const overlaidChildren = children.map(child => { +- if (child.type !== CustomizationType.McpServer) { +- return child; +- } +- const overlay = overlays.get(child.id); +- if (!overlay) { +- return child; +- } +- changed = true; +- return { ...child, state: overlay.state, channel: overlay.channel }; +- }); +- return changed ? { ...customization, children: overlaidChildren } : customization; +- } +-} +- +-/** +- * A per-(session, clientId) handle returned by +- * {@link CopilotAgent.getOrCreateActiveClient}. Reads/writes flow straight +- * through to the owning session's {@link ActiveClient} (the multi-client +- * container), so assigning `tools` / `customizations` updates only this +- * client's slice. +- */ +-class CopilotActiveClientHandle implements IActiveClient { +- constructor( +- private readonly _owner: ActiveClient, +- readonly clientId: string, +- readonly displayName: string | undefined, +- ) { } +- +- get tools(): readonly ToolDefinition[] { +- return this._owner.toolSet.get(this.clientId); +- } +- set tools(tools: readonly ToolDefinition[]) { +- this._owner.toolSet.set(this.clientId, tools); +- } +- +- get customizations(): readonly ClientPluginCustomization[] { +- return this._owner.pluginController.clientInputs(this.clientId); +- } +- set customizations(customizations: readonly ClientPluginCustomization[]) { +- // Fire-and-forget: progress and the settled result flow out via the +- // controller's `onDidPublish` session actions, not the setter. +- this._owner.pluginController.sync(this.clientId, [...customizations]).catch(() => { /* logged inside sync */ }); - } -} - -/** -- * Tracks per-session active client contributions (tools and plugins). -- * Owns the session's {@link SessionPluginController}, which is the -- * authoritative source for both the plugin snapshot (host + client + -- * session-discovered) and per-session action events. Disposing this -- * tears down the controller and any disk watchers it created. +- * Tracks per-session active client contributions (tools and plugins) across +- * potentially several active clients. Owns the session's +- * {@link SessionPluginController}, which is the authoritative source for both +- * the plugin snapshot (host + all clients + session-discovered) and +- * per-session action events, and the {@link ActiveClientToolSet} that merges +- * every client's tools. Disposing this tears down the controller and any disk +- * watchers it created. - */ -class ActiveClient extends Disposable { - /** -- * Live holder of the owning `clientId` and contributed tools. Shared by -- * reference with the session's {@link CopilotAgentSession} so a window -- * reload (new `clientId`, identical tools) is reflected at tool-call -- * stamp time without restarting the SDK session. +- * Live, multi-client registry of contributed tools. Shared by reference +- * with the session's {@link CopilotAgentSession} so a window reload (new +- * `clientId`, identical tools) is reflected at tool-call stamp time without +- * restarting the SDK session, and so tool calls are attributed to the +- * contributing client. - */ -- readonly state = new ActiveClientState(); +- readonly toolSet = new ActiveClientToolSet(); - - public readonly pluginController: SessionPluginController; - +- private readonly _handles = new Map(); +- - constructor( - private readonly _sessionUri: URI, - pluginController: SessionPluginController, @@ -3756,17 +5078,30 @@ index 3f37722b..00000000 - // Forward per-session publish events into the agent's progress - // stream. This replaces the previous clientId-based routing. - this._register(this.pluginController.onDidPublish(action => { -- onDidSessionProgress.fire({ kind: 'action', session: this._sessionUri, action }); +- onDidSessionProgress.fire({ kind: 'action', resource: this._sessionUri, action }); - })); - } - -- updateTools(clientId: string | undefined, tools: readonly ToolDefinition[]): void { -- this.state.update(clientId, tools); +- /** Get (or lazily create) the stable handle for `clientId`. */ +- getOrCreateHandle(clientId: string, displayName: string | undefined): CopilotActiveClientHandle { +- let handle = this._handles.get(clientId); +- if (!handle) { +- handle = new CopilotActiveClientHandle(this, clientId, displayName); +- this._handles.set(clientId, handle); +- } +- return handle; +- } +- +- /** Drop a client's tool and customization contributions from this session. */ +- removeClient(clientId: string): void { +- this._handles.delete(clientId); +- this.toolSet.delete(clientId); +- this.pluginController.removeClient(clientId); - } - - async snapshot(): Promise { - return { -- tools: this.state.tools, +- tools: this.toolSet.merged(), - plugins: await this.pluginController.getAppliedPlugins(), - mcpServers: this._getMcpServers(), - }; @@ -3781,9 +5116,9 @@ index 3f37722b..00000000 - /** - * Returns `true` when the SDK session must be disposed and resumed to - * pick up a changed config. Compares ONLY plugins and the structural -- * tool set (name + description + inputSchema). The `clientId` is -- * deliberately excluded — a clientId-only change is reflected live via -- * {@link state} and never requires a restart. +- * (merged) tool set (name + description + inputSchema). The owning +- * `clientId`s are deliberately excluded — a clientId-only change is +- * reflected live via {@link toolSet} and never requires a restart. - */ - async requiresRestart(snap: IActiveClientSnapshot): Promise { - const plugins = await this.pluginController.getAppliedPlugins(); @@ -3793,30 +5128,34 @@ index 3f37722b..00000000 - if (!equals(snap.mcpServers, this._getMcpServers())) { - return true; - } -- return !this.state.structuralEquals({ tools: snap.tools }); +- return !this.toolSet.structuralEquals(snap.tools); - } -} 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 a0c0a765..00000000 +index 5668c7f6..00000000 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ /dev/null -@@ -1,3141 +0,0 @@ +@@ -1,5364 +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 { CopilotSession, ExitPlanModeRequest, MessageOptions, PermissionRequestResult, SessionConfig, Tool, ToolResultObject, McpServerStatus as SdkMcpServerStatus } from '@github/copilot-sdk'; --import { DeferredPromise } from '../../../../base/common/async.js'; +-import type { CopilotSession, CurrentToolMetadata, ExitPlanModeRequest, McpServersLoadedServer, MessageOptions, PermissionAllowAllMode, PermissionAutoApproval, PermissionRequestResult, PermissionResult, SessionConfig, Tool, ToolResultObject, McpServerStatus as SdkMcpServerStatus } from '@github/copilot-sdk'; +-import { raceCancellation, RunOnceScheduler, Sequencer, Throttler } from '../../../../base/common/async.js'; -import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; +-import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; -import { Emitter } from '../../../../base/common/event.js'; -import { CancellationError, getErrorMessage } from '../../../../base/common/errors.js'; -import { escapeMarkdownSyntaxTokens } from '../../../../base/common/htmlContent.js'; --import { Disposable, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; +-import { Disposable, DisposableMap, IReference, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { Schemas } from '../../../../base/common/network.js'; +-import { isAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; +-import { safeStringify } from '../../../../base/common/objects.js'; -import { isAbsolute, join } from '../../../../base/common/path.js'; -import { extUriBiasedIgnorePathCase, normalizePath } from '../../../../base/common/resources.js'; +-import { StopWatch } from '../../../../base/common/stopwatch.js'; -import { splitLinesIncludeSeparators } from '../../../../base/common/strings.js'; -import { hasKey, isDefined, isObject, isString, type Mutable } from '../../../../base/common/types.js'; -import { URI } from '../../../../base/common/uri.js'; @@ -3825,40 +5164,56 @@ index a0c0a765..00000000 -import { INativeEnvironmentService } from '../../../environment/common/environment.js'; -import { IFileService } from '../../../files/common/files.js'; -import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; --import { ILogService } from '../../../log/common/log.js'; +-import { ILogService, LogLevel } from '../../../log/common/log.js'; -import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; --import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; +-import { getCopilotHomePath } from '../../common/copilotHome.js'; +-import { CopilotCliConfigKey, applyModelFamilyAlias, copilotCliConfigSchema } from '../../common/copilotCliConfig.js'; +-import type { ChatInputRequestWithPlanReview, IAgentHostPlanReviewAction } from '../../common/agentHostPlanReview.js'; +-import { gitHubMcpServerUrl } from '../../common/githubEndpoints.js'; -import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; --import { platformSessionSchema } from '../../common/agentHostSchema.js'; --import { AgentSignal, IMcpNotification } from '../../common/agentService.js'; +-import { AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyAnswer, AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js'; +-import { AgentSession, AgentSignal, AuthenticateParams, IMcpNotification, IRestoredSubagentSession, subagentChatTitle, type IAgentToolPendingConfirmationSignal } from '../../common/agentService.js'; +-import { META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js'; -import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; --import { toToolCallMeta, type IToolCallMeta, type IToolCallUiMeta } from '../../common/meta/agentToolCallMeta.js'; +-import { readToolCallMeta, toToolCallMeta, type IToolCallMeta, type IToolCallUiMeta, type IToolSearchCandidate } from '../../common/meta/agentToolCallMeta.js'; -import { OtelData, type OtelAttributeValue } from '../../common/otlp/otlpLogEmitter.js'; --import type { LanguageModelToolInvokedClassification, LanguageModelToolInvokedEvent } from '../../../telemetry/common/languageModelToolTelemetry.js'; -import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +-import { resolveCopilotConfigSlashCommandOnSend } from '../../common/copilotConfigSlashCommands.js'; +-import { STREAMING_TOOL_DISPLAY_INTERVAL_MS, streamingToolDisplayText } from '../../common/streamingToolCallDisplay.js'; -import { isAgentFeedbackAnnotationsAttachment, renderAgentFeedbackAnnotationsAttachment } from '../../common/meta/agentFeedbackAttachments.js'; -import { ISessionDatabase, ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../../common/sessionDataService.js'; --import { MessageAttachmentKind, ToolCallContributorKind, type FileEdit, type MessageAttachment } from '../../common/state/protocol/state.js'; --import { ActionType, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js'; --import { MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type PendingMessage, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest, type ToolCallResult, type ToolResultContent, type Turn, type UsageInfo, type UsageInfoMeta } from '../../common/state/sessionState.js'; +-import { MessageAttachmentKind, ToolCallContributorKind, type FileEdit, type MessageAttachment, type ToolCallContributor } from '../../common/state/protocol/state.js'; +-import { ActionType, isChatAction, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js'; +-import { MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolResultContentType, buildSubagentSessionUri, getToolSubagentContent, isDefaultChatUri, isSubagentSession, readSessionPromptCacheState, withSessionPromptCacheState, type Message, type PendingMessage, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest, type ToolCallResult, type ToolResultContent, type ToolResultTerminalContent, type Turn, type UsageInfo, type UsageInfoMeta, type IContextAttributionData, type ISessionPromptCacheState } from '../../common/state/sessionState.js'; -import { IAgentConfigurationService } from '../agentConfigurationService.js'; -import type { IExitPlanModeResponse } from './copilotAgent.js'; -import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; --import type { CopilotSessionLaunchPlan, IActiveClientSnapshot, ICopilotSessionLauncher, ICopilotSessionRuntime } from './copilotSessionLauncher.js'; --import { ActiveClientState } from '../activeClientState.js'; +-import { clientToolNamesFromSnapshot, type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from './copilotSessionLauncher.js'; +-import { agentHostModelSupportsToolSearch, CLIENT_TOOL_SEARCH_REFERENCE_NAME, NON_DEFERRED_CLIENT_TOOL_NAMES, RUNTIME_TOOL_SEARCH_TOOL_NAME } from './toolSearchDeferral.js'; +-import { ActiveClientToolSet } from '../activeClientState.js'; +-import { AgentHostTelemetryReporter } from '../agentHostTelemetryReporter.js'; +-import { AgentHostRepoInfoTelemetry } from '../agentHostRepoInfoTelemetry.js'; -import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; -import { buildCopilotSystemNotification } from './copilotSystemNotification.js'; --import { isPromptInvokedCopilotSlashCommand, isRuntimeCopilotSlashCommand, parseLeadingSlashCommand } from './copilotSlashCommandCompletionProvider.js'; +-import { parseLeadingSlashCommand } from '../../common/agentHostSlashCommand.js'; -import type { IUnsandboxedCommandConfirmationRequest, ShellManager } from './copilotShellTools.js'; --import { buildSandboxConfigForSdk } from './sandboxConfigForSdk.js'; +-import { NonPtyShellTerminalStreams } from './copilotNonPtyShellTerminals.js'; +-import { buildSandboxConfigForSdk, type ISdkSandboxConfig } from './sandboxConfigForSdk.js'; -import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; --import { getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool, isTaskCompleteTool, synthesizeSkillToolCall, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; +-import { getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellIntention, getShellLanguage, getStreamingInvocationMessage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isAgentCoordinationTool, isEditTool, isHiddenTool, isShellTool, isTaskCompleteTool, parseCopilotStreamingToolInput, synthesizeSkillToolCall, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; -import { FileEditTracker } from '../shared/fileEditTracker.js'; +-import { ICopilotApiService, type IRestrictedTelemetryContext } from '../shared/copilotApiService.js'; +-import type { IAgentHostRestrictedTelemetryContext } from '../agentHostRestrictedTelemetry.js'; -import { stripProxyErrorMarker, tryBuildChatErrorMeta, tryBuildChatErrorMetaFromFields } from '../shared/forwardedChatError.js'; --import { McpCustomizationController, type ISdkMcpServer } from '../shared/mcpCustomizationController.js'; --import { mapSessionEvents } from './mapSessionEvents.js'; +-import { getEffectiveMcpServerCustomizations, McpCustomizationController, type ISdkMcpServer } from '../shared/mcpCustomizationController.js'; +-import { appendSdkToolResultContent, mapSessionEvents } from './mapSessionEvents.js'; +-import { addSimpleAttachmentDisplayKindToMimeType } from './copilotAttachmentUtils.js'; -import { buildPendingEditContentUri } from './pendingEditContentStore.js'; --import { McpServerStatus, type McpServerState } from '../../common/state/protocol/channels-session/state.js'; +-import { AgentHostStateManager, IAgentHostStateManager } from '../agentHostStateManager.js'; +-import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; +-import { McpAuthRequiredReason, McpServerStatus, type McpAuthRequirement, type McpServerState } from '../../common/state/protocol/channels-session/state.js'; +-import type { ProtectedResourceMetadata } from '../../common/state/protocol/common/state.js'; +-import { CopilotSlashCommandProvider } from './copilotSlashCommandProvider.js'; - -/** - * The full set of agent modes the Copilot SDK accepts. AHP now exposes the @@ -3869,12 +5224,87 @@ index a0c0a765..00000000 -export type CopilotSdkMode = 'interactive' | 'plan' | 'autopilot'; -type CopilotSdkAttachment = Required['attachments'][number]; -type CopilotCommandInvocationResult = Awaited>; --type RuntimeSlashCommandCache = { readonly expiresAt: number; readonly promise: Promise> }; +-type RuntimeSlashCommandInfo = Awaited>['commands'][number]; +-type McpAuthHandler = NonNullable; +-type McpAuthRequest = Parameters[0]; +-type McpAuthResult = Awaited>; +- +-interface IPendingMcpAuthRequest { +- readonly serverName: string; +- readonly resource: ProtectedResourceMetadata; +- readonly requiredScopes: readonly string[]; +- readonly toolCalls: IMcpAuthToolCall[]; +-} - --const COPILOT_HOME_DIRECTORY = '.copilot'; --const SESSION_STATE_DIRECTORY = join(COPILOT_HOME_DIRECTORY, 'session-state'); +-interface IMcpAuthToolCall { +- readonly turnId: string; +- readonly toolCallId: string; +- readonly parentToolCallId: string | undefined; +-} +- +-interface ICopilotActiveToolCall { +- readonly toolName: string; +- readonly displayName: string; +- readonly parameters: Record | undefined; +- readonly content: ToolResultContent[]; +- readonly parentToolCallId: string | undefined; +- readonly mcpServerName: string | undefined; +- readonly contributor: ToolCallContributor | undefined; +- readonly intention: string | undefined; +- meta: IToolCallMeta | undefined; +-} +- +-interface ICopilotStreamingToolCall { +- input: string; +- toolName: string | undefined; +- parentToolCallId: string | undefined; +- started: boolean; +- displayedInputLength: number; +- displayedMessage: string | undefined; +-} +- +-const SESSION_STATE_DIRECTORY = 'session-state'; -const EMPTY_TOOL_RESULT_TEXT = ''; --const RUNTIME_SLASH_COMMAND_CACHE_TTL_MS = 30_000; +- +-function isPermissionDeniedKind(kind: PermissionResult['kind'] | undefined): boolean { +- switch (kind) { +- case 'cancelled': +- case 'denied-by-rules': +- case 'denied-no-approval-rule-and-could-not-request-from-user': +- case 'denied-interactively-by-user': +- case 'denied-by-content-exclusion-policy': +- case 'denied-by-permission-request-hook': +- return true; +- default: +- return false; +- } +-} +- +-function mapPermissionResultToConfirmKind(kind: PermissionResult['kind'] | undefined, resolvedByHook: boolean): 'userAction' | 'setting' | 'confirmationNotNeeded' | 'denied' { +- if (kind === undefined) { +- return 'confirmationNotNeeded'; +- } +- if (isPermissionDeniedKind(kind)) { +- return 'denied'; +- } +- if (kind === 'approved-for-session' || kind === 'approved-for-location') { +- return 'setting'; +- } +- return resolvedByHook ? 'confirmationNotNeeded' : 'userAction'; +-} +- +- +-function normalizeMcpServerUrl(value: string): string | undefined { +- if (!URL.canParse(value)) { +- return undefined; +- } +- const url = new URL(value); +- url.hash = ''; +- url.pathname = url.pathname.replace(/\/+$/, ''); +- return url.href; +-} +- +-type IMappedSessionEvents = { turns: Turn[]; subagentTurnsByToolCallId: ReadonlyMap }; - -function getEmptyToolResultText(binaryResults: readonly { readonly type: 'image' | 'resource' }[] | undefined): string { - if (!binaryResults?.length) { @@ -3901,12 +5331,12 @@ index a0c0a765..00000000 - case 'autopilot': - return { - label: localize('agentHost.planReview.autopilot.label', "Implement with Autopilot"), -- description: localize('agentHost.planReview.autopilot.description', "Auto-approve all tool calls and continue until done."), +- description: localize('agentHost.planReview.autopilot.description', "Continue autonomously until done, using the selected approval level."), - }; - case 'autopilot_fleet': - return { - label: localize('agentHost.planReview.autopilotFleet.label', "Implement with Autopilot Fleet"), -- description: localize('agentHost.planReview.autopilotFleet.description', "Auto-approve all tool calls, including fleet management actions, and continue until done."), +- description: localize('agentHost.planReview.autopilotFleet.description', "Continue autonomously with fleet management, using the selected approval level."), - }; - case 'interactive': - return { @@ -3943,6 +5373,7 @@ index a0c0a765..00000000 -} - -function toCopilotSdkMode(mode: string | undefined): CopilotSdkMode | undefined { +- mode = mode?.toLowerCase() === 'goal' ? 'plan' : mode; - switch (mode) { - case 'interactive': - case 'plan': @@ -4062,8 +5493,7 @@ index a0c0a765..00000000 -} - -function getCopilotCLISessionStateDir(userHome: string): string { -- const xdgHome = process.env['XDG_STATE_HOME']; -- return xdgHome ? join(xdgHome, SESSION_STATE_DIRECTORY) : join(userHome, SESSION_STATE_DIRECTORY); +- return join(getCopilotHomePath(userHome, process.env), SESSION_STATE_DIRECTORY); -} - -/** @@ -4097,6 +5527,7 @@ index a0c0a765..00000000 - */ -export interface ICopilotAgentSessionOptions { - readonly sessionUri: URI; +- readonly chatChannelUri: URI; - readonly rawSessionId: string; - readonly onDidSessionProgress: Emitter; - readonly sessionLauncher: ICopilotSessionLauncher; @@ -4117,19 +5548,40 @@ index a0c0a765..00000000 - */ - readonly resolveMcpChildId: (serverName: string) => string | undefined; - /** -- * Live holder of the owning client's identity, shared by reference with -- * the agent's per-session {@link ActiveClient}. Read at tool-call stamp -- * time so a window reload (new `clientId`, identical tools) stamps with -- * the current id. When omitted, a fresh state seeded from -- * {@link clientSnapshot} is used (test / standalone path). +- * Live registry of every active client's tool contributions, shared by +- * reference with the agent's per-session {@link ActiveClient}. Read at +- * tool-call stamp time so a window reload (new `clientId`, identical +- * tools) stamps with the current owning id, and so each tool call is +- * attributed to whichever client contributed it. When omitted, a fresh +- * empty registry is used (test / standalone path) and client tool calls +- * are left unstamped. - */ -- readonly activeClientState?: ActiveClientState; +- readonly activeClientToolSet?: ActiveClientToolSet; - /** - * Server-side host for the agent host's server tools. When provided, the - * session advertises the server tools (feedback "comments" today, more in - * the future) and exposes SDK tool handlers that execute them in-process. - */ - readonly serverToolHost?: IAgentServerToolHost; +- /** Returns whether the token that launched this session is still the active account token. */ +- readonly isLaunchTokenCurrent?: () => boolean; +- +- /** +- * Invoked whenever this chat's in-flight turn ends — normal completion, +- * abort, or error — leaving the chat idle. Lets the agent run work that +- * must not interrupt a live turn, notably a CLI client restart deferred +- * while the turn was running. Called synchronously from the session's SDK +- * event handling, so the agent must schedule anything that could dispose +- * this session off the current stack. +- */ +- readonly onTurnEnded?: () => void; +- +- /** +- * Platform used to compute the SDK sandbox policy. Defaults to +- * `process.platform`; injectable so tests can exercise the per-OS gating +- * (notably that the sandbox is ignored on Windows) deterministically. +- */ +- readonly platform?: NodeJS.Platform; -} - -/** @@ -4158,12 +5610,73 @@ index a0c0a765..00000000 - * completes a `pending` turn defensively, so a degenerate no-op send cannot - * hang the session. - */ +- +-/** +- * The token/model/cost context for a single model call, used to build a +- * `UsageInfo`. All fields are optional so a partial or empty context (e.g. a +- * subagent usage event seen before the parent's own context) is representable. +- */ +-interface UsageContext { +- inputTokens?: number; +- outputTokens?: number; +- model?: string; +- cacheReadTokens?: number; +- cost?: number; +-} +- +-/** Which SDK source produced an MCP lifecycle log record. */ +-type McpLifecycleOrigin = 'loaded' | 'statusChanged' | 'inventory'; +- +-/** +- * SDK-neutral fields carried into a single MCP lifecycle log record. The +- * `session.mcp_servers_loaded` event, the `session.mcp_server_status_changed` +- * event, and the `rpc.mcp.list` inventory each populate the subset they carry. +- */ +-interface IMcpLifecycleLogInfo { +- readonly name: string; +- readonly status: SdkMcpServerStatus; +- readonly error?: string; +- readonly source?: string; +- readonly transport?: string; +- readonly pluginName?: string; +- readonly pluginVersion?: string; +-} +- -class CopilotTurn { - - private _state: CopilotTurnState = 'pending'; +- private readonly _stopWatch = StopWatch.create(false); +- +- /** +- * This turn's own Copilot cost in nano-AIU, summed from the `copilotUsage` +- * carried by the model calls the turn caused — its own, every subagent's, +- * and any compaction that ran mid-turn. +- * +- * Accumulated synchronously as each event arrives rather than derived from +- * the SDK's session-wide total: that total is read asynchronously, and the +- * terminal `session.idle` can close the turn while a read is in flight, +- * which would drop the turn's last model call from its reported cost. +- */ +- copilotNanoAiu = 0; +- +- /** +- * Per-subagent component cost, in nano-AIU, keyed by `parentToolCallId`. +- * The SDK's session metrics are session-wide and carry no per-agent +- * breakdown, so a subagent's own running total is still accumulated from +- * its usage events in order to report it on the subagent's child session. +- */ +- readonly subagentNanoAiuByToolCallId = new Map(); - -- /** Accumulated Copilot usage for this turn, in nano-AIU. */ -- copilotUsageTotalNanoAiu = 0; +- /** +- * The parent (main-agent) turn's own last context usage — model plus token +- * counts and per-event cost. A subagent's model call contributes to the +- * turn's credits (the SDK's session metrics already include it) but must not +- * overwrite the parent turn's model/context-token usage. Retaining the +- * parent's own last values lets each subagent usage event refresh the parent +- * aggregate's credit total while preserving the model that produced the +- * parent response. +- */ +- parentContextUsage: UsageContext | undefined; - - /** - * Current markdown response part IDs for this turn, keyed by @@ -4176,11 +5689,31 @@ index a0c0a765..00000000 - /** Current reasoning response part IDs for this turn, keyed by `parentToolCallId ?? ''`. */ - readonly reasoningPartIds = new Map(); - -- constructor(readonly id: string) { } +- /** +- * Per-turn tool-call aggregate accumulated across the turn's `assistant.message` rounds (main +- * agent only), for the restricted `toolCallDetails` telemetry. `toolCounts` is keyed by tool name. +- */ +- readonly toolCounts = new Map(); +- toolCallRounds = 0; +- totalToolCalls = 0; +- parallelToolCallRounds = 0; +- parallelToolCallsTotal = 0; +- toolCallDetailsReported = false; +- messageCharLen: number | undefined; +- /** Model of the most recent round, reported as the turn's model. */ +- lastModel: string | undefined; +- +- constructor( +- readonly id: string, +- readonly ordinal: number, +- readonly senderClientId: string | undefined, +- readonly clientType: AgentHostClientType, +- ) { } - - get state(): CopilotTurnState { return this._state; } - get isPending(): boolean { return this._state === 'pending'; } - get isRunning(): boolean { return this._state === 'running'; } +- get duration(): number { return Math.max(0, this._stopWatch.elapsed()); } - - /** Transition `pending → running` on the first SDK event. No-op once running/finished. */ - markRunning(): void { @@ -4203,35 +5736,68 @@ index a0c0a765..00000000 -export class CopilotAgentSession extends Disposable { - readonly sessionId: string; - readonly sessionUri: URI; +- private readonly _chatChannelUri: URI; - - /** Working directory this session operates in, if any. */ - get workingDirectory(): URI | undefined { return this._workingDirectory; } - - /** Tracks active tool invocations so we can produce past-tense messages on completion. */ -- /** Tracks active tool invocations so we can produce past-tense messages on completion. */ -- private readonly _activeToolCalls = new Map | undefined; content: ToolResultContent[]; parentToolCallId: string | undefined; startTimeMs: number; mcpServerName: string | undefined; meta: IToolCallMeta | undefined }>(); +- private readonly _activeToolCalls = new Map(); +- private readonly _streamingToolCalls = new Map(); +- private readonly _streamingToolDisplaySchedulers = this._register(new DisposableMap()); - /** -- * Maps a running subagent's `agentId` to its parent tool call id. Session- -- * scoped rather than per-turn: a subagent's lifetime is bounded by its -- * `subagent.started` / `subagent.completed` events (and background -- * subagents can outlive the parent tool call), so this routing must not be -- * cleared on turn boundaries. +- * Maps a subagent's stable `agentId` to its parent tool call id. Completion +- * ends the current subagent turn, but steering can start another turn with +- * the same id, so mappings live until session teardown. - */ - private readonly _parentToolCallIdsByAgentId = new Map(); +- private readonly _activeSubagentAgentIds = new Set(); +- private readonly _unroutableSubagentToolCallIds = new Set(); +- private readonly _autoApprovals = new Map(); +- private readonly _pendingAutoApprovals = new PendingRequestRegistry(); +- /** Correlates tool execution with the SDK permission lifecycle for `chat.toolApproval` telemetry. */ +- private readonly _toolApprovalRecords = new Map(); - /** Pending permission requests awaiting a renderer-side decision. */ -- private readonly _pendingPermissions = new Map>(); +- private readonly _pendingPermissions = new PendingRequestRegistry(); +- /** Cancels callbacks that began before or during an SDK abort. */ +- private readonly _abortCts = this._register(new MutableDisposable()); +- /** +- * Signatures ({@link safeStringify}) of user-approved `read`/`write` +- * permission requests, keyed by tool call id. The Copilot CLI runtime emits +- * two identical `permission.requested` events for a single file read or +- * write (an internal `path` prompt followed by a `read`/`write` prompt), so +- * without this the user would be asked to approve the same operation twice +- * (issue #324477). An entry is single-use: it auto-approves exactly one +- * subsequent request that is byte-identical to the approved one, then is +- * removed, so approval never carries across a different tool call, a changed +- * path/diff/contents, or a different kind. +- */ +- private readonly _approvedDuplicablePermissionSignatures = new Map(); - /** Pending user input requests awaiting a renderer-side answer. */ -- private readonly _pendingUserInputs = new Map }>; questionId: string }>(); +- private readonly _pendingUserInputs = new PendingRequestRegistry< +- { response: ChatInputResponseKind; answers?: Record }, +- { questionId: string } +- >(); - /** - * Pending elicitation requests awaiting a renderer-side answer. Keyed - * by request id; the schema is retained so the completion handler can - * project the submitted {@link ChatInputAnswer}s back into the - * SDK's {@link ElicitationResult.content} shape. - */ -- private readonly _pendingElicitations = new Map }>; -- readonly schema: ElicitationSchema | undefined; -- }>(); +- private readonly _pendingElicitations = new PendingRequestRegistry< +- { response: ChatInputResponseKind; answers?: Record }, +- { schema: ElicitationSchema | undefined } +- >(); - /** - * Pending plan-review requests originating from the CLI's - * `exitPlanMode.request` RPC. Tracked separately from @@ -4240,12 +5806,14 @@ index a0c0a765..00000000 - * forwards to `session.respondToExitPlanMode`) rather than feeding it - * back through the SDK's `ask_user` callback. - */ -- private readonly _pendingPlanReviews = new Map; -- }>(); +- private readonly _pendingPlanReviews = new PendingRequestRegistry< +- IExitPlanModeResponse, +- { +- readonly actions: readonly string[]; +- readonly recommendedAction: string; +- readonly questionId: string; +- } +- >(); - /** File edit tracker for this session. */ - private readonly _editTracker: FileEditTracker; - /** Session database reference. */ @@ -4261,11 +5829,21 @@ index a0c0a765..00000000 - * {@link resetTurnState}, finalized by {@link _completeActiveTurn}. - */ - private _currentTurn: CopilotTurn | undefined; +- /** Monotonic 0-based ordinal assigned to each turn as it starts, for numeric `turnIndex` telemetry parity. */ +- private _nextTurnOrdinal = 0; - /** - * Protocol turn ID of the active turn, or `''` when idle. Used by file - * edit tracking and emitted on per-turn actions. - */ - private get _turnId(): string { return this._currentTurn?.id ?? ''; } +- /** 0-based ordinal of the active turn within the session, or `0` when idle. */ +- private get _turnOrdinal(): number { return this._currentTurn?.ordinal ?? 0; } +- /** +- * Whether the session currently has an in-flight turn. Used by +- * non-destructive idle release to avoid disconnecting mid-turn. +- */ +- get hasActiveTurn(): boolean { return this._currentTurn !== undefined; } +- get currentTurnClientType(): AgentHostClientType { return this._currentTurn?.clientType ?? AgentHostClientType.Unknown; } - /** - * Last model id seen on the SDK's per-LLM-call `Usage` event (or a - * direct {@link setModel} call). We rely on the @@ -4274,11 +5852,39 @@ index a0c0a765..00000000 - * an LLM turn precedes that turn's `tool_use` events. - */ - private _lastSeenModelId: string | undefined; +- /** +- * Latest session-wide nano-AIU total reported by the SDK's usage metrics +- * (`rpc.usage.getMetrics`), which is authoritative for what the session as a +- * whole has been billed: it folds in every model call plus compaction, +- * covers work billed while no turn was active, and survives resume. +- * +- * Deliberately *not* used to derive per-turn cost. It is session-scoped and +- * read asynchronously, so differencing it against a previous reading races +- * turn boundaries — the SDK's terminal `session.idle` can close a turn while +- * a read is still in flight. Per-turn cost comes from the synchronous +- * per-event `copilotUsage` instead (see {@link CopilotTurn.copilotNanoAiu}). +- */ +- private _sessionTotalNanoAiu = 0; +- private _promptCacheState: ISessionPromptCacheState | undefined; +- private _promptCacheRefreshGeneration = 0; +- /** +- * Serializes the metrics reads behind {@link _refreshSessionUsageMetrics}. Several +- * handlers refresh the total, so without this their RPCs overlap and an older +- * one resolving last would publish a session cost that visibly regresses. A +- * high-water mark cannot be used to reject stale reads instead, because the +- * total is legitimately non-monotonic (see the truncation note below). Keeping +- * one read in flight makes out-of-order resolution impossible, and coalesces +- * the redundant reads that a burst of usage events would otherwise issue. +- */ +- private readonly _sessionUsageMetricsRefreshThrottler = this._register(new Throttler()); - /** SDK session wrapper, set by {@link initializeSession}. */ - private _wrapper!: CopilotSessionWrapper; -- private _runtimeSlashCommandCache: RuntimeSlashCommandCache | undefined; +- private readonly _slashCommandProvider: CopilotSlashCommandProvider; - /** Last agent mode pushed to the SDK via {@link applyMode}, to elide redundant `rpc.mode.set` calls. */ - private _lastAppliedMode: CopilotSdkMode | undefined; +- private _lastAppliedPermissionMode: PermissionAllowAllMode | undefined; +- private _autoApprovalExperimentalModeEnabled = false; +- private readonly _permissionModeSequencer = new Sequencer(); - private readonly _steeringMessagesInFlight = new Set(); - /** - * Steering messages that have been accepted by the SDK but not yet @@ -4303,11 +5909,15 @@ index a0c0a765..00000000 - * subsequent client tool calls with the current id rather than the one - * frozen into {@link _appliedSnapshot}. - */ -- private readonly _activeClientState: ActiveClientState; +- private readonly _activeClientToolSet: ActiveClientToolSet; - /** Tool names that are client-provided, derived from snapshot. */ - private readonly _clientToolNames: ReadonlySet; +- /** Launch-time tool-search decision; kept stable for the lifetime of the SDK session. */ +- private readonly _toolSearchActive: boolean; - /** Deferred promises for pending client tool calls, keyed by toolCallId. */ - private readonly _pendingClientToolCalls = new PendingRequestRegistry(); +- /** Pending SDK MCP auth handler promises, keyed by SDK auth request id. */ +- private readonly _pendingMcpAuthRequests = new PendingRequestRegistry(); - /** `pending-edit-content:` URIs written during permission requests, keyed - * by toolCallId. Cleaned up when the permission resolves or the session - * is disposed. */ @@ -4316,13 +5926,22 @@ index a0c0a765..00000000 - private readonly _onDidSessionProgress: Emitter; - private readonly _sessionLauncher: ICopilotSessionLauncher; - private readonly _launchPlan: CopilotSessionLaunchPlan; +- private readonly _isLaunchTokenStillCurrent: () => boolean; +- /** Notifies the agent that this chat's turn ended. See {@link ICopilotAgentSessionOptions.onTurnEnded}. */ +- private readonly _onTurnEnded: () => void; - private readonly _shellManager: ShellManager | undefined; +- /** Streams runtime-executed shell output into output-only (non-pty) terminal channels. */ +- private readonly _nonPtyShellTerminals: NonPtyShellTerminalStreams; - private readonly _workingDirectory: URI | undefined; - private readonly _customizationDirectory: URI | undefined; - private readonly _serverToolHost: IAgentServerToolHost | undefined; - /** Bridges SDK-reported MCP server state into AHP customization actions. */ - private readonly _mcpCustomizations: McpCustomizationController; - +- private get _storageUri(): URI { +- return isDefaultChatUri(this._chatChannelUri) ? this.sessionUri : this._chatChannelUri; +- } +- - /** - * Fans MCP server notifications (today: `notifications/tools/list_changed`) - * up to the agent and on to the protocol server. Fired by the @@ -4342,7 +5961,31 @@ index a0c0a765..00000000 - private readonly _pendingMcpSamplings = new Set(); - - /** Tracks whether a non-empty activity has been published, so we only emit a clear when needed. */ -- private _hasReportedActivity = false; +- private _hasActivity = false; +- +- /** +- * Last SDK-reported MCP status logged for each server (keyed by server +- * name). Used to suppress duplicate lifecycle log records when the SDK +- * re-reports an unchanged status — the `rpc.mcp.list` seed and the +- * `session.mcp_servers_loaded` event routinely carry the same snapshot. +- */ +- private readonly _lastLoggedMcpStatus = new Map(); +- +- /** Platform used to compute the SDK sandbox policy (injectable for tests). */ +- private readonly _platform: NodeJS.Platform; +- +- get mcpServerStates() { +- return this._mcpCustomizations.runtimeStates; +- } +- +- /** Stateless reporter used to emit restricted GH/MSFT telemetry for this session's model calls. */ +- private readonly _telemetryReporter: AgentHostTelemetryReporter; +- private readonly _repoInfoTelemetry: AgentHostRepoInfoTelemetry; +- private _activeRepoInfoTurn: { +- readonly telemetryMessageId: string; +- cancelled: boolean; +- begin: Promise<{ readonly context: IAgentHostRestrictedTelemetryContext; readonly baseBranch: string | undefined } | undefined>; +- } | undefined; - - constructor( - options: ICopilotAgentSessionOptions, @@ -4352,51 +5995,63 @@ index a0c0a765..00000000 - @IFileService private readonly _fileService: IFileService, - @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService, - @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, +- @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, - @ITelemetryService private readonly _telemetryService: ITelemetryService, +- @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, - ) { - super(); +- this._abortCts.value = new CancellationTokenSource(); - this.sessionId = options.rawSessionId; - this.sessionUri = options.sessionUri; +- this._slashCommandProvider = new CopilotSlashCommandProvider(() => this._wrapper.session.rpc.commands.list({ includeBuiltins: true, includeSkills: true, includeClientCommands: true }).then(c => c.commands), this._logService); +- this._chatChannelUri = options.chatChannelUri; - this._onDidSessionProgress = options.onDidSessionProgress; - this._sessionLauncher = options.sessionLauncher; - this._launchPlan = options.launchPlan; +- this._isLaunchTokenStillCurrent = options.isLaunchTokenCurrent ?? (() => true); +- this._onTurnEnded = options.onTurnEnded ?? (() => { }); - this._shellManager = options.shellManager; +- this._nonPtyShellTerminals = this._register(this._instantiationService.createInstance(NonPtyShellTerminalStreams, options.sessionUri)); - this._workingDirectory = options.workingDirectory; - this._customizationDirectory = options.customizationDirectory; - this._serverToolHost = options.serverToolHost; +- this._platform = options.platform ?? process.platform; +- this._telemetryReporter = new AgentHostTelemetryReporter(this._telemetryService); +- this._repoInfoTelemetry = this._register(this._instantiationService.createInstance(AgentHostRepoInfoTelemetry, this._telemetryReporter)); - - this._appliedSnapshot = options.clientSnapshot ?? { tools: [], plugins: [], mcpServers: {} }; -- this._clientToolNames = new Set(this._appliedSnapshot.tools.map(t => t.name)); -- // Share the agent's live ActiveClientState when provided so clientId -- // changes are observed at stamp time. Standalone / test construction -- // seeds a private instance with the applied tools and no owning client. -- if (options.activeClientState) { -- this._activeClientState = options.activeClientState; -- } else { -- this._activeClientState = new ActiveClientState(); -- this._activeClientState.update(undefined, this._appliedSnapshot.tools); -- } -- -- this._databaseRef = sessionDataService.openDatabase(options.sessionUri); +- this._clientToolNames = clientToolNamesFromSnapshot(this._appliedSnapshot); +- const model = this._launchPlan.kind === 'create' ? this._launchPlan.model : this._launchPlan.fallback.model; +- // Capability decisions use the family-aliased selection so an aliased +- // preview model agrees with the launcher's tool-search gating (which +- // also aliases before checking); the wire model id is unaffected. +- const effectiveModel = applyModelFamilyAlias(model, this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.ModelCapabilityOverrides)); +- this._toolSearchActive = this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.ToolSearchEnabled) === true +- && agentHostModelSupportsToolSearch(effectiveModel?.id) +- && this._clientToolNames.has(CLIENT_TOOL_SEARCH_REFERENCE_NAME); +- // Share the agent's live ActiveClientToolSet when provided so client +- // contributions (and owner identity) are observed at stamp time. +- // Standalone / test construction uses a fresh empty registry, which +- // leaves client tool calls unstamped (no owning client). +- this._activeClientToolSet = options.activeClientToolSet ?? new ActiveClientToolSet(); +- +- this._databaseRef = sessionDataService.openDatabase(this._storageUri); - this._register(toDisposable(() => this._databaseRef.dispose())); -- this._sessionDataDir = sessionDataService.getSessionDataDir(options.sessionUri); +- this._sessionDataDir = sessionDataService.getSessionDataDir(this._storageUri); - -- this._editTracker = this._instantiationService.createInstance(FileEditTracker, options.sessionUri.toString(), this._databaseRef.object); +- this._editTracker = this._instantiationService.createInstance(FileEditTracker, this._storageUri.toString(), this._databaseRef.object); - -- this._mcpCustomizations = this._register(new McpCustomizationController({ +- this._mcpCustomizations = this._register(this._instantiationService.createInstance(McpCustomizationController, { - providerId: this.sessionUri.scheme, - sessionId: this.sessionId, +- sessionUri: this.sessionUri, - resolveChildId: options.resolveMcpChildId, - emit: action => this._emitAction(action), - })); - -- this._register(toDisposable(() => this._denyPendingPermissions())); +- this._register(toDisposable(() => this._cancelAllPendingInteractions())); - this._register(toDisposable(() => this._shellManager?.dispose())); -- this._register(toDisposable(() => this._cancelPendingUserInputs())); -- this._register(toDisposable(() => this._cancelPendingElicitations())); -- this._register(toDisposable(() => this._cancelPendingPlanReviews())); - this._register(toDisposable(() => this._drainPendingSteeringFlips())); -- this._register(toDisposable(() => this._cancelPendingMcpSamplings())); - - // When a shell tool associates a terminal with a tool call, fire a - // tool_content_changed event so the UI can connect to the terminal @@ -4422,16 +6077,16 @@ index a0c0a765..00000000 - }); - })); - } -- this._register(toDisposable(() => this._cancelPendingClientToolCalls())); - } - - // ---- AgentSignal helpers ------------------------------------------------ - - /** Wraps a {@link SessionAction} in an {@link AgentSignal} envelope and emits it. */ +- /** todo@connor4312: AHP is missing a chat activity update action which is needed to drop `SessionAction` here */ - private _emitAction(action: SessionAction | ChatAction, parentToolCallId?: string): void { - this._onDidSessionProgress.fire({ - kind: 'action', -- session: this.sessionUri, +- resource: isChatAction(action) ? this._chatChannelUri : this.sessionUri, - action, - parentToolCallId, - }); @@ -4455,17 +6110,12 @@ index a0c0a765..00000000 - * history.truncate / sessions.fork mapping. - */ - private _beginSteeringTurn(steering: PendingMessage): string { -- const previousTurnId = this._turnId; -- if (previousTurnId) { -- this._emitAction({ -- type: ActionType.ChatTurnComplete, -- turnId: previousTurnId, -- }); -- } +- this._completeActiveTurn(); - const newTurnId = generateUuid(); - this._emitAction({ - type: ActionType.ChatTurnStarted, - turnId: newTurnId, +- startedAt: new Date().toISOString(), - message: steering.message, - queuedMessageId: steering.id, - }); @@ -4477,7 +6127,10 @@ index a0c0a765..00000000 - // `pending`, otherwise an abort during the steering turn would treat it - // as a not-yet-started queued turn and leave it open. - this.resetTurnState(newTurnId); -- this._currentTurn?.markRunning(); +- if (this._currentTurn) { +- this._currentTurn.messageCharLen = steering.message.text.length; +- this._currentTurn.markRunning(); +- } - return newTurnId; - } - @@ -4496,7 +6149,7 @@ index a0c0a765..00000000 - for (const id of ids) { - this._onDidSessionProgress.fire({ - kind: 'steering_consumed', -- session: this.sessionUri, +- chat: this._chatChannelUri, - id, - }); - } @@ -4528,6 +6181,42 @@ index a0c0a765..00000000 - return e.agentId ? this._parentToolCallIdsByAgentId.get(e.agentId) : undefined; - } - +- private _resumeSubagentForEvent(e: { readonly agentId?: string }, message?: Message): void { +- if (!e.agentId || this._activeSubagentAgentIds.has(e.agentId)) { +- return; +- } +- const parentToolCallId = this._parentToolCallIdsByAgentId.get(e.agentId); +- if (!parentToolCallId) { +- return; +- } +- this._activeSubagentAgentIds.add(e.agentId); +- this._onDidSessionProgress.fire({ +- kind: 'subagent_resumed', +- chat: this._chatChannelUri, +- toolCallId: parentToolCallId, +- message, +- }); +- } +- +- private _completeSubagentTurn(agentId: string | undefined, toolCallId?: string): void { +- if (agentId) { +- if (!this._activeSubagentAgentIds.delete(agentId)) { +- return; +- } +- } else if (!toolCallId) { +- return; +- } +- const parentToolCallId = toolCallId ?? (agentId ? this._parentToolCallIdsByAgentId.get(agentId) : undefined); +- if (!parentToolCallId) { +- return; +- } +- this._onDidSessionProgress.fire({ +- kind: 'subagent_completed', +- chat: this._chatChannelUri, +- toolCallId: parentToolCallId, +- }); +- } +- - private _shouldDropUnmappedSubagentEvent(e: { readonly agentId?: string }, eventName: string): boolean { - const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); - if (!parentToolCallId && e.agentId) { @@ -4537,61 +6226,267 @@ index a0c0a765..00000000 - return false; - } - -- /** -- * Starts a fresh `pending` turn, discarding any per-turn streaming state -- * from a previous turn so the next text/reasoning chunk allocates a new -- * response part. The turn becomes `running` on the first SDK event. -- */ -- resetTurnState(turnId: string): void { -- this._currentTurn = new CopilotTurn(turnId); +- private _getToolCallContributor(toolName: string, mcpServerName: string | undefined): ToolCallContributor | undefined { +- const clientToolName = this._clientToolName(toolName); +- if (this._clientToolNames.has(clientToolName)) { +- const clientId = this._activeClientToolSet.ownerOf(clientToolName, this._currentTurn?.senderClientId); +- return clientId ? { kind: ToolCallContributorKind.Client, clientId } : undefined; +- } +- if (mcpServerName) { +- const customizationId = this._mcpCustomizations.customizationIdForServer(mcpServerName); +- return customizationId ? { kind: ToolCallContributorKind.MCP, customizationId } : undefined; +- } +- return undefined; - } - -- private _completeActiveTurn(): void { -- const turn = this._currentTurn; -- if (!turn) { +- private _createToolCallMeta(toolName: string, parameters: Record | undefined): Mutable { +- const toolKind = getToolKind(toolName); +- const subagentMeta = toolKind === 'subagent' ? getSubagentMetadata(parameters) : undefined; +- return { +- toolKind, +- language: toolKind === 'terminal' ? getShellLanguage(toolName) : undefined, +- subagentDescription: subagentMeta?.description, +- subagentAgentName: subagentMeta?.agentName, +- }; +- } +- +- private _getStreamingToolCallDisplay(toolName: string, input: string) { +- const partialInput = parseCopilotStreamingToolInput(input); +- const parameters = partialInput !== null && typeof partialInput === 'object' && !Array.isArray(partialInput) +- ? partialInput as Record +- : undefined; +- return { +- parameters, +- meta: this._createToolCallMeta(toolName, parameters), +- invocationMessage: getStreamingInvocationMessage(toolName, getToolDisplayName(toolName), partialInput, path => this._resolveEditFilePath(path)), +- }; +- } +- +- private _emitStreamingToolCallDisplay(toolCallId: string, streaming: ICopilotStreamingToolCall): void { +- if (!streaming.toolName) { - return; - } -- turn.markCompleted(); +- const display = this._getStreamingToolCallDisplay(streaming.toolName, streaming.input); +- streaming.displayedInputLength = streaming.input.length; +- const message = streamingToolDisplayText(display.invocationMessage); +- if (message === streaming.displayedMessage) { +- return; +- } +- streaming.displayedMessage = message; - this._emitAction({ -- type: ActionType.ChatTurnComplete, -- turnId: turn.id, -- }); -- this._currentTurn = undefined; +- type: ActionType.ChatToolCallDelta, +- turnId: this._turnId, +- toolCallId, +- content: '', +- invocationMessage: display.invocationMessage, +- _meta: toToolCallMeta(display.meta), +- }, streaming.parentToolCallId); +- } +- +- private _scheduleStreamingToolCallDisplay(toolCallId: string): void { +- let scheduler = this._streamingToolDisplaySchedulers.get(toolCallId); +- if (!scheduler) { +- scheduler = new RunOnceScheduler(() => { +- const streaming = this._streamingToolCalls.get(toolCallId); +- if (!streaming?.started || !streaming.toolName) { +- return; +- } +- if (streaming.displayedInputLength === streaming.input.length) { +- return; +- } +- this._emitStreamingToolCallDisplay(toolCallId, streaming); +- }, STREAMING_TOOL_DISPLAY_INTERVAL_MS); +- this._streamingToolDisplaySchedulers.set(toolCallId, scheduler); +- } +- if (!scheduler.isScheduled()) { +- scheduler.schedule(); +- } - } - -- private _getEditFilePaths(parameters: unknown): string[] { -- return getEditFilePaths(parameters).map(path => this._resolveEditFilePath(path)); +- private _beginToolCallRound(parentToolCallId: string | undefined): void { +- const scope = parentToolCallId ?? ''; +- this._currentTurn?.markdownPartIds.delete(scope); +- this._currentTurn?.reasoningPartIds.delete(scope); - } - -- private _resolveEditFilePath(path: string): string { -- if (isAbsolute(path) || !this._workingDirectory || this._workingDirectory.scheme !== Schemas.file) { -- return path; +- /** +- * Starts a fresh `pending` turn, discarding any per-turn streaming state +- * from a previous turn so the next text/reasoning chunk allocates a new +- * response part. The turn becomes `running` on the first SDK event. +- */ +- resetTurnState(turnId: string, senderClientId?: string, clientType = AgentHostClientType.Unknown): void { +- this._streamingToolCalls.clear(); +- this._streamingToolDisplaySchedulers.clearAndDisposeAll(); +- this._currentTurn = new CopilotTurn(turnId, this._nextTurnOrdinal++, senderClientId, clientType); +- } +- +- /** Refreshes prompt-cache state and the session-wide nano-AIU total from the SDK's authoritative usage metrics. */ +- private async _refreshSessionUsageMetrics(): Promise { +- try { +- return await this._sessionUsageMetricsRefreshThrottler.queue(async () => { +- const promptCacheRefreshGeneration = this._promptCacheRefreshGeneration; +- const metrics = await this._wrapper.session.rpc.usage.getMetrics(); +- const modelId = metrics.currentModel; +- if (!this._store.isDisposed && modelId && promptCacheRefreshGeneration === this._promptCacheRefreshGeneration) { +- const cacheExpiresAt = metrics.modelMetrics[modelId]?.cacheExpiresAt; +- this._setPromptCacheState(cacheExpiresAt ? { modelId, cacheExpiresAt } : undefined); +- } +- +- const total = metrics.totalNanoAiu; +- if (typeof total !== 'number' || !Number.isFinite(total) || total < 0 || total === this._sessionTotalNanoAiu) { +- return false; +- } +- this._sessionTotalNanoAiu = total; +- return true; +- }); +- } catch (err) { +- // Also covers the rejection from a throttler disposed mid-read. +- this._logService.trace(`[Copilot:${this.sessionId}] usage.getMetrics RPC failed: ${getErrorMessage(err)}`); +- return false; - } -- return join(this._workingDirectory.fsPath, path); - } - -- private _sendToolInvokedTelemetry(success: boolean, errorCode: string | undefined, toolCall: { readonly toolName: string; readonly startTimeMs: number; readonly mcpServerName: string | undefined }): void { -- let result: LanguageModelToolInvokedEvent['result']; -- if (success) { -- result = 'success'; -- } else if (errorCode === 'rejected' || errorCode === 'denied' || errorCode === 'cancelled') { -- result = 'userCancelled'; -- } else { -- result = 'error'; +- /** +- * The parent-scope Copilot billing metadata for the active turn: the turn's +- * own accumulated cost plus the SDK's session-wide total. Absent until +- * something has actually been billed. +- */ +- private _parentCopilotUsageMeta(): UsageInfoMeta['copilotUsage'] | undefined { +- const turnNanoAiu = this._currentTurn?.copilotNanoAiu ?? 0; +- if (!turnNanoAiu && !this._sessionTotalNanoAiu) { +- return undefined; +- } +- return { +- ...(turnNanoAiu ? { totalNanoAiu: turnNanoAiu } : {}), +- ...(this._sessionTotalNanoAiu ? { sessionTotalNanoAiu: this._sessionTotalNanoAiu } : {}), +- }; +- } +- +- /** Reads the SDK's per-source context-window attribution, or `undefined` when unavailable. */ +- private async _readContextAttribution(): Promise { +- let attribution: IContextAttributionData | undefined; +- try { +- attribution = (await this._wrapper.session.rpc.metadata.getContextAttribution())?.contextAttribution ?? undefined; +- } catch (err) { +- this._logService.trace(`[Copilot:${this.sessionId}] contextAttribution RPC failed: ${getErrorMessage(err)}`); +- return undefined; +- } +- if (!attribution) { +- this._logService.trace(`[Copilot:${this.sessionId}] contextAttribution: null/empty`); +- return undefined; - } +- if (this._logService.getLevel() <= LogLevel.Trace) { +- this._logService.trace(`[Copilot:${this.sessionId}] contextAttribution: totalTokens=${attribution.totalTokens}, entries=${JSON.stringify(attribution.entries.map(e => ({ kind: e.kind, id: e.id, label: e.label, tokens: e.tokens, parentId: e.parentId })))}`); +- } +- return attribution; +- } +- +- private _completeActiveTurn(): void { +- const turn = this._currentTurn; +- if (!turn) { +- return; +- } +- turn.markCompleted(); +- this._reportToolCallDetails(turn, 'success'); +- this._emitAction({ +- type: ActionType.ChatTurnComplete, +- turnId: turn.id, +- duration: turn.duration, +- }); +- this._clearActiveTurn(); +- } - -- const isClientTool = this._clientToolNames.has(toolCall.toolName); -- const toolSourceKind = toolCall.mcpServerName ? 'mcp' : isClientTool ? 'client' : 'agentHost'; -- const invocationTimeMs = Date.now() - toolCall.startTimeMs; +- /** +- * Drops the active turn and reports that this chat is now idle. Every +- * transition out of an in-flight turn must go through here so work the +- * agent defers while a turn runs — notably a pending CLI client restart — +- * is not stranded waiting on a turn that already ended. +- */ +- private _clearActiveTurn(): void { +- this._currentTurn = undefined; +- this._streamingToolCalls.clear(); +- this._streamingToolDisplaySchedulers.clearAndDisposeAll(); +- try { +- this._onTurnEnded(); +- } catch (err) { +- // The turn is already cleared, so the session's own state is +- // consistent. Contain the failure to the agent's bookkeeping rather +- // than letting it escape into SDK event handling — or, on the +- // `send()` failure path, replace the error we are propagating. +- this._logService.error(err, `[Copilot:${this.sessionId}] onTurnEnded callback failed`); +- } +- } - -- this._telemetryService.publicLog2('languageModelToolInvoked', { -- result, -- chatSessionId: this.sessionUri.toString(), -- toolId: toolCall.toolName, -- toolExtensionId: undefined, -- toolSourceKind, -- invocationTimeMs, +- private _reportToolCallDetails(turn: CopilotTurn, responseType: 'success' | 'cancelled' | 'failed'): void { +- if (turn.toolCallDetailsReported) { +- return; +- } +- turn.toolCallDetailsReported = true; +- void this._telemetryReporter.toolCallDetails({ +- provider: 'copilot', +- session: this.sessionUri.toString(), +- turnId: turn.id, +- clientType: turn.clientType, +- model: turn.lastModel, +- responseType, +- toolCounts: Object.fromEntries(turn.toolCounts), +- availableTools: this._appliedSnapshot.tools.map(tool => tool.name), +- numRequests: turn.toolCallRounds, +- turnIndex: turn.ordinal, +- turnDuration: turn.duration, +- messageCharLen: turn.messageCharLen, +- totalToolCalls: turn.totalToolCalls, +- parallelToolCallRounds: turn.parallelToolCallRounds, +- parallelToolCallsTotal: turn.parallelToolCallsTotal, +- }).catch(err => this._logService.trace(`[Copilot:${this.sessionId}] Telemetry emission failed: ${getErrorMessage(err)}`)); +- } +- +- private _reportToolApproval(toolCallId: string, toolName: string | undefined, mcpServerName: string | undefined): void { +- const record = this._toolApprovalRecords.get(toolCallId); +- if (!toolName || isHiddenTool(toolName) || record?.reported) { +- return; +- } +- const confirmKind = mapPermissionResultToConfirmKind(record?.resultKind, record?.resolvedByHook === true); +- this._telemetryReporter.toolApproval({ +- provider: 'copilot', +- session: this.sessionUri.toString(), +- turnId: this._turnId, +- toolId: toolName, +- toolSourceKind: this._toolSourceKindFor(toolName, mcpServerName), +- confirmKind, +- confirmationNotNeededReason: confirmKind === 'confirmationNotNeeded' && record?.resolvedByHook ? 'other' : undefined, +- requestUnsandboxedExecution: record?.requestSandboxBypass ? true : undefined, - }); +- if (record) { +- record.reported = true; +- } +- } +- +- private _reportToolApprovalIfNoPermission(toolCallId: string): void { +- const record = this._toolApprovalRecords.get(toolCallId); +- if (record && !record.permissionRequested) { +- this._reportToolApproval(toolCallId, record.toolName, record.mcpServerName); +- } +- } +- private _toolSourceKindFor(toolName: string, mcpServerName: string | undefined): string { +- if (mcpServerName) { +- return 'mcp'; +- } +- if (this._clientToolNames.has(toolName)) { +- return 'client'; +- } +- return 'internal'; +- } +- +- private _getEditFilePaths(parameters: unknown): string[] { +- return getEditFilePaths(parameters).map(path => this._resolveEditFilePath(path)); +- } +- +- private _resolveEditFilePath(path: string): string { +- if (isAbsolute(path) || !this._workingDirectory || this._workingDirectory.scheme !== Schemas.file) { +- return path; +- } +- return join(this._workingDirectory.fsPath, path); - } - - /** @@ -4690,22 +6585,156 @@ index a0c0a765..00000000 - if (tools.length === 0) { - return []; - } -- return tools.map(def => ({ -- name: def.name, -- description: def.description ?? '', -- parameters: def.inputSchema ?? { type: 'object' as const, properties: {} }, -- handler: async (_args: Record, { toolCallId }) => { -- try { -- // The completion may legitimately arrive before this handler -- // registers; the registry buffers early results so register() -- // resolves immediately in that case. -- return await this._pendingClientToolCalls.register(toolCallId); -- } catch (error) { -- this._logService.error(error, `[Copilot:${this.sessionId}] Failed in client tool handler: tool=${def.name}, toolCallId=${toolCallId}`); -- throw error; +- const toolSearchActive = this._isToolSearchActive(); +- const sessionTools = toolSearchActive +- ? tools +- : tools.filter(def => def.name !== CLIENT_TOOL_SEARCH_REFERENCE_NAME); +- // eslint-disable-next-line @typescript-eslint/no-explicit-any +- return sessionTools.map((def): Tool => { +- if (toolSearchActive && def.name === CLIENT_TOOL_SEARCH_REFERENCE_NAME) { +- return { +- name: RUNTIME_TOOL_SEARCH_TOOL_NAME, +- description: def.description ?? '', +- parameters: def.inputSchema ?? { type: 'object' as const, properties: {} }, +- overridesBuiltInTool: true, +- defer: 'never', +- skipPermission: true, +- handler: this._guarded(async (_args: Record, invocation) => { +- try { +- const candidates = this._toToolSearchCandidates(invocation.availableTools); +- const clientResult = await this._pendingClientToolCalls.registerAndFire( +- invocation.toolCallId, +- () => this._emitToolSearchReady(invocation.toolCallId, candidates), +- ); +- return this._toToolSearchResult(clientResult, invocation.availableTools); +- } catch (error) { +- this._logService.error(error, `[Copilot:${this.sessionId}] Failed in tool-search handler: toolCallId=${invocation.toolCallId}`); +- return this._toolSearchFailure(getErrorMessage(error)); +- } +- }, this._toolSearchFailure('Tool call cancelled: session is aborting'), 'tool-search'), +- }; +- } +- const defer: 'auto' | 'never' | undefined = toolSearchActive +- ? (NON_DEFERRED_CLIENT_TOOL_NAMES.has(def.name) ? 'never' : 'auto') +- : undefined; +- return { +- name: def.name, +- description: def.description ?? '', +- parameters: def.inputSchema ?? { type: 'object' as const, properties: {} }, +- defer, +- handler: this._guarded(async (_args: Record, { toolCallId }) => { +- try { +- return await this._pendingClientToolCalls.register(toolCallId); +- } catch (error) { +- this._logService.error(error, `[Copilot:${this.sessionId}] Failed in client tool handler: tool=${def.name}, toolCallId=${toolCallId}`); +- throw error; +- } +- }, this._toolSearchFailure('Tool call cancelled: session is aborting'), 'client-tool'), +- }; +- }); +- } +- +- private _isToolSearchActive(): boolean { +- return this._toolSearchActive; +- } +- +- private get _abortToken(): CancellationToken { +- return this._abortCts.value?.token ?? CancellationToken.Cancelled; +- } +- +- private _beginAbort(): void { +- if (this._abortToken.isCancellationRequested) { +- return; +- } +- this._abortCts.value?.cancel(); +- this._cancelAllPendingInteractions(); +- } +- +- private _resetAbortToken(): void { +- this._abortCts.value = new CancellationTokenSource(); +- } +- +- /** +- * Guards SDK callbacks against aborts: the synchronous pre-check avoids the `shortcutEvent` macrotask for already-cancelled tokens, while the race releases callbacks that park after the abort sweep. +- * The post-race check catches handler completions that win the cancellation macrotask because promise continuations run as microtasks. +- */ +- private _guarded(handler: (...args: A) => Promise, cancelled: R, label: string): (...args: A) => Promise { +- return async (...args) => { +- const token = this._abortToken; +- if (token.isCancellationRequested) { +- this._logService.info(`[Copilot:${this.sessionId}] Discarding ${label} callback received while aborting`); +- return cancelled; +- } +- const result = await raceCancellation(handler(...args), token, cancelled); +- if (token.isCancellationRequested) { +- this._logService.info(`[Copilot:${this.sessionId}] Discarding ${label} callback result after abort`); +- return cancelled; +- } +- return result; +- }; +- } +- +- private _clientToolName(toolName: string): string { +- return this._isToolSearchActive() +- && toolName === RUNTIME_TOOL_SEARCH_TOOL_NAME +- ? CLIENT_TOOL_SEARCH_REFERENCE_NAME +- : toolName; +- } +- +- private _toToolSearchCandidates(availableTools: readonly CurrentToolMetadata[] | undefined): readonly IToolSearchCandidate[] { +- return (availableTools ?? []) +- .filter(tool => tool.deferLoading) +- .map(tool => ({ +- name: tool.name, +- description: tool.description ?? '', +- })); +- } +- +- private _emitToolSearchReady(toolCallId: string, candidates: readonly IToolSearchCandidate[]): void { +- const tracked = this._activeToolCalls.get(toolCallId); +- if (!tracked) { +- throw new Error(`Tool-search call '${toolCallId}' was not tracked.`); +- } +- this._emitAction({ +- type: ActionType.ChatToolCallReady, +- turnId: this._turnId, +- toolCallId, +- ...(tracked.contributor ? { contributor: tracked.contributor } : {}), +- ...(tracked.intention !== undefined ? { intention: tracked.intention } : {}), +- invocationMessage: getInvocationMessage(tracked.toolName, tracked.displayName, tracked.parameters, path => this._resolveEditFilePath(path)), +- toolInput: getToolInputString(tracked.toolName, tracked.parameters, tracked.parameters ? tryStringify(tracked.parameters) : undefined), +- confirmed: ToolCallConfirmationReason.NotNeeded, +- _meta: toToolCallMeta({ ...(tracked.meta ?? {}), toolSearchCandidates: candidates }), +- }, tracked.parentToolCallId); +- } +- +- private _toolSearchFailure(message: string): ToolResultObject { +- return { textResultForLlm: message, resultType: 'failure', error: message, toolReferences: [] }; +- } +- +- private _toToolSearchResult(clientResult: ToolResultObject, availableTools: readonly CurrentToolMetadata[] | undefined): ToolResultObject { +- const deferred = new Set(); +- for (const tool of availableTools ?? []) { +- if (tool.deferLoading) { +- deferred.add(tool.name); +- if (tool.namespacedName) { +- deferred.add(tool.namespacedName); - } -- }, -- })); +- } +- } +- const clientNames = this._parseToolSearchNames(clientResult.textResultForLlm); +- const toolReferences = clientNames.filter(name => deferred.has(name)); +- this._logService.info(`[Copilot:${this.sessionId}] tool_search override: availableTools=${availableTools?.length ?? 0}, deferred=${deferred.size}, clientMatched=[${clientNames.join(', ')}] -> toolReferences=[${toolReferences.join(', ')}]`); +- return { ...clientResult, toolReferences }; +- } +- +- private _parseToolSearchNames(text: string): string[] { +- try { +- const parsed = JSON.parse(text); +- return Array.isArray(parsed) ? parsed.filter((name): name is string => typeof name === 'string') : []; +- } catch { +- return []; +- } - } - - /** @@ -4721,15 +6750,15 @@ index a0c0a765..00000000 - if (!host) { - return []; - } -- const sessionUri = this.sessionUri.toString(); - return host.definitions.map(def => ({ - name: def.name, - description: def.description ?? '', - parameters: def.inputSchema ?? { type: 'object' as const, properties: {} }, +- defer: 'never' as const, - handler: async (args: Record): Promise => { - try { -- const text = host.executeTool(sessionUri, def.name, args); -- return { textResultForLlm: text, resultType: 'success' }; +- const text = host.executeTool(this._chatChannelUri.toString(), def.name, args); +- return { textResultForLlm: await text, resultType: 'success' }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - this._logService.error(error, `[Copilot:${this.sessionId}] Failed in server tool handler: tool=${def.name}`); @@ -4745,6 +6774,11 @@ index a0c0a765..00000000 - * resolves immediately once it does. - */ - handleClientToolCallComplete(toolCallId: string, result: ToolCallResult) { +- this._approvedDuplicablePermissionSignatures.delete(toolCallId); +- if (!result.success && this._cancelMcpAuthenticationForToolCall(toolCallId)) { +- this._activeToolCalls.delete(toolCallId); +- return; +- } - const textContent = result.content - ?.filter(c => c.type === ToolResultContentType.Text) - .map(c => c.text) @@ -4769,6 +6803,27 @@ index a0c0a765..00000000 - binaryResultsForLlm: binaryResults?.length ? binaryResults : undefined, - }); - } +- +- // Still pending permission, so this call may have errored while getting permission. +- // Go ahead and allow the call which will immediately see the buffered value. +- if (this._pendingPermissions.getMetadata(toolCallId)?.managedApprovalRequired !== true) { +- this.respondToPermissionRequest(toolCallId, true); +- } +- } +- +- private _cancelMcpAuthenticationForToolCall(toolCallId: string): boolean { +- for (const [requestId, pending] of this._pendingMcpAuthRequests.entries()) { +- const toolCallIndex = pending.toolCalls.findIndex(toolCall => toolCall.toolCallId === toolCallId); +- if (toolCallIndex === -1) { +- continue; +- } +- pending.toolCalls.splice(toolCallIndex, 1); +- if (pending.toolCalls.length === 0) { +- this._pendingMcpAuthRequests.respond(requestId, { kind: 'cancelled' }); +- } +- return true; +- } +- return false; - } - - /** @@ -4788,20 +6843,45 @@ index a0c0a765..00000000 - this._wrapper = this._register(wrapper); - this._subscribeToEvents(); - this._subscribeForLogging(); +- this._subscribeForMemoInvalidation(); +- this._subscribeForInstructionsCollectedTelemetry(); +- this._subscribeToPermissionConfigChanges(); +- this._promptCacheState = readSessionPromptCacheState(this._stateManager.getSessionSummary(this.sessionUri.toString())?._meta); +- if (this._launchPlan.kind === 'resume') { +- await this._refreshSessionUsageMetrics(); +- if (this._store.isDisposed) { +- throw new CancellationError(); +- } +- } - - // Advertise the agent host's server tools for this session so clients - // see them as server-provided. Execution happens in-process via the SDK - // tool handlers built in `_createServerSdkTools`. -- this._serverToolHost?.advertise(this.sessionUri.toString()); +- this._serverToolHost?.advertise(this._storageUri.toString()); +- } +- +- private _setPromptCacheState(promptCache: ISessionPromptCacheState | undefined): void { +- const currentSummary = this._stateManager.getSessionSummary(this.sessionUri.toString()); +- const currentMeta = currentSummary?._meta; +- // Concurrent sessions can share `sessionUri`, so the persisted metadata — not this +- // instance's cached value — is authoritative whenever a summary is available. +- const currentPromptCache = currentSummary ? readSessionPromptCacheState(currentMeta) : this._promptCacheState; +- this._promptCacheState = currentPromptCache; +- if (currentPromptCache?.modelId === promptCache?.modelId && currentPromptCache?.cacheExpiresAt === promptCache?.cacheExpiresAt) { +- return; +- } +- this._promptCacheState = promptCache; +- this._stateManager.setSessionMeta(this.sessionUri.toString(), withSessionPromptCacheState(currentMeta, promptCache)); - } - - private _createRuntimeAdapter(): ICopilotSessionRuntime { - return { -- handlePermissionRequest: request => this._handlePermissionRequest(request), -- handleExitPlanModeRequest: (request, invocation) => this._handleExitPlanModeRequest(request, invocation), -- handleUserInputRequest: (request, invocation) => this._handleUserInputRequest(request, invocation), -- handleElicitationRequest: context => this._handleElicitationRequest(context), -- requestUnsandboxedCommandConfirmation: request => this._requestUnsandboxedCommandConfirmation(request), +- handlePermissionRequest: this._guarded(request => this._handlePermissionRequest(request), { kind: 'reject' } satisfies PermissionRequestResult, 'permission'), +- handleExitPlanModeRequest: this._guarded((request, invocation) => this._handleExitPlanModeRequest(request, invocation), { approved: false } satisfies IExitPlanModeResponse, 'exit-plan-mode'), +- handleUserInputRequest: this._guarded((request, invocation) => this._handleUserInputRequest(request, invocation), { answer: '', wasFreeform: true } satisfies UserInputResponse, 'user-input'), +- handleElicitationRequest: this._guarded(context => this._handleElicitationRequest(context), { action: 'cancel' } satisfies ElicitationResult, 'elicitation'), +- handleMcpAuthRequest: this._guarded(request => this._handleMcpAuthRequest(request), { kind: 'cancelled' } satisfies McpAuthResult, 'mcp-auth'), +- requestUnsandboxedCommandConfirmation: this._guarded(request => this._requestUnsandboxedCommandConfirmation(request), false, 'unsandboxed-command-confirmation'), - createClientSdkTools: () => this._createClientSdkTools(), - createServerSdkTools: () => this._createServerSdkTools(), - handlePreToolUse: input => this._handlePreToolUse(input), @@ -4809,21 +6889,226 @@ index a0c0a765..00000000 - }; - } - +- async resolveMcpAuthentication(params: AuthenticateParams): Promise { +- let resolved = false; +- for (const [requestId, pending] of this._pendingMcpAuthRequests.entries()) { +- if (pending.resource.resource !== params.resource || !this._scopesSatisfy(params.scopes, pending.requiredScopes)) { +- continue; +- } +- for (const toolCall of pending.toolCalls) { +- this._emitAction({ +- type: ActionType.ChatToolCallAuthResolved, +- turnId: toolCall.turnId, +- toolCallId: toolCall.toolCallId, +- }, toolCall.parentToolCallId); +- } +- resolved = this._pendingMcpAuthRequests.respond(requestId, { kind: 'token', accessToken: params.token }) || resolved; +- } +- return resolved; +- } +- +- private async _handleMcpAuthRequest(request: McpAuthRequest): Promise { +- const githubToken = request.reason === 'initial' && this._scopesFromChallenge(request.wwwAuthenticateParams?.scope).length === 0 +- ? await this._initialGitHubMcpToken(request) +- : undefined; +- if (githubToken) { +- this._logService.info(`[Copilot:${this.sessionId}] Reusing the existing GitHub token for initial GitHub MCP authentication`); +- return { kind: 'token', accessToken: githubToken }; +- } +- const resource = this._protectedResourceFromMcpAuthRequest(request); +- const requiredScopes = this._scopesFromChallenge(request.wwwAuthenticateParams?.scope); +- const oauthClient: McpAuthRequirement['oauthClient'] = request.staticClientConfig?.publicClient +- ? { clientId: request.staticClientConfig.clientId } +- : request.staticClientConfig?.clientSecret +- ? { clientId: request.staticClientConfig.clientId, clientSecret: request.staticClientConfig.clientSecret } +- : undefined; +- const auth: McpAuthRequirement = { +- reason: this._mcpAuthRequiredReason(request.reason), +- ...(oauthClient ? { oauthClient } : {}), +- resource, +- requiredScopes: requiredScopes.length ? [...requiredScopes] : undefined, +- description: request.wwwAuthenticateParams?.error, +- }; +- const toolCalls = this._activeMcpToolCalls(request.serverName); +- const result = this._pendingMcpAuthRequests.register(request.requestId, { +- serverName: request.serverName, +- resource, +- requiredScopes, +- toolCalls, +- }); +- this._mcpCustomizations.applyOne({ +- name: request.serverName, +- state: { +- kind: McpServerStatus.AuthRequired, +- ...auth, +- }, +- }); +- for (const toolCall of toolCalls) { +- this._emitAction({ +- type: ActionType.ChatToolCallAuthRequired, +- turnId: toolCall.turnId, +- toolCallId: toolCall.toolCallId, +- auth, +- }, toolCall.parentToolCallId); +- } +- this._logService.info(`[Copilot:${this.sessionId}] MCP server '${request.serverName}' requires authentication for ${resource.resource}`); +- return result; +- } +- +- private _activeMcpToolCalls(serverName: string): IMcpAuthToolCall[] { +- if (!this._turnId) { +- return []; +- } +- const result: IMcpAuthToolCall[] = []; +- for (const [toolCallId, toolCall] of this._activeToolCalls) { +- if (toolCall.mcpServerName === serverName) { +- result.push({ turnId: this._turnId, toolCallId, parentToolCallId: toolCall.parentToolCallId }); +- } +- } +- return result; +- } +- +- private async _initialGitHubMcpToken(request: McpAuthRequest): Promise { +- const githubToken = this._launchPlan.githubToken; +- const requestUrl = normalizeMcpServerUrl(request.serverUrl); +- if (!githubToken || requestUrl === undefined) { +- return undefined; +- } +- const configuredUrls = [gitHubMcpServerUrl(undefined)]; +- try { +- const resolvedUrl = gitHubMcpServerUrl(await this._copilotApiService.resolveApiEndpoint(githubToken)); +- if (resolvedUrl) { +- configuredUrls.push(resolvedUrl); +- } +- } catch (error) { +- this._logService.warn(`[Copilot:${this.sessionId}] Failed to resolve the GitHub MCP server URL: ${getErrorMessage(error)}`); +- return undefined; +- } +- return configuredUrls.some(u => u && requestUrl === normalizeMcpServerUrl(u)) ? githubToken : undefined; +- } +- +- private _protectedResourceFromMcpAuthRequest(request: McpAuthRequest): ProtectedResourceMetadata { +- if (request.resourceMetadata) { +- try { +- const parsed = JSON.parse(request.resourceMetadata); +- if (isAuthorizationProtectedResourceMetadata(parsed)) { +- return parsed; +- } +- this._logService.warn(`[Copilot:${this.sessionId}] Ignoring invalid MCP protected-resource metadata for '${request.serverName}'`); +- } catch (err) { +- this._logService.warn(`[Copilot:${this.sessionId}] Failed to parse MCP protected-resource metadata for '${request.serverName}'`, err); +- } +- } +- const scopes = this._scopesFromChallenge(request.wwwAuthenticateParams?.scope); +- return { +- resource: request.serverUrl, +- resource_name: request.serverName, +- scopes_supported: scopes.length ? scopes.slice() : undefined, +- }; +- } +- +- private _scopesFromChallenge(scope: string | undefined): readonly string[] { +- return scope?.split(/\s+/).map(s => s.trim()).filter(s => s.length > 0) ?? []; +- } +- +- private _mcpAuthRequiredReason(reason: McpAuthRequest['reason']): McpAuthRequiredReason { +- switch (reason) { +- case 'refresh': +- case 'reauth': +- return McpAuthRequiredReason.Expired; +- case 'upscope': +- return McpAuthRequiredReason.InsufficientScope; +- case 'initial': +- default: +- return McpAuthRequiredReason.Required; +- } +- } +- +- private _scopesSatisfy(provided: readonly string[] | undefined, required: readonly string[]): boolean { +- if (required.length === 0 || provided === undefined) { +- return true; +- } +- const providedSet = new Set(provided); +- return required.every(scope => providedSet.has(scope)); +- } +- +- private _cancelPendingMcpAuthRequests(): void { +- this._pendingMcpAuthRequests.denyAll({ kind: 'cancelled' }); +- } +- +- private _cancelPendingMcpAuthRequestsForServer(serverName: string): void { +- for (const [requestId, pending] of this._pendingMcpAuthRequests.entries()) { +- if (pending.serverName !== serverName) { +- continue; +- } +- for (const toolCall of pending.toolCalls) { +- this._emitAction({ +- type: ActionType.ChatToolCallAuthResolved, +- turnId: toolCall.turnId, +- toolCallId: toolCall.toolCallId, +- }, toolCall.parentToolCallId); +- } +- this._pendingMcpAuthRequests.respond(requestId, { kind: 'cancelled' }); +- } +- } +- - // ---- session operations ------------------------------------------------- - -- async send(prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, mode?: CopilotSdkMode): Promise { +- async send(prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, mode?: CopilotSdkMode, senderClientId?: string, clientType = AgentHostClientType.Unknown): Promise { +- this._resetAbortToken(); - if (turnId && this._currentTurn?.id !== turnId) { - // Establish the `pending` turn for this message. Callers normally - // call `resetTurnState` just before `send()`; this covers the - // direct-send path and is a no-op when the turn already exists. -- this.resetTurnState(turnId); +- this.resetTurnState(turnId, senderClientId, clientType); +- } +- if (this._currentTurn) { +- this._currentTurn.messageCharLen = prompt.length; +- } +- const turn = this._currentTurn; +- try { +- await this._send(prompt, attachments, mode); +- } catch (err) { +- // A rejected send never reaches the SDK's agentic loop, so no +- // `session.idle` will ever arrive to close this turn. The host turns +- // the rejection into a `ChatError` that finalizes the protocol turn, +- // so drop our handle to match: leaving it set makes the chat look +- // busy forever, which blocks idle eviction and parks any deferred +- // client restart for the rest of the process's life. +- if (turn && this._currentTurn === turn) { +- this._clearActiveTurn(); +- } +- throw err; - } +- } +- private async _send(prompt: string, attachments: readonly MessageAttachment[] | undefined, mode: CopilotSdkMode | undefined): Promise { - this._logService.info(`[Copilot:${this.sessionId}] sendMessage called: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}" (${attachments?.length ?? 0} attachments)`); - - const slashCommand = parseLeadingSlashCommand(prompt); - if (slashCommand?.command === 'compact') { - try { -- await this._wrapper.session.rpc.history.compact(); +- const result = await this._wrapper.session.rpc.history.compact(); +- // Compaction reduces the number of tokens currently occupying the context window. Report the +- // new occupancy so the context-usage widget refreshes immediately. Emitted before +- // `_completeActiveTurn` since the reducer drops usage for a non-active turn. +- const usedTokens = result.contextWindow?.currentTokens; +- if (typeof usedTokens === 'number') { +- // `session.compaction_complete` has already folded the summarization call's +- // cost into the turn by the time this RPC resolves; refresh the session total +- // so the report carries both. +- await this._refreshSessionUsageMetrics(); +- const copilotUsage = this._parentCopilotUsageMeta(); +- this._emitAction({ +- type: ActionType.ChatUsage, +- turnId: this._turnId, +- usage: { +- inputTokens: usedTokens, +- outputTokens: 0, +- model: this._lastSeenModelId, +- ...(copilotUsage ? { _meta: { copilotUsage } } : {}), +- }, +- }); +- } - this.emitInitialMarkdown(localize('copilotAgent.compactionCompleted', "Compaction completed")); - } catch (err) { - if (getErrorMessage(err).toLowerCase().includes('nothing to compact')) { @@ -4841,52 +7126,22 @@ index a0c0a765..00000000 - this._completeActiveTurn(); - return; - } -- if (slashCommand && isRuntimeCopilotSlashCommand(slashCommand.command) && await this.hasRuntimeSlashCommand(slashCommand.command)) { -- let result: CopilotCommandInvocationResult; -- try { -- result = await this._wrapper.session.rpc.commands.invoke({ -- name: slashCommand.command, -- }); -- } catch (err) { -- this._logService.error(err, `[Copilot:${this.sessionId}] rpc.commands.invoke(${slashCommand.command}) failed`); -- throw err; -- } -- switch (result.kind) { -- case 'text': -- this._emitMarkdownDelta(result.markdown === true ? result.text : escapeMarkdownSyntaxTokens(result.text)); -- break; -- case 'completed': -- if (result.message) { -- this._emitMarkdownDelta(result.message); -- } -- break; -- case 'agent-prompt': { -- const runtimeMode = toCopilotSdkMode(result.mode); -- if (runtimeMode) { -- mode = runtimeMode; -- } -- prompt = result.prompt; -- break; -- } -- default: -- this._logService.warn(`[Copilot:${this.sessionId}] Unexpected /${slashCommand.command} command result kind: ${result.kind}`); -- this._emitMarkdownDelta(localize('copilotSlashCommand.unsupportedRuntimeResult', "The /{0} command returned an unsupported result.", slashCommand.command)); -- break; -- } -- if (result.kind !== 'agent-prompt') { -- this._completeActiveTurn(); -- return; -- } -- } -- if (slashCommand && isPromptInvokedCopilotSlashCommand(slashCommand.command)) { -- prompt = slashCommand.rest ? `/${slashCommand.command} ${slashCommand.rest}` : `/${slashCommand.command}`; -- } -- if (slashCommand?.command === 'plan') { -- mode = 'plan'; -- prompt = slashCommand.rest; -- } -- if (slashCommand?.command === 'rubber-duck') { -- if (this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.RubberDuck) !== true) { +- const configAction = slashCommand ? resolveCopilotConfigSlashCommandOnSend(slashCommand.command, slashCommand.rawRest) : undefined; +- if (configAction) { +- // Workbench config-action command (permission/mode toggle, e.g. +- // `/autopilot `, `/plan`, `/yolo`). The config is applied +- // client-side on accept via the session provider; here we re-apply the +- // mode for this turn (belt-and-suspenders) and strip the command token +- // so it is not dispatched to the runtime as a runtime command. +- // `autoApprove` changes are already reflected in the session config and +- // applied by `syncPermissionMode('turn-start')` below. +- const sdkMode = toCopilotSdkMode(configAction.applyConfig[SessionConfigKey.Mode]); +- if (sdkMode) { +- mode = sdkMode; +- } +- prompt = configAction.strippedPrompt; +- } else if (slashCommand?.command === 'rubber-duck') { +- if (this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.RubberDuck) !== true) { - // Feature not enabled — pass the remaining text through as a plain - // message rather than injecting agent instructions for an unavailable agent. - prompt = slashCommand.rest; @@ -4896,63 +7151,109 @@ index a0c0a765..00000000 - ? `The user has requested a rubber duck review via the /rubber-duck command. Use the task tool with agent_type: "rubber-duck" to get an independent critique of your current approach, plan, or recent work. Summarize the relevant context for the rubber duck agent so it has what it needs to evaluate it.\n\nAdditional instructions: ${userPrompt}` - : 'The user has requested a rubber duck review via the /rubber-duck command. Use the task tool with agent_type: "rubber-duck" to get an independent critique of your current approach, plan, or recent work. Summarize the relevant context for the rubber duck agent so it has what it needs to evaluate it.'; - } -- } -- -- const sdkAttachments = attachments?.length -- ? (await Promise.all(attachments.map(a => this._toSdkAttachment(a)))).filter(isDefined) -- : undefined; -- if (sdkAttachments?.length) { -- this._logService.trace(`[Copilot:${this.sessionId}] Attachments: ${JSON.stringify(sdkAttachments.map(a => ({ type: a.type })))}`); -- } -- -- await this.applyMode(mode); -- await this._wrapper.session.send({ prompt, attachments: sdkAttachments?.length ? sdkAttachments : undefined }); -- this._logService.info(`[Copilot:${this.sessionId}] session.send() returned`); +- } else if (slashCommand) { +- const runtimeSlashCommand = await this._slashCommandProvider.resolveSlashCommand(slashCommand.command); +- // Skills can be passed as is to the runtime. +- if (runtimeSlashCommand && runtimeSlashCommand.kind !== 'skill') { +- let result: CopilotCommandInvocationResult; +- try { +- result = await this._wrapper.session.rpc.commands.invoke({ +- name: runtimeSlashCommand.name, +- ...(slashCommand.rawRest.length > 0 ? { input: slashCommand.rawRest } : {}), +- }); +- } catch (err) { +- this._logService.error(err, `[Copilot:${this.sessionId}] rpc.commands.invoke(${slashCommand.command}) failed`); +- throw err; +- } +- switch (result.kind) { +- case 'text': +- this._emitMarkdownDelta(result.markdown === true ? result.text : escapeMarkdownSyntaxTokens(result.text)); +- break; +- case 'completed': +- if (result.message) { +- this._emitMarkdownDelta(result.message); +- } +- break; +- case 'agent-prompt': { +- const runtimeMode = toCopilotSdkMode(result.mode); +- if (runtimeMode) { +- mode = runtimeMode; +- } +- prompt = result.prompt; +- break; +- } +- case 'select-subcommand': +- this._emitMarkdownDelta(localize( +- 'copilotSlashCommand.selectSubcommandResult', +- "The /{0} command requires selecting a subcommand. Available options: {1}", +- result.command, +- result.options.map(option => option.name).join(', '), +- )); +- break; +- default: +- // The runtime can be newer than these compiled SDK types, so an +- // unknown kind must be logged rather than silently swallowed (the +- // turn would otherwise complete with no user-facing output). +- this._logService.warn(`[Copilot:${this.sessionId}] Unhandled slash command result kind: ${(result as { kind: string }).kind}`); +- break; +- } +- if (result.runtimeSettingsChanged === true) { +- this._slashCommandProvider.clearCache(); +- } +- if (result.kind !== 'agent-prompt') { +- this._completeActiveTurn(); +- return; +- } +- } +- } +- +- const sdkAttachments = attachments?.length +- ? (await Promise.all(attachments.map(a => this._toSdkAttachment(a)))).filter(isDefined) +- : undefined; +- if (sdkAttachments?.length) { +- this._logService.trace(`[Copilot:${this.sessionId}] Attachments: ${JSON.stringify(sdkAttachments.map(a => ({ type: a.type })))}`); +- } +- +- await this.applyMode(mode); +- await this.syncPermissionMode('turn-start'); +- await this._applyEffectiveSandboxConfig(); +- await this._reconcileMcpServerEnablement(); +- await this._wrapper.session.send({ prompt, attachments: sdkAttachments?.length ? sdkAttachments : undefined }); +- this._logService.info(`[Copilot:${this.sessionId}] session.send() returned`); - } - - async hasRuntimeSlashCommand(command: string): Promise { - try { -- return (await this._getRuntimeSlashCommands()).has(command); +- return !!(await this._slashCommandProvider.resolveSlashCommand(command)); - } catch (err) { - this._logService.warn(`[Copilot:${this.sessionId}] rpc.commands.list failed`, err); - return false; - } - } - -- private _getRuntimeSlashCommands(): Promise> { -- const now = Date.now(); -- if (this._runtimeSlashCommandCache && this._runtimeSlashCommandCache.expiresAt > now) { -- return this._runtimeSlashCommandCache.promise; +- async getRuntimeSlashCommands(options?: { readonly maxWaitMs?: number }): Promise { +- try { +- return await this._slashCommandProvider.getSlashCommands(options); +- } catch (err) { +- this._logService.warn(`[Copilot:${this.sessionId}] rpc.commands.list failed`, err); +- return []; - } -- const promise = this._wrapper.session.rpc.commands.list({ includeBuiltins: true, includeSkills: false, includeClientCommands: false }) -- .then(result => new Set(result.commands.filter(command => command.kind === 'builtin').map(command => command.name))); -- this._runtimeSlashCommandCache = { expiresAt: now + RUNTIME_SLASH_COMMAND_CACHE_TTL_MS, promise }; -- promise.catch(() => { -- if (this._runtimeSlashCommandCache?.promise === promise) { -- this._runtimeSlashCommandCache = undefined; -- } -- }); -- return promise; - } - - /** -- * Translate a protocol {@link MessageAttachment} into the Copilot CLI -- * SDK's `attachments` payload shape. Resource attachments map to the -- * SDK's reference-style `file`/`directory`/`selection` variants (the -- * {@link MessageAttachmentBase.displayKind} advisory hint controls -- * which one). Embedded resources (e.g. inline image bytes) map to the -- * SDK's `blob` variant. -- * Simple attachments with a model representation map to `text/plain` -- * blob attachments. +- * Translate a protocol {@link MessageAttachment} into the Copilot CLI SDK's `attachments` payload shape. Resource +- * attachments map to the SDK's reference-style `file`/`directory`/`selection` variants (the +- * {@link MessageAttachmentBase.displayKind} advisory hint controls which one). Embedded resources (e.g. inline +- * image bytes, or unsaved editor content) map to the SDK's `blob` variant, and simple attachments with a model +- * representation map to `text/plain` blob attachments. - * - * Any Resource attachment carrying a {@link TextSelection} (e.g. `displayKind === 'selection'` or `'symbol'`) is - * mapped to the SDK's `selection` variant so the range survives the round-trip — keying off the `selection` field -- * rather than just `displayKind` avoids symbol attachments degrading to a plain file reference (#315193). -- * -- * For selections we read the resource content from disk and slice it -- * by the carried range (the protocol's {@link TextSelection} only -- * carries the range, not the inline text). On read failure the -- * selection downgrades to a plain file reference. +- * rather than just `displayKind` avoids symbol attachments degrading to a plain file reference (#315193). For those +- * we read the resource content from disk and slice it by the carried range (the protocol's {@link TextSelection} +- * only carries the range, not the inline text); on read failure the selection downgrades to a plain file reference. +- * A textual embedded resource already carries the exact inline text to send (the whole live buffer for a document, +- * or just the selected text for a selection), so it is forwarded as-is without further slicing. - */ - private async _toSdkAttachment(attachment: MessageAttachment): Promise { - if (isAgentFeedbackAnnotationsAttachment(attachment)) { @@ -4972,7 +7273,7 @@ index a0c0a765..00000000 - return { - type: 'blob' as const, - data: encodeBase64(VSBuffer.fromString(attachment.modelRepresentation)), -- mimeType: 'text/plain', +- mimeType: addSimpleAttachmentDisplayKindToMimeType(attachment), - displayName: attachment.label, - }; - } @@ -5048,7 +7349,14 @@ index a0c0a765..00000000 - * answer questions or fill in elicitation forms. - */ - private _isAutopilotMode(): boolean { -- return this._configurationService.getEffectiveValue(this.sessionUri.toString(), platformSessionSchema, SessionConfigKey.Mode) === 'autopilot'; +- return this._configurationService.getEffectiveValue(this._storageUri.toString(), platformSessionSchema, SessionConfigKey.Mode) === 'autopilot'; +- } +- +- /** +- * Whether VS Code's auto-reply setting is enabled in the root config. +- */ +- private _isAutoReplyEnabled(): boolean { +- return this._configurationService.getRootValue(platformRootSchema, AgentHostAutoReplyEnabledConfigKey) === true; - } - - async sendSteering(steeringMessage: PendingMessage): Promise { @@ -5058,12 +7366,14 @@ index a0c0a765..00000000 - this._steeringMessagesInFlight.add(steeringMessage.id); - this._logService.info(`[Copilot:${this.sessionId}] Sending steering message: "${steeringMessage.message.text.substring(0, 100)}"`); - try { +- await this._reconcileMcpServerEnablement(); +- this._pendingSteeringFlips.set(steeringMessage.id, steeringMessage); - await this._wrapper.session.send({ - prompt: steeringMessage.message.text, - mode: 'immediate', - }); -- this._pendingSteeringFlips.set(steeringMessage.id, steeringMessage); - } catch (err) { +- this._pendingSteeringFlips.delete(steeringMessage.id); - this._logService.error(`[Copilot:${this.sessionId}] Steering message failed`, err); - } finally { - this._steeringMessagesInFlight.delete(steeringMessage.id); @@ -5071,18 +7381,85 @@ index a0c0a765..00000000 - } - - async getMessages(): Promise { -- const events = await this._wrapper.session.getEvents(); -- let db: ISessionDatabase | undefined; -- try { -- db = this._databaseRef.object; -- } catch { -- // Database may not exist yet — that's fine -- } -- const result = await mapSessionEvents(this.sessionUri, db, events, this._workingDirectory); +- const result = await this._getMappedEvents(); - return result.turns; - } - - async getSubagentMessages(parentToolCallId: string): Promise { +- const result = await this._getMappedEvents(); +- const turns = result.subagentTurnsByToolCallId.get(parentToolCallId) ?? []; +- return turns; +- } +- +- /** +- * Returns the subagent child sessions discoverable in this session's event +- * log, derived from the same {@link mapSessionEvents} reconstruction used +- * for {@link getMessages}/{@link getSubagentMessages}. Lets a parent +- * restore register every child up-front instead of each child re-fetching +- * and re-reconstructing the full parent event log. +- */ +- async getSubagentSessions(): Promise { +- const result = await this._getMappedEvents(); +- if (result.subagentTurnsByToolCallId.size === 0) { +- return []; +- } +- const parentSessionStr = this._storageUri.toString(); +- const out: IRestoredSubagentSession[] = []; +- for (const turn of result.turns) { +- for (const rp of turn.responseParts) { +- if (rp.kind !== ResponsePartKind.ToolCall) { +- continue; +- } +- const tc = rp.toolCall; +- const childTurns = result.subagentTurnsByToolCallId.get(tc.toolCallId); +- if (!childTurns || childTurns.length === 0) { +- continue; +- } +- const content = (tc as { content?: readonly ToolResultContent[] }).content; +- const subagentContent = content ? getToolSubagentContent({ content }) : undefined; +- // Prefer the spawning Task tool's short `description` (captured on +- // the parent tool call's `_meta`) so restored peer tabs match the +- // live path's concise, per-task naming; fall back to the agent +- // type's display name. +- const taskDescription = readToolCallMeta(tc).subagentDescription; +- out.push({ +- resource: URI.parse(buildSubagentSessionUri(parentSessionStr, tc.toolCallId)), +- toolCallId: tc.toolCallId, +- title: subagentChatTitle(taskDescription, subagentContent?.title), +- turns: childTurns, +- }); +- } +- } +- return out; +- } +- +- /** +- * Memoized `getEvents()` + {@link mapSessionEvents} result, shared by +- * {@link getMessages}, {@link getSubagentMessages} and +- * {@link getSubagentSessions}. A single session open reads and +- * reconstructs the full parent event log once instead of once per +- * subagent. The memo is scoped to the resume/restore wave: it is dropped +- * whenever the persisted event log could change (see +- * {@link _invalidateMappedEvents}) and on dispose, so it never serves +- * stale turns for an actively-running session. +- */ +- private _mappedEventsMemo: Promise | undefined; +- +- private _getMappedEvents(): Promise { +- if (!this._mappedEventsMemo) { +- const pending = this._computeMappedEvents(); +- this._mappedEventsMemo = pending; +- // Don't cache a rejected reconstruction — let the next caller retry. +- pending.catch(() => { +- if (this._mappedEventsMemo === pending) { +- this._mappedEventsMemo = undefined; +- } +- }); +- } +- return this._mappedEventsMemo; +- } +- +- private async _computeMappedEvents(): Promise { - const events = await this._wrapper.session.getEvents(); - let db: ISessionDatabase | undefined; - try { @@ -5090,15 +7467,46 @@ index a0c0a765..00000000 - } catch { - // Database may not exist yet — that's fine - } -- const result = await mapSessionEvents(this.sessionUri, db, events, this._workingDirectory); -- return result.subagentTurnsByToolCallId.get(parentToolCallId) ?? []; +- const result = await mapSessionEvents(this._storageUri, db, events, { +- workingDirectory: this._workingDirectory, +- model: this._launchPlan.kind === 'create' +- ? this._launchPlan.model +- : this._launchPlan.fallback.model, +- }); +- return result; +- } +- +- /** Drop the memoized event reconstruction; the next read rebuilds it. */ +- private _invalidateMappedEvents(): void { +- this._mappedEventsMemo = undefined; - } - - async abort(): Promise { - this._logService.info(`[Copilot:${this.sessionId}] Aborting session...`); -- this._denyPendingPermissions(); +- this._beginAbort(); - this._drainPendingSteeringFlips(); -- await this._wrapper.session.abort(); +- try { +- await this._wrapper.session.abort(); +- } catch (error) { +- this._resetAbortToken(); +- throw error; +- } +- } +- +- /** +- * Aborts before tearing down so that in-flight {@link _guarded} callbacks +- * settle rather than hang: disposing the {@link _abortCts} would drop each +- * racing `onCancellationRequested` listener without ever firing it, leaving +- * a callback that parks its deferred after the teardown sweep with nothing +- * left to resolve it. The sweep registered in the constructor stays as the +- * backstop, since {@link _beginAbort} no-ops when already aborted. +- */ +- override dispose(): void { +- void this._editTracker.flushAttribution().catch(error => { +- this._logService.warn(`[Copilot:${this.sessionId}] Failed to flush edit attribution: ${error}`); +- }); +- this._beginAbort(); +- super.dispose(); - } - - /** @@ -5108,7 +7516,12 @@ index a0c0a765..00000000 - * truncation or fork operations that modify the session files). - */ - async destroySession(): Promise { -- await this._wrapper.session.disconnect(); +- try { +- await this._editTracker.flushAttribution(); +- } catch (error) { +- this._logService.warn(`[Copilot:${this.sessionId}] Failed to flush edit attribution: ${error}`); +- } +- await this._wrapper.disconnect(); - } - - async setModel(model: string, reasoningEffort?: SessionConfig['reasoningEffort'], contextTier?: SessionConfig['contextTier']): Promise { @@ -5168,6 +7581,75 @@ index a0c0a765..00000000 - } - } - +- async startMcpServer(id: string): Promise { +- const serverName = this._mcpCustomizations.serverNameForCustomizationId(id); +- if (!serverName) { +- this._logService.warn(`[Copilot:${this.sessionId}] Cannot start unknown MCP server customization ${id}`); +- return; +- } +- try { +- await this._wrapper.session.rpc.mcp.startServer({ serverName }); +- } finally { +- // Reconcile against the SDK's real state. The live +- // `session.mcp_server_status_changed` stream already reports the +- // connect (`pending` -> `connected`/`failed`); this covers the case +- // where the start rejects before any status is emitted. +- this._seedMcpServersFromRpc(); +- } +- } +- +- private async _reconcileMcpServerEnablement(): Promise { +- const desiredCustomizations = this._stateManager.getSessionState(this.sessionUri.toString())?.customizations ?? []; +- const desiredServers = getEffectiveMcpServerCustomizations(desiredCustomizations); +- if (desiredServers.length === 0) { +- return; +- } +- await this._refreshMcpServersFromRpc(); +- let changed = false; +- for (const server of this._mcpCustomizations.serverEnablement()) { +- const desired = desiredServers.find(customization => customization.id === server.customizationId)?.enabled; +- if (desired === undefined || desired === server.enabled) { +- continue; +- } +- try { +- if (desired) { +- // Re-enabling restarts the server. The SDK reports the +- // connect live (`pending` -> `connected`/`failed`), so no +- // optimistic state is written here. Mark `changed` now +- // (before the enable) so the trailing refresh always runs +- // even if the enable rejects. +- changed = true; +- await this._wrapper.session.rpc.mcp.enable({ serverName: server.serverName }); +- } else { +- await this._disableMcpServer(server.serverName); +- changed = true; +- } +- } catch (e) { +- this._logService.error(e, `[Copilot:${this.sessionId}] Failed to ${desired ? 'enable' : 'disable'} MCP server ${server.serverName}`); +- } +- } +- if (changed) { +- await this._refreshMcpServersFromRpc(); +- } +- } +- +- private async _disableMcpServer(serverName: string): Promise { +- // disable() hangs until pending auth requests have resolved. +- // reported to the SDK folks though arguable whether it's a bug or not... +- this._cancelPendingMcpAuthRequestsForServer(serverName); +- await this._wrapper.session.rpc.mcp.disable({ serverName }); +- } +- +- async stopMcpServer(id: string): Promise { +- const serverName = this._mcpCustomizations.serverNameForCustomizationId(id); +- if (!serverName) { +- this._logService.warn(`[Copilot:${this.sessionId}] Cannot stop unknown MCP server customization ${id}`); +- return; +- } +- await this._wrapper.session.rpc.mcp.stopServer({ serverName }); +- this._mcpCustomizations.applyOne({ name: serverName, state: { kind: McpServerStatus.Stopped } }); +- } +- - /** - * Forwards an App→host `sampling/createMessage` request received - * over the AHP `mcp://` channel to `rpc.mcp.executeSampling`. The @@ -5188,10 +7670,11 @@ index a0c0a765..00000000 - const mcpRequestId = generateUuid(); - this._pendingMcpSamplings.add(requestId); - try { +- type McpExecuteSamplingParams = Parameters[0]; - const result = await this._wrapper.session.rpc.mcp.executeSampling({ - requestId, - serverName, -- mcpRequestId, +- mcpRequestId: mcpRequestId as unknown as McpExecuteSamplingParams['mcpRequestId'], - request: params, - }); - if (result.action === 'success') { @@ -5238,8 +7721,6 @@ index a0c0a765..00000000 - private async _handlePermissionRequest( - request: ITypedPermissionRequest, - ): Promise { -- this._logService.info(`[Copilot:${this.sessionId}] Permission request: kind=${request.kind}`); -- - try { - const toolCallId = request.toolCallId; - if (!toolCallId) { @@ -5247,9 +7728,61 @@ index a0c0a765..00000000 - this._logService.warn(`[Copilot:${this.sessionId}] Permission request without toolCallId, auto-denying: kind=${request.kind}`); - return { kind: 'reject' }; - } +- 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 { kind: 'reject' }; +- } +- +- const managedApprovalRequired = request.managedApprovalRequired === true; +- const autoApproval = !managedApprovalRequired && this._lastAppliedPermissionMode === 'auto' +- ? await this._takeAutoApproval(toolCallId) +- : undefined; +- const recommendation = autoApproval?.recommendation; +- if (recommendation === 'approve' && !request.requestSandboxBypass) { +- if (request.kind === 'custom-tool' +- && typeof request.toolName === 'string' +- && this._clientToolNames.has(this._clientToolName(request.toolName)) +- ) { +- const trackedToolCall = this._activeToolCalls.get(toolCallId); +- const displayName = trackedToolCall?.displayName ?? getToolDisplayName(request.toolName); +- const parameters = trackedToolCall?.parameters; +- const parentToolCallId = trackedToolCall?.parentToolCallId; +- this._onDidSessionProgress.fire({ +- kind: 'pending_confirmation', +- chat: this._chatChannelUri, +- state: { +- status: ToolCallStatus.PendingConfirmation, +- toolCallId, +- toolName: request.toolName, +- displayName, +- invocationMessage: getInvocationMessage(request.toolName, displayName, parameters, path => this._resolveEditFilePath(path)), +- toolInput: getToolInputString(request.toolName, parameters, tryStringify(parameters)), +- riskAssessment: autoApproval?.reason +- ? { +- kind: ToolCallRiskAssessmentKind.Judge, +- status: ToolCallRiskAssessmentStatus.Complete, +- reason: autoApproval.reason, +- safety: 1, +- } +- : undefined, +- }, +- parentToolCallId, +- }); +- } +- return { kind: 'approve-once' }; +- } +- +- const approvedSignature = this._approvedDuplicablePermissionSignatures.get(toolCallId); +- if (approvedSignature !== undefined) { +- this._approvedDuplicablePermissionSignatures.delete(toolCallId); +- if (!managedApprovalRequired && (request.kind === 'write' || request.kind === 'read') && safeStringify(request) === approvedSignature) { +- this._logService.info(`[Copilot:${this.sessionId}] Auto-approving duplicate ${request.kind} permission request for tool call ${toolCallId}`); +- return { kind: 'approve-once' }; +- } +- } - - const sessionResourcePath = this._getInternalSessionResourcePath(request); -- if (sessionResourcePath) { +- if (!managedApprovalRequired && sessionResourcePath) { - this._logService.info(`[Copilot:${this.sessionId}] Auto-approving internal session resource ${sessionResourcePath}`); - return { kind: 'approve-once' }; - } @@ -5261,7 +7794,7 @@ index a0c0a765..00000000 - // read those same files back, and prompting the user to - // approve a read of bytes they themselves attached is - // redundant. -- if (request.kind === 'read' && typeof request.path === 'string' +- if (!managedApprovalRequired && request.kind === 'read' && typeof request.path === 'string' - && this._isSessionAttachmentPath(request.path) - ) { - this._logService.info(`[Copilot:${this.sessionId}] Auto-approving session attachment ${request.path}`); @@ -5272,7 +7805,7 @@ index a0c0a765..00000000 - // Copilot SDK itself. The SDK spills oversized tool results to - // `os.tmpdir()/copilot-tool-output-…txt` and then asks the model - // to read them back in a follow-up turn — no need to confirm. -- if (request.kind === 'read' && typeof request.path === 'string') { +- if (!managedApprovalRequired && request.kind === 'read' && typeof request.path === 'string') { - if (isCopilotSdkToolOutputTempFile(request.path, this._environmentService.tmpDir.fsPath)) { - this._logService.info(`[Copilot:${this.sessionId}] Auto-approving Copilot SDK tool-output temp file ${request.path}`); - return { kind: 'approve-once' }; @@ -5284,7 +7817,7 @@ index a0c0a765..00000000 - // workspace, shell, or network, so prompting for them is redundant - // noise. Tools that explicitly require confirmation (e.g. revealing - // unreviewed review comments) are excluded so the user is prompted. -- if (request.kind === 'custom-tool' && typeof request.toolName === 'string' +- if (!managedApprovalRequired && request.kind === 'custom-tool' && typeof request.toolName === 'string' - && this._serverToolHost?.toolNames.includes(request.toolName) - && !this._serverToolHost.requiresConfirmation(request.toolName) - ) { @@ -5292,30 +7825,61 @@ index a0c0a765..00000000 - return { kind: 'approve-once' }; - } - -- const isShellRequest = request.kind === 'shell' -- || (request.kind === 'custom-tool' && typeof request.toolName === 'string' && isShellTool(request.toolName)); +- // The SDK's built-in terminal reports `kind: 'shell'`. The Agent Host's +- // terminal override is registered as an SDK custom tool named `bash` or +- // `powershell`, so it reports `kind: 'custom-tool'` instead. +- const customShellToolName = request.kind === 'custom-tool' +- && typeof request.toolName === 'string' +- && isShellTool(request.toolName) +- ? request.toolName +- : undefined; +- const isShellRequest = request.kind === 'shell' || customShellToolName !== undefined; +- const trackedToolName = this._activeToolCalls.get(toolCallId)?.toolName; +- const shellToolName = request.kind === 'shell' +- ? trackedToolName +- : customShellToolName; +- // Only emit a language when the executing shell tool is known. +- // Missing language fails closed in SessionPermissionManager. +- const shellLanguage: IAgentToolPendingConfirmationSignal['shellLanguage'] = +- isShellRequest && (shellToolName === 'bash' || shellToolName === 'powershell') +- ? shellToolName +- : undefined; +- if (isShellRequest && shellLanguage === undefined) { +- this._logService.warn(`[Copilot:${this.sessionId}] Shell permission request has no recognized shell tool name; requiring confirmation: toolCallId=${toolCallId}, toolName=${shellToolName ?? '(missing)'}`); +- } +- +- if (!managedApprovalRequired && request.kind === 'custom-tool' +- && typeof request.toolName === 'string' +- && this._clientToolNames.has(this._clientToolName(request.toolName)) +- && this._pendingClientToolCalls.hasBufferedResult(toolCallId) +- ) { +- this._logService.info(`[Copilot:${this.sessionId}] Auto-approving client tool ${request.toolName} because its result arrived before the permission request`); +- return { kind: 'approve-once' }; +- } - - this._logService.info(`[Copilot:${this.sessionId}] Requesting confirmation for tool call: ${toolCallId}`); - -- const deferred = new DeferredPromise(); -- this._pendingPermissions.set(toolCallId, deferred); +- const pendingPermission = this._pendingPermissions.register(toolCallId, { managedApprovalRequired }); - -- if (isShellRequest && await this._isShellSandboxedByDefault()) { +- // Auto-approve shell commands that run sandboxed by default, since the +- // sandbox already contains them. Commands that opted OUT of the sandbox +- // (`requestSandboxBypass`) are an elevation of privilege and must +- // fall through to the normal confirmation flow — otherwise enabling +- // `sandbox.allowBypass` would let the model escape the sandbox with no +- // prompt at all. +- if (!managedApprovalRequired && isShellRequest && !request.requestSandboxBypass && await this._isShellSandboxedByDefault()) { - // Session may have been disposed while we awaited the engine - // check; if so the deferred has already been settled and - // removed, so leave it alone. -- if (this._pendingPermissions.get(toolCallId) === deferred) { -- this._pendingPermissions.delete(toolCallId); +- if (this._pendingPermissions.has(toolCallId)) { +- this._pendingPermissions.respond(toolCallId, { kind: 'approve-once' }); - this._logService.info(`[Copilot:${this.sessionId}] Auto-approving sandboxed shell command for tool call ${toolCallId}`); - return { kind: 'approve-once' }; - } - return { kind: 'reject' }; - } - -- // Derive display information from the permission request kind -- const { confirmationTitle, invocationMessage, toolInput, permissionKind, permissionPath } = getPermissionDisplay(request, this._workingDirectory); -- -- // For write permission requests, build an FileEdit preview so the +- // For write permission requests, build a FileEdit preview so the - // client can show a diff before the user approves or denies. This - // awaits async filesystem operations; the SDK already calls - // `handlePermissionRequest` from an arbitrary async context, so the @@ -5330,34 +7894,54 @@ index a0c0a765..00000000 - return { kind: 'reject' }; - } - +- const isNewFile = edits?.items.some(edit => !edit.before && !!edit.after); +- const { confirmationTitle, invocationMessage, toolInput, permissionKind, permissionPath } = getPermissionDisplay(request, this._workingDirectory, isNewFile); +- - // Fire a pending_confirmation signal to transition the tool to PendingConfirmation - const toolName = request.toolName ?? request.kind; - // Forward the tool's parentToolCallId (if any) so the host can - // route the resulting ChatToolCallReady to the correct - // subagent session — without it the action would land on the - // parent session, which has no matching ChatToolCallStart. -- const parentToolCallId = this._activeToolCalls.get(toolCallId)?.parentToolCallId; +- const trackedToolCall = this._activeToolCalls.get(toolCallId); +- const parentToolCallId = trackedToolCall?.parentToolCallId; - this._onDidSessionProgress.fire({ - kind: 'pending_confirmation', -- session: this.sessionUri, +- chat: this._chatChannelUri, - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId, - toolName, - displayName: getToolDisplayName(toolName), +- contributor: trackedToolCall?.contributor, +- intention: trackedToolCall?.intention, - invocationMessage, - toolInput, - confirmationTitle, +- riskAssessment: autoApproval?.reason +- ? { +- kind: ToolCallRiskAssessmentKind.Judge, +- status: ToolCallRiskAssessmentStatus.Complete, +- reason: autoApproval.reason, +- safety: recommendation === 'approve' ? 1 : 0, +- } +- : undefined, - edits, - }, - permissionKind, - permissionPath, +- managedApprovalRequired, +- requestSandboxBypass: request.requestSandboxBypass, +- shellLanguage, - parentToolCallId, - }); - -- const approved = await deferred.p; -- this._logService.info(`[Copilot:${this.sessionId}] Permission response: toolCallId=${toolCallId}, approved=${approved}`); -- return { kind: approved ? 'approve-once' : 'reject' }; +- const result = await pendingPermission; +- this._logService.info(`[Copilot:${this.sessionId}] Permission response: toolCallId=${toolCallId}, result=${result.kind}`); +- if (!managedApprovalRequired && result.kind === 'approve-once' && (request.kind === 'write' || request.kind === 'read')) { +- this._approvedDuplicablePermissionSignatures.set(toolCallId, safeStringify(request)); +- } +- return result; - } catch (error) { - this._logService.error(error, `[Copilot:${this.sessionId}] Failed to handle permission request: kind=${request.kind}, toolCallId=${request.toolCallId ?? 'missing'}`); - throw error; @@ -5405,16 +7989,16 @@ index a0c0a765..00000000 - * terminal tool is enabled) or through the SDK's built-in shell tool wrapped - * by the `sandboxConfig` we pushed via `session.options.update`. - * -- * In both cases the shell tool prompts on its own when escalating to -- * unsandboxed execution, so the SDK's pre-call `shell` permission prompt -- * is redundant and we auto-approve it. +- * Callers use this to auto-approve shell permission prompts that the sandbox +- * already contains. Commands that explicitly opt out of the sandbox +- * (`requestSandboxBypass`) are excluded by the caller, since the +- * sandbox no longer contains them. - * - * Returns false when neither sandbox path is configured, so the standard - * confirmation flow is preserved. - */ - private async _isShellSandboxedByDefault(): Promise { -- const customTerminalToolEnabled = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.EnableCustomTerminalTool) === true; -- if (customTerminalToolEnabled) { +- if (this._isCustomTerminalToolEnabled()) { - if (!this._shellManager) { - return false; - } @@ -5423,8 +8007,150 @@ index a0c0a765..00000000 - // SDK-managed shell path: gate on the same host config that - // `CopilotSessionLauncher` reads when forwarding `sandboxConfig` to - // the SDK, so the two stay in lock-step. +- return this._computeSdkSandboxConfig() !== undefined; +- } +- +- /** +- * `true` when the AgentHost's own shell tools (wrapped by +- * {@link TerminalSandboxEngine}) replace the SDK's built-in shell. In that +- * mode the SDK sandbox config is unused, so we neither forward nor toggle it. +- */ +- private _isCustomTerminalToolEnabled(): boolean { +- return this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.EnableCustomTerminalTool) === true; +- } +- +- /** +- * The SDK-shaped sandbox policy for this session, mirroring +- * {@link CopilotSessionLauncher}'s computation: `undefined` when the custom +- * terminal tool is enabled (the host's own terminal sandbox engine handles +- * containment) or when the host sandbox config evaluates to disabled +- * (including on Windows, where the sandbox is not supported). +- */ +- private _computeSdkSandboxConfig(): ISdkSandboxConfig | undefined { +- if (this._isCustomTerminalToolEnabled()) { +- return undefined; +- } +- const sandbox = this._configurationService.getRootValue(sandboxConfigSchema, AgentHostSandboxConfigKey.Sandbox); +- return buildSandboxConfigForSdk(this._platform, sandbox); +- } +- +- /** +- * `true` when the session runs with bypass approvals — either the global +- * auto-approve setting or the session's `autoApprove` ("Allow All") +- * level. Agent mode is an orthogonal axis and does not affect approvals. +- */ +- private _isBypassApprovals(): boolean { +- if (this._configurationService.getRootValue(platformRootSchema, AgentHostGlobalAutoApproveEnabledConfigKey) === true) { +- return true; +- } +- return this._configurationService.getEffectiveValue(this._storageUri.toString(), platformSessionSchema, SessionConfigKey.AutoApprove) === 'autoApprove'; +- } +- +- private _getSdkPermissionMode(): PermissionAllowAllMode { +- if (this._isBypassApprovals()) { +- return 'on'; +- } +- return this._getConfiguredApprovalLevel() === 'assisted' +- ? 'auto' +- : 'off'; +- } +- +- private _getConfiguredApprovalLevel(): string { +- return this._configurationService.getEffectiveValue(this._storageUri.toString(), platformSessionSchema, SessionConfigKey.AutoApprove) ?? 'default'; +- } +- +- private _getConfiguredAgentMode(): string { +- return this._configurationService.getEffectiveValue(this._storageUri.toString(), platformSessionSchema, SessionConfigKey.Mode) ?? 'interactive'; +- } +- +- private _subscribeToPermissionConfigChanges(): void { +- this._register(this._configurationService.onDidRootConfigChange(() => { +- void this._syncPermissionModeAfterConfigChange(); +- })); +- this._register(this._configurationService.onDidSessionConfigChange(event => { +- if (event.session === this._storageUri.toString() && Object.hasOwn(event.config, SessionConfigKey.AutoApprove)) { +- void this._syncPermissionModeAfterConfigChange(); +- } +- })); +- } +- +- private async _syncPermissionModeAfterConfigChange(): Promise { +- try { +- await this.syncPermissionMode('config-change'); +- } catch (error) { +- this._logService.error(error, `[Copilot:${this.sessionId}] Failed to apply permission config change; aborting active turn`); +- try { +- await this.abort(); +- } catch (abortError) { +- this._logService.error(abortError, `[Copilot:${this.sessionId}] Failed to abort after permission config sync failure`); +- } +- } +- } +- +- private async _takeAutoApproval(toolCallId: string): Promise { +- if (this._autoApprovals.has(toolCallId)) { +- const autoApproval = this._autoApprovals.get(toolCallId) ?? undefined; +- this._autoApprovals.delete(toolCallId); +- return autoApproval; +- } +- return this._pendingAutoApprovals.register(toolCallId); +- } +- +- private _recordAutoApproval(toolCallId: string, autoApproval: PermissionAutoApproval | undefined): void { +- if (this._pendingAutoApprovals.respond(toolCallId, autoApproval)) { +- return; +- } +- this._autoApprovals.set(toolCallId, autoApproval ?? null); +- } +- +- syncPermissionMode(source: 'config-change' | 'turn-start'): Promise { +- return this._permissionModeSequencer.queue(async () => { +- const mode = this._getSdkPermissionMode(); +- const configuredLevel = this._getConfiguredApprovalLevel(); +- 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 === 'auto'; +- if (this._autoApprovalExperimentalModeEnabled !== experimentalModeEnabled) { +- const experimentalResult = await this._wrapper.session.rpc.options.update({ isExperimentalMode: experimentalModeEnabled }); +- if (!experimentalResult.success) { +- throw new Error(`Copilot SDK rejected experimental mode update required by permission mode '${mode}'`); +- } +- this._autoApprovalExperimentalModeEnabled = experimentalModeEnabled; +- this._logService.info(`[Copilot:${this.sessionId}] ${experimentalModeEnabled ? 'Enabled' : 'Disabled'} SDK experimental mode for permission mode '${mode}'`); +- } +- if (this._lastAppliedPermissionMode === mode) { +- return; +- } +- const result = await this._wrapper.session.rpc.permissions.setAllowAll({ mode }); +- if (!result.success || (result.mode !== undefined && result.mode !== mode)) { +- throw new Error(`Copilot SDK rejected permission mode '${mode}'`); +- } +- this._lastAppliedPermissionMode = mode; +- }); +- } +- +- /** +- * Apply the SDK sandbox policy for the request that is about to be sent. +- * +- * Skips the SDK sandbox entirely when the custom terminal tool is enabled +- * (the host's own terminal sandbox engine handles containment and the SDK's +- * built-in shell is unused). Otherwise it always pushes the effective state +- * so the SDK never retains a stale or auto-discovered sandbox: the +- * configured policy unless the request runs with bypass approvals, or an +- * explicitly disabled sandbox when no sandbox is configured (setting off, +- * or Windows). +- */ +- private async _applyEffectiveSandboxConfig(): Promise { +- if (this._isCustomTerminalToolEnabled()) { +- return; +- } - const sandbox = this._configurationService.getRootValue(sandboxConfigSchema, AgentHostSandboxConfigKey.Sandbox); -- return buildSandboxConfigForSdk(process.platform, sandbox) !== undefined; +- const base = buildSandboxConfigForSdk(this._platform, sandbox); +- const sandboxConfig: ISdkSandboxConfig | { enabled: false } = (base && !this._isBypassApprovals()) ? base : { enabled: false }; +- try { +- await this._wrapper.session.rpc.options.update({ sandboxConfig }); +- } catch (err) { +- this._logService.warn(`[Copilot:${this.sessionId}] Failed to update sandbox config for request`, err); +- } - } - - /** @@ -5460,7 +8186,7 @@ index a0c0a765..00000000 - this._logService.warn(`[Copilot:${this.sessionId}] Failed to check file for edit preview: ${filePath}`, err); - } - -- const afterUri = buildPendingEditContentUri(this.sessionUri.toString(), toolCallId, filePath); +- const afterUri = buildPendingEditContentUri(this._storageUri.toString(), toolCallId, filePath); - try { - await this._fileService.writeFile(afterUri, VSBuffer.fromString(newFileContents)); - } catch (err) { @@ -5490,19 +8216,15 @@ index a0c0a765..00000000 - } - - respondToPermissionRequest(requestId: string, approved: boolean): boolean { -- const deferred = this._pendingPermissions.get(requestId); -- if (deferred) { -- this._pendingPermissions.delete(requestId); +- if (this._pendingPermissions.respond(requestId, approved ? { kind: 'approve-once' } : { kind: 'denied-interactively-by-user' })) { - this._deletePendingEditContent(requestId); -- deferred.complete(approved); - return true; - } - return false; - } - - private async _requestUnsandboxedCommandConfirmation(request: IUnsandboxedCommandConfirmationRequest): Promise { -- const deferred = new DeferredPromise(); -- this._pendingPermissions.set(request.toolCallId, deferred); +- const pendingPermission = this._pendingPermissions.register(request.toolCallId, { managedApprovalRequired: false }); - - const displayName = getToolDisplayName(request.toolName); - const blockedDomains = request.blockedDomains?.length ? request.blockedDomains.join(', ') : undefined; @@ -5515,9 +8237,10 @@ index a0c0a765..00000000 - ? localize('agentHost.unsandboxedCommandConfirmation.blockedDomains', "This command needs to access blocked network domain(s): {0}.", blockedDomains) - : localize('agentHost.unsandboxedCommandConfirmation.generic', "This command needs to run outside the sandbox."); - +- const parentToolCallId = this._activeToolCalls.get(request.toolCallId)?.parentToolCallId; - this._onDidSessionProgress.fire({ - kind: 'pending_confirmation', -- session: this.sessionUri, +- chat: this._chatChannelUri, - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId: request.toolCallId, @@ -5533,67 +8256,85 @@ index a0c0a765..00000000 - // Mirrors the workbench's sandbox-aware analyzer, which forces - // `isAutoApproveAllowed: false` whenever `requiresUnsandboxConfirmation` - // is set. -- parentToolCallId: this._activeToolCalls.get(request.toolCallId)?.parentToolCallId, +- parentToolCallId, - }); - -- return deferred.p; +- return (await pendingPermission).kind === 'approve-once'; - } - - // ---- user input handling ------------------------------------------------ - - /** -- * Handles a user input request from the SDK (ask_user tool) by firing a -- * `user_input_request` progress event and waiting for the renderer to -- * respond via {@link respondToUserInputRequest}. +- * Handles a user input request from the SDK (ask_user tool). Auto-answers when the user is unavailable; otherwise waits for the renderer to respond via {@link respondToUserInputRequest}. - */ - private async _handleUserInputRequest( - request: UserInputRequest, - _invocation: { sessionId: string }, - ): Promise { +- const requestId = generateUuid(); +- const questionId = generateUuid(); +- const inputRequest: ChatInputRequest = { +- id: requestId, +- questions: [request.choices && request.choices.length > 0 +- ? { +- kind: ChatInputQuestionKind.SingleSelect, +- id: questionId, +- message: request.question, +- required: true, +- options: request.choices.map(c => ({ id: c, label: c })), +- allowFreeformInput: request.allowFreeform ?? true, +- } +- : { +- kind: ChatInputQuestionKind.Text, +- id: questionId, +- message: request.question, +- required: true, +- }, +- ], +- }; +- - const isAutopilot = this._isAutopilotMode(); -- if (isAutopilot) { +- if (isAutopilot || this._isAutoReplyEnabled()) { +- this._emitAction({ +- type: ActionType.ChatInputRequested, +- request: inputRequest, +- }); +- this._emitAction({ +- type: ActionType.ChatInputCompleted, +- requestId, +- response: ChatInputResponseKind.Accept, +- answers: { +- [questionId]: { +- state: ChatInputAnswerState.Submitted, +- value: { +- kind: ChatInputAnswerValueKind.Text, +- value: AgentHostAutoReplyAnswer, +- }, +- }, +- }, +- }); - return { -- answer: 'The user is not available to answer your question. Choose a pragmatic option best aligned with the context of the request.', +- answer: AgentHostAutoReplyAnswer, - wasFreeform: true, - }; - } +- if (!this.hasActiveTurn) { +- this._logService.warn(`[Copilot:${this.sessionId}] Rejecting user input request without an active turn`); +- return { answer: 'No active turn', wasFreeform: true }; +- } - - const questionPreview = request.question.substring(0, 100); - try { -- const requestId = generateUuid(); -- const questionId = generateUuid(); - this._logService.info(`[Copilot:${this.sessionId}] User input request: requestId=${requestId}, question="${questionPreview}"`); - -- const deferred = new DeferredPromise<{ response: ChatInputResponseKind; answers?: Record }>(); -- this._pendingUserInputs.set(requestId, { deferred, questionId }); -- -- // Build the protocol ChatInputRequest from the SDK's simple format -- const inputRequest: ChatInputRequest = { -- id: requestId, -- questions: [request.choices && request.choices.length > 0 -- ? { -- kind: ChatInputQuestionKind.SingleSelect, -- id: questionId, -- message: request.question, -- required: true, -- options: request.choices.map(c => ({ id: c, label: c })), -- allowFreeformInput: request.allowFreeform ?? true, -- } -- : { -- kind: ChatInputQuestionKind.Text, -- id: questionId, -- message: request.question, -- required: true, -- }, -- ], -- }; +- const pendingInput = this._pendingUserInputs.register(requestId, { questionId }); - - this._emitAction({ - type: ActionType.ChatInputRequested, - request: inputRequest, - }); - -- const result = await deferred.p; +- const result = await pendingInput; - this._logService.info(`[Copilot:${this.sessionId}] User input response: requestId=${requestId}, response=${result.response}`); - - if (result.response !== ChatInputResponseKind.Accept || !result.answers) { @@ -5642,6 +8383,10 @@ index a0c0a765..00000000 - if (isAutopilot) { - return { action: 'cancel' }; - } +- if (!this.hasActiveTurn) { +- this._logService.warn(`[Copilot:${this.sessionId}] Rejecting elicitation request without an active turn`); +- return { action: 'decline' }; +- } - - const messagePreview = context.message.substring(0, 100); - try { @@ -5654,8 +8399,7 @@ index a0c0a765..00000000 - ? Object.entries(schema.properties).map(([fieldName, field]) => elicitationFieldToQuestion(fieldName, field, requiredSet.has(fieldName))) - : undefined; - -- const deferred = new DeferredPromise<{ response: ChatInputResponseKind; answers?: Record }>(); -- this._pendingElicitations.set(requestId, { deferred, schema }); +- const pendingElicitation = this._pendingElicitations.register(requestId, { schema }); - - const inputRequest: ChatInputRequest = { - id: requestId, @@ -5669,7 +8413,7 @@ index a0c0a765..00000000 - request: inputRequest, - }); - -- const result = await deferred.p; +- const result = await pendingElicitation; - this._logService.info(`[Copilot:${this.sessionId}] Elicitation response: requestId=${requestId}, response=${result.response}`); - - if (result.response === ChatInputResponseKind.Decline) { @@ -5701,24 +8445,16 @@ index a0c0a765..00000000 - } - - respondToUserInputRequest(requestId: string, response: ChatInputResponseKind, answers?: Record): boolean { -- const pendingPlanReview = this._pendingPlanReviews.get(requestId); +- const pendingPlanReview = this._pendingPlanReviews.getMetadata(requestId); - if (pendingPlanReview) { -- this._pendingPlanReviews.delete(requestId); -- pendingPlanReview.deferred.complete(this._resolveExitPlanMode(pendingPlanReview, response, answers)); -- return true; +- return this._pendingPlanReviews.respond(requestId, this._resolveExitPlanMode(pendingPlanReview, response, answers)); - } - -- const pendingElicitation = this._pendingElicitations.get(requestId); -- if (pendingElicitation) { -- this._pendingElicitations.delete(requestId); -- pendingElicitation.deferred.complete({ response, answers }); +- if (this._pendingElicitations.respond(requestId, { response, answers })) { - return true; - } - -- const pending = this._pendingUserInputs.get(requestId); -- if (pending) { -- this._pendingUserInputs.delete(requestId); -- pending.deferred.complete({ response, answers }); +- if (this._pendingUserInputs.respond(requestId, { response, answers })) { - return true; - } - return false; @@ -5809,7 +8545,7 @@ index a0c0a765..00000000 - return { - approved: true, - selectedAction, -- ...(isAutopilot ? { autoApproveEdits: true } : {}), +- ...(isAutopilot && this._isBypassApprovals() ? { autoApproveEdits: true } : {}), - }; - } - @@ -5857,18 +8593,94 @@ index a0c0a765..00000000 - } - } - -- // ---- event wiring ------------------------------------------------------- +- private async _beginRepoInfoTelemetry(telemetryMessageId: string, clientType: AgentHostClientType, isCurrent: () => boolean): Promise<{ readonly context: IAgentHostRestrictedTelemetryContext; readonly baseBranch: string | undefined } | undefined> { +- let resolved: { readonly context: IAgentHostRestrictedTelemetryContext; readonly baseBranch: string | undefined } | undefined; +- try { +- resolved = await this._resolveRepoInfoTelemetryContext(); +- } catch (error) { +- this._logService.warn(`[Copilot:${this.sessionId}] Failed to resolve repository info telemetry context: ${getErrorMessage(error)}`); +- return undefined; +- } +- if (!resolved || this._store.isDisposed || !isCurrent()) { +- return undefined; +- } +- await this._repoInfoTelemetry.reportBegin(resolved.context, this.sessionUri.toString(), telemetryMessageId, clientType, this._workingDirectory, resolved.baseBranch, isCurrent); +- return resolved; +- } - -- private _subscribeToEvents(): void { -- const wrapper = this._wrapper; -- const sessionId = this.sessionId; +- private async _endRepoInfoTelemetry(telemetryMessageId: string, resolved: { readonly context: IAgentHostRestrictedTelemetryContext; readonly baseBranch: string | undefined } | undefined, isCurrent: () => boolean): Promise { +- if (!resolved || this._store.isDisposed || !isCurrent()) { +- return; +- } +- await this._repoInfoTelemetry.reportEnd(resolved.context, this.sessionUri.toString(), telemetryMessageId, this._workingDirectory, resolved.baseBranch, isCurrent); +- } - -- this._register(wrapper.onSystemNotification(e => { -- const notification = buildCopilotSystemNotification(e); -- if (!notification) { -- this._logService.trace(`[Copilot:${sessionId}] Ignoring system.notification kind=${e.data.kind.type}`); -- return; -- } +- private _completeActiveRepoInfoTelemetry(): void { +- const turn = this._activeRepoInfoTurn; +- if (!turn) { +- return; +- } +- this._activeRepoInfoTurn = undefined; +- const isCurrent = () => !turn.cancelled && this._isLaunchTokenCurrent(); +- void turn.begin.then(resolved => this._endRepoInfoTelemetry(turn.telemetryMessageId, resolved, isCurrent)); +- } +- +- private _cancelActiveRepoInfoTelemetry(): void { +- const turn = this._activeRepoInfoTurn; +- if (!turn) { +- return; +- } +- this._activeRepoInfoTurn = undefined; +- turn.cancelled = true; +- void turn.begin.finally(() => this._repoInfoTelemetry.clearTurn(turn.telemetryMessageId)); +- } +- +- private async _resolveRepoInfoTelemetryContext(): Promise<{ readonly context: IAgentHostRestrictedTelemetryContext; readonly baseBranch: string | undefined } | undefined> { +- if (this._configurationService.getRootValue(platformRootSchema, AgentHostDisableRepoInfoTelemetryConfigKey) === true) { +- return undefined; +- } +- const githubToken = this._launchPlan.githubToken; +- if (!githubToken) { +- return undefined; +- } +- const [rawContext, baseBranch] = await Promise.all([ +- this._copilotApiService.resolveRestrictedTelemetryContext(githubToken), +- this._databaseRef.object.getMetadata(META_DIFF_BASE_BRANCH), +- ]); +- if (!rawContext.restrictedTelemetryEnabled && !rawContext.isInternal) { +- return undefined; +- } +- return { context: this._toRepoInfoTelemetryContext(rawContext), baseBranch }; +- } +- +- private _isLaunchTokenCurrent(): boolean { +- return this._launchPlan.githubToken !== undefined && this._isLaunchTokenStillCurrent(); +- } +- +- private _toRepoInfoTelemetryContext(context: IRestrictedTelemetryContext): IAgentHostRestrictedTelemetryContext { +- return { +- restrictedTelemetryEnabled: context.restrictedTelemetryEnabled, +- trackingId: context.trackingId, +- telemetryEndpoint: context.telemetryEndpoint ? `${context.telemetryEndpoint.replace(/\/+$/, '')}/telemetry` : undefined, +- isInternal: context.isInternal === true, +- userName: context.userName, +- isVscodeTeamMember: context.isVscodeTeamMember === true, +- copilotIgnoreEnabled: context.copilotIgnoreEnabled, +- }; +- } +- +- // ---- event wiring ------------------------------------------------------- +- +- private _subscribeToEvents(): void { +- const wrapper = this._wrapper; +- const sessionId = this.sessionId; +- +- this._register(wrapper.onSystemNotification(e => { +- const notification = buildCopilotSystemNotification(e); +- if (!notification) { +- this._logService.trace(`[Copilot:${sessionId}] Ignoring system.notification kind=${e.data.kind.type}`); +- return; +- } - - this._logService.info(`[Copilot:${sessionId}] System notification received: kind=${e.data.kind.type}`); - if (this._turnId) { @@ -5877,17 +8689,22 @@ index a0c0a765..00000000 - turnId: this._turnId, - part: { - kind: ResponsePartKind.SystemNotification, -- content: notification.content, +- content: notification.messageText, - }, - }); - return; - } +- if (!notification.startsTurn) { +- this._logService.trace(`[Copilot:${sessionId}] Ignoring passive system.notification kind=${e.data.kind.type} without an active turn`); +- return; +- } - - const turnId = generateUuid(); - this.resetTurnState(turnId); - this._emitAction({ - type: ActionType.ChatTurnStarted, - turnId, +- startedAt: new Date().toISOString(), - message: { - text: notification.messageText, - origin: { kind: MessageKind.SystemNotification }, @@ -5897,11 +8714,9 @@ index a0c0a765..00000000 - - // Handle `user.message` events with three responsibilities: - // -- // 1. Skip SDK-injected (`source !== 'user'`) messages outright — -- // they are skill content / harness injections that must not -- // surface to the user and must not be associated with a turn -- // boundary (the SDK's truncate/fork mapping keys off the -- // user-visible message's event id). +- // 1. Skip subagent and SDK-injected (`source !== 'user'`) messages +- // outright — neither represents a root user turn and neither may +- // be associated with the root turn boundary. - // - // 2. If the content matches a steering message we acknowledged - // via {@link sendSteering}, promote it to its own protocol @@ -5915,6 +8730,10 @@ index a0c0a765..00000000 - // so doing this for synthetic injections would permanently - // pin the wrong event to the turn. - this._register(wrapper.onUserMessage(e => { +- if (e.agentId) { +- this._resumeSubagentForEvent(e, { text: e.data.content, origin: { kind: MessageKind.User } }); +- return; +- } - if (e.data.source && e.data.source.toLowerCase() !== 'user') { - return; - } @@ -5931,6 +8750,7 @@ index a0c0a765..00000000 - - this._register(wrapper.onMessageDelta(e => { - this._logService.trace(`[Copilot:${sessionId}] delta: ${e.data.deltaContent}`); +- this._resumeSubagentForEvent(e); - if (this._shouldDropUnmappedSubagentEvent(e, 'assistant.message_delta')) { - return; - } @@ -5939,6 +8759,40 @@ index a0c0a765..00000000 - - this._register(wrapper.onMessage(e => { - this._logService.info(`[Copilot:${sessionId}] Full message received: ${e.data.content.length} chars`); +- this._resumeSubagentForEvent(e); +- // Report the enhanced GH `request.options.tools` event for this model call — parity with +- // the Copilot extension, which emits it per LLM request. `assistant.message` is the +- // agent-host's per-model-call boundary; we correlate on its client-minted `x-request-id`. +- // Main agent only: `_appliedSnapshot.tools` is the session's tool set, which does not +- // describe a subagent's model call, so subagent messages (mapped or dropped) are skipped. +- if (!e.agentId) { +- const clientType = this._currentTurn?.clientType ?? AgentHostClientType.Unknown; +- void this._telemetryReporter.assistantMessageReceived(this.sessionUri.toString(), clientType, e.data.clientRequestId, this._appliedSnapshot.tools).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.sessionUri.toString(), clientType, e.data.content, this._turnOrdinal, e.data.clientRequestId).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 +- // too); the tool-count stats only apply to rounds that carried tool requests. +- const turn = this._currentTurn; +- if (turn) { +- turn.toolCallRounds++; +- if (e.data.model) { +- turn.lastModel = e.data.model; +- } +- const toolRequests = e.data.toolRequests; +- if (toolRequests?.length) { +- turn.totalToolCalls += toolRequests.length; +- if (toolRequests.length > 1) { +- turn.parallelToolCallRounds++; +- turn.parallelToolCallsTotal += toolRequests.length; +- } +- for (const req of toolRequests) { +- turn.toolCounts.set(req.name, (turn.toolCounts.get(req.name) ?? 0) + 1); +- } +- } +- } +- } - // The SDK fires a `message` event with the full assembled content after - // streaming deltas. If deltas already created a markdown part for this - // turn, the live state is up to date and we skip. Only emit a fresh @@ -5946,45 +8800,126 @@ index a0c0a765..00000000 - // where the SDK delivered the full message at once). - // - // Other fields (toolRequests, reasoningText, encryptedContent) are -- // only used for history reconstruction and live tool calls fire -- // their own tool_start events, so we can safely drop them here. -- if (!e.data.content) { -- return; -- } +- // only used for history reconstruction and live tool calls fire their +- // own tool_start events, so we can safely drop them here. - if (this._shouldDropUnmappedSubagentEvent(e, 'assistant.message')) { - return; - } - const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); - const markdownScope = parentToolCallId ?? ''; -- if (this._currentTurn?.markdownPartIds.has(markdownScope)) { +- if (e.data.content && !this._currentTurn?.markdownPartIds.has(markdownScope)) { +- const partId = generateUuid(); +- this._currentTurn?.markdownPartIds.set(markdownScope, partId); +- this._emitAction({ +- type: ActionType.ChatResponsePart, +- turnId: this._turnId, +- part: { kind: ResponsePartKind.Markdown, id: partId, content: e.data.content }, +- }, parentToolCallId); +- } +- if (e.data.toolRequests?.length) { +- // Wait for the full message boundary; clearing on an earlier tool delta would duplicate assembled markdown. +- this._beginToolCallRound(parentToolCallId); +- } +- })); +- +- // TODO@connor4312: Remove this correlation once the SDK permission callback includes auto-approval data. +- this._register(wrapper.onPermissionRequested(e => { +- const toolCallId = e.data.permissionRequest.toolCallId; +- if (!toolCallId) { - return; - } -- const partId = generateUuid(); -- this._currentTurn?.markdownPartIds.set(markdownScope, partId); -- this._emitAction({ -- type: ActionType.ChatResponsePart, -- turnId: this._turnId, -- part: { kind: ResponsePartKind.Markdown, id: partId, content: e.data.content }, -- }, parentToolCallId); +- this._recordAutoApproval(toolCallId, e.data.promptRequest?.autoApproval); +- const existing = this._toolApprovalRecords.get(toolCallId); +- const permissionRequest = e.data.permissionRequest as { requestSandboxBypass?: boolean; toolName?: string }; +- this._toolApprovalRecords.set(toolCallId, { +- permissionRequested: true, +- resolvedByHook: existing?.resolvedByHook || e.data.resolvedByHook === true, +- requestSandboxBypass: existing?.requestSandboxBypass || permissionRequest.requestSandboxBypass === true, +- resultKind: existing?.resultKind, +- toolName: existing?.toolName ?? permissionRequest.toolName, +- mcpServerName: existing?.mcpServerName, +- reported: existing?.reported ?? false, +- }); +- })); +- +- this._register(wrapper.onPermissionCompleted(e => { +- const toolCallId = e.data.toolCallId; +- if (!toolCallId) { +- return; +- } +- const existing = this._toolApprovalRecords.get(toolCallId); +- const record = { +- permissionRequested: existing?.permissionRequested ?? true, +- resolvedByHook: existing?.resolvedByHook ?? false, +- requestSandboxBypass: existing?.requestSandboxBypass ?? false, +- resultKind: e.data.result.kind, +- toolName: existing?.toolName, +- mcpServerName: existing?.mcpServerName, +- reported: existing?.reported ?? false, +- }; +- this._toolApprovalRecords.set(toolCallId, record); +- this._reportToolApproval(toolCallId, record.toolName, record.mcpServerName); +- if (isPermissionDeniedKind(record.resultKind)) { +- this._toolApprovalRecords.delete(toolCallId); +- } +- })); +- +- this._register(wrapper.onToolCallDelta(e => { +- this._logService.trace(`[Copilot:${sessionId}] Tool call delta: ${e.data.toolName ?? ''} (${e.data.toolCallId})`); +- this._resumeSubagentForEvent(e); +- if (this._shouldDropUnmappedSubagentEvent(e, 'assistant.tool_call_delta')) { +- return; +- } +- +- const existing = this._streamingToolCalls.get(e.data.toolCallId); +- const streaming = existing ?? { +- input: '', +- toolName: undefined, +- parentToolCallId: undefined, +- started: false, +- displayedInputLength: 0, +- displayedMessage: undefined, +- }; +- streaming.input += e.data.inputDelta; +- if (e.data.toolName) { +- if (streaming.toolName && streaming.toolName !== e.data.toolName) { +- this._logService.warn(`[Copilot:${sessionId}] Tool call ${e.data.toolCallId} changed name while streaming from ${streaming.toolName} to ${e.data.toolName}`); +- } else { +- streaming.toolName = e.data.toolName; +- } +- } +- this._streamingToolCalls.set(e.data.toolCallId, streaming); +- +- const toolName = streaming.toolName; +- if (!toolName || isHiddenTool(toolName) || isTaskCompleteTool(toolName) || this._clientToolNames.has(this._clientToolName(toolName))) { +- return; +- } +- if (!streaming.started) { +- streaming.parentToolCallId = this._parentToolCallIdForSubagentEvent(e); +- } +- +- if (!streaming.started) { +- streaming.started = true; +- this._emitAction({ +- type: ActionType.ChatToolCallStart, +- turnId: this._turnId, +- toolCallId: e.data.toolCallId, +- toolName, +- displayName: getToolDisplayName(toolName), +- contributor: this._getToolCallContributor(toolName, undefined), +- _meta: toToolCallMeta(this._createToolCallMeta(toolName, undefined)), +- }, streaming.parentToolCallId); +- this._emitStreamingToolCallDisplay(e.data.toolCallId, streaming); +- return; +- } +- this._scheduleStreamingToolCallDisplay(e.data.toolCallId); - })); - - this._register(wrapper.onToolStart(e => { - if (isHiddenTool(e.data.toolName)) { +- this._streamingToolDisplaySchedulers.deleteAndDispose(e.data.toolCallId); +- this._streamingToolCalls.delete(e.data.toolCallId); - this._logService.trace(`[Copilot:${sessionId}] Tool started (hidden): ${e.data.toolName}`); -- // The CLI uses the `report_intent` tool to signal what the -- // agent is currently doing. Surface this as session activity -- // so the UI can show a live "what is the agent doing now?" -- // hint while the turn is in progress. -- if (e.data.toolName === 'report_intent') { -- const intent = (e.data.arguments as { intent?: unknown } | undefined)?.intent; -- if (typeof intent === 'string' && intent.length > 0) { -- this._hasReportedActivity = true; -- this._emitAction({ -- type: ActionType.SessionActivityChanged, -- activity: intent, -- }); -- } -- } - return; - } - this._logService.info(`[Copilot:${sessionId}] Tool started: ${e.data.toolName}`); @@ -6000,61 +8935,72 @@ index a0c0a765..00000000 - toolArgs = tryStringify(parameters); - } - const displayName = getToolDisplayName(e.data.toolName); -- if (this._shouldDropUnmappedSubagentEvent(e, 'tool.execution_start')) { +- const streamed = this._streamingToolCalls.get(e.data.toolCallId); +- this._streamingToolDisplaySchedulers.deleteAndDispose(e.data.toolCallId); +- if (streamed?.started && streamed.displayedInputLength < streamed.input.length) { +- this._emitStreamingToolCallDisplay(e.data.toolCallId, streamed); +- } +- this._streamingToolCalls.delete(e.data.toolCallId); +- if (streamed?.toolName && streamed.toolName !== e.data.toolName) { +- this._logService.warn(`[Copilot:${sessionId}] Tool call ${e.data.toolCallId} started as ${e.data.toolName} after streaming as ${streamed.toolName}`); +- } +- this._resumeSubagentForEvent(e); +- if (!streamed?.started && this._shouldDropUnmappedSubagentEvent(e, 'tool.execution_start')) { +- this._unroutableSubagentToolCallIds.add(e.data.toolCallId); - return; - } -- const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); -- this._activeToolCalls.set(e.data.toolCallId, { toolName: e.data.toolName, displayName, parameters, content: [], parentToolCallId, startTimeMs: Date.now(), mcpServerName: e.data.mcpServerName, meta: undefined }); +- const parentToolCallId = streamed?.parentToolCallId ?? this._parentToolCallIdForSubagentEvent(e); +- const clientToolName = this._clientToolName(e.data.toolName); +- const isClientTool = this._clientToolNames.has(clientToolName); +- const contributor = this._getToolCallContributor(e.data.toolName, e.data.mcpServerName); +- const intention = getShellIntention(e.data.toolName, parameters); +- this._activeToolCalls.set(e.data.toolCallId, { +- toolName: e.data.toolName, +- displayName, +- parameters, +- content: [], +- parentToolCallId, +- mcpServerName: e.data.mcpServerName, +- contributor, +- intention, +- meta: undefined, +- }); +- const existingApproval = this._toolApprovalRecords.get(e.data.toolCallId); +- const approvalRecord = { +- permissionRequested: existingApproval?.permissionRequested ?? false, +- resolvedByHook: existingApproval?.resolvedByHook ?? false, +- requestSandboxBypass: existingApproval?.requestSandboxBypass ?? false, +- resultKind: existingApproval?.resultKind, +- toolName: e.data.toolName, +- mcpServerName: e.data.mcpServerName, +- reported: existingApproval?.reported ?? false, +- }; +- this._toolApprovalRecords.set(e.data.toolCallId, approvalRecord); +- if (approvalRecord.resultKind !== undefined) { +- this._reportToolApproval(e.data.toolCallId, e.data.toolName, e.data.mcpServerName); +- } +- if (isShellTool(e.data.toolName)) { +- this._nonPtyShellTerminals.track(e.data.toolCallId, displayName); +- } - if (isTaskCompleteTool(e.data.toolName)) { -- const scope = parentToolCallId ?? ''; -- this._currentTurn?.markdownPartIds.delete(scope); -- this._currentTurn?.reasoningPartIds.delete(scope); +- this._beginToolCallRound(parentToolCallId); - return; - } -- const toolKind = getToolKind(e.data.toolName); -- const subagentMeta = toolKind === 'subagent' ? getSubagentMetadata(parameters) : undefined; -- -- let contributor: { readonly kind: ToolCallContributorKind.Client; readonly clientId: string } | { readonly kind: ToolCallContributorKind.MCP; readonly customizationId: string } | undefined; -- const isClientTool = this._clientToolNames.has(e.data.toolName); -- if (isClientTool && this._activeClientState.clientId) { -- contributor = { kind: ToolCallContributorKind.Client, clientId: this._activeClientState.clientId }; -- } else if (e.data.mcpServerName) { -- const customizationId = this._mcpCustomizations.customizationIdForServer(e.data.mcpServerName); -- if (customizationId !== undefined) { -- contributor = { kind: ToolCallContributorKind.MCP, customizationId }; -- } -- } -- -- // A new tool call invalidates the current markdown and reasoning -- // parts so the next text/reasoning delta after the tool call -- // starts a fresh part. Without invalidating reasoning here, a -- // later round of reasoning (after tool_start/tool_complete) -- // would silently append to the pre-tool-call reasoning block. -- this._currentTurn?.markdownPartIds.delete(parentToolCallId ?? ''); -- this._currentTurn?.reasoningPartIds.delete(parentToolCallId ?? ''); - -- const meta: Mutable = { toolKind, language: toolKind === 'terminal' ? getShellLanguage(e.data.toolName) : undefined }; -- if (subagentMeta?.description) { -- meta.subagentDescription = subagentMeta.description; -- } -- if (subagentMeta?.agentName) { -- meta.subagentAgentName = subagentMeta.agentName; -- } -- if (toolArgs !== undefined) { -- meta.toolArguments = toolArgs; +- if (!streamed?.started) { +- this._beginToolCallRound(parentToolCallId); - } +- +- const meta = this._createToolCallMeta(e.data.toolName, parameters); - if (e.data.mcpServerName) { - meta.mcpServerName = e.data.mcpServerName; - } - if (e.data.mcpToolName) { - meta.mcpToolName = e.data.mcpToolName; - } -- // TODO(sdk-gap): the Copilot SDK doesn't yet surface MCP App -- // `_meta.ui.resourceUri` on `tool.execution_start`; we attach -- // it on `tool.execution_complete` below so the App webview -- // mounts on completion. Drop-in once the SDK exposes it here: -- // const resourceUri = e.data.toolDescription?._meta?.ui?.resourceUri; -- // if (resourceUri) { meta.ui = { resourceUri }; } +- // eslint-disable-next-line local/code-no-untyped-meta-access -- Copilot SDK's own typed `_meta`, not the AHP protocol bag. +- const resourceUri = e.data.toolDescription?._meta?.ui?.resourceUri; +- this._setToolCallUiMeta(meta, resourceUri, e.data.mcpServerName); - - // Stash the start-time meta on the tracked tool call so the - // `tool.execution_complete` emission below can merge any @@ -6065,15 +9011,18 @@ index a0c0a765..00000000 - tracked.meta = meta; - } - -- this._emitAction({ -- type: ActionType.ChatToolCallStart, -- turnId: this._turnId, -- toolCallId: e.data.toolCallId, -- toolName: e.data.toolName, -- displayName, -- contributor, -- _meta: toToolCallMeta(meta), -- }, parentToolCallId); +- if (!streamed?.started) { +- this._emitAction({ +- type: ActionType.ChatToolCallStart, +- turnId: this._turnId, +- toolCallId: e.data.toolCallId, +- toolName: e.data.toolName, +- displayName, +- intention, +- contributor, +- _meta: toToolCallMeta(meta), +- }, parentToolCallId); +- } - - // No client is connected to run this client tool. Fail it - // immediately instead of leaving it pending until the @@ -6082,14 +9031,19 @@ index a0c0a765..00000000 - // tool.execution_complete for this id is suppressed. - if (isClientTool && !contributor) { - this._logService.warn(`[Copilot:${sessionId}] Client tool '${e.data.toolName}' started with no connected client; failing it immediately.`); +- this._reportToolApprovalIfNoPermission(e.data.toolCallId); +- this._toolApprovalRecords.delete(e.data.toolCallId); - this._activeToolCalls.delete(e.data.toolCallId); - this._emitAction({ - type: ActionType.ChatToolCallReady, - turnId: this._turnId, - toolCallId: e.data.toolCallId, -- invocationMessage: getInvocationMessage(e.data.toolName, displayName, parameters), +- ...(contributor ? { contributor } : {}), +- ...(intention !== undefined ? { intention } : {}), +- invocationMessage: getInvocationMessage(e.data.toolName, displayName, parameters, path => this._resolveEditFilePath(path)), - toolInput: getToolInputString(e.data.toolName, parameters, toolArgs), - confirmed: ToolCallConfirmationReason.NotNeeded, +- _meta: toToolCallMeta(meta), - }, parentToolCallId); - this._emitAction({ - type: ActionType.ChatToolCallComplete, @@ -6109,11 +9063,11 @@ index a0c0a765..00000000 - return; - } - -- // For client tools, do NOT auto-ready — the tool handler will fire -- // a separate tool_ready signal once the deferred is in place (or -- // the permission flow fires it first). MCP tools have no such -- // handler and are auto-readied below alongside built-in tools. -- if (contributor?.kind === ToolCallContributorKind.Client) { +- const clientToolAutoApproved = contributor?.kind === ToolCallContributorKind.Client && this._lastAppliedPermissionMode === 'on'; +- const shouldWaitForClientToolReady = contributor?.kind === ToolCallContributorKind.Client +- && !isAgentCoordinationTool(e.data.toolName) +- && !clientToolAutoApproved; +- if (shouldWaitForClientToolReady) { - return; - } - @@ -6121,15 +9075,20 @@ index a0c0a765..00000000 - type: ActionType.ChatToolCallReady, - turnId: this._turnId, - toolCallId: e.data.toolCallId, -- invocationMessage: getInvocationMessage(e.data.toolName, displayName, parameters), +- ...(contributor ? { contributor } : {}), +- ...(intention !== undefined ? { intention } : {}), +- invocationMessage: getInvocationMessage(e.data.toolName, displayName, parameters, path => this._resolveEditFilePath(path)), - toolInput: getToolInputString(e.data.toolName, parameters, toolArgs), - confirmed: ToolCallConfirmationReason.NotNeeded, +- _meta: toToolCallMeta(clientToolAutoApproved ? { ...meta, autoApproveBySetting: true } : meta), - }, parentToolCallId); - })); - - this._register(wrapper.onToolComplete(async e => { +- this._approvedDuplicablePermissionSignatures.delete(e.data.toolCallId); - const tracked = this._activeToolCalls.get(e.data.toolCallId); - if (!tracked) { +- this._unroutableSubagentToolCallIds.delete(e.data.toolCallId); - return; - } - const parentToolCallId = tracked.parentToolCallId ?? this._parentToolCallIdForSubagentEvent(e); @@ -6138,12 +9097,15 @@ index a0c0a765..00000000 - return; - } - this._logService.info(`[Copilot:${sessionId}] Tool completed: ${e.data.toolCallId}`); +- this._reportToolApprovalIfNoPermission(e.data.toolCallId); - this._activeToolCalls.delete(e.data.toolCallId); +- this._autoApprovals.delete(e.data.toolCallId); +- this._toolApprovalRecords.delete(e.data.toolCallId); +- this._pendingAutoApprovals.respond(e.data.toolCallId, undefined); - const displayName = tracked.displayName; - const toolOutput = e.data.error?.message ?? e.data.result?.content; - - if (isTaskCompleteTool(tracked.toolName)) { -- this._sendToolInvokedTelemetry(e.data.success, e.data.error?.code, tracked); - const summary = getTaskCompleteMarkdown(tracked.parameters, toolOutput); - if (summary) { - this._emitAction({ @@ -6160,6 +9122,41 @@ index a0c0a765..00000000 - content.push({ type: ToolResultContentType.Text, text: toolOutput }); - } - +- // Attach the pty terminal reference for shell tools before folding in +- // SDK result content, so a `shell_exit` lands its completion data on +- // the terminal block (skip if any terminal block was already added +- // while the tool was running). +- const ptyTerminalUri = isShellTool(tracked.toolName) ? this._shellManager?.getTerminalUriForToolCall(e.data.toolCallId) : undefined; +- let retireNonPtyShellTracking = !!ptyTerminalUri; +- if (ptyTerminalUri && !content.some(c => c.type === ToolResultContentType.Terminal)) { +- content.push({ +- type: ToolResultContentType.Terminal, +- resource: ptyTerminalUri, +- title: tracked.displayName, +- }); +- } +- +- const shellExit = appendSdkToolResultContent(content, e.data.result?.contents, { session: this.sessionUri, toolCallId: e.data.toolCallId, title: tracked.displayName }); +- if (isShellTool(tracked.toolName) && !ptyTerminalUri) { +- const completion = this._nonPtyShellTerminals.completeToolCall(e.data.toolCallId, toolOutput, shellExit); +- if (completion) { +- retireNonPtyShellTracking = completion.shouldRetire; +- const terminalIndex = content.findIndex(c => c.type === ToolResultContentType.Terminal); +- if (terminalIndex === -1) { +- content.push({ +- type: ToolResultContentType.Terminal, +- resource: completion.uri, +- title: tracked.displayName, +- isPty: false, +- ...(completion.result ? { result: completion.result } : {}), +- }); +- } else if (completion.result) { +- const terminalBlock = content[terminalIndex] as ToolResultTerminalContent; +- content[terminalIndex] = { ...terminalBlock, result: completion.result }; +- } +- } +- } +- - const command = isString(tracked.parameters?.command) ? tracked.parameters.command : undefined; - const filePaths = isEditTool(tracked.toolName, command) ? this._getEditFilePaths(tracked.parameters) : []; - for (const filePath of filePaths) { @@ -6173,58 +9170,32 @@ index a0c0a765..00000000 - } - } - -- // Add terminal content reference for shell tools (skip if already -- // added during onDidAssociateTerminal while the tool was running) -- if (isShellTool(tracked.toolName) && this._shellManager) { -- const terminalUri = this._shellManager.getTerminalUriForToolCall(e.data.toolCallId); -- if (terminalUri && !content.some(c => c.type === ToolResultContentType.Terminal && c.resource === terminalUri)) { -- content.push({ -- type: ToolResultContentType.Terminal, -- resource: terminalUri, -- title: tracked.displayName, -- }); -- } -- } -- -- this._sendToolInvokedTelemetry(e.data.success, e.data.error?.code, tracked); -- // eslint-disable-next-line local/code-no-untyped-meta-access -- Copilot SDK's own typed `_meta`, not the AHP protocol bag. -- const resourceUri = e.data.toolDescription?._meta?.ui?.resourceUri; -- let completeMeta: IToolCallMeta | undefined = tracked.meta; -- if (resourceUri) { -- const ui: Mutable = { resourceUri }; -- if (tracked.mcpServerName) { -- const channel = this._mcpCustomizations.channelForServer(tracked.mcpServerName); -- if (channel !== undefined) { -- ui.channel = channel; -- } -- } -- // Merge the `ui` namespace on top of whatever meta we -- // emitted at start time (`toolKind`, `subagentDescription`, -- // `toolArguments`, …). Reducers replace the whole `_meta` -- // blob, so we must do the merge here. -- completeMeta = { ...(tracked.meta ?? {}), ui }; -- } - this._emitAction({ - type: ActionType.ChatToolCallComplete, - turnId: this._turnId, - toolCallId: e.data.toolCallId, - result: { - success: e.data.success, -- pastTenseMessage: getPastTenseMessage(tracked.toolName, displayName, tracked.parameters, e.data.success), +- pastTenseMessage: getPastTenseMessage(tracked.toolName, displayName, tracked.parameters, e.data.success, e.data.success ? toolOutput : undefined, path => this._resolveEditFilePath(path)), - content: content.length > 0 ? content : undefined, - error: e.data.error, - }, -- _meta: completeMeta ? toToolCallMeta(completeMeta) : undefined, +- _meta: tracked.meta ? toToolCallMeta(tracked.meta) : undefined, - }, parentToolCallId); +- if (retireNonPtyShellTracking) { +- // Preserve the terminal result in chat state before removing its +- // now-redundant live output resource from the host. +- this._nonPtyShellTerminals.retire(e.data.toolCallId); +- } - })); - - this._register(wrapper.onIdle(e => { - this._logService.info(`[Copilot:${sessionId}] Session idle`); -- // Clear any in-progress activity description set during the -- // turn (e.g. via the `report_intent` tool) — the agent is no -- // longer doing anything once the loop settles. -- if (this._hasReportedActivity) { -- this._hasReportedActivity = false; +- if (e.data.aborted) { +- this._resetAbortToken(); +- } +- if (this._hasActivity) { +- this._hasActivity = false; - this._emitAction({ - type: ActionType.SessionActivityChanged, - activity: undefined, @@ -6247,10 +9218,12 @@ index a0c0a765..00000000 - // queued-message case; reading `e.data.aborted` is the authoritative - // SDK signal that lets us also tear down the aborted running turn. - if (e.data.aborted) { +- this._cancelActiveRepoInfoTelemetry(); - if (turn.isRunning) { - this._logService.trace(`[Copilot:${sessionId}] Idle from abort; tearing down running turn ${turn.id}`); +- this._reportToolCallDetails(turn, 'cancelled'); - turn.markAborted(); -- this._currentTurn = undefined; +- this._clearActiveTurn(); - } else { - this._logService.trace(`[Copilot:${sessionId}] Idle from abort; leaving ${turn.state} turn ${turn.id} open`); - } @@ -6260,6 +9233,7 @@ index a0c0a765..00000000 - // turn here means the SDK went idle before emitting any event for it - // (a degenerate no-op send); complete it defensively so the session - // does not hang. +- this._completeActiveRepoInfoTelemetry(); - this._completeActiveTurn(); - })); - @@ -6269,6 +9243,23 @@ index a0c0a765..00000000 - // clickable file link, matching the `view`-tool display style. - this._register(wrapper.onSkillInvoked(e => { - this._logService.info(`[Copilot:${sessionId}] Skill invoked: ${e.data.name} (${e.data.path})`); +- this._resumeSubagentForEvent(e); +- if (this._shouldDropUnmappedSubagentEvent(e, 'skill.invoked')) { +- return; +- } +- // Restricted `skillContentRead`: which skill file was loaded. Main-agent only, like the other restricted events. +- if (!e.agentId) { +- this._telemetryReporter.skillContentRead({ +- clientType: this._currentTurn?.clientType ?? AgentHostClientType.Unknown, +- name: e.data.name, +- path: e.data.path, +- content: e.data.content, +- source: e.data.source, +- pluginName: e.data.pluginName, +- pluginVersion: e.data.pluginVersion, +- }); +- } +- const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); - const synth = synthesizeSkillToolCall(e.data, e.id); - this._emitAction({ - type: ActionType.ChatToolCallStart, @@ -6276,14 +9267,14 @@ index a0c0a765..00000000 - toolCallId: synth.toolCallId, - toolName: synth.toolName, - displayName: synth.displayName, -- }); +- }, parentToolCallId); - this._emitAction({ - type: ActionType.ChatToolCallReady, - turnId: this._turnId, - toolCallId: synth.toolCallId, - invocationMessage: synth.invocationMessage, - confirmed: ToolCallConfirmationReason.NotNeeded, -- }); +- }, parentToolCallId); - this._emitAction({ - type: ActionType.ChatToolCallComplete, - turnId: this._turnId, @@ -6292,32 +9283,52 @@ index a0c0a765..00000000 - success: true, - pastTenseMessage: synth.pastTenseMessage, - }, -- }); +- }, parentToolCallId); - })); - - this._register(wrapper.onSubagentStarted(e => { - if (e.agentId) { - this._parentToolCallIdsByAgentId.set(e.agentId, e.data.toolCallId); +- this._activeSubagentAgentIds.add(e.agentId); - } - this._logService.info(`[Copilot:${sessionId}] Subagent started: toolCallId=${e.data.toolCallId}, agent=${e.data.agentName}`); +- const tracked = this._activeToolCalls.get(e.data.toolCallId); - this._onDidSessionProgress.fire({ - kind: 'subagent_started', -- session: this.sessionUri, +- chat: this._chatChannelUri, - toolCallId: e.data.toolCallId, - agentName: e.data.agentName, - agentDisplayName: e.data.agentDisplayName, - agentDescription: e.data.agentDescription, +- // The spawning Task tool's short `description` input (captured on +- // tool start) is the concise per-task tab title for the subagent's +- // read-only peer chat — distinct even for same-type subagents. +- taskDescription: tracked?.meta?.subagentDescription, +- // The full delegated instruction (the spawning tool's `prompt` +- // argument) seeds the subagent peer chat's opening request. +- taskPrompt: typeof tracked?.parameters?.prompt === 'string' ? tracked.parameters.prompt : undefined, +- // When the spawning tool call is itself an inner tool of +- // another subagent, its recorded parent is the tool call one +- // level up — the tool call in whose (subagent) chat this +- // spawning tool lives. The host uses it to route the +- // discovery content block to that immediate parent chat, at +- // any nesting depth. +- parentToolCallId: tracked?.parentToolCallId, - }); - })); - - this._register(wrapper.onSessionError(e => { - this._logService.error(`[Copilot:${sessionId}] Session error: ${e.data.errorType} - ${e.data.message}`); +- if (this._currentTurn) { +- this._reportToolCallDetails(this._currentTurn, 'failed'); +- } - // Prefer the structured SDK fields (the Copilot CLI classifies its own - // CAPI errors); fall back to decoding a forwarded marker from the message. - const meta = tryBuildChatErrorMetaFromFields(e.data) ?? tryBuildChatErrorMeta(e.data.message); - this._emitAction({ - type: ActionType.ChatError, - turnId: this._turnId, +- duration: this._currentTurn?.duration ?? 0, - error: { - errorType: e.data.errorType, - message: stripProxyErrorMarker(e.data.message), @@ -6327,48 +9338,276 @@ index a0c0a765..00000000 - }); - })); - +- // Tracks the last parent-scope usage so the async attribution enrichment +- // can re-emit a complete action (with accumulated credits, quota, etc.). +- let lastParentUsage: UsageInfo | undefined; +- let lastParentUsageTurnId: string | undefined; +- let autoModeResolved: { readonly turnId: string; readonly data: NonNullable } | undefined; +- +- this._register(wrapper.onAutoModeResolved(e => { +- this._lastSeenModelId = e.data.chosenModel; +- const turnId = this._turnId; +- this._logService.info(`[Copilot:${sessionId}] Auto mode resolved to ${e.data.chosenModel}${e.data.reasoningBucket ? ` (${e.data.reasoningBucket})` : ''}`); +- if (!turnId) { +- return; +- } +- if (!e.agentId) { +- this._telemetryReporter.autoModeRouterDecision({ +- session: this.sessionUri.toString(), +- turnId, +- clientType: this._currentTurn?.clientType ?? AgentHostClientType.Unknown, +- chosenModel: e.data.chosenModel, +- predictedLabel: e.data.predictedLabel, +- confidence: e.data.confidence, +- candidateModels: e.data.candidateModels, +- categoryScores: e.data.categoryScores, +- }); +- } +- autoModeResolved = { turnId, data: e.data }; +- const priorUsage = lastParentUsageTurnId === turnId ? lastParentUsage : undefined; +- const usage: UsageInfo = { +- ...priorUsage, +- model: e.data.chosenModel, +- _meta: { +- ...(priorUsage?._meta ?? {}), +- autoModeResolved: e.data, +- }, +- }; +- lastParentUsage = usage; +- lastParentUsageTurnId = turnId; +- this._emitAction({ +- type: ActionType.ChatUsage, +- turnId, +- usage, +- }); +- })); +- - this._register(wrapper.onUsage(e => { -- const metadata: UsageInfoMeta = {}; -- if (typeof e.data.cost === 'number') { -- metadata.cost = e.data.cost; +- this._resumeSubagentForEvent(e); +- // Usage events for a subagent's model calls carry the subagent's +- // `agentId`. Every model call — the parent's own and every subagent's — +- // is folded into the turn's cost below, so such an event additionally +- // needs only the subagent's own running component total emitted to its +- // child session (via `parentToolCallId`) for the subagent tool to show +- // its own cost. +- const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); +- if (!parentToolCallId && !e.agentId && !e.data.parentToolCallId) { +- this._promptCacheRefreshGeneration++; +- if (e.data.model && e.data.cacheExpiresAt) { +- this._setPromptCacheState({ modelId: e.data.model, cacheExpiresAt: e.data.cacheExpiresAt }); +- } else if (e.data.model && this._promptCacheState?.modelId !== e.data.model) { +- this._setPromptCacheState(undefined); +- } - } -- // TODO: `copilotUsage` is marked `asInternal` in the SDK schema so it is not exposed on the generated +- // `copilotUsage` is marked `asInternal` in the SDK schema so it is not exposed on the generated - // `AssistantUsageData` type, but it is present at runtime. Read it dynamically. -- const copilotUsage = (e.data as unknown as Record).copilotUsage as { totalNanoAiu?: number } | undefined; -- const turn = this._currentTurn; -- if (turn && typeof copilotUsage?.totalNanoAiu === 'number') { -- turn.copilotUsageTotalNanoAiu += copilotUsage.totalNanoAiu; -- metadata.copilotUsage = { -- ...copilotUsage, -- totalNanoAiu: turn.copilotUsageTotalNanoAiu, -- }; -- } +- const copilotUsage = readCopilotUsage(e.data); - // `quotaSnapshots` is likewise `asInternal` in the SDK schema (not on the generated type) but is - // present at runtime. Forward the per-category snapshots on `_meta` so the client can keep the - // account quota UI current. Mirrors the extension-host CLI path, which feeds these into its quota service. - const quotaSnapshots = normalizeQuotaSnapshots((e.data as unknown as Record).quotaSnapshots); -- if (quotaSnapshots) { -- metadata.quotaSnapshots = quotaSnapshots; -- } +- const turn = this._currentTurn; +- - if (typeof e.data.model === 'string' && e.data.model) { - this._lastSeenModelId = e.data.model; - } -- const usage: UsageInfo = { +- +- // This event's own context usage (the model call that produced it). +- const eventContext = { - inputTokens: e.data.inputTokens, - outputTokens: e.data.outputTokens, - model: e.data.model, - cacheReadTokens: e.data.cacheReadTokens, -- ...(Object.keys(metadata).length > 0 ? { _meta: metadata } : {}), +- ...(typeof e.data.cost === 'number' ? { cost: e.data.cost } : {}), +- }; +- +- // Record the parent agent's own context usage so subagent events +- // don't overwrite the model/context tokens shown for the parent turn. +- if (!parentToolCallId && turn) { +- turn.parentContextUsage = eventContext; +- } +- +- // Builds a usage object carrying the given context's tokens/model plus +- // the credit total for the given scope. `copilotUsage` is the scope's +- // Copilot billing metadata, or `undefined` when nothing is billed yet. +- const buildUsage = (context: UsageContext, scopedCopilotUsage: UsageInfoMeta['copilotUsage'], isParentScope: boolean): UsageInfo => { +- const metadata: UsageInfoMeta = {}; +- if (typeof context.cost === 'number') { +- metadata.cost = context.cost; +- } +- if (isParentScope && autoModeResolved?.turnId === this._turnId) { +- metadata.autoModeResolved = autoModeResolved.data; +- } +- if (scopedCopilotUsage) { +- metadata.copilotUsage = scopedCopilotUsage; +- } +- if (quotaSnapshots) { +- metadata.quotaSnapshots = quotaSnapshots; +- } +- return { +- inputTokens: context.inputTokens, +- outputTokens: context.outputTokens, +- model: context.model, +- cacheReadTokens: context.cacheReadTokens, +- ...(Object.keys(metadata).length > 0 ? { _meta: metadata } : {}), +- }; - }; +- +- // Fold this call's cost into the turn before building any report, so the +- // emission below already carries it. Every model call the turn caused +- // counts toward it, subagents included. Done synchronously here rather +- // than from the SDK's session total, which is read across an await that +- // the terminal `session.idle` can beat. +- if (turn && copilotUsage) { +- turn.copilotNanoAiu += copilotUsage.totalNanoAiu; +- if (parentToolCallId) { +- const scopedTotal = (turn.subagentNanoAiuByToolCallId.get(parentToolCallId) ?? 0) + copilotUsage.totalNanoAiu; +- turn.subagentNanoAiuByToolCallId.set(parentToolCallId, scopedTotal); +- } +- } +- +- // Parent turn aggregate: a subagent event must not replace the parent +- // turn's own model/context-token usage, so preserve the parent's context. +- const parentContext = parentToolCallId ? (turn?.parentContextUsage ?? {}) : eventContext; +- const parentUsage = buildUsage(parentContext, this._parentCopilotUsageMeta(), true); +- lastParentUsage = parentUsage; +- lastParentUsageTurnId = this._turnId; - this._emitAction({ - type: ActionType.ChatUsage, - turnId: this._turnId, -- usage, +- usage: parentUsage, +- }); +- +- // Subagent component: additionally report the subagent's own running +- // total to its child session. The SDK's session metrics carry no +- // per-agent breakdown, so this is the only source for it. +- if (parentToolCallId) { +- const scopedTotal = turn?.subagentNanoAiuByToolCallId.get(parentToolCallId); +- const subagentCopilotUsage = copilotUsage && scopedTotal !== undefined +- ? { ...copilotUsage, totalNanoAiu: scopedTotal } +- : undefined; +- this._emitAction({ +- type: ActionType.ChatUsage, +- turnId: this._turnId, +- usage: buildUsage(eventContext, subagentCopilotUsage, false), +- }, parentToolCallId); +- } +- })); +- +- // After each usage event, asynchronously refresh the SDK's session-wide total +- // (authoritative for the session, and the only source that sees work billed +- // outside a turn) and re-emit the parent aggregate with it. For main-agent +- // calls the per-source context-window attribution is fetched and merged in +- // too — a subagent runs against its own context, so its events must not +- // rewrite the parent's attribution. The reducer replaces `activeTurn.usage`, +- // so the widget picks up the update on the next render cycle. +- // +- // Losing this re-emit to a turn that ended mid-flight costs only the session +- // total's freshness; the turn's own cost was already reported synchronously. +- this._register(wrapper.onUsage(async e => { +- const isSubagentEvent = !!this._parentToolCallIdForSubagentEvent(e); +- const turnId = this._turnId; +- // Capture the base usage before the await boundary so concurrent +- // usage events don't overwrite what we merge into. +- const baseUsage = lastParentUsageTurnId === turnId ? lastParentUsage : undefined; +- const usage: UsageInfo = baseUsage ?? { +- inputTokens: e.data.inputTokens, +- outputTokens: e.data.outputTokens, +- model: e.data.model, +- cacheReadTokens: e.data.cacheReadTokens, +- }; +- await this._refreshSessionUsageMetrics(); +- const attribution = isSubagentEvent ? undefined : await this._readContextAttribution(); +- if (!turnId) { +- return; +- } +- // If the turn changed while we were awaiting, don't pollute the +- // new turn's state with stale data. Likewise, guard against a newer +- // usage event having arrived — only enrich if baseUsage is current. +- if (turnId !== this._turnId || usage !== lastParentUsage || lastParentUsageTurnId !== turnId) { +- return; +- } +- const copilotUsage = this._parentCopilotUsageMeta(); +- if (!attribution && !copilotUsage) { +- return; +- } +- const enriched: UsageInfo = { +- ...usage, +- _meta: { +- ...(usage._meta ?? {}), +- ...(copilotUsage ? { copilotUsage } : {}), +- ...(attribution ? { contextAttribution: attribution } : {}), +- }, +- }; +- lastParentUsage = enriched; +- lastParentUsageTurnId = turnId; +- this._emitAction({ +- type: ActionType.ChatUsage, +- turnId, +- usage: enriched, - }); - })); - +- // 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. +- // +- // A compaction that runs *during* a turn is that turn's cost, so fold it in like any other +- // call. One that runs between turns belongs to no turn: it is reflected in the session total +- // 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. +- this._register(wrapper.onSessionCompactionComplete(async e => { +- if (e.agentId || e.data.success === false) { +- return; +- } +- const copilotUsage = readCopilotUsage(e.data.compactionTokensUsed); +- // Report the turn's cost before awaiting anything. The terminal `session.idle` +- // can arrive while the metrics read is in flight and close the turn, after +- // which the reducer drops usage for it — so a compaction whose turn ends +- // immediately (e.g. one followed by a failing model call) would never be +- // persisted if this waited. +- const emitParentUsage = (): string | undefined => { +- const turnId = this._turnId; +- const parentCopilotUsage = this._parentCopilotUsageMeta(); +- if (!turnId || !parentCopilotUsage) { +- return undefined; +- } +- // Preserve the parent turn's own model/context tokens: the compaction call's tokens describe +- // the summarization request, not the conversation, so they must not replace what is shown. +- const base = lastParentUsageTurnId === turnId ? lastParentUsage : undefined; +- const usage: UsageInfo = { +- ...base, +- model: base?.model ?? this._lastSeenModelId, +- _meta: { +- ...(base?._meta ?? {}), +- copilotUsage: parentCopilotUsage, +- }, +- }; +- lastParentUsage = usage; +- lastParentUsageTurnId = turnId; +- this._emitAction({ +- type: ActionType.ChatUsage, +- turnId, +- usage, +- }); +- return turnId; +- }; +- +- const turn = this._currentTurn; +- if (turn && copilotUsage) { +- turn.copilotNanoAiu += copilotUsage.totalNanoAiu; +- emitParentUsage(); +- } +- // Then pick up the session-wide total, which also covers a compaction billed +- // while no turn was active, and re-emit so the widget reflects it. +- const turnIdBeforeRefresh = this._turnId; +- if (await this._refreshSessionUsageMetrics() && turnIdBeforeRefresh === this._turnId) { +- emitParentUsage(); +- } +- })); +- - this._register(wrapper.onReasoningDelta(e => { - this._logService.trace(`[Copilot:${sessionId}] Reasoning delta: ${e.data.deltaContent.length} chars`); +- this._resumeSubagentForEvent(e); - if (this._shouldDropUnmappedSubagentEvent(e, 'assistant.reasoning_delta')) { - return; - } @@ -6380,6 +9619,13 @@ index a0c0a765..00000000 - // before sending). The SDK and AHP share the same three modes - // (`interactive` / `plan` / `autopilot`), so we map directly. - this._register(wrapper.onSessionModeChanged(e => { +- // Sub-agents (e.g. a `task` tool sub-agent running in plan mode) +- // emit their own `session.mode_changed` events carrying an +- // `agentId`. +- if (e.agentId) { +- this._logService.trace(`[Copilot:${sessionId}] Ignoring subagent session.mode_changed: agentId=${e.agentId}, ${e.data.previousMode} -> ${e.data.newMode}`); +- return; +- } - this._logService.info(`[Copilot:${sessionId}] session.mode_changed: ${e.data.previousMode} -> ${e.data.newMode}`); - const newMode = e.data.newMode; - if (newMode !== 'interactive' && newMode !== 'plan' && newMode !== 'autopilot') { @@ -6392,11 +9638,23 @@ index a0c0a765..00000000 - // Translate SDK-reported MCP server lifecycle into AHP customization - // actions. The controller decides whether each server is a - // plugin-derived child (narrow `SessionMcpServerStateChanged`) or a -- // bare top-level entry (`SessionCustomizationUpdated`). +- // bare top-level entry (`SessionCustomizationUpdated`). Each state +- // change is also logged (with structured metadata) so it flows to the +- // agent host's OTLP log stream and the per-server Output channels. - this._register(wrapper.onMcpServersLoaded(e => { +- this._logMcpServersSnapshot(e.data.servers.map((s: McpServersLoadedServer) => ({ +- name: s.name, +- status: s.status, +- error: s.error, +- source: s.source, +- transport: s.transport, +- pluginName: s.pluginName, +- pluginVersion: s.pluginVersion, +- })), 'loaded'); - this._applyMcpServerList(e.data.servers); - })); - this._register(wrapper.onMcpServerStatusChanged(e => { +- this._logMcpServerLifecycle({ name: e.data.serverName, status: e.data.status, error: e.data.error, origin: 'statusChanged' }); - const server = this._toSdkMcpServer(e.data.serverName, e.data.status, e.data.error); - if (!server) { - this._mcpCustomizations.remove(e.data.serverName); @@ -6406,8 +9664,12 @@ index a0c0a765..00000000 - })); - - this._register(wrapper.onToolsUpdated(() => { +- this._slashCommandProvider.clearCache(); - this._fireMcpToolsListChanged(); - })); +- this._register(wrapper.onCommandsChanged(() => { +- this._slashCommandProvider.clearCache(); +- })); - - // Seed the inventory with any servers the SDK has already loaded by - // the time we attach. The `session.mcp_servers_loaded` event may @@ -6424,33 +9686,108 @@ index a0c0a765..00000000 - * next live event arrives. - */ - private _seedMcpServersFromRpc(): void { +- this._refreshMcpServersFromRpc().catch(err => { +- this._logService.warn(`[Copilot:${this.sessionId}] Failed to seed MCP server inventory`, err); +- }); +- } +- +- private async _refreshMcpServersFromRpc(): Promise { - const mcpRpc = this._wrapper.session.rpc?.mcp; - if (!mcpRpc) { -- // Older SDKs (and test mocks) may not expose the MCP RPC surface. - return; - } -- mcpRpc.list().then(result => { -- if (this._store.isDisposed) { -- return; -- } +- const result = await mcpRpc.list(); +- if (!this._store.isDisposed) { +- this._logMcpServersSnapshot(result.servers.map(s => ({ +- name: s.name, +- status: s.status, +- error: s.error, +- source: s.source, +- pluginName: s.sourcePlugin, +- pluginVersion: s.sourcePluginVersion, +- })), 'inventory'); - this._applyMcpServerList(result.servers); -- }, err => { -- this._logService.warn(`[Copilot:${this.sessionId}] Failed to seed MCP server inventory`, err); -- }); +- } - } - - private _applyMcpServerList(servers: readonly { readonly name: string; readonly status: SdkMcpServerStatus; readonly error?: string }[]): void { - const sdkServers = servers -- .map(s => this._toSdkMcpServer(s.name, s.status, s.error)) -- .filter(isDefined); +- .map(s => this._toSdkMcpServer(s.name, s.status, s.error)); - this._mcpCustomizations.applyAll(sdkServers); - } - - /** -- * Broadcasts `notifications/tools/list_changed` for every MCP server -- * currently in the `Ready` state. The SDK's `session.tools_updated` -- * event is a coarse "tools refreshed" hint that doesn't identify -- * which server changed, so we fan out to all ready channels. Clients +- * Logs a full MCP inventory snapshot ({@link _logMcpServerLifecycle} per +- * server), then forgets the dedup entry for any server that dropped out of +- * the snapshot so a later re-add re-logs its arrival. +- */ +- private _logMcpServersSnapshot(servers: readonly IMcpLifecycleLogInfo[], origin: McpLifecycleOrigin): void { +- const seen = new Set(); +- for (const server of servers) { +- seen.add(server.name); +- this._logMcpServerLifecycle({ ...server, origin }); +- } +- for (const name of [...this._lastLoggedMcpStatus.keys()]) { +- if (!seen.has(name)) { +- this._lastLoggedMcpStatus.delete(name); +- } +- } +- } +- +- /** +- * Emits a single structured MCP lifecycle log record for `server`, +- * deduplicated by SDK status so an unchanged re-report stays quiet. Failed +- * servers log at `error` (carrying the failure text in the body and an +- * `errorType` attribute); every other transition logs at `info`. Records +- * flow through {@link ILogService} to the agent host's OTLP log stream. +- */ +- private _logMcpServerLifecycle(server: IMcpLifecycleLogInfo & { readonly origin: McpLifecycleOrigin }): void { +- if (this._lastLoggedMcpStatus.get(server.name) === server.status) { +- return; +- } +- this._lastLoggedMcpStatus.set(server.name, server.status); +- +- const state = this._translateSdkMcpStatus(server.name, server.status, server.error); +- const attributes: Record = { +- mcpEvent: server.origin, +- mcpServer: server.name, +- mcpStatus: server.status, +- mcpState: state.kind, +- }; +- if (server.source) { attributes.mcpSource = server.source; } +- if (server.transport) { attributes.mcpTransport = server.transport; } +- if (server.pluginName) { attributes.mcpPlugin = server.pluginName; } +- if (server.pluginVersion) { attributes.mcpPluginVersion = server.pluginVersion; } +- if (state.kind === McpServerStatus.Error) { attributes.errorType = state.error.errorType; } +- +- const detail = server.error ? `: ${server.error}` : ''; +- const message = `[Copilot:${this.sessionId}] MCP server '${server.name}' ${server.status} (${state.kind})${detail}`; +- if (server.status === 'failed') { +- this._logService.error(message, new OtelData(attributes)); +- } else { +- this._logService.info(message, new OtelData(attributes)); +- } +- } +- +- private _setToolCallUiMeta(meta: Mutable, resourceUri: string | undefined, mcpServerName: string | undefined): void { +- if (!resourceUri) { +- return; +- } +- const ui: Mutable = { resourceUri }; +- if (mcpServerName) { +- const channel = this._mcpCustomizations.channelForServer(mcpServerName); +- if (channel !== undefined) { +- ui.channel = channel; +- } +- } +- meta.ui = ui; +- } +- +- /** +- * Broadcasts `notifications/tools/list_changed` for every MCP server +- * currently in the `Ready` state. The SDK's `session.tools_updated` +- * event is a coarse "tools refreshed" hint that doesn't identify +- * which server changed, so we fan out to all ready channels. Clients - * are expected to refetch `tools/list` on each notification. - */ - private _fireMcpToolsListChanged(): void { @@ -6469,23 +9806,17 @@ index a0c0a765..00000000 - - /** - * Translates the SDK's flat MCP status string into AHP's discriminated -- * {@link McpServerState} union. Returns `undefined` for -- * `not_configured`, which has no AHP equivalent — the server is -- * dropped from the inventory. -- * -- * V1 maps `needs-auth` to {@link McpServerStatus.Starting}: OAuth -- * handling is intentionally out of scope, so authRequired transitions -- * are masked as "still connecting" until the auth pipeline lands. +- * {@link McpServerState} union. - */ -- private _toSdkMcpServer(name: string, status: SdkMcpServerStatus, error?: string): ISdkMcpServer | undefined { -- const state = this._translateSdkMcpStatus(status, error); -- if (!state) { -- return undefined; -- } -- return { name, state }; +- private _toSdkMcpServer(name: string, status: SdkMcpServerStatus, error?: string): ISdkMcpServer { +- return { +- name, +- state: this._translateSdkMcpStatus(name, status, error), +- enabled: status !== 'disabled', +- }; - } - -- private _translateSdkMcpStatus(status: SdkMcpServerStatus, error?: string): McpServerState | undefined { +- private _translateSdkMcpStatus(name: string, status: SdkMcpServerStatus, error?: string): McpServerState { - switch (status) { - case 'connected': - return { kind: McpServerStatus.Ready }; @@ -6498,16 +9829,18 @@ index a0c0a765..00000000 - }, - }; - case 'pending': -- case 'needs-auth': -- // TODO: surface `needs-auth` as McpServerStatus.AuthRequired -- // once OAuth wiring is in place. +- case 'needs-auth': { +- const previous = this._mcpCustomizations.stateForServer(name); +- if (previous?.kind === McpServerStatus.AuthRequired) { +- return previous; +- } - return { kind: McpServerStatus.Starting }; +- } - case 'disabled': -- return { kind: McpServerStatus.Stopped }; - case 'not_configured': -- return undefined; +- return { kind: McpServerStatus.Stopped }; - default: -- return undefined; +- return { kind: McpServerStatus.Stopped }; - } - } - @@ -6528,7 +9861,7 @@ index a0c0a765..00000000 - * propagate to all subscribed clients via `session/configChanged`. - */ - private _syncAhpConfigFromSdkMode(sdkMode: CopilotSdkMode): void { -- const sessionUri = this.sessionUri.toString(); +- const sessionUri = this._storageUri.toString(); - const patch: Record = {}; - switch (sdkMode) { - case 'plan': @@ -6553,12 +9886,15 @@ index a0c0a765..00000000 - * `currentMode` so the model can continue with implementation. - */ - private async _handleExitPlanModeRequest(data: ExitPlanModeRequest, _invocation: { sessionId: string }): Promise { +- const turnId = this._currentTurn?.id; +- if (!turnId) { +- this._logService.warn(`[Copilot:${this.sessionId}] Rejecting plan review request without an active turn`); +- return { approved: false }; +- } - const requestId = generateUuid(); - const questionId = generateUuid(); - this._logService.info(`[Copilot:${this.sessionId}] exitPlanMode.request: rpcId=${requestId}, actions=[${data.actions.join(',')}], recommended=${data.recommendedAction}`); - -- -- // Resolve the plan file path so we can embed a markdown link. - let planPath: string | null = null; - try { - const planRead = await this._wrapper.session.rpc.plan.read(); @@ -6566,16 +9902,11 @@ index a0c0a765..00000000 - } catch (err) { - this._logService.warn(`[Copilot:${this.sessionId}] rpc.plan.read failed for exit_plan_mode: ${err instanceof Error ? err.message : String(err)}`); - } -- -- // Build the input-request markdown: summary + link to the plan file. -- let message = data.summary || localize('agentHost.planReview.fallbackSummary', "A plan is ready for review."); -- if (planPath) { -- const planUri = URI.file(planPath); -- message += `\n\n[${localize('agentHost.planReview.viewPlanLink', "View full plan")}](${planUri.toString()})`; +- if (this._currentTurn?.id !== turnId) { +- this._logService.warn(`[Copilot:${this.sessionId}] Rejecting plan review request after its turn ended`); +- return { approved: false }; - } - -- this._emitMarkdownDelta(message); -- - const options = data.actions.map(actionId => { - const desc = getPlanActionDescription(actionId); - return { @@ -6586,8 +9917,23 @@ index a0c0a765..00000000 - }; - }); - -- const inputRequest: ChatInputRequest = { +- const actions: IAgentHostPlanReviewAction[] = options.map(option => ({ +- id: option.id, +- label: option.label, +- ...(option.description ? { description: option.description } : {}), +- ...(option.recommended ? { default: true } : {}), +- })); +- +- const inputRequest: ChatInputRequestWithPlanReview = { - id: requestId, +- planReview: { +- title: localize('agentHost.planReview.title', "Review Plan"), +- content: data.summary || localize('agentHost.planReview.fallbackSummary', "A plan is ready for review."), +- actions, +- canProvideFeedback: true, +- answerQuestionId: questionId, +- ...(planPath ? { planUri: URI.file(planPath).toString() } : {}), +- }, - questions: [{ - kind: ChatInputQuestionKind.SingleSelect, - id: questionId, @@ -6599,17 +9945,15 @@ index a0c0a765..00000000 - }], - }; - -- const deferred = new DeferredPromise(); -- this._pendingPlanReviews.set(requestId, { +- const pendingPlanReview = this._pendingPlanReviews.register(requestId, { - actions: data.actions, - recommendedAction: data.recommendedAction, - questionId, -- deferred, - }); - - this._onDidSessionProgress.fire({ - kind: 'action', -- session: this.sessionUri, +- resource: this._chatChannelUri, - action: { - type: ActionType.ChatInputRequested, - request: inputRequest, @@ -6617,17 +9961,134 @@ index a0c0a765..00000000 - }); - - try { -- return await deferred.p; +- return await pendingPlanReview; - } catch (err) { - this._logService.error(err, `[Copilot:${this.sessionId}] exitPlanMode.request handler failed: rpcId=${requestId}`); - return { approved: false }; - } - } - +- /** +- * Drop the memoized event reconstruction whenever the persisted event log +- * could have changed, so {@link _getMappedEvents} never serves stale turns +- * once the session resumes activity. While the session is idle (e.g. during +- * a historical session open) none of these fire, so the whole restore wave +- * coalesces to a single reconstruction. +- */ +- private _subscribeForMemoInvalidation(): void { +- const wrapper = this._wrapper; +- const invalidate = () => this._invalidateMappedEvents(); +- // New content appended to the log. +- this._register(wrapper.onUserMessage(invalidate)); +- this._register(wrapper.onTurnStart(invalidate)); +- this._register(wrapper.onMessage(invalidate)); +- this._register(wrapper.onToolStart(invalidate)); +- this._register(wrapper.onToolComplete(invalidate)); +- this._register(wrapper.onSubagentStarted(invalidate)); +- this._register(wrapper.onSubagentCompleted(invalidate)); +- this._register(wrapper.onSubagentFailed(invalidate)); +- this._register(wrapper.onTurnEnd(invalidate)); +- // In-place rewrites of the persisted log. +- this._register(wrapper.onSessionCompactionComplete(invalidate)); +- this._register(wrapper.onSessionTruncation(invalidate)); +- this._register(wrapper.onSessionSnapshotRewind(invalidate)); +- } +- +- /** +- * Emits `instructionsCollected` per user message. +- * Attempts to match local chat's `ComputeAutomaticInstructions` +- * emitter (`src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts`) +- */ +- private _subscribeForInstructionsCollectedTelemetry(): void { +- const wrapper = this._wrapper; +- const sessionId = this.sessionId; +- +- this._register(wrapper.onUserMessage(e => { +- // Skip subagent and SDK-injected messages (matches guard on this event above). +- if (e.agentId || (e.data.source && e.data.source.toLowerCase() !== 'user')) { +- return; +- } +- void (async () => { +- let sources; +- try { +- sources = (await wrapper.session.rpc.instructions.getSources()).sources; +- } catch (err) { +- this._logService.trace(`[Copilot:${sessionId}] Failed to fetch instruction sources for telemetry: ${getErrorMessage(err)}`); +- return; +- } +- +- let agentInstructionsCount = 0; +- let applyingInstructionsCount = 0; +- let referencedInstructionsCount = 0; +- let claudeMdCount = 0; +- for (const s of sources) { +- // The SDK marks copilot-instructions.md (home/repo) and root-level +- // AGENTS.md / CLAUDE.md / GEMINI.md as `home`/`repo`/`model` +- if (s.type === 'home' || s.type === 'repo' || s.type === 'model') { +- agentInstructionsCount++; +- } +- +- if (s.applyTo && s.applyTo.length > 0) { +- applyingInstructionsCount++; +- } +- +- if (s.type === 'child-instructions' || s.type === 'nested-agents') { +- referencedInstructionsCount++; +- } +- +- const lastSep = Math.max(s.sourcePath.lastIndexOf('/'), s.sourcePath.lastIndexOf('\\')); +- const filename = lastSep >= 0 ? s.sourcePath.slice(lastSep + 1) : s.sourcePath; +- if (filename === 'CLAUDE.md') { +- claudeMdCount++; +- } +- } +- +- type AgentHostInstructionsCollectedEvent = { +- provider: string; +- agentSessionId: string; +- isSubagentSession: boolean; +- totalInstructionsCount: number; +- agentInstructionsCount: number; +- applyingInstructionsCount: number; +- referencedInstructionsCount: number; +- claudeMdCount: number; +- }; +- type AgentHostInstructionsCollectedClassification = { +- provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Agent Host provider that emitted this event (e.g. copilotcli). Absent on local rows; use presence to distinguish AH from local.' }; +- agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Agent Host session identifier. Absent on local rows.' }; +- isSubagentSession: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the emission was from a subagent session.' }; +- totalInstructionsCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of instruction sources loaded by the Agent Host session.' }; +- agentInstructionsCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of top-level agent instruction files (copilot-instructions.md, AGENTS.md, CLAUDE.md, GEMINI.md) among the loaded sources.' }; +- applyingInstructionsCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of loaded instruction sources that carry an applyTo glob pattern. Semantic shift from the local field, which counts sources whose applyTo matched the current request context.' }; +- referencedInstructionsCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of loaded instruction sources discovered transitively (child-instructions via subdirectory walk, or nested AGENTS.md). Semantic shift from the local field, which counts sources added via explicit references in other instruction files.' }; +- claudeMdCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of CLAUDE.md files among the loaded sources.' }; +- owner: 'amunger'; +- comment: 'Agent Host emission of agentHost.instructionsCollected. Carries the subset of the local shape that can be honestly (or close-analogously) computed from the SDK\'s InstructionSource list; other fields are intentionally omitted (see source comment).'; +- }; +- this._telemetryService.publicLog2('agentHost.instructionsCollected', { +- provider: this.sessionUri.scheme, +- agentSessionId: AgentSession.id(this.sessionUri), +- isSubagentSession: isSubagentSession(this.sessionUri), +- totalInstructionsCount: sources.length, +- agentInstructionsCount, +- applyingInstructionsCount, +- referencedInstructionsCount, +- claudeMdCount, +- }); +- })().catch(err => { +- this._logService.trace(`[Copilot:${sessionId}] instructionsCollected telemetry failed: ${getErrorMessage(err)}`); +- }); +- })); +- } +- - private _subscribeForLogging(): void { - const wrapper = this._wrapper; - const sessionId = this.sessionId; - +- this._register(wrapper.onUnhandledEvent(e => { +- this._logService.trace(`[Copilot:${sessionId}] Unhandled SDK event: ${safeStringify(e)}`); +- })); +- - this._register(wrapper.onSessionStart(e => { - this._logService.trace(`[Copilot:${sessionId}] Session started: model=${e.data.selectedModel ?? 'default'}, producer=${e.data.producer}`); - })); @@ -6656,6 +10117,21 @@ index a0c0a765..00000000 - - this._register(wrapper.onSessionModelChange(e => { - this._logService.trace(`[Copilot:${sessionId}] Model changed: ${e.data.previousModel ?? '(none)'} -> ${e.data.newModel}`); +- if (!e.agentId) { +- this._promptCacheRefreshGeneration++; +- if (e.data.previousModel !== e.data.newModel) { +- this._setPromptCacheState(undefined); +- } +- void this._refreshSessionUsageMetrics(); +- } +- })); +- +- this._register(wrapper.onManagedSettingsResolved(e => { +- this._logService.info(`[Copilot:${sessionId}] Managed settings resolved: source=${e.data.source}, managedKeys=${e.data.managedKeys.join(',') || '(none)'}, bypassPermissionsDisabled=${e.data.bypassPermissionsDisabled}, failClosed=${e.data.failClosed}`); +- })); +- +- this._register(wrapper.onManagedSettingsEnforced(e => { +- this._logService.warn(`[Copilot:${sessionId}] Managed settings enforced: action=${e.data.action}, setting=${e.data.setting}, escalation=${e.data.escalation ?? '(none)'}, failClosed=${e.data.failClosed}, message=${e.data.message}`); - })); - - this._register(wrapper.onSessionHandoff(e => { @@ -6688,6 +10164,13 @@ index a0c0a765..00000000 - - this._register(wrapper.onUserMessage(e => { - this._logService.trace(`[Copilot:${sessionId}] User message: ${e.data.content.length} chars, ${e.data.attachments?.length ?? 0} attachments`); +- // Restricted `conversation.messageText` (source=user): the raw user prompt text. Emit only +- // for genuine human prompts on the main agent — skip subagent turns (driven by the parent) +- // and SDK-injected synthetic messages (skill/harness injections carry a non-`user` source, +- // matching `isSyntheticUserMessage`) so injected content is not reported as the user's prompt. +- if (!e.agentId && (!e.data.source || e.data.source.toLowerCase() === 'user')) { +- void this._telemetryReporter.userMessageText(this.sessionUri.toString(), this._currentTurn?.clientType ?? AgentHostClientType.Unknown, e.data.content, this._turnOrdinal).catch(err => this._logService.trace(`[Copilot:${this.sessionId}] Telemetry emission failed: ${getErrorMessage(err)}`)); +- } - })); - - this._register(wrapper.onPendingMessagesModified(() => { @@ -6697,10 +10180,34 @@ index a0c0a765..00000000 - this._register(wrapper.onTurnStart(e => { - this._currentTurn?.markRunning(); - this._logService.trace(`[Copilot:${sessionId}] Turn started: ${e.data.turnId}`); +- if (!e.agentId) { +- const telemetryMessageId = this._currentTurn?.id ?? e.data.turnId; +- if (this._activeRepoInfoTurn?.telemetryMessageId === telemetryMessageId) { +- return; +- } +- this._cancelActiveRepoInfoTelemetry(); +- const turn: NonNullable = { +- telemetryMessageId, +- cancelled: false, +- begin: Promise.resolve(undefined), +- }; +- const isCurrent = () => !turn.cancelled && this._isLaunchTokenCurrent(); +- turn.begin = this._beginRepoInfoTelemetry(telemetryMessageId, this._currentTurn?.clientType ?? AgentHostClientType.Unknown, isCurrent); +- this._activeRepoInfoTurn = turn; +- } - })); - - this._register(wrapper.onIntent(e => { - this._logService.trace(`[Copilot:${sessionId}] Intent: ${e.data.intent}`); +- const activity = e.data.intent || undefined; +- if (activity === undefined && !this._hasActivity) { +- return; +- } +- this._hasActivity = activity !== undefined; +- this._emitAction({ +- type: ActionType.SessionActivityChanged, +- activity, +- }); - })); - - this._register(wrapper.onReasoning(e => { @@ -6713,6 +10220,10 @@ index a0c0a765..00000000 - - this._register(wrapper.onAbort(e => { - this._logService.trace(`[Copilot:${sessionId}] Aborted: ${e.data.reason}`); +- this._cancelActiveRepoInfoTelemetry(); +- if (this._currentTurn?.isRunning) { +- this._reportToolCallDetails(this._currentTurn, 'cancelled'); +- } - })); - - this._register(wrapper.onToolUserRequested(e => { @@ -6721,6 +10232,30 @@ index a0c0a765..00000000 - - this._register(wrapper.onToolPartialResult(e => { - this._logService.trace(`[Copilot:${sessionId}] Tool partial result: ${e.data.toolCallId} (${e.data.partialOutput.length} chars)`); +- const tracked = this._activeToolCalls.get(e.data.toolCallId); +- if (!tracked || !isShellTool(tracked.toolName)) { +- return; +- } +- if (this._shellManager?.getTerminalUriForToolCall(e.data.toolCallId)) { +- // Client-hosted pty shell — its terminal channel streams live output itself. +- return; +- } +- const appended = this._nonPtyShellTerminals.append(e.data.toolCallId, e.data.partialOutput); +- if (appended?.created) { +- const { uri } = appended; +- tracked.content.push({ +- type: ToolResultContentType.Terminal, +- resource: uri, +- title: tracked.displayName, +- isPty: false, +- }); +- this._emitAction({ +- type: ActionType.ChatToolCallContentChanged, +- turnId: this._turnId, +- toolCallId: e.data.toolCallId, +- content: tracked.content, +- }, tracked.parentToolCallId); +- } - })); - - this._register(wrapper.onToolProgress(e => { @@ -6736,27 +10271,13 @@ index a0c0a765..00000000 - })); - - this._register(wrapper.onSubagentCompleted(e => { -- if (e.agentId) { -- this._parentToolCallIdsByAgentId.delete(e.agentId); -- } +- this._completeSubagentTurn(e.agentId, e.data.toolCallId); - this._logService.trace(`[Copilot:${sessionId}] Subagent completed: ${e.data.agentName}`); -- this._onDidSessionProgress.fire({ -- kind: 'subagent_completed', -- session: this.sessionUri, -- toolCallId: e.data.toolCallId, -- }); - })); - - this._register(wrapper.onSubagentFailed(e => { -- if (e.agentId) { -- this._parentToolCallIdsByAgentId.delete(e.agentId); -- } +- this._completeSubagentTurn(e.agentId, e.data.toolCallId); - this._logService.error(`[Copilot:${sessionId}] Subagent failed: ${e.data.agentName} - ${e.data.error}`); -- this._onDidSessionProgress.fire({ -- kind: 'subagent_completed', -- session: this.sessionUri, -- toolCallId: e.data.toolCallId, -- }); - })); - - this._register(wrapper.onSubagentSelected(e => { @@ -6769,6 +10290,9 @@ index a0c0a765..00000000 - - this._register(wrapper.onHookEnd(e => { - this._logService.trace(`[Copilot:${sessionId}] Hook ended: ${e.data.hookType} (${e.data.hookInvocationId}), success=${e.data.success}`); +- if (e.data.hookType === 'agentStop') { +- this._completeSubagentTurn(e.agentId); +- } - })); - - this._register(wrapper.onSystemMessage(e => { @@ -6826,12 +10350,31 @@ index a0c0a765..00000000 - - // ---- cleanup ------------------------------------------------------------ - +- /** +- * Cancels every pending interaction for abort and dispose. This completes synchronously before any awaiter resumes, so ordering is not significant. +- */ +- private _cancelAllPendingInteractions(): void { +- this._cancelPendingAutoApprovals(); +- this._denyPendingPermissions(); +- this._cancelPendingUserInputs(); +- this._cancelPendingElicitations(); +- this._cancelPendingPlanReviews(); +- this._cancelPendingMcpAuthRequests(); +- this._cancelPendingMcpSamplings(); +- this._cancelPendingClientToolCalls(); +- } +- +- private _cancelPendingAutoApprovals(): void { +- this._pendingAutoApprovals.denyAll(undefined); +- this._autoApprovals.clear(); +- } +- - private _denyPendingPermissions(): void { -- for (const [toolCallId, deferred] of this._pendingPermissions) { +- for (const [toolCallId] of this._pendingPermissions.entries()) { - this._deletePendingEditContent(toolCallId); -- deferred.complete(false); - } -- this._pendingPermissions.clear(); +- this._pendingPermissions.denyAll({ kind: 'reject' }); +- this._approvedDuplicablePermissionSignatures.clear(); - } - - /** @@ -6850,24 +10393,15 @@ index a0c0a765..00000000 - } - - private _cancelPendingUserInputs(): void { -- for (const [, pending] of this._pendingUserInputs) { -- pending.deferred.complete({ response: ChatInputResponseKind.Cancel }); -- } -- this._pendingUserInputs.clear(); +- this._pendingUserInputs.denyAll({ response: ChatInputResponseKind.Cancel }); - } - - private _cancelPendingElicitations(): void { -- for (const [, pending] of this._pendingElicitations) { -- pending.deferred.complete({ response: ChatInputResponseKind.Cancel }); -- } -- this._pendingElicitations.clear(); +- this._pendingElicitations.denyAll({ response: ChatInputResponseKind.Cancel }); - } - - private _cancelPendingPlanReviews(): void { -- for (const [, pending] of this._pendingPlanReviews) { -- pending.deferred.complete({ approved: false }); -- } -- this._pendingPlanReviews.clear(); +- this._pendingPlanReviews.denyAll({ approved: false }); - } - - private _cancelPendingMcpSamplings(): void { @@ -6909,6 +10443,30 @@ index a0c0a765..00000000 -} - -/** +- * Reads the SDK's internal `copilotUsage` billing payload, carried on both the `assistant.usage` +- * event and `session.compaction_complete`'s `compactionTokensUsed`. It is marked `asInternal` in +- * the SDK schema, so it is absent from the generated types (`AssistantUsageData`, +- * `CompactionCompleteCompactionTokensUsed`) even though it is present at runtime — hence the +- * dynamic read. This is the source for per-turn and per-subagent cost, accumulated synchronously +- * as each event arrives; only the session-wide total comes from the SDK's usage metrics. +- * Returns `undefined` when the payload carries no usable nano-AIU total. +- */ +-function readCopilotUsage(raw: unknown): { totalNanoAiu: number } & Record | undefined { +- if (!raw || typeof raw !== 'object') { +- return undefined; +- } +- const usage = (raw as Record).copilotUsage; +- if (!usage || typeof usage !== 'object') { +- return undefined; +- } +- const totalNanoAiu = (usage as Record).totalNanoAiu; +- if (typeof totalNanoAiu !== 'number' || !Number.isFinite(totalNanoAiu) || totalNanoAiu < 0) { +- return undefined; +- } +- return { ...(usage as Record), totalNanoAiu }; +-} +- +-/** - * Normalizes the SDK's internal `quotaSnapshots` field — present on the `assistant.usage` event at - * runtime but absent from the generated `AssistantUsageData` type — into the serializable shape - * carried on {@link UsageInfoMeta.quotaSnapshots}. Returns `undefined` when no usable snapshot is present. @@ -6943,12 +10501,24 @@ index a0c0a765..00000000 - } - return hasAny ? result : undefined; -} +diff --git a/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts b/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts +index 24e6d697..005d108b 100644 +--- a/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts ++++ b/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts +@@ -5,3 +5,6 @@ + +-import type { GitHubTelemetryNotification } from '@github/copilot-sdk'; ++// `GitHubTelemetryNotification` 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 GitHubTelemetryNotification = any; + import { ITelemetryData, ITelemetryService } from '../../../telemetry/common/telemetry.js'; 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 2c055e48..00000000 +index 49a195b7..00000000 --- a/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts +++ /dev/null -@@ -1,502 +0,0 @@ +@@ -1,517 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -7082,27 +10652,41 @@ index 2c055e48..00000000 - const content = await fileService.readFile(agent.uri); - const raw = content.value.toString(); - const md = parseFrontMatter(raw); -- // 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 description = md?.getStringValue('description'); -- const tools = md?.getStringArrayValue('tools'); -- const prompt = md?.body ?? raw; -- let model: string | undefined = md?.getStringValue('model') ?? undefined; -- const models = md?.getStringArrayValue('model') ?? undefined; -- if (!model && models && Array.isArray(models) && models.length > 0) { -- model = models[0]; -- } -- -- configs.push({ -- name, -- ...(description ? { description } : {}), -- ...(model ? { model } : {}), -- tools: tools && tools.length > 0 ? tools : null, -- prompt, -- }); +- if (!md) { +- configs.push({ +- name: 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 description = md.getStringValue('description'); +- const tools = md.getStringArrayValue('tools'); +- const skills = md.getStringArrayValue('skills'); +- let infer = md.getBooleanValue('infer'); +- const disableModelInvocation = md.getBooleanValue('disable-model-invocation'); +- if (infer === undefined && disableModelInvocation === true) { +- infer = false; +- } +- const prompt = md.body ?? raw; +- let model: string | undefined = md.getStringValue('model') ?? undefined; +- const models = md.getStringArrayValue('model') ?? undefined; +- if (!model && models && Array.isArray(models) && models.length > 0) { +- model = models[0]; +- } +- configs.push({ +- name, +- ...(description ? { description } : {}), +- ...(model ? { model } : {}), +- tools: tools && tools.length > 0 ? tools : null, +- ...(skills !== undefined ? { skills } : {}), +- ...(infer !== undefined ? { infer } : {}), +- prompt, +- }); +- } - } catch { - // Skip agents whose file cannot be read - } @@ -7442,6 +11026,7 @@ index 2c055e48..00000000 - // We serialize only the essential fields, replacing URIs with strings. - const serialize = (plugins: readonly IParsedPlugin[]) => { - return JSON.stringify(plugins.map(p => ({ +- format: p.format, - 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 })), - skills: p.skills.map(s => ({ uri: s.uri.toString(), name: s.name })), @@ -7453,28 +11038,32 @@ index 2c055e48..00000000 -} 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 97470ca6..00000000 +index 0644a831..00000000 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ /dev/null -@@ -1,353 +0,0 @@ +@@ -1,644 +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, ExitPlanModeRequest, ExitPlanModeResult, PermissionRequestResult, ResumeSessionConfig, SessionConfig, Tool } from '@github/copilot-sdk'; +-import type { CopilotClient, ExitPlanModeRequest, ExitPlanModeResult, NamedProviderConfig, PermissionRequestResult, ProviderModelConfig, ResumeSessionConfig, SessionConfig, Tool } from '@github/copilot-sdk'; -import { coalesce } from '../../../../base/common/arrays.js'; -import { Schemas } from '../../../../base/common/network.js'; -import { URI } from '../../../../base/common/uri.js'; -import { IFileService } from '../../../files/common/files.js'; --import { ILogService } from '../../../log/common/log.js'; --import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; +-import { ILogService, LogLevel } from '../../../log/common/log.js'; +-import { CopilotCliConfigKey, applyModelFamilyAlias, copilotCliConfigSchema, normalizeToolSearchDeferThreshold } from '../../common/copilotCliConfig.js'; +-import { agentHostModelSupportsToolSearch, CLIENT_TOOL_SEARCH_REFERENCE_NAME } from './toolSearchDeferral.js'; -import { AgentHostSessionSyncEnabledConfigKey, platformRootSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; -import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; -import { IAgentConfigurationService } from '../agentConfigurationService.js'; -import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; +-import { IByokLmBridgeRegistry } from '../byokLmBridgeRegistry.js'; +-import { IByokLmProxyService, type IByokLmProxyHandle } from './byokLmProxyService.js'; +-import type { IByokLmModelInfo } from '../../common/agentHostByokLm.js'; -import type { ModelSelection, ToolDefinition } from '../../common/state/protocol/state.js'; --import type { ActiveClientState } from '../activeClientState.js'; +-import type { ActiveClientToolSet } from '../activeClientState.js'; -import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; -import { ShellManager, createShellTools, type IUnsandboxedCommandConfirmationRequest } from './copilotShellTools.js'; -import { toSdkHooks, toSdkInstructionDirectories, toSdkMcpServers, toSdkMcpServersFromConfigMap, toSdkSessionCustomAgents, toSdkSkillDirectories } from './copilotPluginConverters.js'; @@ -7482,9 +11071,21 @@ index 97470ca6..00000000 -import type { ITypedPermissionRequest } from './copilotToolDisplay.js'; -import type { ICopilotPluginInfo } from './copilotAgent.js'; -import { agentHostPromptRegistry, type IAgentHostPromptContext } from './prompts/promptRegistry.js'; +-import { describeSystemMessageConfig } from './prompts/systemMessage.js'; -import './prompts/allPrompts.js'; +-import { StopWatch } from '../../../../base/common/stopwatch.js'; - -export const ThinkingLevelConfigKey = 'thinkingLevel'; +-/** +- * Config key for the numeric "Context Size" selection (a context-window token count). Mapped to the +- * SDK's two-valued {@link SessionConfig.contextTier} by {@link getCopilotContextTier}. +- */ +-export const ContextSizeConfigKey = 'contextSize'; +-/** +- * @deprecated Legacy config key that stored the resolved tier string (`'default'` / `'long_context'`) +- * directly. Replaced by the numeric {@link ContextSizeConfigKey}; still read from persisted sessions +- * for backward compatibility. +- */ -export const ContextTierConfigKey = 'contextTier'; - -const ReasoningEfforts = ['low', 'medium', 'high', 'xhigh'] as const; @@ -7492,6 +11093,7 @@ index 97470ca6..00000000 - -const ContextTiers = ['default', 'long_context'] as const; -type ContextTier = NonNullable; +-const AGENT_HOST_COPILOT_CLIENT_NAME = 'vscode-agent-host'; - -type UserInputHandler = NonNullable; -type UserInputRequest = Parameters[0]; @@ -7500,6 +11102,10 @@ index 97470ca6..00000000 -type ElicitationHandler = NonNullable; -type ElicitationContext = Parameters[0]; -type ElicitationResult = Awaited>; +-type McpAuthHandler = NonNullable; +-type McpAuthRequest = Parameters[0]; +-type McpAuthContext = Parameters[1]; +-type McpAuthResponse = Awaited>; -type SessionHooks = NonNullable; -type PreToolUseHookInput = Parameters>[0]; -type PostToolUseHookInput = Parameters>[0]; @@ -7512,9 +11118,9 @@ index 97470ca6..00000000 - * Immutable snapshot of the active client's structural contributions at - * session creation time. Used to detect when the session needs to be - * refreshed. Root MCP servers participate in restart detection because they -- * are merged into the SDK session config. The owning `clientId` is +- * are merged into the SDK session config. The owning `clientId`s are - * deliberately NOT part of this snapshot: client identity is tracked live via -- * {@link ActiveClientState} so a window +- * {@link ActiveClientToolSet} so a window - * reload (new `clientId`, identical tools/plugins) does not force a restart. - */ -export interface IActiveClientSnapshot { @@ -7523,11 +11129,22 @@ index 97470ca6..00000000 - readonly mcpServers: AgentHostMcpServers; -} - +-/** +- * The set of client-tool names the agent sees for a snapshot — each tool's +- * `ToolDefinition.name` (the camelCase `toolReferenceName`). Used both to gate +- * tool-specific prompt sections at launch and to route client tool calls during +- * the session, so the two stay derived from one definition. +- */ +-export function clientToolNamesFromSnapshot(snapshot: IActiveClientSnapshot): ReadonlySet { +- return new Set(snapshot.tools.map(tool => tool.name)); +-} +- -export interface ICopilotSessionRuntime { - handlePermissionRequest(request: ITypedPermissionRequest): Promise; - handleExitPlanModeRequest(request: ExitPlanModeRequest, invocation: { sessionId: string }): Promise; - handleUserInputRequest(request: UserInputRequest, invocation: UserInputInvocation): Promise; - handleElicitationRequest(context: ElicitationContext): Promise; +- handleMcpAuthRequest(request: McpAuthRequest, context: McpAuthContext): Promise; - requestUnsandboxedCommandConfirmation(request: IUnsandboxedCommandConfirmationRequest): Promise; - handlePreToolUse(input: PreToolUseHookInput): Promise; - handlePostToolUse(input: PostToolUseHookInput): Promise; @@ -7551,22 +11168,43 @@ index 97470ca6..00000000 - readonly client: CopilotSessionClient; - readonly sessionId: string; - readonly workingDirectory: URI | undefined; +- /** +- * The additional working directories beyond the primary process root +- * ({@link workingDirectory} = index 0). These are the peer roots of a +- * multi-root session's ordered set — the directories the agent should be +- * granted tool access to in addition to its process cwd. Empty (or absent) +- * for a single-root session. Passed through so the SDK can register them as +- * extra accessible roots once that surface is available; the process still +- * launches in {@link workingDirectory}. +- */ +- readonly additionalDirectories?: readonly URI[]; - readonly resolvedAgentName: string | undefined; - readonly snapshot: IActiveClientSnapshot; - /** -- * Live, long-lived holder of the owning client's identity. Read at -- * tool-call stamp time so a window reload (new `clientId`, identical -- * tools) stamps subsequent client tool calls with the current id -- * rather than the one frozen into {@link snapshot} at creation. +- * Live, long-lived registry of every active client's tool contributions. +- * Read at tool-call stamp time so a window reload (new `clientId`, +- * identical tools) stamps subsequent client tool calls with the current +- * owning id rather than the one frozen into {@link snapshot} at creation, +- * and so a tool call is attributed to whichever client contributed it. - */ -- readonly activeClientState: ActiveClientState; +- readonly activeClientToolSet: ActiveClientToolSet; - readonly shellManager: ShellManager | undefined; - readonly githubToken: string | undefined; +- +- /** +- * Whether this is a workspace-less session. Threaded into the +- * prompt context so the resolved system message gets the scratch/repoless +- * variant. Named to match the `workspaceless` marker used throughout the AH +- * layer (session `_meta`, stored metadata) that this value flows from. +- */ +- readonly workspaceless?: boolean; -} - -export interface ICopilotCreateSessionLaunchPlan extends ICopilotSessionLaunchBase { - readonly kind: 'create'; - readonly model: ModelSelection | undefined; +- readonly longContextWindow?: number; +- readonly freeLongContext?: boolean; -} - -export interface ICopilotResumeSessionLaunchPlan extends ICopilotSessionLaunchBase { @@ -7574,16 +11212,18 @@ index 97470ca6..00000000 - readonly workingDirectory: URI; - readonly fallback: { - readonly model: ModelSelection | undefined; +- readonly longContextWindow?: number; +- readonly freeLongContext?: boolean; - }; -} - -export type CopilotSessionLaunchPlan = ICopilotCreateSessionLaunchPlan | ICopilotResumeSessionLaunchPlan; - --function isReasoningEffort(value: string | undefined): value is ReasoningEffort { +-export function isCopilotReasoningEffort(value: unknown): value is ReasoningEffort { - return ReasoningEfforts.some(reasoningEffort => reasoningEffort === value); -} - --function isContextTier(value: string | undefined): value is ContextTier { +-function isContextTier(value: unknown): value is ContextTier { - return ContextTiers.some(contextTier => contextTier === value); -} - @@ -7609,18 +11249,22 @@ index 97470ca6..00000000 -} - -/** +- * Messages from a failed Copilot SDK `session.resume` that positively indicate +- * the session has no events on disk, so there is no history to lose. Includes +- * the post-"Start Over" case, where `truncateSession` leaves zero events. +- */ +-const RESUMABLE_HISTORY_ABSENT_PATTERNS = [ +- /\bSession not found\b/i, +- /\bno events\b/i, +- /\bempty session\b/i, +-]; +- +-/** - * Decide whether a Copilot SDK `resumeSession` failure should fall back to -- * `createSession({ sessionId })`. We want to preserve the original -- * recovery for empty / truncated sessions (e.g. after the user invoked -- * "Start Over", which calls `truncateSession` and leaves the on-disk -- * session with zero events - the SDK then refuses to resume it), but we -- * must NOT silently swallow corruption / schema-validation / parse -- * failures: those should surface so the user sees the real error and the -- * original session contents are not masked by a fresh empty session. -- * -- * Heuristic: any `-32603` Internal Error is treated as the empty-session -- * case UNLESS the message clearly indicates corruption, schema -- * validation, parse failure, or malformed input. +- * `createSession({ sessionId })`, which presents the session as having no +- * history. Deliberately an allowlist: a fallback on an unrelated failure (a +- * transient `network fetch failed` is also `-32603`) discards a live session's +- * history and leaves it exposed to empty-session GC. - */ -function shouldCreateEmptySessionAfterResumeError(err: unknown): boolean { - if (getCopilotSdkErrorCode(err) !== -32603) { @@ -7628,26 +11272,167 @@ index 97470ca6..00000000 - } - - const message = getErrorMessage(err); -- return !/\b(corrupt|corrupted|invalid|validation|schema|must be|parse|malformed|unexpected token)\b/i.test(message); +- return RESUMABLE_HISTORY_ABSENT_PATTERNS.some(pattern => pattern.test(message)); +-} +- +-function isCustomAgentNotFoundError(err: unknown): boolean { +- return getCopilotSdkErrorCode(err) === -32603 && /\bCustom agent '.+' not found\b/i.test(getErrorMessage(err)); -} - --export function getCopilotReasoningEffort(model: ModelSelection | undefined): SessionConfig['reasoningEffort'] { +-/** +- * Resolves the reasoning effort: a recognized override level wins over the +- * model picker's thinking level; an unrecognized override is ignored (degrades +- * to the picker). Validation is against the known effort levels only — the +- * caller/operator is responsible for choosing a level the model supports. +- */ +-export function getCopilotReasoningEffort(model: ModelSelection | undefined, effortOverride?: string): SessionConfig['reasoningEffort'] { +- if (isCopilotReasoningEffort(effortOverride)) { +- return effortOverride; +- } - const thinkingLevel = model?.config?.[ThinkingLevelConfigKey]; -- return isReasoningEffort(thinkingLevel) ? thinkingLevel : undefined; +- return isCopilotReasoningEffort(thinkingLevel) ? thinkingLevel : undefined; +-} +- +-/** +- * Resolves the reasoning effort, applying the host-level override and logging +- * whether it applied. Shared by the launcher (create) and +- * `CopilotAgent._changeModel` (mid-session model change) for consistency. +- */ +-export function resolveCopilotReasoningEffort(model: ModelSelection | undefined, configurationService: IAgentConfigurationService, logService: ILogService, sessionId: string): SessionConfig['reasoningEffort'] { +- const rawOverride = configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.ReasoningEffortOverride); +- // '' is the schema's unset marker, so an unset override reads as `undefined`. +- const override = rawOverride ? rawOverride : undefined; +- if (override !== undefined) { +- if (isCopilotReasoningEffort(override)) { +- logService.info(`[Copilot:${sessionId}] Applying reasoning-effort override '${override}'`); +- } else { +- logService.warn(`[Copilot:${sessionId}] Ignoring invalid reasoning-effort override '${override}'; expected one of [${ReasoningEfforts.join(', ')}]`); +- } +- } +- return getCopilotReasoningEffort(model, override); +-} +- +-export function getCopilotContextTier(model: ModelSelection | undefined, longContextWindow?: number, freeLongContext?: boolean): SessionConfig['contextTier'] { +- // Legacy persisted selections stored the resolved tier string directly under the deprecated key. +- const legacyTier = model?.config?.[ContextTierConfigKey]; +- if (isContextTier(legacyTier)) { +- return legacyTier; +- } +- // The "Context Size" picker exposes numeric token-count enum values, so a current selection arrives +- // under `contextSize` as a token count. Map it to the SDK's two-valued tier using the model's +- // long-context window: only a selection that reaches that window opts into `long_context`. Without +- // the window (model exposes no picker, or the model list isn't loaded) leave the SDK on its default +- // tier. +- const contextSize = model?.config?.[ContextSizeConfigKey]; +- if (contextSize === undefined) { +- // When the model's long-context tier costs the same as the default tier, +- // always opt into long_context — no picker is shown and the user gets the +- // larger window for free. +- return freeLongContext ? 'long_context' : undefined; +- } +- const selectedWindow = Number(contextSize); +- if (!Number.isFinite(selectedWindow) || typeof longContextWindow !== 'number') { +- return undefined; +- } +- return selectedWindow >= longContextWindow ? 'long_context' : 'default'; -} - --export function getCopilotContextTier(model: ModelSelection | undefined): SessionConfig['contextTier'] { -- const contextTier = model?.config?.[ContextTierConfigKey]; -- return isContextTier(contextTier) ? contextTier : undefined; +-/** +- * Resolve the BYOK provider/model session config for `sessionId` from the +- * renderer's active bridge. Returns empty — the session launches without BYOK +- * models — when BYOK is gated off (no active bridge), when the renderer reports +- * no BYOK models, or when enumeration fails; `startProxy` is invoked only once +- * at least one model is present. +- * +- * Each vendor maps to one `type: 'openai'` / `wireApi: 'responses'` provider +- * whose `baseUrl` points at the proxy and authenticates with the session-scoped +- * `Bearer .`; each model is surfaced under the +- * provider-qualified selection id `vendor/id`, matching what the renderer's +- * `AgentHostByokLmHandler` resolves. +- * +- * Extracted from {@link CopilotSessionLauncher} so the synthesis and gating are +- * unit-testable without instantiating the launcher; the launcher passes a +- * `startProxy` thunk that memoizes the single shared proxy handle. +- */ +-export async function resolveByokSessionConfig( +- sessionId: string, +- bridgeRegistry: IByokLmBridgeRegistry, +- startProxy: () => Promise, +- logService: ILogService, +-): Promise<{ providers?: NamedProviderConfig[]; models?: ProviderModelConfig[] }> { +- // Surface the serving window's BYOK models. The registry does not union +- // windows' model sets — all serving windows expose the same set, so it picks +- // one (see `IByokLmBridgeRegistry`) and the proxy routes inference there. +- let byokModels: IByokLmModelInfo[]; +- try { +- byokModels = [...bridgeRegistry.getModels()]; +- } catch (err) { +- logService.warn(`[Copilot:${sessionId}] Failed to enumerate BYOK models from renderer bridges`, err); +- return {}; +- } +- if (byokModels.length === 0) { +- return {}; +- } +- // Deduplicate by selection id (`vendor/id`). The same BYOK model can be +- // reported more than once — e.g. when two renderer bridges are transiently +- // serving during a window hand-off (continuing a chat into a new session) — +- // and the runtime rejects a session config with duplicate BYOK model +- // selection ids ("Duplicate BYOK model selection id ..."). +- const seenSelectionIds = new Set(); +- byokModels = byokModels.filter(m => { +- const selectionId = `${m.vendor}/${m.id}`; +- if (seenSelectionIds.has(selectionId)) { +- return false; +- } +- seenSelectionIds.add(selectionId); +- return true; +- }); +- // `startProxy` binds a local loopback listener — unlikely to fail, but it +- // must never break session materialization (which fires the cross-window +- // `sessionAdded` broadcast). Degrade to no BYOK config on failure. +- let handle: IByokLmProxyHandle; +- try { +- handle = await startProxy(); +- } catch (err) { +- logService.warn(`[Copilot:${sessionId}] Failed to start BYOK loopback proxy`, err); +- return {}; +- } +- const providers: NamedProviderConfig[] = [...new Set(byokModels.map(m => m.vendor))].map(vendor => ({ +- name: vendor, +- type: 'openai', +- wireApi: 'responses', +- baseUrl: handle.providerBaseUrl(vendor), +- bearerToken: `${handle.nonce}.${sessionId}`, +- })); +- const models: ProviderModelConfig[] = byokModels.map(m => ({ +- id: m.id, +- provider: m.vendor, +- ...(m.name !== undefined ? { name: m.name } : {}), +- ...(m.maxContextWindowTokens !== undefined ? { maxContextWindowTokens: m.maxContextWindowTokens } : {}), +- })); +- logService.info(`[Copilot:${sessionId}] Wired ${models.length} BYOK model(s) across ${providers.length} provider(s) via loopback proxy ${handle.baseUrl}`); +- return { providers, models }; -} - -export class CopilotSessionLauncher implements ICopilotSessionLauncher { - +- /** +- * Memoized handle for the single shared BYOK loopback proxy, started lazily +- * on the first session launch that surfaces BYOK models (see +- * {@link _resolveByokSessionConfig}). Held as a promise so concurrent +- * launches share one bind. Released and cleared by +- * {@link disposeByokProxyHandle} when the owning Copilot client/runtime is +- * stopped, so the next start mints a fresh nonce. +- */ +- private _byokProxyHandle: Promise | undefined; +- - constructor( - @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, - @IAgentHostTerminalManager private readonly _terminalManager: IAgentHostTerminalManager, - @ILogService private readonly _logService: ILogService, - @IFileService private readonly _fileService: IFileService, +- @IByokLmProxyService private readonly _byokLmProxyService: IByokLmProxyService, +- @IByokLmBridgeRegistry private readonly _byokLmBridgeRegistry: IByokLmBridgeRegistry, - ) { } - - async launch(plan: CopilotSessionLaunchPlan, runtime: ICopilotSessionRuntime): Promise { @@ -7657,33 +11442,49 @@ index 97470ca6..00000000 - return this._createSession(plan, config, sandboxConfig); - } - +- let fallbackPlan = plan; +- let fallbackConfig = config; - try { -- this._logService.info(`[Copilot:${plan.sessionId}] Calling SDK resumeSession...`); -- const raw = await plan.client.resumeSession(plan.sessionId, { -- ...config, -- workingDirectory: plan.workingDirectory.fsPath, -- ...(plan.resolvedAgentName ? { agent: plan.resolvedAgentName } : {}), -- }); -- this._logService.info(`[Copilot:${plan.sessionId}] SDK resumeSession succeeded`); +- const stopWatch = new StopWatch(); +- this._logService.trace(`[Copilot:${plan.sessionId}] Calling SDK resumeSession...`); +- const raw = await plan.client.resumeSession(plan.sessionId, config); +- this._logService.trace(`[Copilot:${plan.sessionId}] SDK resumeSession succeeded after ${stopWatch.elapsed()}ms`); - await this._applySandboxConfig(raw, sandboxConfig, plan.sessionId); - return new CopilotSessionWrapper(raw); - } catch (err) { -- const errCode = getCopilotSdkErrorCode(err); -- const errMsg = getErrorMessage(err); +- let resumeError = err; +- const errCode = getCopilotSdkErrorCode(resumeError); +- const errMsg = getErrorMessage(resumeError); - this._logService.warn(`[Copilot:${plan.sessionId}] SDK resumeSession failed: code=${errCode}, message=${errMsg}`); -- // The SDK fails to resume sessions that have no messages. -- // Fall back to creating a new session with the same ID, -- // seeding model & working directory from stored metadata. -- if (!shouldCreateEmptySessionAfterResumeError(err)) { -- throw err; +- if (plan.resolvedAgentName && 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`); +- try { +- const raw = await fallbackPlan.client.resumeSession(fallbackPlan.sessionId, fallbackConfig); +- await this._applySandboxConfig(raw, sandboxConfig, plan.sessionId); +- return new CopilotSessionWrapper(raw); +- } catch (retryErr) { +- resumeError = retryErr; +- this._logService.warn(`[Copilot:${plan.sessionId}] SDK resumeSession without custom agent failed: code=${getCopilotSdkErrorCode(retryErr)}, message=${getErrorMessage(retryErr)}`); +- } +- } +- // Only a session with no events on disk may fall back to creating a +- // fresh one under the same ID (seeding model & working directory +- // from stored metadata); every other failure propagates. +- if (!shouldCreateEmptySessionAfterResumeError(resumeError)) { +- this._logService.warn(`[Copilot:${plan.sessionId}] Resume failure does not indicate an empty session; surfacing it instead of replacing the session with an empty one`); +- throw resumeError; - } - -- this._logService.warn(`[Copilot:${plan.sessionId}] Resume failed (code=-32603), falling back to createSession with same ID`); +- this._logService.warn(`[Copilot:${plan.sessionId}] Resume reported no session history; falling back to createSession with same ID`); - const wrapper = await this._createSession({ -- ...plan, +- ...fallbackPlan, - kind: 'create', -- model: plan.fallback.model, -- }, config, sandboxConfig); +- model: fallbackPlan.fallback.model, +- longContextWindow: fallbackPlan.fallback.longContextWindow, +- freeLongContext: fallbackPlan.fallback.freeLongContext, +- }, fallbackConfig, sandboxConfig); - this._logService.info(`[Copilot:${plan.sessionId}] Fallback createSession succeeded`); - return wrapper; - } @@ -7695,8 +11496,8 @@ index 97470ca6..00000000 - sessionId: plan.sessionId, - streaming: true, - model: plan.model?.id, -- reasoningEffort: getCopilotReasoningEffort(plan.model), -- contextTier: getCopilotContextTier(plan.model), +- reasoningEffort: resolveCopilotReasoningEffort(plan.model, this._configurationService, this._logService, plan.sessionId), +- contextTier: getCopilotContextTier(plan.model, plan.longContextWindow, plan.freeLongContext), - ...(plan.resolvedAgentName ? { agent: plan.resolvedAgentName } : {}), - workingDirectory: plan.workingDirectory?.fsPath, - }); @@ -7708,7 +11509,7 @@ index 97470ca6..00000000 - * Compute the SDK-shaped sandbox policy to push to the runtime for the - * SDK's built-in shell tool. - * -- * Returns `undefined` when {@link AgentHostConfigKey.EnableCustomTerminalTool} +- * Returns `undefined` when {@link CopilotCliConfigKey.EnableCustomTerminalTool} - * is ON — in that case the AgentHost provides its own shell tools, which - * wrap commands via the host terminal sandbox engine, so no SDK-side - * sandbox policy is needed. Otherwise the policy is derived from the @@ -7717,7 +11518,7 @@ index 97470ca6..00000000 - * `buildSandboxConfigForCLI` does for the Copilot extension's CLI path. - */ - private _computeSandboxConfig(): ISdkSandboxConfig | undefined { -- const enableCustomTerminalTool = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.EnableCustomTerminalTool) === true; +- const enableCustomTerminalTool = this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.EnableCustomTerminalTool) === true; - if (enableCustomTerminalTool) { - return undefined; - } @@ -7746,9 +11547,51 @@ index 97470ca6..00000000 - } - } - +- /** +- * Launcher-bound wrapper over {@link resolveByokSessionConfig}: supplies the +- * active bridge registry and a `startProxy` thunk that memoizes the single +- * shared proxy handle for this launcher (started lazily on first use). +- */ +- private _resolveByokSessionConfig(sessionId: string): Promise<{ providers?: NamedProviderConfig[]; models?: ProviderModelConfig[] }> { +- return resolveByokSessionConfig(sessionId, this._byokLmBridgeRegistry, () => { +- if (!this._byokProxyHandle) { +- this._byokProxyHandle = this._byokLmProxyService.start(); +- } +- return this._byokProxyHandle; +- }, this._logService); +- } +- +- /** +- * Release the memoized BYOK loopback proxy handle (if any) and clear it so +- * the next session launch mints a fresh nonce. Idempotent. +- * +- * **Ownership invariant.** The caller MUST stop the Copilot client/runtime +- * subprocess before invoking this: disposing the handle drops the proxy's +- * refcount and may rebind it on a different port/nonce, so a still-running +- * subprocess would silently lose its endpoint — see {@link IByokLmProxyHandle}. +- * Invoked from `CopilotAgent._stopClient` / `CopilotAgent.shutdown` after the +- * client has stopped. +- */ +- async disposeByokProxyHandle(): Promise { +- const handle = this._byokProxyHandle; +- this._byokProxyHandle = undefined; +- if (!handle) { +- return; +- } +- try { +- (await handle).dispose(); +- } catch { +- // The lazy `start()` rejected; there is nothing to release. +- } +- } +- - private async _buildSessionConfig(plan: CopilotSessionLaunchPlan, runtime: ICopilotSessionRuntime): Promise { - const plugins = plan.snapshot.plugins; -- const enableCustomTerminalTool = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.EnableCustomTerminalTool) === true; +- // Synthesize BYOK provider/model config (empty when BYOK is gated off or the +- // renderer reports no BYOK models), merged into the returned config so both +- // createSession and resumeSession advertise the models to the runtime. +- const byok = await this._resolveByokSessionConfig(plan.sessionId); +- const enableCustomTerminalTool = this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.EnableCustomTerminalTool) === true; - let shellTools: Awaited> = []; - if (enableCustomTerminalTool) { - if (!plan.shellManager) { @@ -7765,15 +11608,45 @@ index 97470ca6..00000000 - const skillDirectories = toSdkSkillDirectories(pluginsWithoutDirs.flatMap(p => p.skills)); - const instructionDirectories = toSdkInstructionDirectories(plugins.flatMap(p => p.instructions)); - const model = plan.kind === 'create' ? plan.model : plan.fallback.model; +- const clientToolNames = clientToolNamesFromSnapshot(plan.snapshot); +- // Prompt routing and capability decisions use the family-aliased +- // selection; the wire model id in _createSession comes from plan.model +- // and is unaffected. +- const effectiveModel = applyModelFamilyAlias(model, this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.ModelCapabilityOverrides)); +- if (model && effectiveModel !== model) { +- this._logService.info(`[Copilot:${plan.sessionId}] Model capability override: routing prompt for '${model.id}' as family '${effectiveModel?.id}'`); +- } +- const toolSearchActive = this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.ToolSearchEnabled) === true +- && agentHostModelSupportsToolSearch(effectiveModel?.id) +- && clientToolNames.has(CLIENT_TOOL_SEARCH_REFERENCE_NAME); +- const toolSearchDeferThreshold = normalizeToolSearchDeferThreshold(this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.ToolSearchDeferThreshold)); - const promptContext: IAgentHostPromptContext = { -- getSetting: key => this._configurationService.getRootValue(agentHostCustomizationConfigSchema, key), +- getSetting: key => this._configurationService.getRootValue(copilotCliConfigSchema, key), +- hasClientTool: name => clientToolNames.has(name), +- workspaceless: plan.workspaceless === true, +- toolSearchActive, - }; +- // Resolved once per (re)launch — the SDK has no mid-session system-message +- // update, so this reflects the model/tools/settings at launch time. Log a +- // summary at info for prompt observability; the full config at trace. +- const systemMessage = agentHostPromptRegistry.resolveSystemMessageConfig(effectiveModel, promptContext); +- this._logService.info(`[Copilot:${plan.sessionId}] Resolved system message: ${describeSystemMessageConfig(systemMessage)}`); +- if (this._logService.getLevel() <= LogLevel.Trace) { +- // Guarded: a `replace`-mode prompt's content can be multiple KB, so only +- // serialize it when trace output is actually emitted. +- this._logService.trace(`[Copilot:${plan.sessionId}] System message config: ${JSON.stringify(systemMessage, (_key, value) => typeof value === 'function' ? '[transform fn]' : value)}`); +- } - return { -- clientName: 'vscode', +- ...byok, +- clientName: AGENT_HOST_COPILOT_CLIENT_NAME, - enableMcpApps: true, +- enableFileHooks: true, +- enableConfigDiscovery: true, +- requestExtensions: false, // force-disable copilot extension management tools (otherwise enabled in experimental mode) - onPermissionRequest: request => runtime.handlePermissionRequest(request), - onUserInputRequest: (request, invocation) => runtime.handleUserInputRequest(request, invocation), - onElicitationRequest: context => runtime.handleElicitationRequest(context), +- onMcpAuthRequest: (request, context) => runtime.handleMcpAuthRequest(request, context), - hooks: toSdkHooks(pluginsWithoutDirs.flatMap(p => p.hooks), { - onPreToolUse: input => runtime.handlePreToolUse(input), - onPostToolUse: input => runtime.handlePostToolUse(input), @@ -7782,9 +11655,11 @@ index 97470ca6..00000000 - onExitPlanModeRequest: (request, invocation) => runtime.handleExitPlanModeRequest(request, invocation), - workingDirectory: plan.workingDirectory?.fsPath, - customAgents, +- agent: plan.resolvedAgentName, - skillDirectories, - instructionDirectories, -- systemMessage: agentHostPromptRegistry.resolveSystemMessageConfig(model, promptContext), +- systemMessage, +- toolSearch: toolSearchActive ? { enabled: true, deferThreshold: toolSearchDeferThreshold } : { enabled: false }, - pluginDirectories: coalesce(plugins.map(p => p.pluginDir)) - .filter(d => d.scheme === Schemas.file).map(d => d.fsPath), - tools: [...shellTools, ...runtime.createClientSdkTools(), ...runtime.createServerSdkTools()], @@ -7807,21 +11682,23 @@ index 97470ca6..00000000 - // session must opt in via `remoteSession` to actually export - // events. Without this, sessions default to "off". - remoteSession: this._configurationService.getRootValue(platformRootSchema, AgentHostSessionSyncEnabledConfigKey) === true ? 'export' : undefined, +- enableManagedSettings: true, - }; - } -} 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 04eba456..00000000 +index 38a5da77..00000000 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts +++ /dev/null -@@ -1,247 +0,0 @@ +@@ -1,314 +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 { CopilotSession, SessionEventPayload, SessionEventType } from '@github/copilot-sdk'; +-import type { CopilotSession, SessionEvent, SessionEventPayload, SessionEventType } from '@github/copilot-sdk'; +-import { DeferredPromise } from '../../../../base/common/async.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; - @@ -7832,15 +11709,43 @@ index 04eba456..00000000 - */ -export class CopilotSessionWrapper extends Disposable { - +- private readonly _handledEventTypes = new Set(); +- private readonly _onUnhandledEvent = this._register(new Emitter()); +- readonly onUnhandledEvent = this._onUnhandledEvent.event; +- private readonly _shutdown = new DeferredPromise(); +- private _disconnectPromise: Promise | undefined; +- - constructor(readonly session: CopilotSession) { - super(); +- const unsubscribeAll = session.on(event => { +- if (event.type === 'session.shutdown') { +- void this._shutdown.complete(); +- } +- if (!this._handledEventTypes.has(event.type)) { +- this._onUnhandledEvent.fire(event); +- } +- }); +- this._register(toDisposable(unsubscribeAll)); - this._register(toDisposable(() => { -- session.disconnect().catch(() => { /* best-effort */ }); +- void this.disconnect().catch(() => { /* best-effort */ }); - })); - } - - get sessionId(): string { return this.session.sessionId; } - +- /** Disconnects once the request completes or the SDK reports session shutdown. */ +- disconnect(): Promise { +- if (this._shutdown.isSettled) { +- return this._shutdown.p; +- } +- this._disconnectPromise ??= this.session.disconnect().catch(error => { +- if (!this._shutdown.isSettled) { +- throw error; +- } +- }); +- return Promise.race([this._disconnectPromise, this._shutdown.p]); +- } +- - private _onMessageDelta: Event> | undefined; - get onMessageDelta(): Event> { - return this._onMessageDelta ??= this._sdkEvent('assistant.message_delta'); @@ -7851,6 +11756,11 @@ index 04eba456..00000000 - return this._onMessage ??= this._sdkEvent('assistant.message'); - } - +- private _onToolCallDelta: Event> | undefined; +- get onToolCallDelta(): Event> { +- return this._onToolCallDelta ??= this._sdkEvent('assistant.tool_call_delta'); +- } +- - private _onToolStart: Event> | undefined; - get onToolStart(): Event> { - return this._onToolStart ??= this._sdkEvent('tool.execution_start'); @@ -7861,6 +11771,16 @@ index 04eba456..00000000 - return this._onToolComplete ??= this._sdkEvent('tool.execution_complete'); - } - +- private _onPermissionRequested: Event> | undefined; +- get onPermissionRequested(): Event> { +- return this._onPermissionRequested ??= this._sdkEvent('permission.requested'); +- } +- +- private _onPermissionCompleted: Event> | undefined; +- get onPermissionCompleted(): Event> { +- return this._onPermissionCompleted ??= this._sdkEvent('permission.completed'); +- } +- - private _onIdle: Event> | undefined; - get onIdle(): Event> { - return this._onIdle ??= this._sdkEvent('session.idle'); @@ -7896,6 +11816,21 @@ index 04eba456..00000000 - return this._onSessionModelChange ??= this._sdkEvent('session.model_change'); - } - +- private _onAutoModeResolved: Event> | undefined; +- get onAutoModeResolved(): Event> { +- return this._onAutoModeResolved ??= this._sdkEvent('session.auto_mode_resolved'); +- } +- +- private _onManagedSettingsResolved: Event> | undefined; +- get onManagedSettingsResolved(): Event> { +- return this._onManagedSettingsResolved ??= this._sdkEvent('session.managed_settings_resolved'); +- } +- +- private _onManagedSettingsEnforced: Event> | undefined; +- get onManagedSettingsEnforced(): Event> { +- return this._onManagedSettingsEnforced ??= this._sdkEvent('session.managed_settings_enforced'); +- } +- - private _onSessionHandoff: Event> | undefined; - get onSessionHandoff(): Event> { - return this._onSessionHandoff ??= this._sdkEvent('session.handoff'); @@ -8056,8 +11991,16 @@ index 04eba456..00000000 - return this._onToolsUpdated ??= this._sdkEvent('session.tools_updated'); - } - +- private _onCommandsChanged: Event> | undefined; +- get onCommandsChanged(): Event> { +- return this._onCommandsChanged ??= this._sdkEvent('commands.changed'); +- } +- - private _sdkEvent(eventType: K): Event> { -- const emitter = this._register(new Emitter>()); +- const emitter = this._register(new Emitter>({ +- onDidAddFirstListener: () => this._handledEventTypes.add(eventType), +- onDidRemoveLastListener: () => this._handledEventTypes.delete(eventType), +- })); - const unsubscribe = this.session.on(eventType, (data: SessionEventPayload) => emitter.fire(data)); - this._register(toDisposable(unsubscribe)); - return emitter.event; @@ -8065,10 +12008,10 @@ index 04eba456..00000000 -} diff --git a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts deleted file mode 100644 -index d0db4dea..00000000 +index 9b409c4a..00000000 --- a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts +++ /dev/null -@@ -1,1089 +0,0 @@ +@@ -1,811 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -8077,8 +12020,6 @@ index d0db4dea..00000000 -import type { Tool, ToolResultObject } from '@github/copilot-sdk'; -import { generateUuid } from '../../../../base/common/uuid.js'; -import { URI } from '../../../../base/common/uri.js'; --import { removeAnsiEscapeCodes } from '../../../../base/common/strings.js'; --import * as platform from '../../../../base/common/platform.js'; -import { Disposable, DisposableStore, type IReference, toDisposable } from '../../../../base/common/lifecycle.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { IEnvironmentService } from '../../../environment/common/environment.js'; @@ -8093,22 +12034,18 @@ index d0db4dea..00000000 -import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; -import { createAgentHostSandboxEngine } from './agentHostSandboxEngine.js'; -import { IAgentConfigurationService } from '../agentConfigurationService.js'; +-import { DEFAULT_SHELL_COMMAND_TIMEOUT_MS, executeShellCommand, isMultilineCommand, prefixForHistorySuppression, prepareOutputForModel, shellTypeForExecutable, type IShellCommandResult, type ShellType } from '../shared/shellCommandExecution.js'; - --/** -- * Maximum scrollback content (in bytes) returned to the model in tool results. -- */ --const MAX_OUTPUT_BYTES = 80_000; -- --/** -- * Default command timeout in milliseconds (120 seconds). -- */ --const DEFAULT_TIMEOUT_MS = 120_000; +-// Re-exported for consumers (and tests) that historically imported these +-// shell helpers from this module. Their canonical home is the shared, +-// agent-agnostic shellCommandExecution module. +-export { isMultilineCommand, prefixForHistorySuppression, shellTypeForExecutable }; +-export type { ShellType }; - -/** -- * The sentinel prefix used to detect command completion in terminal output. -- * The full sentinel format is: `<<_EXIT_>>`. +- * Message returned to the model when a command switches to the terminal's +- * alternate buffer (typically an interactive full-screen UI). - */ --const SENTINEL_PREFIX = '<<>>"`; -- } -- return `echo "${SENTINEL_PREFIX}${sentinelId}_EXIT_$?>>>"`; --} -- --/** -- * For POSIX shells (bash/zsh) that honor `HISTCONTROL=ignorespace` / -- * `HIST_IGNORE_SPACE`, prepending a single space prevents the command from -- * being recorded in shell history. The shell integration scripts opt these -- * settings in via the `VSCODE_PREVENT_SHELL_HISTORY` env var (set when the -- * terminal is created with `preventShellHistory: true`). PowerShell -- * suppresses history through PSReadLine instead, so no prefix is needed. -- */ --export function prefixForHistorySuppression(shellType: ShellType): string { -- return shellType === 'powershell' ? '' : ' '; --} -- --export function isMultilineCommand(command: string): boolean { -- const normalized = command.replace(/\r\n|\r/g, '\n'); -- return /(?>>'); -- const exitCodeStr = endIdx >= 0 ? afterMarker.substring(0, endIdx) : afterMarker.trim(); -- const exitCode = parseInt(exitCodeStr, 10); -- return { -- found: true, -- exitCode: isNaN(exitCode) ? -1 : exitCode, -- outputBeforeSentinel, -- }; --} -- --function prepareOutputForModel(rawOutput: string): string { -- let text = removeAnsiEscapeCodes(rawOutput).trim(); -- if (text.length > MAX_OUTPUT_BYTES) { -- text = text.substring(text.length - MAX_OUTPUT_BYTES); -- } -- return text; --} -- --// --------------------------------------------------------------------------- -// Tool implementations -// --------------------------------------------------------------------------- - @@ -8477,25 +12309,33 @@ index d0db4dea..00000000 - return { toolResult, keepShellBusy: options?.keepShellBusy }; -} - --function makeBackgroundExecutionResult(text: string): IShellExecutionResult { -- return makeExecutionResult(makeSuccessResult(text), { keepShellBusy: true }); --} -- --function makeAltBufferExecutionResult(): IShellExecutionResult { -- return makeExecutionResult(makeFailureResult(ALT_BUFFER_MESSAGE, 'alternateBuffer'), { keepShellBusy: true }); --} -- --function registerAltBufferHandler( -- shell: IManagedShell, -- terminalManager: IAgentHostTerminalManager, -- logService: ILogService, -- disposables: DisposableStore, -- finish: (result: IShellExecutionResult) => void, --): void { -- void terminalManager.createAltBufferPromise(shell.terminalUri, disposables).then(() => { -- logService.info('[ShellTool] Command entered alternate buffer'); -- finish(makeAltBufferExecutionResult()); -- }); +-/** +- * Maps the neutral {@link IShellCommandResult} produced by the shared shell +- * executor to the Copilot SDK {@link ToolResultObject} shape expected by the +- * shell tools. +- */ +-function shellCommandResultToExecutionResult(result: IShellCommandResult, timeoutMs: number): IShellExecutionResult { +- switch (result.status) { +- case 'completed': { +- const exitCode = result.exitCode ?? 0; +- const text = `Exit code: ${exitCode}\n${result.output}`; +- return makeExecutionResult(exitCode === 0 ? makeSuccessResult(text) : makeFailureResult(text)); +- } +- case 'shellExited': +- return makeExecutionResult(makeFailureResult(`Shell exited with code ${result.exitCode}\n${result.output}`)); +- case 'timeout': +- return makeExecutionResult(makeFailureResult( +- `Command timed out after ${Math.round(timeoutMs / 1000)}s. Partial output:\n${result.output}`, +- 'timeout', +- )); +- case 'background': +- return makeExecutionResult( +- makeSuccessResult('The user chose to continue this command in the background. The terminal is still running.'), +- { keepShellBusy: true }, +- ); +- case 'altBuffer': +- return makeExecutionResult(makeFailureResult(ALT_BUFFER_MESSAGE, 'alternateBuffer'), { keepShellBusy: true }); +- } -} - -async function executeCommandInShell( @@ -8505,9 +12345,10 @@ index d0db4dea..00000000 - terminalManager: IAgentHostTerminalManager, - logService: ILogService, -): Promise { -- const result = terminalManager.supportsCommandDetection(shell.terminalUri) -- ? await executeCommandWithShellIntegration(shell, command, timeoutMs, terminalManager, logService) -- : await executeCommandWithSentinel(shell, command, timeoutMs, terminalManager, logService); +- const result = shellCommandResultToExecutionResult( +- await executeShellCommand(shell, command, timeoutMs, terminalManager, logService), +- timeoutMs, +- ); - return { - ...result, - toolResult: { @@ -8517,182 +12358,6 @@ index d0db4dea..00000000 - }; -} - --/** -- * Execute a command using shell integration (OSC 633) for completion detection. -- * No sentinel echo is injected — the shell's own command-finished signal -- * provides the exit code and cleanly delineated output. -- */ --async function executeCommandWithShellIntegration( -- shell: IManagedShell, -- command: string, -- timeoutMs: number, -- terminalManager: IAgentHostTerminalManager, -- logService: ILogService, --): Promise { -- const disposables = new DisposableStore(); -- -- const result = new Promise(resolve => { -- let resolved = false; -- const finish = (result: IShellExecutionResult) => { -- if (resolved) { -- return; -- } -- resolved = true; -- disposables.dispose(); -- resolve(result); -- }; -- -- disposables.add(terminalManager.onCommandFinished(shell.terminalUri, event => { -- const output = prepareOutputForModel(event.output); -- const exitCode = event.exitCode ?? 0; -- logService.info(`[ShellTool] Command completed (shell integration) with exit code ${exitCode}`); -- if (exitCode === 0) { -- finish(makeExecutionResult(makeSuccessResult(`Exit code: ${exitCode}\n${output}`))); -- } else { -- finish(makeExecutionResult(makeFailureResult(`Exit code: ${exitCode}\n${output}`))); -- } -- })); -- -- registerAltBufferHandler(shell, terminalManager, logService, disposables, finish); -- -- disposables.add(terminalManager.onExit(shell.terminalUri, (exitCode: number) => { -- logService.info(`[ShellTool] Shell exited unexpectedly with code ${exitCode}`); -- const fullContent = terminalManager.getContent(shell.terminalUri) ?? ''; -- const output = prepareOutputForModel(fullContent); -- finish(makeExecutionResult(makeFailureResult(`Shell exited with code ${exitCode}\n${output}`))); -- })); -- -- disposables.add(terminalManager.onClaimChanged(shell.terminalUri, (claim) => { -- if (claim.kind === TerminalClaimKind.Session && !claim.toolCallId) { -- logService.info(`[ShellTool] Continuing in background (claim narrowed)`); -- finish(makeBackgroundExecutionResult('The user chose to continue this command in the background. The terminal is still running.')); -- } -- })); -- -- const timer = setTimeout(() => { -- logService.warn(`[ShellTool] Command timed out after ${timeoutMs}ms`); -- const fullContent = terminalManager.getContent(shell.terminalUri) ?? ''; -- const output = prepareOutputForModel(fullContent); -- finish(makeExecutionResult(makeFailureResult( -- `Command timed out after ${Math.round(timeoutMs / 1000)}s. Partial output:\n${output}`, -- 'timeout', -- ))); -- }, timeoutMs); -- disposables.add(toDisposable(() => clearTimeout(timer))); -- -- }); -- -- try { -- await terminalManager.sendText(shell.terminalUri, `${prefixForHistorySuppression(shell.shellType)}${command}`, { -- shouldExecute: true, -- bracketedPasteMode: shouldUseBracketedPasteMode(command), -- }); -- } catch (err) { -- disposables.dispose(); -- throw err; -- } -- -- return result; --} -- --/** -- * Fallback: execute a command using a sentinel echo to detect completion. -- * Used when shell integration is not available. -- */ --async function executeCommandWithSentinel( -- shell: IManagedShell, -- command: string, -- timeoutMs: number, -- terminalManager: IAgentHostTerminalManager, -- logService: ILogService, --): Promise { -- const sentinelId = makeSentinelId(); -- const sentinelCmd = buildSentinelCommand(sentinelId, shell.shellType); -- const disposables = new DisposableStore(); -- -- const contentBefore = terminalManager.getContent(shell.terminalUri) ?? ''; -- const offsetBefore = contentBefore.length; -- -- const result = new Promise(resolve => { -- let resolved = false; -- const finish = (result: IShellExecutionResult) => { -- if (resolved) { -- return; -- } -- resolved = true; -- disposables.dispose(); -- resolve(result); -- }; -- -- const checkForSentinel = () => { -- const fullContent = terminalManager.getContent(shell.terminalUri) ?? ''; -- // Clamp offset: the terminal manager trims content when it exceeds -- // 100k chars (slices to last 80k). If trimming happened after we -- // captured offsetBefore, scan from the start of the current buffer. -- const clampedOffset = Math.min(offsetBefore, fullContent.length); -- const newContent = fullContent.substring(clampedOffset); -- const parsed = parseSentinel(newContent, sentinelId); -- if (parsed.found) { -- const output = prepareOutputForModel(parsed.outputBeforeSentinel); -- logService.info(`[ShellTool] Command completed with exit code ${parsed.exitCode}`); -- if (parsed.exitCode === 0) { -- finish(makeExecutionResult(makeSuccessResult(`Exit code: ${parsed.exitCode}\n${output}`))); -- } else { -- finish(makeExecutionResult(makeFailureResult(`Exit code: ${parsed.exitCode}\n${output}`))); -- } -- } -- }; -- -- disposables.add(terminalManager.onData(shell.terminalUri, () => { -- checkForSentinel(); -- })); -- -- registerAltBufferHandler(shell, terminalManager, logService, disposables, finish); -- -- disposables.add(terminalManager.onExit(shell.terminalUri, (exitCode: number) => { -- logService.info(`[ShellTool] Shell exited unexpectedly with code ${exitCode}`); -- const fullContent = terminalManager.getContent(shell.terminalUri) ?? ''; -- const newContent = fullContent.substring(offsetBefore); -- const output = prepareOutputForModel(newContent); -- finish(makeExecutionResult(makeFailureResult(`Shell exited with code ${exitCode}\n${output}`))); -- })); -- -- disposables.add(terminalManager.onClaimChanged(shell.terminalUri, (claim) => { -- if (claim.kind === TerminalClaimKind.Session && !claim.toolCallId) { -- logService.info(`[ShellTool] Continuing in background (claim narrowed)`); -- finish(makeBackgroundExecutionResult('The user chose to continue this command in the background. The terminal is still running.')); -- } -- })); -- -- const timer = setTimeout(() => { -- logService.warn(`[ShellTool] Command timed out after ${timeoutMs}ms`); -- const fullContent = terminalManager.getContent(shell.terminalUri) ?? ''; -- const newContent = fullContent.substring(offsetBefore); -- const output = prepareOutputForModel(newContent); -- finish(makeExecutionResult(makeFailureResult( -- `Command timed out after ${Math.round(timeoutMs / 1000)}s. Partial output:\n${output}`, -- 'timeout', -- ))); -- }, timeoutMs); -- disposables.add(toDisposable(() => clearTimeout(timer))); -- -- checkForSentinel(); -- }); -- -- try { -- await terminalManager.sendText(shell.terminalUri, `${prefixForHistorySuppression(shell.shellType)}${command}`, { -- shouldExecute: true, -- bracketedPasteMode: shouldUseBracketedPasteMode(command), -- }); -- await terminalManager.sendText(shell.terminalUri, sentinelCmd, { shouldExecute: true }); -- } catch (err) { -- disposables.dispose(); -- throw err; -- } -- -- return result; --} -- -// --------------------------------------------------------------------------- -// Public factory -// --------------------------------------------------------------------------- @@ -8770,7 +12435,7 @@ index d0db4dea..00000000 - }, - overridesBuiltInTool: true, - handler: async (args, invocation) => { -- const timeoutMs = args.timeout ?? DEFAULT_TIMEOUT_MS; +- const timeoutMs = args.timeout ?? DEFAULT_SHELL_COMMAND_TIMEOUT_MS; - const ref = await shellManager.getOrCreateShell( - shellType, - invocation.toolCallId, @@ -9160,100 +12825,55 @@ index d0db4dea..00000000 -} diff --git a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts deleted file mode 100644 -index 25849598..00000000 +index 73f819f4..00000000 --- a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts +++ /dev/null -@@ -1,155 +0,0 @@ +@@ -1,230 +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 { CancellationToken } from '../../../../base/common/cancellation.js'; --import { localize } from '../../../../nls.js'; -import { AgentSession } from '../../common/agentService.js'; -import { CompletionItem, CompletionItemKind, CompletionsParams } from '../../common/state/protocol/commands.js'; --import { MessageAttachmentKind } from '../../common/state/protocol/state.js'; --import { toCommandCompletionAttachmentMeta } from '../../common/meta/agentCompletionAttachmentMeta.js'; +-import { Customization, CustomizationType, DirectoryCustomization, MessageAttachmentKind, PluginCustomization, SkillCustomization } from '../../common/state/protocol/state.js'; +-import { getCompletionAction, toCommandCompletionAttachmentMeta } from '../../common/meta/agentCompletionAttachmentMeta.js'; +-import { getCopilotConfigSlashCommandItems, ICopilotConfigSlashCommandState, isCopilotConfigSlashCommand } from '../../common/copilotConfigSlashCommands.js'; -import { CompletionTriggerCharacter, IAgentHostCompletionItemProvider } from '../agentHostCompletions.js'; --import { extractLeadingSlashToken } from '../agentHostSlashCompletion.js'; +-import { extractLeadingSlashToken, extractWhitespaceDelimitedSlashToken, matchesSlashCompletion } from '../agentHostSlashCompletion.js'; +-import { SYNCED_CUSTOMIZATION_SCHEME } from '../../common/agentHostFileSystemService.js'; +-import type { CopilotSession } from '@github/copilot-sdk'; - --/** -- * Slash-command name and the token we surface to the user / round-trip on -- * the {@link MessageAttachmentKind.Simple} attachment's `_meta`. -- */ --export type CopilotSlashCommandName = 'plan' | 'compact' | 'research' | 'rubber-duck' | 'env' | 'review' | 'security-review'; -- --const COMMANDS: readonly CopilotSlashCommandName[] = ['plan', 'compact', 'research', 'rubber-duck', 'env', 'review', 'security-review']; --const RUNTIME_RPC_INVOKED_COMMANDS = new Set(['env']); --const PROMPT_INVOKED_COMMANDS = new Set(['research', 'review', 'security-review']); --function getCommandDescription(command: CopilotSlashCommandName): string { -- switch (command) { -- case 'plan': return localize('copilotSlashCommand.plan.description', "Create an implementation plan before coding"); -- case 'compact': return localize('copilotSlashCommand.compact.description', "Free up context by compacting the conversation history"); -- case 'research': return localize('copilotSlashCommand.research.description', "Run deep research on a topic using search and web sources"); -- case 'rubber-duck': return localize('copilotSlashCommand.rubberDuck.description', "Get an independent critique of the current approach"); -- case 'env': return localize('copilotSlashCommand.env.description', "Show loaded environment details"); -- case 'review': return localize('copilotSlashCommand.review.description', "Run code review agent to analyze changes"); -- case 'security-review': return localize('copilotSlashCommand.securityReview.description', "Analyze staged and unstaged changes for security vulnerabilities"); -- } --} +-export { parseLeadingSlashCommand } from '../../common/agentHostSlashCommand.js'; - --export function isRuntimeCopilotSlashCommand(command: CopilotSlashCommandName): boolean { -- return RUNTIME_RPC_INVOKED_COMMANDS.has(command); --} +-const HIDDEN_RUNTIME_COMMANDS = new Set(['agent', 'app', 'changelog', 'context', 'copy', 'exit', 'extensions', 'feedback', 'help', 'ide', 'instructions', 'login', 'logout', 'mcp', 'model', 'new', 'plugin', 'rename', 'restart', 'resume', 'sandbox', 'session', 'settings', 'skills', 'statusline', 'streamer-mode', 'subagents', 'tasks', 'terminal-setup', 'theme', 'undo', 'update', 'user', 'voice', 'worktree', 'autopilot', 'yolo', 'cd', 'cwd', 'after', 'before', 'add-dir', 'allow-all', 'list-dirs', 'reset-allowed-tools']); - --export function isPromptInvokedCopilotSlashCommand(command: CopilotSlashCommandName): boolean { -- return PROMPT_INVOKED_COMMANDS.has(command); --} +-export const DEFAULT_RUNTIME_SLASH_COMMAND_COMPLETION_WAIT_MS = 300; - --function commandExpectsInput(command: CopilotSlashCommandName): boolean { -- return command !== 'compact' && command !== 'env'; --} -/** -- * Lookup hook used by {@link CopilotSlashCommandCompletionProvider} to -- * decide whether history-dependent commands (e.g. `/compact`) make sense -- * for a given session. Sessions that haven't been materialized yet — i.e. -- * the user hasn't sent a first message — have no history. +- * Lookup hooks used by {@link CopilotSlashCommandCompletionProvider} to +- * retrieve runtime slash command metadata and apply feature gating. - */ -export interface ICopilotSlashCommandSessionInfo { -- /** `sessionId` is the raw id (URI path without the leading slash). */ -- hasHistory(sessionId: string): boolean; - /** - * Whether the experimental rubber duck critic subagent is enabled via -- * the agent host config. When absent or `false`, `/rubber-duck` is hidden. +- * the agent host config. When provided and `false`, `/rubber-duck` is hidden. - */ - isRubberDuckEnabled?(): boolean; -- /** Whether the runtime reports the given slash command as available. */ -- hasRuntimeSlashCommand?(sessionId: string, command: string): Promise; --} -- --/** -- * Result of {@link parseLeadingSlashCommand}. -- */ --export interface IParsedLeadingSlashCommand { -- readonly command: CopilotSlashCommandName; -- /** Trimmed text following the command (empty if none). */ -- readonly rest: string; +- /** Runtime slash commands discovered from the SDK session. */ +- getRuntimeSlashCommands?(sessionId: string, options?: ICopilotRuntimeSlashCommandQueryOptions): Promise; +- getSessionCustomizations: (session: string) => Promise; +- /** +- * The session's current config state (`mode` / `autoApprove` axes), used to +- * filter config-action slash command completions so only the state-changing +- * forms are offered. When omitted, all forms are offered. +- */ +- getSessionConfigState?(sessionId: string): ICopilotConfigSlashCommandState | undefined; -} - --/** -- * Parses a Copilot CLI slash command at the very start of `prompt`. -- * -- * The command must be `/plan`, `/compact`, `/research`, `/rubber-duck`, `/env`, `/review`, or `/security-review`, -- * followed either by end-of-input or by at least one whitespace character. -- * `/compact-hello`, `/plans`, or a leading-space `/compact` all return -- * `undefined`. Match is case-sensitive. -- */ --export function parseLeadingSlashCommand(prompt: string): IParsedLeadingSlashCommand | undefined { -- const match = /^\/(plan|compact|research|rubber-duck|env|review|security-review)(?:$|\s+([\s\S]*))/.exec(prompt); -- if (!match) { -- return undefined; -- } -- return { -- command: match[1] as CopilotSlashCommandName, -- rest: (match[2] ?? '').trim(), -- }; +-export interface ICopilotRuntimeSlashCommandQueryOptions { +- readonly maxWaitMs?: number; -} - -/** @@ -9263,86 +12883,223 @@ index 25849598..00000000 - * - * The returned items carry a {@link MessageAttachmentKind.Simple} - * attachment, which the workbench bridge maps into command/skill completion -- * attachments. Command dispatch happens text-side in -- * `CopilotAgentSession.send` via {@link parseLeadingSlashCommand}, so the -- * feature works whether the user picks the item or types it manually. +- * attachments. Runtime command dispatch is text-side in `CopilotAgentSession.send`; +- * client-side config commands also share the same leading slash parser. - */ -export class CopilotSlashCommandCompletionProvider implements IAgentHostCompletionItemProvider { - readonly kinds: ReadonlySet = new Set([CompletionItemKind.UserMessage]); - readonly triggerCharacters = [CompletionTriggerCharacter.Slash] as const; - -- constructor(private readonly copilotcliId: string, private readonly _sessionInfo?: ICopilotSlashCommandSessionInfo) { } +- constructor( +- private readonly copilotcliId: string, +- private readonly _sessionInfo: ICopilotSlashCommandSessionInfo, +- private readonly _runtimeSlashCommandCompletionWaitMs: number = DEFAULT_RUNTIME_SLASH_COMMAND_COMPLETION_WAIT_MS, +- ) { } - - async provideCompletionItems(params: CompletionsParams, _token: CancellationToken): Promise { - if (AgentSession.provider(params.channel) !== this.copilotcliId) { - return []; - } -- const leading = extractLeadingSlashToken(params.text, params.offset); +- const leadingTokenForSkills = extractWhitespaceDelimitedSlashToken(params.text, params.offset); +- const leadingTokenForCommands = extractLeadingSlashToken(params.text, params.offset); +- const leading = leadingTokenForCommands ?? leadingTokenForSkills; +- const returnJustSkills = !leadingTokenForCommands && !!leadingTokenForSkills; - if (!leading) { - return []; - } - - // Raw session id is the URI path without the leading slash. - const sessionId = AgentSession.id(params.channel); -- const hasHistory = this._sessionInfo?.hasHistory(sessionId) ?? true; -- - // `/abc` → typed = 'abc'; empty after just '/' → typed = ''. - const typed = leading.typed; -- const rubberDuckEnabled = this._sessionInfo?.isRubberDuckEnabled?.() ?? false; -- const items: CompletionItem[] = []; -- for (const command of COMMANDS) { -- if (typed.length > 0 && !command.startsWith(typed)) { +- return await this._getRuntimeSlashCommandCompletionInfo(sessionId, typed, leading, returnJustSkills); +- } +- +- private async _getKnownSkills(sessionId: string) { +- const knownCommands = new Set(); +- const customizations = await this._sessionInfo.getSessionCustomizations(sessionId) ?? []; +- for (const c of customizations) { +- if (c.type === CustomizationType.McpServer || !c.enabled || !c.children) { - continue; - } -- // `/compact` only makes sense once the session has prior turns to compact. -- if (command === 'compact' && !hasHistory) { +- for (const child of c.children) { +- if (child.type === CustomizationType.Skill) { +- knownCommands.add(this._toSlashCommandCandidate(c, child)); +- } +- } +- } +- return knownCommands; +- } +- +- private _toSlashCommandCandidate(container: PluginCustomization | DirectoryCustomization, skill: SkillCustomization): string { +- // see getCanonicalPluginCommandId +- let slashCommandName = skill.name; +- if (container.type === CustomizationType.Plugin && !isSyncedCustomization(container) && skill.name !== container.name) { +- slashCommandName = `${container.name}:${skill.name}`; +- } +- return slashCommandName; +- } +- +- private async _getRuntimeSlashCommandCompletionInfo(sessionId: string, typed: string, { rangeStart, rangeEnd }: { rangeStart: number; rangeEnd: number }, returnJustSkills: boolean): Promise { +- const [runtimeCommands, knownSkills] = await Promise.all([ +- this._sessionInfo.getRuntimeSlashCommands?.(sessionId, { maxWaitMs: this._runtimeSlashCommandCompletionWaitMs }) ?? [], +- this._getKnownSkills(sessionId) +- ]); +- const typedLower = typed.toLowerCase(); +- const rubberDuckEnabled = this._sessionInfo?.isRubberDuckEnabled?.() ?? true; +- const completionItems: CompletionItem[] = []; +- const addedAliases = new Set(); +- +- for (const command of runtimeCommands) { +- if (!command.name) { - continue; - } -- // `/rubber-duck` is only available when the feature is enabled. -- if (command === 'rubber-duck' && !rubberDuckEnabled) { +- if (returnJustSkills && command.kind !== 'skill') { - continue; - } -- if (isRuntimeCopilotSlashCommand(command) && !(await this._sessionInfo?.hasRuntimeSlashCommand?.(sessionId, command))) { +- if (command.kind === 'skill' && knownSkills.has(command.name)) { +- // This is a known skill, so we don't want to show it in the runtime command completion list. - continue; - } -- items.push({ -- insertText: commandExpectsInput(command) ? '/' + command + ' ' : '/' + command, -- rangeStart: 0, -- rangeEnd: leading.rangeEnd, -- attachment: { -- type: MessageAttachmentKind.Simple, -- label: '/' + command, -- _meta: toCommandCompletionAttachmentMeta({ command, description: getCommandDescription(command) }), -- }, -- }); +- if (HIDDEN_RUNTIME_COMMANDS.has(command.name) || command.aliases?.some(alias => HIDDEN_RUNTIME_COMMANDS.has(alias))) { +- continue; +- } +- // Config-action commands (permission/mode toggles) are surfaced below +- // as workbench-defined items; skip any runtime command that collides +- // with them (e.g. a runtime `plan`) to avoid duplicate suggestions. +- if (isCopilotConfigSlashCommand(command.name) || command.aliases?.some(alias => isCopilotConfigSlashCommand(alias))) { +- continue; +- } +- if (!rubberDuckEnabled && command.name === 'rubber-duck') { +- continue; +- } +- if (!matchesSlashCompletion(typedLower, command.name) && !command.aliases?.some(alias => matchesSlashCompletion(typedLower, alias))) { +- continue; +- } +- // Use structured input choices as options; if there are none, emit a single item for the command and surface any free-text hint as a prompt. +- const options: (NonNullable['choices']>[number] & { argumentHint?: string })[] = []; +- +- // If we have a hint, then this means we have a structured command with sub commands or options. +- // I.e. the standalone command is also valie. +- if (command.input?.hint || !command.input?.choices?.length) { +- options.push({ name: '', description: command.description, argumentHint: command.input?.hint }); +- } +- if (command.input?.choices?.length) { +- options.push(...command.input.choices); +- } +- +- // Generate completion items for each alias and option combination. +- // If there are no options, generate a single completion item for the alias. +- const aliases = Array.from(new Set([command.name].concat(command.aliases ?? []))); +- aliases +- .filter(alias => !addedAliases.has(alias)) +- .forEach(alias => { +- options +- .forEach(option => { +- // Add a trailing space after the command (and sub command/option if present). +- // This is so user can continue to type additional arguments after the command and option. +- const insertText = `/${alias}${option.name ? ' ' + option.name : ''} `; +- const description = option.description ?? command.description; +- const argumentHint = option.argumentHint; +- addedAliases.add(alias); +- +- completionItems.push({ +- insertText, +- rangeStart: rangeStart, +- rangeEnd: rangeEnd, +- attachment: { +- type: MessageAttachmentKind.Simple, +- label: insertText, +- _meta: toCommandCompletionAttachmentMeta({ +- command: command.name, +- ...(description !== undefined ? { description } : {}), +- ...(argumentHint !== undefined ? { argumentHint } : {}) +- }), +- }, +- }); +- }); +- }); - } -- return items; +- +- // Prepend workbench-defined config-action commands (permission/mode +- // toggles). These are not runtime SDK commands; they carry an `action` +- // bag on their `_meta` that the workbench interprets on accept. Only +- // offered for leading `/command` tokens (not the whitespace-delimited +- // skill form). +- if (!returnJustSkills) { +- const configState = this._sessionInfo.getSessionConfigState?.(sessionId); +- for (const item of getCopilotConfigSlashCommandItems(typed, configState)) { +- completionItems.push({ +- insertText: item.insertText, +- rangeStart, +- rangeEnd, +- attachment: { +- type: MessageAttachmentKind.Simple, +- label: item.label, +- _meta: toCommandCompletionAttachmentMeta({ +- command: item.command, +- description: item.description, +- ...(item.argumentHint !== undefined ? { argumentHint: item.argumentHint } : {}), +- action: { applyConfig: item.applyConfig }, +- }), +- }, +- }); +- } +- } +- +- const getSortText = (item: CompletionItem): string => { +- return getCompletionAction(item.attachment._meta) ? item.attachment.label : item.insertText; +- }; +- return completionItems.sort((a, b) => getSortText(a).localeCompare(getSortText(b))); - } -} +- +-export type ICopilotRuntimeSlashCommandInfo = Awaited>['commands'][number]; +- +-function isSyncedCustomization(container: PluginCustomization): boolean { +- return container.uri.startsWith(SYNCED_CUSTOMIZATION_SCHEME + ':'); +-} +diff --git a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandProvider.ts b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandProvider.ts +index 34a750af..cab1676d 100644 +--- a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandProvider.ts ++++ b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandProvider.ts +@@ -5,3 +5,10 @@ + +-import type { CopilotClient } from '@github/copilot-sdk'; ++// `CopilotClient` came from '@github/copilot-sdk' (removed in !!APP_NAME!!). ++// Only the slash-command listing surface is referenced here; model it locally. ++type CopilotClient = { ++ rpc: { ++ // eslint-disable-next-line @typescript-eslint/no-explicit-any ++ commands: { list(): Promise<{ commands: any[] }> }; ++ }; ++}; + import { ILogService } from '../../../log/common/log.js'; diff --git a/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts b/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts deleted file mode 100644 -index a5d5c783..00000000 +index 56653c57..00000000 --- a/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts +++ /dev/null -@@ -1,52 +0,0 @@ +@@ -1,75 +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 { SessionEventPayload } from '@github/copilot-sdk'; +-import type { SessionEventPayload, SystemNotification } from '@github/copilot-sdk'; +-import { softAssertNever } from '../../../../base/common/assert.js'; -import { localize } from '../../../../nls.js'; - -export interface ICopilotSystemNotification { -- /** Body shown inside an active turn; cleaned from SDK `system.notification.data.content`. */ -- readonly content: string; - /** Text for a new system-origin AHP turn; derived from SDK `data.kind` metadata, e.g. shell completion `description`. */ - readonly messageText: string; +- /** Whether the runtime notification wakes the agent loop when it arrives while idle. */ +- readonly startsTurn: boolean; -} - -export function buildCopilotSystemNotification(event: SessionEventPayload<'system.notification'>): ICopilotSystemNotification | undefined { - const data = event.data; -- const kind = data.kind; +- const kind: SystemNotification = data.kind; - const content = cleanSystemNotificationContent(data.content); - if (!content) { - return undefined; @@ -9352,22 +13109,44 @@ index a5d5c783..00000000 - case 'shell_completed': - case 'shell_detached_completed': { - const description = kind.description; -- const shellId = kind.shellId; - return { -- content, - messageText: description - ? localize('agentHost.copilot.systemNotification.shellDescriptionCompleted', "`{0}` completed", description) -- : shellId -- ? localize('agentHost.copilot.systemNotification.shellIdCompleted', "Shell `{0}` completed", shellId) -- : localize('agentHost.copilot.systemNotification.shellCompleted', "Shell completed"), +- : localize('agentHost.copilot.systemNotification.shellCompleted', "Shell completed"), +- startsTurn: true, - }; - } - case 'agent_completed': - return { -- content, -- messageText: localize('agentHost.copilot.systemNotification.agentCompleted', "Background agent completed"), +- messageText: kind.status === 'failed' +- ? localize('agentHost.copilot.systemNotification.agentFailed', "Background agent {0} failed", kind.agentId) +- : localize('agentHost.copilot.systemNotification.agentCompleted', "Background agent {0} completed", kind.agentId), +- startsTurn: true, +- }; +- case 'agent_idle': +- return { +- messageText: localize('agentHost.copilot.systemNotification.agentIdle', "Background agent {0} is complete", kind.agentId), +- startsTurn: true, +- }; +- case 'new_inbox_message': +- return { +- messageText: localize('agentHost.copilot.systemNotification.newInboxMessage', "New inbox message from {0}", kind.senderName), +- startsTurn: false, +- }; +- case 'instruction_discovered': +- return { +- messageText: localize('agentHost.copilot.systemNotification.instructionDiscovered', "Instruction discovered: {0}", kind.description ?? kind.sourcePath), +- startsTurn: false, +- }; +- case 'unclassified': +- // External-host notifications that do not match a runtime-owned kind. +- // Use the cleaned content and wake the agent when idle. +- return { +- messageText: content, +- startsTurn: true, - }; - default: +- softAssertNever(kind); - return undefined; - } -} @@ -9410,12 +13189,10 @@ index 998236f0..00000000 - return parseCopilotTokenFields(token).get('rt') === '1'; -} diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts -index 3a4c2d71..f596430b 100644 +index ccd83b10..c006701d 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts -@@ -3,7 +3,10 @@ - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ +@@ -5,3 +5,6 @@ -import type { PermissionRequest } from '@github/copilot-sdk'; +// `PermissionRequest` originally came from '@github/copilot-sdk' (removed in @@ -9423,18 +13200,30 @@ index 3a4c2d71..f596430b 100644 +// as a minimal local shape. +type PermissionRequest = { kind: 'shell' | 'write' | 'mcp' | 'read' | 'url' | 'custom-tool' | 'hook' | 'memory' | 'extension-management' | 'extension-permission-access' }; import { hasKey } from '../../../../base/common/types.js'; - import { URI } from '../../../../base/common/uri.js'; - import { appendEscapedMarkdownInlineCode, escapeMarkdownLinkLabel, MarkdownString } from '../../../../base/common/htmlContent.js'; +diff --git a/src/vs/platform/agentHost/node/copilot/diskSessionFsProvider.ts b/src/vs/platform/agentHost/node/copilot/diskSessionFsProvider.ts +index e60d0365..8202ce67 100644 +--- a/src/vs/platform/agentHost/node/copilot/diskSessionFsProvider.ts ++++ b/src/vs/platform/agentHost/node/copilot/diskSessionFsProvider.ts +@@ -6,3 +6,10 @@ + import { promises as fs } from 'fs'; +-import type { SessionFsFileInfo, SessionFsProvider } from '@github/copilot-sdk'; ++// `SessionFsFileInfo`/`SessionFsProvider` originally came from ++// '@github/copilot-sdk' (removed in !!APP_NAME!!). The provider is no longer ++// handed to a live Copilot runtime; keep a minimal local stand-in so the ++// implementation keeps compiling. ++// eslint-disable-next-line @typescript-eslint/no-explicit-any ++type SessionFsFileInfo = any; ++// eslint-disable-next-line @typescript-eslint/no-empty-object-type ++interface SessionFsProvider {} + import { dirname, join } from '../../../../base/common/path.js'; diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts -index 93eccbf8..fd4755bc 100644 +index 8656e507..bbd2ef44 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts -@@ -3,7 +3,17 @@ - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ +@@ -5,3 +5,14 @@ --import type { AssistantMessageToolRequest, Attachment, SessionEvent, ToolExecutionCompleteData } from '@github/copilot-sdk'; -+// These four types originally came from '@github/copilot-sdk' (removed in +-import type { AssistantMessageToolRequest, Attachment, SessionEvent, ToolExecutionCompleteContent, ToolExecutionCompleteData } from '@github/copilot-sdk'; ++// These five types originally came from '@github/copilot-sdk' (removed in +// !!APP_NAME!! — the proprietary Copilot binaries can't be redistributed). They +// are deep SDK discriminated unions consumed only by the now test-only +// event-to-Turn mapping below; alias them to `any` so the mapping keeps @@ -9443,11 +13232,10 @@ index 93eccbf8..fd4755bc 100644 +type AssistantMessageToolRequest = any; +type Attachment = any; +type SessionEvent = any; ++type ToolExecutionCompleteContent = any; +type ToolExecutionCompleteData = any; +/* eslint-enable @typescript-eslint/no-explicit-any */ import { decodeBase64 } from '../../../../base/common/buffer.js'; - import { basename } from '../../../../base/common/path.js'; - import { isString } from '../../../../base/common/types.js'; diff --git a/src/vs/platform/agentHost/node/copilot/prompts/allPrompts.ts b/src/vs/platform/agentHost/node/copilot/prompts/allPrompts.ts deleted file mode 100644 index 6c69ad78..00000000 @@ -9472,17 +13260,17 @@ index 6c69ad78..00000000 -import './anthropicPrompt.js'; diff --git a/src/vs/platform/agentHost/node/copilot/prompts/anthropicPrompt.ts b/src/vs/platform/agentHost/node/copilot/prompts/anthropicPrompt.ts deleted file mode 100644 -index befa9348..00000000 +index 9308a0f5..00000000 --- a/src/vs/platform/agentHost/node/copilot/prompts/anthropicPrompt.ts +++ /dev/null -@@ -1,80 +0,0 @@ +@@ -1,72 +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 { SectionOverride, SystemMessageSection } from '@github/copilot-sdk'; --import { AgentHostConfigKey } from '../../../common/agentHostCustomizationConfig.js'; +-import { CopilotCliConfigKey } from '../../../common/copilotCliConfig.js'; -import type { ModelSelection } from '../../../common/state/protocol/state.js'; -import { agentHostPromptRegistry, type IAgentHostPrompt, type IAgentHostPromptContext } from './promptRegistry.js'; -import { COPILOT_AGENT_HOST_IDENTITY } from './systemMessage.js'; @@ -9525,23 +13313,15 @@ index befa9348..00000000 - }; -} - --/** -- * Returns whether `model` is a Claude Opus 4.8 model. Mirrors the Copilot -- * extension's version-specific `isOpus47`, matching both the SDK dashed id -- * (`claude-opus-4-8`) and the CAPI dotted id (`claude-opus-4.8`). -- */ +-/** Whether `model` is Claude Opus 4.8 — matches the SDK dashed id and the CAPI dotted id. */ -function isOpus48(model: ModelSelection): boolean { - return model.id.startsWith('claude-opus-4-8') || model.id.startsWith('claude-opus-4.8'); -} - -/** -- * Resolves the Opus 4.8 agent prompt for Claude Opus 4.8 Copilot SDK sessions. -- * -- * Mirrors the Copilot extension's version-specific opus resolver -- * (`Claude47OpusPrompt`, gated by `isOpus47` + a setting): it matches only Opus -- * 4.8 via {@link isOpus48}, and is opt-in via -- * {@link AgentHostConfigKey.Opus48Prompt}. When the setting is off it returns -- * `undefined` and the registry falls back to the default system message. +- * Opus 4.8 agent prompt for Claude Opus 4.8 sessions: matches only Opus 4.8 and +- * is opt-in via {@link CopilotCliConfigKey.Opus48Prompt}. Off → falls back to the +- * default system message. - */ -class Claude48OpusPromptResolver implements IAgentHostPrompt { - static readonly familyPrefixes: readonly string[] = []; @@ -9551,46 +13331,74 @@ index befa9348..00000000 - } - - resolveSectionOverrides(_model: ModelSelection, context: IAgentHostPromptContext): Partial> | undefined { -- return context.getSetting(AgentHostConfigKey.Opus48Prompt) === true ? opus48SectionOverrides() : undefined; +- return context.getSetting(CopilotCliConfigKey.Opus48Prompt) === true ? opus48SectionOverrides() : undefined; - } -} - -agentHostPromptRegistry.registerPrompt(Claude48OpusPromptResolver); diff --git a/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts b/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts deleted file mode 100644 -index 793cf5e1..00000000 +index 2e4bb7a6..00000000 --- a/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts +++ /dev/null -@@ -1,148 +0,0 @@ +@@ -1,241 +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 { SectionOverride, SystemMessageConfig, SystemMessageSection } from '@github/copilot-sdk'; --import { agentHostCustomizationConfigSchema } from '../../../common/agentHostCustomizationConfig.js'; +-import { copilotCliConfigSchema } from '../../../common/copilotCliConfig.js'; -import type { SchemaValue } from '../../../common/agentHostSchema.js'; -import type { ModelSelection } from '../../../common/state/protocol/state.js'; --import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE, fullSystemPrompt, sectionOverrides } from './systemMessage.js'; +-import { appendSystemMessageContent, COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS, COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS, COPILOT_AGENT_HOST_SYSTEM_MESSAGE, fullSystemPrompt, sectionOverrides } from './systemMessage.js'; +-import { resolveToolInstructionsOverride, toolSearchInstructionLines } from './toolInstructions.js'; - --type CustomizationConfigDefinition = typeof agentHostCustomizationConfigSchema.definition; +-type CopilotCliConfigDefinition = typeof copilotCliConfigSchema.definition; - -/** - * Read-time context handed to prompt contributors so they can gate behavior on - * host configuration — the agent-host equivalent of the Copilot extension - * injecting `IConfigurationService` into a resolver. - * -- * Scoped to the host customization schema so contributors (and tests) read +- * Scoped to the Copilot CLI config schema so contributors (and tests) read - * settings in a fully-typed way without depending on the whole configuration - * service. - */ -export interface IAgentHostPromptContext { - /** -- * Returns the host-level value for a customization setting, or `undefined` +- * Returns the host-level value for a Copilot CLI setting, or `undefined` - * when unset. Mirrors `IAgentConfigurationService.getRootValue` bound to -- * {@link agentHostCustomizationConfigSchema}. +- * {@link copilotCliConfigSchema}. +- */ +- getSetting(key: K): SchemaValue | undefined; +- +- /** +- * Returns whether a *client* tool is available in the session, addressed by +- * the camelCase `toolReferenceName` the agent sees it under (e.g. +- * `openBrowserPage`). Used to gate tool-specific instructions on the tool +- * being present, the agent-host equivalent of the Copilot extension +- * inspecting its tool set. +- * +- * Scope: client tools only (the forwarded workbench tools). It does NOT see +- * shell tools, server-SDK tools, or MCP-provided tools — those aren't in the +- * session snapshot at launch (MCP is discovered dynamically). A line that +- * gates on one of those names silently resolves to `false`; broadening this +- * is the context-enrichment follow-up. +- */ +- hasClientTool(name: string): boolean; +- +- /** Whether deferred tool search is active for this session. */ +- toolSearchActive: boolean; +- +- /** +- * Whether this is a workspace-less session. When `true`, the +- * resolved system message gets a scratch/repoless section (see +- * {@link COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS}) telling the agent its +- * working directory is a scratch dir, not a code repo. Set by the launcher +- * from the session's `workspaceless` marker. - */ -- getSetting(key: K): SchemaValue | undefined; +- workspaceless: boolean; -} - -/** @@ -9673,7 +13481,26 @@ index 793cf5e1..00000000 - } - - /** -- * Resolves the {@link SystemMessageConfig} for a session's model. +- * Resolves the {@link SystemMessageConfig} for a session's model: the +- * per-model (or default) config from {@link _resolveModelConfig}, with the +- * model-agnostic section overrides from {@link _withUniversalSections} +- * layered on top. +- * +- * Lifetime: the SDK accepts a system message only at session create/resume +- * (there is no mid-session update), so this is resolved once per (re)launch +- * and any tool-gated content reflects the tool set at that moment. A change +- * to the session's tools/plugins is part of the launcher's restart-detection +- * snapshot, so it re-launches the session and recomputes this; an in-flight +- * turn keeps the prompt it launched with. +- */ +- resolveSystemMessageConfig(model: ModelSelection | undefined, context: IAgentHostPromptContext): SystemMessageConfig { +- const config = this._withUniversalSections(this._resolveModelConfig(model, context), context); +- const withWorkspacelessScratch = this._withWorkspacelessScratch(config, context); +- return appendSystemMessageContent(withWorkspacelessScratch, COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS); +- } +- +- /** +- * Resolves the per-model config, before universal sections are layered on. - * - * Falls back to {@link COPILOT_AGENT_HOST_SYSTEM_MESSAGE} when the model is - * unknown (e.g. server-side "Auto" selection where no model is chosen at @@ -9681,7 +13508,7 @@ index 793cf5e1..00000000 - * contributor opts out for the current {@link context} (e.g. a setting that - * gates it is disabled). - */ -- resolveSystemMessageConfig(model: ModelSelection | undefined, context: IAgentHostPromptContext): SystemMessageConfig { +- private _resolveModelConfig(model: ModelSelection | undefined, context: IAgentHostPromptContext): SystemMessageConfig { - if (!model) { - return COPILOT_AGENT_HOST_SYSTEM_MESSAGE; - } @@ -9703,6 +13530,52 @@ index 793cf5e1..00000000 - } - return COPILOT_AGENT_HOST_SYSTEM_MESSAGE; - } +- +- /** +- * Layers section overrides that apply to EVERY model on top of the per-model +- * (or default) config. Currently this is only the `tool_instructions` section +- * (see {@link resolveToolInstructionsOverride}), which the agent host wants +- * for all models rather than gating per-model like the Opus prompt. +- * +- * Only `customize`-mode configs carry section overrides, so this is a no-op +- * for a contributor's full `replace` prompt (which owns the entire system +- * message and intentionally drops the SDK foundation) and for `append` mode. +- * A `replace` contributor that wants the universal guidance re-includes it +- * itself by rendering `universalToolInstructions` (in `toolInstructions.ts`) +- * from its `resolveFullSystemPrompt`, mirroring how the extension's full-prompt +- * models inline the same lines. +- * +- * A per-model `tool_instructions` override is composed with — not overwritten +- * by — the universal lines (see {@link resolveToolInstructionsOverride}). +- */ +- private _withUniversalSections(config: SystemMessageConfig, context: IAgentHostPromptContext): SystemMessageConfig { +- if (config.mode !== 'customize') { +- return config; +- } +- const toolInstructions = resolveToolInstructionsOverride(name => context.hasClientTool(name), config.sections?.tool_instructions, toolSearchInstructionLines(context.toolSearchActive)); +- if (!toolInstructions) { +- return config; +- } +- return { ...config, sections: { ...config.sections, tool_instructions: toolInstructions } }; +- } +- +- /** +- * Appends the scratch/repoless workspace-less guidance (see +- * {@link COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS}) as customize-mode +- * `content` when {@link IAgentHostPromptContext.workspaceless} is set, so it +- * composes on top of whatever sections the per-model (or default) config +- * carries while keeping the SDK foundation intact. +- * +- * No-op for workspace-bound sessions and for a full `replace` prompt (which +- * owns the entire system message and intentionally drops the SDK foundation). +- */ +- private _withWorkspacelessScratch(config: SystemMessageConfig, context: IAgentHostPromptContext): SystemMessageConfig { +- if (!context.workspaceless || config.mode !== 'customize') { +- return config; +- } +- const content = config.content ? `${config.content}\n\n${COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS}` : COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS; +- return { ...config, content }; +- } -} - -/** @@ -9712,10 +13585,10 @@ index 793cf5e1..00000000 -export const agentHostPromptRegistry = new AgentHostPromptRegistry(); diff --git a/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts b/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts deleted file mode 100644 -index 1b52c8cc..00000000 +index 46b97407..00000000 --- a/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts +++ /dev/null -@@ -1,51 +0,0 @@ +@@ -1,117 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -9730,6 +13603,21 @@ index 1b52c8cc..00000000 - */ -export const COPILOT_AGENT_HOST_IDENTITY = 'You are an AI assistant using Copilot CLI runtime 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 CLI runtime in VS Code.'; - +-/** Response-formatting contract for workspace links emitted by Agent Host models. */ +-export const COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS = [ +- '', +- 'Always use Markdown links when referring to existing files, folders, or symbols in the workspace. This is very important for helping the user understand your responses.', +- '- File: use the file name as the link text and the absolute filesystem path as the target, for example [foo.ts](/path/to/foo.ts).', +- '- Folder: links to folders are also supported, with an absolute path to the folder as the target, for example [src/](/path/to/src).', +- '- Symbol: link to symbols by using the containing file path with a 1-based line number as the target, for example [myMethod](/path/to/foo.ts:42).', +- '- Use `/` path separators in link targets, including on Windows (`C:/path/to/foo.ts`).', +- '- If a file path has spaces, wrap the target in angle brackets: [foo bar.ts]().', +- '- Use absolute filesystem paths rather than `file://` URIs.', +- '- Do not provide line ranges.', +- '- Use a markdown link format every time you refer to a file, folder, or symbol, not just the first time.', +- '', +-].join('\n'); +- -/** - * Default system-message customization applied to every Copilot CLI agent-host - * session that has no per-model override registered in the @@ -9749,6 +13637,23 @@ index 1b52c8cc..00000000 -} satisfies SystemMessageConfig; - -/** +- * Scratch/repoless guidance appended to a workspace-less chat's system message. +- * A workspace-less chat's working directory is a throwaway SCRATCH dir, not a +- * code repository — so this tells the agent not to treat it like a project, to +- * stay read-only on real repos, and to delegate code changes to a dedicated +- * session. Modeled on the GitHub app's `build_general_chat_system_message`. +- */ +-export const COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS = [ +- '', +- 'This is a lightweight workspace-less chat, not tied to any project or workspace. The user opens it for quick questions, navigation, and triage.', +- '', +- '- Your working directory is a SCRATCH directory for running commands and saving throwaway artifacts — it is NOT a code repository. Do not treat it as a project to build, test, or commit.', +- '- If the user points you at a real repository, prefer read-only operations: read files, search code, and inspect git metadata (branch, log, diff, status) to answer questions. Avoid modifying files or running builds, tests, linters, or installs in their working copies.', +- '- When the user wants code changes, test runs, or any work that modifies or executes against a real project, delegate it to a dedicated session rather than doing it here.', +- '', +-].join('\n'); +- +-/** - * Builds a {@link SystemMessageConfig} that fully replaces the CLI/SDK system - * prompt with `content`. - * @@ -9767,21 +13672,76 @@ index 1b52c8cc..00000000 -export function sectionOverrides(sections: Partial>): SystemMessageConfig { - return { mode: 'customize', sections }; -} -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 5d3d6ce3..00000000 ---- a/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts -+++ /dev/null -@@ -1,133 +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 { AgentSandboxEnabledValue } from '../../../sandbox/common/settings.js'; +-/** Appends universal content without changing a full-prompt replacement. */ +-export function appendSystemMessageContent(config: SystemMessageConfig, content: string): SystemMessageConfig { +- if (config.mode === 'replace') { +- return config; +- } +- const existing = config.content; +- return { ...config, content: existing ? `${existing}\n\n${content}` : content }; +-} +- +-/** +- * One-line, log-friendly summary of a resolved {@link SystemMessageConfig} — +- * the mode plus, for `customize`, which sections are overridden and with what +- * action (e.g. `mode=customize sections=[identity:replace, tool_instructions:append]`). +- * +- * Keeps prompt observability cheap at `info` level without dumping full prompt +- * text on every session launch (log the whole config at `trace` for that). +- */ +-export function describeSystemMessageConfig(config: SystemMessageConfig): string { +- if (config.mode === 'replace') { +- return `mode=replace (content length ${config.content.length})`; +- } +- if (config.mode === 'customize') { +- const parts = Object.entries(config.sections ?? {}).map(([name, override]) => { +- const action = override?.action; +- return `${name}:${typeof action === 'function' ? 'transform' : action}`; +- }); +- // The customize convenience `content` is appended after all sections; note +- // it so the summary doesn't understate what was sent. +- const content = config.content ? ` +content(length ${config.content.length})` : ''; +- return `mode=customize sections=[${parts.join(', ')}]${content}`; +- } +- return `mode=append (content length ${config.content?.length ?? 0})`; +-} +diff --git a/src/vs/platform/agentHost/node/copilot/prompts/toolInstructions.ts b/src/vs/platform/agentHost/node/copilot/prompts/toolInstructions.ts +index 4e9d6557..24960c86 100644 +--- a/src/vs/platform/agentHost/node/copilot/prompts/toolInstructions.ts ++++ b/src/vs/platform/agentHost/node/copilot/prompts/toolInstructions.ts +@@ -5,3 +5,6 @@ + +-import type { SectionOverride } from '@github/copilot-sdk'; ++// `SectionOverride` 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 SectionOverride = any; + 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 78dbfd71..00000000 +--- a/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts ++++ /dev/null +@@ -1,156 +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 { AgentSandboxEnabledValue } from '../../../sandbox/common/settings.js'; -import { AgentHostSandboxKey, type ISandboxConfigValue } from '../../common/sandboxConfigSchema.js'; - -/** +- * Whether the SDK sandbox is supported on Windows. Not enabled yet, so the +- * builders bail out early on `win32`; the Windows handling is kept so support +- * can be turned on by flipping this flag once the runtime is ready. Typed as +- * `boolean` (not the `false` literal) so the Windows branches are not flagged +- * as unreachable by control-flow narrowing. +- */ +-const WINDOWS_SANDBOX_SUPPORTED: boolean = false; +- +-/** - * Per-platform filesystem rule bundle accepted under each `fileSystem.` - * sub-key (`AgentHostSandboxKey.LinuxFileSystem` etc.) in the AgentHost root - * sandbox config bag. Mirrors the workbench's `chat.agent.sandbox.fileSystem.*` @@ -9805,6 +13765,7 @@ index 5d3d6ce3..00000000 - */ -export interface ISdkSandboxConfig { - enabled: true; +- allowBypass?: boolean; - userPolicy: { - filesystem: { - readwritePaths?: string[]; @@ -9824,7 +13785,7 @@ index 5d3d6ce3..00000000 - * opaque `sandboxConfig` shape the Copilot SDK forwards to the runtime - * via `session.options.update`. - * -- * Used when {@link AgentHostConfigKey.EnableCustomTerminalTool} is OFF — the +- * Used when {@link CopilotCliConfigKey.EnableCustomTerminalTool} is OFF — the - * SDK's built-in shell tool runs the user's commands, so we have to push the - * sandbox policy down into the SDK itself. When the custom terminal tool is - * ON, the AgentHost's own {@link TerminalSandboxEngine} wraps commands and @@ -9838,9 +13799,14 @@ index 5d3d6ce3..00000000 - * `readwritePaths`. - * - Network: `allowNetwork` opens outbound to everything and drops the - * allow/deny lists. Otherwise the allow/deny lists open outbound when -- * set so they're actually enforced; macOS fails closed because the -- * runtime has no per-host filter (Seatbelt would silently degrade to -- * "allow all outbound"). +- * set so they're actually enforced; host lists are currently disabled on +- * all platforms (fail closed) because the runtime does not yet enforce +- * them reliably everywhere. +- * +- * Windows is not supported yet, so this bails out early and returns `undefined` +- * there. The Windows handling below is intentionally kept (and exercised when +- * {@link WINDOWS_SANDBOX_SUPPORTED} is flipped) so support can be turned on once +- * the runtime is ready. - */ -export function buildSandboxConfigForSdk( - platform: NodeJS.Platform, @@ -9850,6 +13816,12 @@ index 5d3d6ce3..00000000 - return undefined; - } - +- // Typed as `boolean` (not the `false` literal) so the Windows branches below +- // are not flagged as unreachable by control-flow narrowing. +- if (platform === 'win32' && !WINDOWS_SANDBOX_SUPPORTED) { +- return undefined; +- } +- - const enabledRaw = platform === 'win32' && sandbox[AgentHostSandboxKey.WindowsEnabled] !== undefined - ? sandbox[AgentHostSandboxKey.WindowsEnabled] - : sandbox[AgentHostSandboxKey.Enabled]; @@ -9883,8 +13855,9 @@ index 5d3d6ce3..00000000 - } - } - -- const allowAllNetwork = enabledRaw === AgentSandboxEnabledValue.AllowNetwork; -- const hostListsEnforceable = platform !== 'darwin'; +- const legacyAllowAllNetwork = enabledRaw === AgentSandboxEnabledValue.AllowNetwork; +- const allowAllNetwork = legacyAllowAllNetwork || (enabledRaw === AgentSandboxEnabledValue.On && sandbox[AgentHostSandboxKey.AllowNetwork] === true); +- const hostListsEnforceable = false; - const rawAllow = sandbox[AgentHostSandboxKey.AllowedNetworkDomains]; - const rawBlock = sandbox[AgentHostSandboxKey.DeniedNetworkDomains]; - const allowedHosts = !allowAllNetwork && hostListsEnforceable && rawAllow?.length ? [...rawAllow] : undefined; @@ -9892,6 +13865,7 @@ index 5d3d6ce3..00000000 - const allowOutbound = allowAllNetwork || !!allowedHosts || !!blockedHosts; - return { - enabled: true, +- allowBypass: true, - userPolicy: { - filesystem: { - ...(readwrite.size ? { readwritePaths: [...readwrite] } : {}), @@ -9907,12 +13881,10 @@ index 5d3d6ce3..00000000 - }; -} diff --git a/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts b/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts -index 9331c71b..e48523e3 100644 +index 7977aed4..61206e5d 100644 --- a/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts +++ b/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts -@@ -3,7 +3,19 @@ - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ +@@ -5,3 +5,15 @@ -import type { CopilotClient } from '@github/copilot-sdk'; +// `CopilotClient` came from '@github/copilot-sdk' (removed in !!APP_NAME!!). @@ -9928,68 +13900,86 @@ index 9331c71b..e48523e3 100644 + skills: { discover(params: unknown): Promise }; + }; +}; - import { CancellationToken } from '../../../../base/common/cancellation.js'; - import { CancellationError } from '../../../../base/common/errors.js'; - import { Emitter, Event } from '../../../../base/common/event.js'; + import { appendFile, mkdir } from 'fs/promises'; diff --git a/src/vs/platform/agentHost/node/otel/agentHostOTelService.ts b/src/vs/platform/agentHost/node/otel/agentHostOTelService.ts -index 61cf2cf7..655f5b97 100644 +index 090437f5..1a933ef8 100644 --- a/src/vs/platform/agentHost/node/otel/agentHostOTelService.ts +++ b/src/vs/platform/agentHost/node/otel/agentHostOTelService.ts -@@ -5,7 +5,6 @@ - - import { mkdir } from 'fs/promises'; +@@ -7,3 +7,2 @@ import { mkdir } from 'fs/promises'; import { dirname, join } from '../../../../base/common/path.js'; -import type { TelemetryConfig } from '@github/copilot-sdk'; import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; - import { URI } from '../../../../base/common/uri.js'; - import { INativeEnvironmentService } from '../../../environment/common/environment.js'; -@@ -20,7 +19,7 @@ import { - } from '../../../otel/node/otlp/outboundForwarder.js'; - import { OTelSqliteStore } from '../../../otel/node/sqlite/otelSqliteStore.js'; +@@ -25,3 +24,3 @@ import { OTelSqliteStore } from '../../../otel/node/sqlite/otelSqliteStore.js'; import { AgentHostOTelSpansDbSubPath } from '../../common/agentService.js'; --import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; -+import { IAgentHostOTelService, type TelemetryConfig } from '../../common/otel/agentHostOTelService.js'; +-import { AgentHostSessionTitleAttribute, AgentHostSessionTitleSpanName, AgentHostSessionUriAttribute, IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; ++import { AgentHostSessionTitleAttribute, AgentHostSessionTitleSpanName, AgentHostSessionUriAttribute, IAgentHostOTelService, type TelemetryConfig } from '../../common/otel/agentHostOTelService.js'; - /** Sub-path under the user data directory where the span DB lives. */ - const SPANS_DB_SUBPATH = AgentHostOTelSpansDbSubPath; +diff --git a/src/vs/platform/agentHost/test/node/agentHostGitHubTelemetryRouter.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitHubTelemetryRouter.test.ts +index 4ade3719..35e8e7f7 100644 +--- a/src/vs/platform/agentHost/test/node/agentHostGitHubTelemetryRouter.test.ts ++++ b/src/vs/platform/agentHost/test/node/agentHostGitHubTelemetryRouter.test.ts +@@ -5,3 +5,6 @@ + +-import type { GitHubTelemetryNotification } from '@github/copilot-sdk'; ++// `GitHubTelemetryNotification` 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 GitHubTelemetryNotification = any; + import assert from 'assert'; diff --git a/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts b/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts deleted file mode 100644 -index c2421fb6..00000000 +index 29a501b2..00000000 --- a/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts +++ /dev/null -@@ -1,125 +0,0 @@ +@@ -1,317 +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 { SectionOverride, SystemMessageSection } from '@github/copilot-sdk'; --import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; +-import type { SectionOverride, SystemMessageConfig, SystemMessageSection } from '@github/copilot-sdk'; +-import { CopilotCliConfigKey, applyModelFamilyAlias, copilotCliConfigSchema } from '../../common/copilotCliConfig.js'; -import type { SchemaValues } from '../../common/agentHostSchema.js'; -import type { ModelSelection } from '../../common/state/protocol/state.js'; -import { AgentHostPromptRegistry, agentHostPromptRegistry, type IAgentHostPromptContext } from '../../node/copilot/prompts/promptRegistry.js'; --import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE } from '../../node/copilot/prompts/systemMessage.js'; +-import { COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS, COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS, COPILOT_AGENT_HOST_SYSTEM_MESSAGE } from '../../node/copilot/prompts/systemMessage.js'; +-import { BrowserChatToolReferenceName } from '../../../browserView/common/browserChatToolReferenceNames.js'; +-import { CLIENT_TOOL_SEARCH_REFERENCE_NAME } from '../../common/toolSearchConstants.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import '../../node/copilot/prompts/allPrompts.js'; - --/** Builds a prompt context backed by an in-memory bag of customization settings. */ --function context(settings: SchemaValues = {}): IAgentHostPromptContext { -- return { getSetting: key => settings[key] }; +-/** +- * Builds a prompt context backed by an in-memory bag of customization settings +- * and an optional set of available tool names. +- */ +-function context(settings: SchemaValues = {}, tools: readonly string[] = [], workspaceless = false, toolSearchActive = false): IAgentHostPromptContext { +- const toolNames = new Set(tools); +- return { +- getSetting: key => settings[key], +- hasClientTool: name => toolNames.has(name), +- workspaceless, +- toolSearchActive, +- }; -} - -suite('AgentHostPromptRegistry', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - +- const withFileLinkInstructions = (config: SystemMessageConfig): SystemMessageConfig => ({ +- ...config, +- content: config.content ? `${config.content}\n\n${COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS}` : COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS, +- }); +- - test('falls back to the default system message when no model is provided', () => { - const registry = new AgentHostPromptRegistry(); -- assert.deepStrictEqual(registry.resolveSystemMessageConfig(undefined, context()), COPILOT_AGENT_HOST_SYSTEM_MESSAGE); +- assert.deepStrictEqual(registry.resolveSystemMessageConfig(undefined, context()), withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE)); - }); - - test('falls back to the default when no contributor matches the model', () => { - const registry = new AgentHostPromptRegistry(); -- assert.deepStrictEqual(registry.resolveSystemMessageConfig({ id: 'unknown-model' }, context()), COPILOT_AGENT_HOST_SYSTEM_MESSAGE); +- assert.deepStrictEqual(registry.resolveSystemMessageConfig({ id: 'unknown-model' }, context()), withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE)); - }); - - test('a contributor can fully replace the system prompt (replace mode)', () => { @@ -10016,7 +14006,7 @@ index c2421fb6..00000000 - }); - assert.deepStrictEqual( - registry.resolveSystemMessageConfig({ id: 'claude-sonnet' }, context()), -- { mode: 'customize', sections: { guidelines: { action: 'append', content: 'Be concise.' } } } +- withFileLinkInstructions({ mode: 'customize', sections: { guidelines: { action: 'append', content: 'Be concise.' } } }) - ); - }); - @@ -10030,7 +14020,7 @@ index c2421fb6..00000000 - }); - assert.deepStrictEqual( - registry.resolveSystemMessageConfig({ id: 'claude-sonnet' }, context()), -- COPILOT_AGENT_HOST_SYSTEM_MESSAGE +- withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE) - ); - }); - @@ -10056,16 +14046,16 @@ index c2421fb6..00000000 - registry.registerPrompt(class { - static readonly familyPrefixes = ['claude']; - resolveSectionOverrides(_model: ModelSelection, ctx: IAgentHostPromptContext): Partial> | undefined { -- return ctx.getSetting(AgentHostConfigKey.Opus48Prompt) === true ? { tone: { action: 'append', content: 'GATED' } } : undefined; +- return ctx.getSetting(CopilotCliConfigKey.Opus48Prompt) === true ? { tone: { action: 'append', content: 'GATED' } } : undefined; - } - }); - assert.deepStrictEqual( -- registry.resolveSystemMessageConfig({ id: 'claude-x' }, context({ [AgentHostConfigKey.Opus48Prompt]: true })), -- { mode: 'customize', sections: { tone: { action: 'append', content: 'GATED' } } } +- registry.resolveSystemMessageConfig({ id: 'claude-x' }, context({ [CopilotCliConfigKey.Opus48Prompt]: true })), +- withFileLinkInstructions({ mode: 'customize', sections: { tone: { action: 'append', content: 'GATED' } } }) - ); - assert.deepStrictEqual( - registry.resolveSystemMessageConfig({ id: 'claude-x' }, context()), -- COPILOT_AGENT_HOST_SYSTEM_MESSAGE +- withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE) - ); - }); - @@ -10073,76 +14063,337 @@ index c2421fb6..00000000 - const opusModel: ModelSelection = { id: 'claude-opus-4-8' }; - - function resolveOpus(enabled: boolean | undefined) { -- return agentHostPromptRegistry.resolveSystemMessageConfig(opusModel, context(enabled === undefined ? {} : { [AgentHostConfigKey.Opus48Prompt]: enabled })); +- return agentHostPromptRegistry.resolveSystemMessageConfig(opusModel, context(enabled === undefined ? {} : { [CopilotCliConfigKey.Opus48Prompt]: enabled })); - } - - test('applies customize overrides only when enabled', () => { -- assert.strictEqual(resolveOpus(undefined), COPILOT_AGENT_HOST_SYSTEM_MESSAGE); -- assert.strictEqual(resolveOpus(false), COPILOT_AGENT_HOST_SYSTEM_MESSAGE); +- assert.deepStrictEqual(resolveOpus(undefined), withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE)); +- assert.deepStrictEqual(resolveOpus(false), withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE)); - assert.strictEqual(resolveOpus(true).mode, 'customize'); - }); - }); +- +- suite('model capability overrides (family alias)', () => { +- // The launcher composes `applyModelFamilyAlias` with the registry (see +- // `_buildSessionConfig`); this guards that composition end-to-end using +- // the real Opus contributor, whose custom `matchesModel` checks the id. +- // The alias helper's own behavior is covered in copilotCliConfig.test.ts. +- test('an aliased preview model routes to the family contributor', () => { +- const overrides = { 'preview-model-x': { family: 'claude-opus-4-8' } }; +- const result = agentHostPromptRegistry.resolveSystemMessageConfig( +- applyModelFamilyAlias({ id: 'preview-model-x' }, overrides), +- context({ [CopilotCliConfigKey.Opus48Prompt]: true }) +- ); +- assert.strictEqual(result.mode, 'customize'); +- }); +- }); +- +- suite('workspace-less scratch/repoless wiring', () => { +- test('appends the scratch instructions to the default config for a workspace-less chat', () => { +- const registry = new AgentHostPromptRegistry(); +- assert.deepStrictEqual( +- registry.resolveSystemMessageConfig(undefined, context({}, [], true)), +- { +- mode: 'customize', +- sections: COPILOT_AGENT_HOST_SYSTEM_MESSAGE.sections, +- content: `${COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS}\n\n${COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS}`, +- } +- ); +- }); +- +- test('is a no-op for a workspace-bound session', () => { +- const registry = new AgentHostPromptRegistry(); +- assert.deepStrictEqual( +- registry.resolveSystemMessageConfig(undefined, context({}, [], false)), +- withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE) +- ); +- }); +- +- test('composes with per-model customize content for a workspace-less chat', () => { +- const registry = new AgentHostPromptRegistry(); +- registry.registerPrompt(class { +- static readonly familyPrefixes = ['claude']; +- resolveSectionOverrides(): Partial> { +- return { guidelines: { action: 'append', content: 'Be concise.' } }; +- } +- }); +- assert.deepStrictEqual( +- registry.resolveSystemMessageConfig({ id: 'claude-sonnet' }, context({}, [], true)), +- { +- mode: 'customize', +- sections: { guidelines: { action: 'append', content: 'Be concise.' } }, +- content: `${COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS}\n\n${COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS}`, +- } +- ); +- }); +- +- test('does not append scratch instructions to a full replace prompt', () => { +- const registry = new AgentHostPromptRegistry(); +- registry.registerPrompt(class { +- static readonly familyPrefixes = ['gpt-5']; +- resolveFullSystemPrompt(): string { +- return 'FULL PROMPT'; +- } +- }); +- assert.deepStrictEqual( +- registry.resolveSystemMessageConfig({ id: 'gpt-5-mini' }, context({}, [], true)), +- { mode: 'replace', content: 'FULL PROMPT' } +- ); +- }); +- }); +- +- suite('universal tool instructions wiring', () => { +- // The browser line is the registered universal tool-instruction (see +- // toolInstructions.ts). These guard that the registry layers it end-to-end; +- // the composition/gating itself is covered in toolInstructions.test.ts. +- const BROWSER_LINE = 'Use the browser tools (openBrowserPage, readPage, etc.) when beneficial for front-end tasks, such as when visualizing or validating UI changes.'; +- const browserTools = [BrowserChatToolReferenceName.OpenBrowserPage, BrowserChatToolReferenceName.ReadPage]; +- +- test('is a no-op when the session exposes no matching tools', () => { +- const registry = new AgentHostPromptRegistry(); +- assert.deepStrictEqual(registry.resolveSystemMessageConfig({ id: 'm' }, context({}, ['anyTool'])), withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE)); +- }); +- +- test('layers the browser tool_instructions onto the default config when browser tools are present', () => { +- const registry = new AgentHostPromptRegistry(); +- assert.deepStrictEqual( +- registry.resolveSystemMessageConfig({ id: 'm' }, context({}, browserTools)), +- withFileLinkInstructions({ +- mode: 'customize', +- sections: { +- identity: COPILOT_AGENT_HOST_SYSTEM_MESSAGE.sections.identity, +- tool_instructions: { action: 'append', content: `\n${BROWSER_LINE}` }, +- }, +- }) +- ); +- }); +- +- test('composes the browser line with a per-model tool_instructions override', () => { +- const registry = new AgentHostPromptRegistry(); +- registry.registerPrompt(class { +- static readonly familyPrefixes = ['claude']; +- resolveSectionOverrides(): Partial> { +- return { tool_instructions: { action: 'append', content: 'Always prefer ripgrep.' } }; +- } +- }); +- assert.deepStrictEqual( +- registry.resolveSystemMessageConfig({ id: 'claude-x' }, context({}, browserTools)), +- withFileLinkInstructions({ mode: 'customize', sections: { tool_instructions: { action: 'append', content: `\nAlways prefer ripgrep.\n${BROWSER_LINE}` } } }) +- ); +- }); +- +- test('leaves a per-model tool_instructions override untouched when no browser tools are present', () => { +- const registry = new AgentHostPromptRegistry(); +- registry.registerPrompt(class { +- static readonly familyPrefixes = ['claude']; +- resolveSectionOverrides(): Partial> { +- return { tool_instructions: { action: 'append', content: 'Always prefer ripgrep.' } }; +- } +- }); +- assert.deepStrictEqual( +- registry.resolveSystemMessageConfig({ id: 'claude-x' }, context({}, ['anyTool'])), +- withFileLinkInstructions({ mode: 'customize', sections: { tool_instructions: { action: 'append', content: 'Always prefer ripgrep.' } } }) +- ); +- }); +- }); +- +- suite('tool search instructions wiring', () => { +- // End-to-end guard that the registry layers the tool-search line only +- // when `toolSearchActive` AND the client tool-search tool are both +- // present; the composition/gating itself is covered in +- // toolInstructions.test.ts. +- const TOOL_SEARCH_LINE = `Most tools are deferred and hidden until you search for them. Before calling a tool that has not already been loaded, ALWAYS use tool search first with a short description of the capability you need, then call the specific tool it returns; tools it returns are immediately available and must not be searched for again.`; +- +- test('layers the tool-search line onto the default config when active and the tool-search tool is present', () => { +- const registry = new AgentHostPromptRegistry(); +- assert.deepStrictEqual( +- registry.resolveSystemMessageConfig({ id: 'm' }, context({}, [CLIENT_TOOL_SEARCH_REFERENCE_NAME], false, true)), +- withFileLinkInstructions({ +- mode: 'customize', +- sections: { +- identity: COPILOT_AGENT_HOST_SYSTEM_MESSAGE.sections.identity, +- tool_instructions: { action: 'append', content: `\n${TOOL_SEARCH_LINE}` }, +- }, +- }) +- ); +- }); +- +- test('is a no-op when tool search is inactive even if the tool-search tool is present', () => { +- const registry = new AgentHostPromptRegistry(); +- assert.deepStrictEqual( +- registry.resolveSystemMessageConfig({ id: 'm' }, context({}, [CLIENT_TOOL_SEARCH_REFERENCE_NAME], false, false)), +- withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE) +- ); +- }); +- +- test('is a no-op when active but the client does not expose the tool-search tool', () => { +- const registry = new AgentHostPromptRegistry(); +- assert.deepStrictEqual( +- registry.resolveSystemMessageConfig({ id: 'm' }, context({}, ['anyTool'], false, true)), +- withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE) +- ); +- }); +- +- test('composes the tool-search line with a per-model tool_instructions override', () => { +- const registry = new AgentHostPromptRegistry(); +- registry.registerPrompt(class { +- static readonly familyPrefixes = ['claude']; +- resolveSectionOverrides(): Partial> { +- return { tool_instructions: { action: 'append', content: 'Always prefer ripgrep.' } }; +- } +- }); +- assert.deepStrictEqual( +- registry.resolveSystemMessageConfig({ id: 'claude-x' }, context({}, [CLIENT_TOOL_SEARCH_REFERENCE_NAME], false, true)), +- withFileLinkInstructions({ mode: 'customize', sections: { tool_instructions: { action: 'append', content: `\nAlways prefer ripgrep.\n${TOOL_SEARCH_LINE}` } } }) +- ); +- }); +- }); -}); +diff --git a/src/vs/platform/agentHost/test/node/buildSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/buildSessionEvents.test.ts +index ee58bc9b..b7a509bf 100644 +--- a/src/vs/platform/agentHost/test/node/buildSessionEvents.test.ts ++++ b/src/vs/platform/agentHost/test/node/buildSessionEvents.test.ts +@@ -12,3 +12,6 @@ import { buildSessionEventLogFromTurns, buildSessionEventsFromTurns, serializeSe + import { mapSessionEvents } 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; + 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 bd411161..00000000 +index 2cb594ef..00000000 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ /dev/null -@@ -1,2591 +0,0 @@ +@@ -1,5205 +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, CopilotSession, ModelInfo, SessionEventPayload, SessionEventType, TypedSessionEventHandler } from '@github/copilot-sdk'; +-import type { CopilotClient, CopilotClientOptions, CopilotSession, GitHubTelemetryNotification, PermissionAllowAllMode, PermissionRequest, SessionEvent, SessionEventHandler, SessionEventPayload, SessionEventType, TypedSessionEventHandler } from '@github/copilot-sdk'; -import type Anthropic from '@anthropic-ai/sdk'; -import type { CCAModel } from '@vscode/copilot-api'; -import assert from 'assert'; -import * as fs from 'fs/promises'; -import * as os from 'os'; -import { VSBuffer } from '../../../../base/common/buffer.js'; --import { DeferredPromise } from '../../../../base/common/async.js'; +-import { DeferredPromise, timeout } from '../../../../base/common/async.js'; -import { isCancellationError } from '../../../../base/common/errors.js'; -import { Disposable, type DisposableStore, type IDisposable, type IReference } from '../../../../base/common/lifecycle.js'; --import { Event } from '../../../../base/common/event.js'; +-import { Emitter, Event } from '../../../../base/common/event.js'; -import { Schemas } from '../../../../base/common/network.js'; -import { waitForState } from '../../../../base/common/observable.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { INativeEnvironmentService } from '../../../environment/common/environment.js'; -import { FileService } from '../../../files/common/fileService.js'; --import { IFileService } from '../../../files/common/files.js'; +-import { IFileService, type IStat } from '../../../files/common/files.js'; -import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; -import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; -import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; -import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; --import { ILogService, NullLogService } from '../../../log/common/log.js'; +-import { ILogService, LogLevel, NullLogService } from '../../../log/common/log.js'; +-import { IAgentHostProxyResolver } from '../../node/agentHostProxyResolver.js'; +-import type { IAgentHostClientProxyConnection } from '../../common/agentHostClientProxyChannel.js'; +-import type { IByokLmBridgeConnection, IByokLmModelInfo } from '../../common/agentHostByokLm.js'; -import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; -import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; --import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js'; +-import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; +-import { CopilotCliConfigKey } from '../../common/copilotCliConfig.js'; +-import { AgentHostCopilotMultiRootEnabledConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey } from '../../common/agentHostSchema.js'; -import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; --import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, type AgentSignal, type IAgentActionSignal, type IAgentSessionMetadata } from '../../common/agentService.js'; +-import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, type AgentSignal, type IAgentCreateChatForkSource, type IAgentSessionMetadata, type IAgentSpawnChatEvent } from '../../common/agentService.js'; -import { ISessionDataService } from '../../common/sessionDataService.js'; --import { buildSubagentSessionUri, buildChatUri, CustomizationLoadStatus, MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, customizationId, type ClientPluginCustomization, type MarkdownResponsePart, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js'; --import { CustomizationType, type ToolDefinition } from '../../common/state/protocol/state.js'; --import { ActionType, type ChatAction, type IDeltaAction, type SessionAction } from '../../common/state/sessionActions.js'; +-import { buildDefaultChatUri, buildChatUri, buildSubagentChatUri, parseRequiredSessionUriFromChatUri, CustomizationLoadStatus, MessageKind, ResponsePartKind, ROOT_STATE_URI, ToolResultContentType, TurnState, customizationId, type ClientPluginCustomization, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js'; +-import { CustomizationType, SessionStatus, ToolCallContributorKind, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; +-import { ActionType, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js'; - -import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; --import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; --import { IAgentHostGitService } from '../../node/agentHostGitService.js'; +-import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentHostStateManager.js'; +-import { IAgentHostGitService, type IBranch, type IDefaultBranch } from '../../common/agentHostGitService.js'; -import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; -import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; -import { AgentHostCompletions, IAgentHostCompletions } from '../../node/agentHostCompletions.js'; --import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE, CopilotAgent, getCopilotWorktreeName, getCopilotWorktreesRoot } from '../../node/copilot/copilotAgent.js'; +-import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE, CopilotAgent, CopilotSessionEntry, rebaseUnder, REFRESH_DEBOUNCE_MS } from '../../node/copilot/copilotAgent.js'; +-import { COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS } from '../../node/copilot/prompts/systemMessage.js'; -import { NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; +-import { IAgentHostReviewService, NULL_REVIEW_SERVICE } from '../../common/agentHostReviewService.js'; +-import { IAgentHostGitHubEndpointService } from '../../node/agentHostGitHubEndpointService.js'; +-import { createTestGitHubEndpointService } from './testGitHubEndpointService.js'; -import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; --import { CopilotBranchNameGenerator, ICopilotBranchNameGenerator, getCopilotBranchNameHintFromMessage, normalizeCopilotBranchName } from '../../node/copilot/copilotBranchNameGenerator.js'; +-import { AgentBranchNameGenerator, getAgentBranchNameHintFromMessage, normalizeAgentBranchName } from '../../node/shared/agentBranchNameGenerator.js'; -import type { CopilotSessionLaunchPlan, IActiveClientSnapshot } from '../../node/copilot/copilotSessionLauncher.js'; -import { ShellManager } from '../../node/copilot/copilotShellTools.js'; +-import { registerPendingEditContentProvider } from '../../node/copilot/pendingEditContentStore.js'; -import { SessionDatabase } from '../../node/sessionDatabase.js'; -import { createNullSessionDataService } from '../common/sessionTestHelpers.js'; --import { ActiveClientState } from '../../node/activeClientState.js'; --import { ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest } from '../../node/shared/copilotApiService.js'; +-import { ActiveClientToolSet } from '../../node/activeClientState.js'; +-import { ByokLmBridgeRegistry, IByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js'; +-import { ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest, type IRestrictedTelemetryContext } from '../../node/shared/copilotApiService.js'; +-import type { IAgentHostInternalTelemetryContext, IAgentHostRestrictedTelemetryContext } from '../../node/agentHostRestrictedTelemetry.js'; +-import { injectSideChatContext } from '../../node/agentPeerChats.js'; +- +-/** +- * Test helpers for the single `_sessions` container. All chats (default + peers) +- * live inside the owning session's {@link CopilotSessionEntry}, keyed by chat URI +- * string; the default chat is the entry's `defaultChat`. These wrap that +- * structure so tests can inject/observe fakes without reaching into private +- * container internals. +- */ +-function sessionsMap(agent: CopilotAgent): Map { +- return (agent as unknown as { _sessions: Map })._sessions; +-} +- +-function defaultChatUri(session: URI): URI { +- return URI.parse(buildDefaultChatUri(session)); +-} +- +-/** Inject (or replace) a session's default-chat stub. */ +-function setDefaultSessionStub(agent: CopilotAgent, sessionId: string, stub: unknown): void { +- const sessions = sessionsMap(agent); +- const defaultChatKey = buildDefaultChatUri(AgentSession.uri('copilotcli', sessionId).toString()); +- let entry = sessions.get(sessionId); +- if (!entry) { +- entry = new CopilotSessionEntry(); +- sessions.set(sessionId, entry); +- } +- entry.setDefaultChat(defaultChatKey, new CopilotSessionEntry(stub as CopilotAgentSession)); +-} +- +-/** Inject a peer-chat stub into its owning session's entry (creating the entry if needed). */ +-function setPeerChatStub(agent: CopilotAgent, chatUri: URI, stub: unknown): void { +- const sessionId = AgentSession.id(URI.parse(parseRequiredSessionUriFromChatUri(chatUri))); +- const sessions = sessionsMap(agent); +- let entry = sessions.get(sessionId); +- if (!entry) { +- entry = new CopilotSessionEntry(); +- sessions.set(sessionId, entry); +- } +- entry.registerPeerChat(chatUri.toString(), new CopilotSessionEntry(stub as CopilotAgentSession)); +-} +- +-/** Resolve a peer-chat stub from its owning session's entry. */ +-function getPeerChatStub(agent: CopilotAgent, chatUri: URI): CopilotAgentSession | undefined { +- const sessionId = AgentSession.id(URI.parse(parseRequiredSessionUriFromChatUri(chatUri))); +- return sessionsMap(agent).get(sessionId)?.getPeerChat(chatUri.toString()); +-} +- +-/** True when a peer chat is tracked in its owning session's entry. */ +-function hasPeerChatStub(agent: CopilotAgent, chatUri: URI): boolean { +- const sessionId = AgentSession.id(URI.parse(parseRequiredSessionUriFromChatUri(chatUri))); +- return sessionsMap(agent).get(sessionId)?.hasPeerChat(chatUri.toString()) ?? false; +-} +- +-/** Total number of peer chats tracked across all sessions. */ +-function peerChatCount(agent: CopilotAgent): number { +- let count = 0; +- for (const entry of sessionsMap(agent).values()) { +- count += entry.peerChatKeys().length; +- } +- return count; +-} - -class TestAgentPluginManager implements IAgentPluginManager { - declare readonly _serviceBrand: undefined; @@ -10158,22 +14409,25 @@ index bd411161..00000000 - declare readonly _serviceBrand: undefined; - - repositoryRoot: URI | undefined = undefined; +- headCommit: string | undefined = '0'.repeat(40); - addedWorktrees: { repositoryRoot: URI; worktree: URI; branchName: string; startPoint: string }[] = []; - addedExistingWorktrees: { repositoryRoot: URI; worktree: URI; branchName: string }[] = []; - removedWorktrees: { repositoryRoot: URI; worktree: URI }[] = []; - existingBranches = new Set(); - dirtyWorkingDirectories = new Set(); - -- async isInsideWorkTree(): Promise { return false; } - async getCurrentBranch(): Promise { return undefined; } -- async getDefaultBranch(): Promise { return undefined; } -- async getBranches(): Promise { return []; } +- async getDefaultBranch(): Promise { return undefined; } +- async getBranch(): Promise { return undefined; } +- async getRefs(): Promise { return []; } +- async getBranches(): Promise { return []; } - async getRepositoryRoot(): Promise { return this.repositoryRoot; } - async getWorktreeRoots(): Promise { return []; } - async addWorktree(repositoryRoot: URI, worktree: URI, branchName: string, startPoint: string): Promise { - this.addedWorktrees.push({ repositoryRoot, worktree, branchName, startPoint }); - this.existingBranches.add(branchName); - } +- async copyWorktreeIncludeFiles(): Promise { } - async addExistingWorktree(repositoryRoot: URI, worktree: URI, branchName: string): Promise { - this.addedExistingWorktrees.push({ repositoryRoot, worktree, branchName }); - } @@ -10189,7 +14443,8 @@ index bd411161..00000000 - async commitAll(): Promise { } - async restore(): Promise { } - async hasUpstream(): Promise { return false; } -- async pushBranch(): Promise { } +- async pull(): Promise { } +- async push(): Promise { } - async getSessionGitState(): Promise { return undefined; } - async computeSessionFileDiffs(): Promise { return undefined; } - async showBlob(): Promise { return undefined; } @@ -10197,8 +14452,17 @@ index bd411161..00000000 - async commitTree(): Promise { return undefined; } - async updateRef(): Promise { } - async deleteRefs(): Promise { } -- async revParse(): Promise { return undefined; } +- 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 getUntrackedPaths(): Promise<[]> { return []; } +- async getBranchDiffSafetyInfo(): Promise { return undefined; } +- async getDiffPatchBetweenRefs(): Promise { return undefined; } -} - -class TestAgentHostTerminalManager implements IAgentHostTerminalManager { @@ -10221,6 +14485,10 @@ index bd411161..00000000 - getTerminalInfos(): [] { return []; } - getTerminalState(): undefined { return undefined; } - async getDefaultShell(): Promise { return '/bin/bash'; } +- createOutputTerminal(): void { } +- appendOutputTerminalData(): void { } +- resetOutputTerminal(): void { } +- finalizeOutputTerminal(): void { } -} - -class TestCopilotApiService implements ICopilotApiService { @@ -10229,6 +14497,10 @@ index bd411161..00000000 - readonly utilityCalls: { token: string; request: ICopilotUtilityChatCompletionRequest; options?: ICopilotApiServiceRequestOptions }[] = []; - response = 'generated-branch-name'; - error: Error | undefined; +- apiEndpoint: string | undefined; +- userLogin: string | undefined; +- readonly restrictedTelemetryContexts = new Map(); +- readonly restrictedTelemetryContextCalls: string[] = []; - - messages(_githubToken: string, _request: Anthropic.MessageCreateParamsStreaming, _options?: ICopilotApiServiceRequestOptions): AsyncGenerator; - messages(_githubToken: string, _request: Anthropic.MessageCreateParamsNonStreaming, _options?: ICopilotApiServiceRequestOptions): Promise; @@ -10238,6 +14510,19 @@ index bd411161..00000000 - async countTokens(): Promise { throw new Error('not used'); } - async models(): Promise { return []; } - async responses(): Promise { throw new Error('not used'); } +- async resolveRestrictedTelemetryContext(githubToken: string): Promise { +- this.restrictedTelemetryContextCalls.push(githubToken); +- return this.restrictedTelemetryContexts.get(githubToken) ?? { +- restrictedTelemetryEnabled: false, +- trackingId: undefined, +- telemetryEndpoint: undefined, +- isInternal: false, +- userName: undefined, +- isVscodeTeamMember: false, +- }; +- } +- async resolveApiEndpoint() { return this.apiEndpoint; } +- async resolveUserLogin() { return this.userLogin; } - async utilityChatCompletion(githubToken: string, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise { - this.utilityCalls.push({ token: githubToken, request, options }); - if (this.error) { @@ -10278,39 +14563,31 @@ index bd411161..00000000 - whenIdle(): Promise { return Promise.resolve(); } -} -type CopilotModelsList = CopilotClient['rpc']['models']['list']; +-type CopilotModelInfo = Awaited>['models'][number]; - -interface ITestCopilotModelInfo { - readonly id: string; - readonly name: string; - readonly capabilities?: { - readonly supports?: { readonly vision?: boolean }; -- readonly limits?: { readonly max_context_window_tokens?: number }; -- }; -- readonly policy?: { readonly state?: NonNullable['state'] }; -- readonly billing?: ModelInfo['billing'] & { -- readonly priceCategory?: string; -- readonly tokenPrices?: { -- readonly contextMax?: number; -- readonly inputPrice?: number; -- readonly cachePrice?: number; -- readonly cacheWritePrice?: number; -- readonly outputPrice?: number; -- readonly longContext?: { readonly contextMax?: number; readonly inputPrice?: number; readonly cachePrice?: number; readonly cacheWritePrice?: number; readonly outputPrice?: number }; -- }; +- readonly limits?: { readonly max_context_window_tokens?: number; readonly max_output_tokens?: number; readonly max_prompt_tokens?: number }; - }; -- readonly modelPickerPriceCategory?: string; -- readonly supportedReasoningEfforts?: ModelInfo['supportedReasoningEfforts']; -- readonly defaultReasoningEffort?: ModelInfo['defaultReasoningEffort']; +- readonly policy?: { readonly state?: NonNullable['state'] }; +- readonly billing?: CopilotModelInfo['billing']; +- readonly modelPickerCategory?: CopilotModelInfo['modelPickerCategory']; +- readonly modelPickerPriceCategory?: CopilotModelInfo['modelPickerPriceCategory']; +- readonly supportedReasoningEfforts?: CopilotModelInfo['supportedReasoningEfforts']; +- readonly defaultReasoningEffort?: CopilotModelInfo['defaultReasoningEffort']; -} - --interface ITestCopilotClient extends Pick { +-interface ITestCopilotClient extends Pick { - readonly rpc: { - readonly sessions: { readonly fork: CopilotClient['rpc']['sessions']['fork'] }; - readonly models: { readonly list: CopilotModelsList }; - }; -} - --function toSdkModelInfo(model: ITestCopilotModelInfo): ModelInfo { +-function toSdkModelInfo(model: ITestCopilotModelInfo): CopilotModelInfo { - return { - id: model.id, - name: model.name, @@ -10321,10 +14598,13 @@ index bd411161..00000000 - }, - limits: { - max_context_window_tokens: model.capabilities?.limits?.max_context_window_tokens ?? 0, +- max_output_tokens: model.capabilities?.limits?.max_output_tokens, +- max_prompt_tokens: model.capabilities?.limits?.max_prompt_tokens, - }, - }, - ...(model.policy ? { policy: { state: model.policy.state ?? 'enabled', terms: '' } } : {}), - ...(model.billing ? { billing: model.billing } : {}), +- ...(model.modelPickerCategory ? { modelPickerCategory: model.modelPickerCategory } : {}), - ...(model.modelPickerPriceCategory ? { modelPickerPriceCategory: model.modelPickerPriceCategory } : {}), - ...(model.supportedReasoningEfforts ? { supportedReasoningEfforts: model.supportedReasoningEfforts } : {}), - ...(model.defaultReasoningEffort ? { defaultReasoningEffort: model.defaultReasoningEffort } : {}), @@ -10369,7 +14649,6 @@ index bd411161..00000000 - this.listSessionCallCount++; - return this._sessions; - } -- async listModels(): ReturnType { return this._models.map(toSdkModelInfo); } - async getSessionMetadata(sessionId: string): ReturnType { - this.getSessionMetadataCalls.push(sessionId); - return this._sessions.find(s => s.sessionId === sessionId); @@ -10389,9 +14668,46 @@ index bd411161..00000000 - -class MockCopilotSession { - readonly sessionId = 'test-session-1'; +- readonly rpc = { +- options: { +- update: async () => ({ success: true }), +- }, +- permissions: { +- setAllowAll: async ({ mode }: { mode: PermissionAllowAllMode }) => ({ success: true, mode }), +- }, +- }; +- private readonly _handlers = new Set(); +- private readonly _typedHandlers = new Map) => void>>(); +- +- on(_handler: SessionEventHandler): () => void; +- on(_eventType: K, _handler: TypedSessionEventHandler): () => void; +- on(eventTypeOrHandler: K | SessionEventHandler, handler?: TypedSessionEventHandler): () => void { +- if (typeof eventTypeOrHandler === 'function') { +- this._handlers.add(eventTypeOrHandler); +- return () => this._handlers.delete(eventTypeOrHandler); +- } +- if (!handler) { +- throw new Error(`Missing handler for ${eventTypeOrHandler}`); +- } +- let handlers = this._typedHandlers.get(eventTypeOrHandler); +- if (!handlers) { +- handlers = new Set(); +- this._typedHandlers.set(eventTypeOrHandler, handlers); +- } +- const typedHandler = handler as (event: SessionEventPayload) => void; +- handlers.add(typedHandler); +- return () => handlers.delete(typedHandler); +- } - -- on(_eventType: K, _handler: TypedSessionEventHandler): () => void { -- return () => { }; +- emit(event: SessionEventPayload): void { +- const sessionEvent = event as SessionEvent; +- for (const handler of this._handlers) { +- handler(sessionEvent); +- } +- const typedEvent = event as SessionEventPayload; +- for (const handler of this._typedHandlers.get(event.type) ?? []) { +- handler(typedEvent); +- } - } - - async send(): Promise { return ''; } @@ -10416,11 +14732,29 @@ index bd411161..00000000 - getSpansDbPath() { - return undefined; - } +- emitSessionTitleChanged() { } - async flush() { - // - } -} - +-class TestProxyResolver implements IAgentHostProxyResolver { +- declare readonly _serviceBrand: undefined; +- resolveProxyCalls = 0; +- resolvedProxy: string | undefined; +- +- register(_clientId: string, _connection: IAgentHostClientProxyConnection): IDisposable { +- return Disposable.None; +- } +- +- async resolveProxy(_url: string): Promise { +- this.resolveProxyCalls++; +- return this.resolvedProxy; +- } +- +- readonly fetch: typeof globalThis.fetch = (input, init) => globalThis.fetch(input, init); +-} +- -class ResumePathCopilotAgent extends CopilotAgent { - constructor( - private readonly _copilotClient: ITestCopilotClient, @@ -10429,10 +14763,15 @@ index bd411161..00000000 - @ISessionDataService sessionDataService: ISessionDataService, - @IAgentHostGitService gitService: IAgentHostGitService, - @IAgentConfigurationService configurationService: IAgentConfigurationService, -- @ICopilotBranchNameGenerator branchNameGenerator: ICopilotBranchNameGenerator, +- @IAgentHostStateManager stateManager: AgentHostStateManager, - @IAgentHostCompletions completions: IAgentHostCompletions, +- @INativeEnvironmentService environmentService: INativeEnvironmentService, +- @IByokLmBridgeRegistry byokBridgeRegistry: IByokLmBridgeRegistry, +- @ITelemetryService telemetryService: ITelemetryService, +- @IAgentHostProxyResolver proxyResolver: IAgentHostProxyResolver, +- @ICopilotApiService copilotApiService: ICopilotApiService, - ) { -- super(logService, instantiationService, sessionDataService, gitService, configurationService, new MockAgentHostOTelService(), branchNameGenerator, completions, NULL_CHECKPOINT_SERVICE); +- super(logService, instantiationService, sessionDataService, gitService, configurationService, stateManager, createTestGitHubEndpointService(), new MockAgentHostOTelService(), completions, NULL_CHECKPOINT_SERVICE, NULL_REVIEW_SERVICE, environmentService, byokBridgeRegistry, telemetryService, copilotApiService, proxyResolver); - this._enablePlanModeOnClient(this._copilotClient as CopilotClient); - } - @@ -10444,6 +14783,11 @@ index bd411161..00000000 -class TestableCopilotAgent extends CopilotAgent { - private readonly _fakeSessions = new Map(); - readonly resumeCalls: string[] = []; +- readonly createdClientOptions: CopilotClientOptions[] = []; +- +- // Keep model-refresh retries effectively instant in tests. +- protected override readonly _modelRefreshBaseDelayMs = 1; +- protected override readonly _modelRefreshMaxDelayMs = 2; - - constructor( - private readonly _copilotClient: ITestCopilotClient, @@ -10452,14 +14796,20 @@ index bd411161..00000000 - @ISessionDataService sessionDataService: ISessionDataService, - @IAgentHostGitService gitService: IAgentHostGitService, - @IAgentConfigurationService configurationService: IAgentConfigurationService, -- @ICopilotBranchNameGenerator branchNameGenerator: ICopilotBranchNameGenerator, +- @IAgentHostStateManager stateManager: AgentHostStateManager, - @IAgentHostCompletions completions: IAgentHostCompletions, +- @INativeEnvironmentService environmentService: INativeEnvironmentService, +- @IByokLmBridgeRegistry byokBridgeRegistry: IByokLmBridgeRegistry, +- @ITelemetryService telemetryService: ITelemetryService, +- @IAgentHostProxyResolver proxyResolver: IAgentHostProxyResolver, +- @ICopilotApiService copilotApiService: ICopilotApiService, - ) { -- super(logService, instantiationService, sessionDataService, gitService, configurationService, new MockAgentHostOTelService(), branchNameGenerator, completions, NULL_CHECKPOINT_SERVICE); +- super(logService, instantiationService, sessionDataService, gitService, configurationService, stateManager, createTestGitHubEndpointService(), new MockAgentHostOTelService(), completions, NULL_CHECKPOINT_SERVICE, NULL_REVIEW_SERVICE, environmentService, byokBridgeRegistry, telemetryService, copilotApiService, proxyResolver); - this._enablePlanModeOnClient(this._copilotClient as CopilotClient); - } - -- protected override _createCopilotClient(): CopilotClient { +- protected override _createCopilotClient(options: CopilotClientOptions): CopilotClient { +- this.createdClientOptions.push(options); - return this._copilotClient as CopilotClient; - } - @@ -10488,7 +14838,7 @@ index bd411161..00000000 - emitInitialMarkdown: (content: string) => { - emitter.fire({ - kind: 'action', -- session: sessionUri, +- resource: sessionUri, - action: { - type: ActionType.ChatResponsePart, - turnId, @@ -10499,40 +14849,47 @@ index bd411161..00000000 - } as unknown as CopilotAgentSession; - return stub; - } +-} - -- resolveWorktreeForTest(config: Parameters[0], sessionId: string, prompt?: string): Promise { -- return this._resolveSessionWorkingDirectory(config, sessionId, prompt); -- } +-function getCreatedClientOptions(agent: CopilotAgent): readonly CopilotClientOptions[] { +- assert.ok(agent instanceof TestableCopilotAgent); +- return agent.createdClientOptions; -} - --function createTestAgentContext(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService; copilotApiService?: ICopilotApiService }): { agent: CopilotAgent; instantiationService: IInstantiationService; configurationService: IAgentConfigurationService; fileService: FileService } { +-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; logService?: ILogService; proxyResolver?: IAgentHostProxyResolver; byokBridgeRegistry?: IByokLmBridgeRegistry }): { agent: CopilotAgent; instantiationService: IInstantiationService; configurationService: IAgentConfigurationService; fileService: FileService; stateManager: AgentHostStateManager } { - const services = new ServiceCollection(); -- const logService = new NullLogService(); +- const logService = options?.logService ?? new NullLogService(); - const fileService = options?.fileService ?? disposables.add(new FileService(logService)); - const stateManager = disposables.add(new AgentHostStateManager(logService)); - const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); - services.set(ILogService, logService); - services.set(IFileService, fileService); - services.set(IAgentConfigurationService, configService); +- services.set(IAgentHostStateManager, stateManager); +- services.set(IAgentHostGitHubEndpointService, options?.gitHubEndpointService ?? createTestGitHubEndpointService()); - services.set(ISessionDataService, options?.sessionDataService ?? createNullSessionDataService()); - services.set(IAgentPluginManager, options?.pluginManager ?? new TestAgentPluginManager()); - services.set(IAgentHostGitService, options?.gitService ?? new TestAgentHostGitService()); +- services.set(IAgentHostReviewService, NULL_REVIEW_SERVICE); - services.set(IAgentHostTerminalManager, new TestAgentHostTerminalManager()); - services.set(IAgentHostOTelService, { - _serviceBrand: undefined, - getSdkTelemetryConfig: async () => undefined, - getSpansDbPath: () => undefined, +- emitSessionTitleChanged: () => { }, - flush: async () => undefined, - }); - services.set(IAgentHostCompletions, disposables.add(new AgentHostCompletions(logService))); +- services.set(IAgentHostProxyResolver, options?.proxyResolver ?? new TestProxyResolver()); +- services.set(IByokLmBridgeRegistry, options?.byokBridgeRegistry ?? new ByokLmBridgeRegistry()); - const copilotApiService = options?.copilotApiService ?? new TestCopilotApiService(); - services.set(ICopilotApiService, copilotApiService); -- services.set(ICopilotBranchNameGenerator, new CopilotBranchNameGenerator(copilotApiService, logService)); -- services.set(ITelemetryService, NullTelemetryService); +- services.set(ITelemetryService, options?.telemetryService ?? NullTelemetryService); - if (options?.environmentServiceRegistration !== 'none') { - const environmentService = { - _serviceBrand: undefined, -- userHome: URI.file('/mock-home'), +- userHome: options?.userHome ?? URI.from({ scheme: Schemas.inMemory, path: '/mock-home' }), +- tmpDir: URI.from({ scheme: Schemas.inMemory, path: '/mock-tmp' }), - } as INativeEnvironmentService; - services.set(INativeEnvironmentService, environmentService); - } @@ -10541,41 +14898,44 @@ index bd411161..00000000 - const agent = options?.copilotClient - ? instantiationService.createInstance(options.useRealResumePath ? ResumePathCopilotAgent : TestableCopilotAgent, options.copilotClient) - : instantiationService.createInstance(CopilotAgent); -- return { agent, instantiationService, configurationService: configService, fileService }; +- return { agent, instantiationService, configurationService: configService, fileService, stateManager }; -} - --function createTestAgent(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; copilotApiService?: ICopilotApiService }): CopilotAgent { +-function createTestAgent(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; logService?: ILogService; byokBridgeRegistry?: IByokLmBridgeRegistry }): CopilotAgent { - return createTestAgentContext(disposables, options).agent; -} - -type CopilotCreateSessionOptions = Parameters[0]; - --function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationService: IInstantiationService): { readonly session: CopilotAgentSession; readonly createOptions: () => CopilotCreateSessionOptions | undefined } { +-function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationService: IInstantiationService, options?: { readonly mockSession?: MockCopilotSession; readonly activeClientToolSet?: ActiveClientToolSet; readonly snapshot?: IActiveClientSnapshot }): { readonly session: CopilotAgentSession; readonly createOptions: () => CopilotCreateSessionOptions | undefined } { - const sessionUri = AgentSession.uri('copilotcli', 'test-session-1'); - const shellManager = instantiationService.createInstance(ShellManager, sessionUri, undefined); - let createOptions: CopilotCreateSessionOptions | undefined; +- const mockSession = options?.mockSession ?? new MockCopilotSession(); - const launchPlan: CopilotSessionLaunchPlan = { - kind: 'create', - client: { - createSession: async options => { - createOptions = options; -- return new MockCopilotSession() as unknown as CopilotSession; +- return mockSession as unknown as CopilotSession; - }, -- resumeSession: async () => new MockCopilotSession() as unknown as CopilotSession, +- resumeSession: async () => mockSession as unknown as CopilotSession, - }, -- activeClientState: new ActiveClientState(), +- activeClientToolSet: options?.activeClientToolSet ?? new ActiveClientToolSet(), - sessionId: 'test-session-1', - workingDirectory: undefined, - resolvedAgentName: undefined, -- snapshot: { tools: [], plugins: [], mcpServers: {} }, +- snapshot: options?.snapshot ?? { tools: [], plugins: [], mcpServers: {} }, - shellManager, - githubToken: 'token', - model: undefined, - }; -- const session = (agent as unknown as { -- _createAgentSession: (launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined) => CopilotAgentSession; -- })._createAgentSession(launchPlan, undefined); -- return { session, createOptions: () => createOptions }; +- const agentInternals = (agent as unknown as { +- _getOrCreateActiveClient: (session: URI, directory: URI | undefined) => unknown; +- _createAgentSession: (launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: unknown) => CopilotAgentSession; +- }); +- const activeClient = agentInternals._getOrCreateActiveClient(sessionUri, undefined); +- return { session: agentInternals._createAgentSession(launchPlan, undefined, activeClient), createOptions: () => createOptions }; -} - -function withoutUndefinedProperties(metadata: IAgentSessionMetadata): Record { @@ -10611,30 +14971,253 @@ index bd411161..00000000 -suite('CopilotAgent', () => { - const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - +- test('installs the GitHub telemetry callback in CopilotClientOptions', async () => { +- const client = new TestCopilotClient([]); +- const agent = createTestAgent(disposables, { copilotClient: client }) as TestableCopilotAgent; +- try { +- await agent.listSessions(); +- assert.strictEqual(typeof getCreatedClientOptions(agent).at(-1)?.onGitHubTelemetry, 'function'); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('routes exact legacy targets exclusively and falls back to generic forwarding', async () => { +- const client = new TestCopilotClient([]); +- const copilotApiService = new TestCopilotApiService(); +- copilotApiService.restrictedTelemetryContexts.set('restricted-token', { +- restrictedTelemetryEnabled: true, +- trackingId: 'restricted-tid', +- telemetryEndpoint: 'https://telemetry.example', +- isInternal: true, +- userName: 'octocat', +- isVscodeTeamMember: true, +- }); +- const telemetryService = disposables.add(new class extends AgentHostTelemetryService { +- readonly genericEvents: string[] = []; +- readonly enhancedEvents: string[] = []; +- readonly internalEvents: string[] = []; +- restrictedEnabled = false; +- +- override publicLog(eventName: string): void { +- this.genericEvents.push(eventName); +- } +- override sendEnhancedGHTelemetryEvent(eventName: string): void { +- this.enhancedEvents.push(eventName); +- } +- override sendEnhancedGHTelemetryEventForContext(_context: IAgentHostRestrictedTelemetryContext, eventName: string): void { +- this.enhancedEvents.push(eventName); +- } +- override sendInternalMSFTTelemetryEvent(eventName: string): void { +- this.internalEvents.push(eventName); +- } +- override sendInternalMSFTTelemetryEventForContext(_context: IAgentHostInternalTelemetryContext, eventName: string): void { +- this.internalEvents.push(eventName); +- } +- override setRestrictedTelemetryEnabled(enabled: boolean): void { +- this.restrictedEnabled = enabled; +- super.setRestrictedTelemetryEnabled(enabled); +- } +- }(NullTelemetryService)); +- const agent = createTestAgent(disposables, { copilotClient: client, copilotApiService, telemetryService }) as TestableCopilotAgent; +- try { +- await agent.authenticate('https://api.github.com', 'restricted-token'); +- for (let i = 0; i < 100 && !telemetryService.restrictedEnabled; i++) { +- await Promise.resolve(); +- } +- await agent.listSessions(); +- 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: ['copilotCli/unknown_restricted', 'copilotCli/tool_call_executed'], +- enhanced: ['engine.messages.length'], +- internal: ['engine.messages.length'], +- }); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('routes exact targets using the current auth token and reflects token changes', async () => { +- const client = new TestCopilotClient([]); +- const copilotApiService = new TestCopilotApiService(); +- copilotApiService.restrictedTelemetryContexts.set('token-a', { +- restrictedTelemetryEnabled: true, +- trackingId: 'token-a-tid', +- telemetryEndpoint: 'https://token-a.telemetry.example/', +- isInternal: true, +- userName: 'token-a-user', +- isVscodeTeamMember: true, +- }); +- copilotApiService.restrictedTelemetryContexts.set('token-b', { +- restrictedTelemetryEnabled: false, +- trackingId: 'token-b-tid', +- telemetryEndpoint: undefined, +- isInternal: false, +- userName: 'token-b-user', +- isVscodeTeamMember: false, +- }); +- const telemetryService = disposables.add(new class extends AgentHostTelemetryService { +- readonly enhancedContexts: IAgentHostRestrictedTelemetryContext[] = []; +- readonly internalContexts: IAgentHostInternalTelemetryContext[] = []; +- override sendEnhancedGHTelemetryEventForContext(context: IAgentHostRestrictedTelemetryContext): void { +- this.enhancedContexts.push(context); +- } +- override sendInternalMSFTTelemetryEventForContext(context: IAgentHostInternalTelemetryContext): void { +- this.internalContexts.push(context); +- } +- }(NullTelemetryService)); +- const agent = createTestAgent(disposables, { copilotClient: client, copilotApiService, telemetryService }) as TestableCopilotAgent; +- try { +- await agent.authenticate('https://api.github.com', 'token-a'); +- await agent.listSessions(); +- 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); +- +- 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); +- } +- }); +- - test('advertises Copilot as its display name', async () => { - const agent = createTestAgent(disposables); - try { - assert.deepStrictEqual(agent.getDescriptor(), { - provider: 'copilotcli', - displayName: 'Copilot', -- description: 'Copilot SDK agent running in a dedicated process', +- description: 'Copilot SDK agent running in the local agent host process', +- capabilities: { multipleChats: { fork: true, sideChat: true } }, - }); - } finally { - await disposeAgent(agent); - } - }); - -- test('uses the Copilot CLI sibling worktrees root convention', () => { -- assert.strictEqual( -- getCopilotWorktreesRoot(URI.file('/Users/me/src/vscode')).fsPath, -- URI.file('/Users/me/src/vscode.worktrees').fsPath, -- ); +- test('advertises multipleWorkingDirectories only when the hidden setting is enabled', async () => { +- const { agent, stateManager } = createTestAgentContext(disposables); +- try { +- const setMultiRoot = (enabled: boolean) => stateManager.dispatchServerAction(ROOT_STATE_URI, { +- type: ActionType.RootConfigChanged, +- config: { [AgentHostCopilotMultiRootEnabledConfigKey]: enabled }, +- }); +- const disabledByDefault = agent.getDescriptor().capabilities?.multipleWorkingDirectories; +- setMultiRoot(true); +- const whenEnabled = agent.getDescriptor().capabilities?.multipleWorkingDirectories; +- setMultiRoot(false); +- const afterDisabling = agent.getDescriptor().capabilities?.multipleWorkingDirectories; +- assert.deepStrictEqual({ disabledByDefault, whenEnabled, afterDisabling }, { +- disabledByDefault: undefined, +- whenEnabled: { immutablePrimary: true }, +- afterDisabling: undefined, +- }); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- suite('spawned chat channel', () => { +- function fireSignal(agent: CopilotAgent, signal: AgentSignal): void { +- (agent as unknown as { _onDidSessionProgress: { fire(s: AgentSignal): void } })._onDidSessionProgress.fire(signal); +- } +- +- 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(parseRequiredSessionUriFromChatUri(parentChat), toolCallId); +- +- 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 }); +- +- 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); +- } +- }); - }); - - test('uses generated Agents-window Copilot CLI branch names', async () => { - const copilotApiService = new TestCopilotApiService(); - copilotApiService.response = 'add-agent-host-config'; -- const generator = new CopilotBranchNameGenerator(copilotApiService, new NullLogService()); +- const generator = new AgentBranchNameGenerator(copilotApiService, new NullLogService()); - - assert.deepStrictEqual({ - generated: await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', message: 'Add agent host config', githubToken: 'token' }), @@ -10642,35 +15225,95 @@ index bd411161..00000000 - token: copilotApiService.utilityCalls[0]?.token, - promptIncludesUserText: copilotApiService.utilityCalls[0]?.request.messages.some(message => message.content.includes('Add agent host config')), - }, { -- generated: 'agents/add-agent-host-config-12345678', -- fallback: 'agents/add-agent-host-config-12345678', +- generated: 'agents/add-agent-host-config', +- fallback: 'agents/add-agent-host-config', - token: 'token', - promptIncludesUserText: true, - }); - }); - -- test('uses Git extension branch-derived worktree folder names', () => { -- assert.strictEqual(getCopilotWorktreeName('agents/add-agent-host-config-12345678'), 'agents-add-agent-host-config-12345678'); -- }); -- -- test('keeps generated branch names short', async () => { +- test('finds an available branch name when candidates collide', async () => { - const copilotApiService = new TestCopilotApiService(); -- copilotApiService.response = 'a'.repeat(100); -- const generator = new CopilotBranchNameGenerator(copilotApiService, new NullLogService()); -- -- assert.strictEqual( -- (await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', message: 'Add agent host config', githubToken: 'token' })).length, -- 'agents/'.length + 48 + '-12345678'.length, -- ); -- }); -- -- test('normalizes generated branch names', () => { -- assert.deepStrictEqual({ -- simple: normalizeCopilotBranchName('feature-branch'), -- uppercase: normalizeCopilotBranchName('Feature-Branch'), -- special: normalizeCopilotBranchName('Fix: Add new feature! (#42)'), -- unicode: normalizeCopilotBranchName('café-feature'), -- empty: normalizeCopilotBranchName('🚀🎉'), +- copilotApiService.response = 'add-agent-host-config'; +- const generator = new AgentBranchNameGenerator(copilotApiService, new NullLogService()); +- const collisions = new Set([ +- 'agents/add-agent-host-config', +- 'agents/add-agent-host-config-12345678', +- 'agents/12345678-aaaa-bbbb-cccc-123456789abc', +- ]); +- const exhaustedCandidates: string[] = []; +- let exhaustionError: string | undefined; +- try { +- await generator.generateBranchName({ +- sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', +- branchNameCollides: async name => { +- exhaustedCandidates.push(name); +- return true; +- }, +- }); +- } catch (error) { +- exhaustionError = error instanceof Error ? error.message : String(error); +- } +- +- assert.deepStrictEqual({ +- unique: await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', message: 'Add agent host config', githubToken: 'token', branchNameCollides: async () => false }), +- collision: await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', message: 'Add agent host config', githubToken: 'token', branchNameCollides: async name => name === 'agents/add-agent-host-config' }), +- repeatedCollision: await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', message: 'Add agent host config', githubToken: 'token', branchNameCollides: async name => collisions.has(name) }), +- fallbackCollision: await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', branchNameCollides: async name => collisions.has(name) }), +- exhaustion: { +- error: exhaustionError, +- candidateCount: exhaustedCandidates.length, +- firstCandidate: exhaustedCandidates[0], +- lastCandidate: exhaustedCandidates.at(-1), +- }, +- }, { +- unique: 'agents/add-agent-host-config', +- collision: 'agents/add-agent-host-config-12345678', +- repeatedCollision: 'agents/add-agent-host-config-12345678-2', +- fallbackCollision: 'agents/12345678-aaaa-bbbb-cccc-123456789abc-2', +- exhaustion: { +- error: 'Unable to find an available branch name after checking 100 candidates', +- candidateCount: 100, +- firstCandidate: 'agents/12345678-aaaa-bbbb-cccc-123456789abc', +- lastCandidate: 'agents/12345678-aaaa-bbbb-cccc-123456789abc-100', +- }, +- }); +- }); +- +- test('prepends the branch prefix ahead of the built-in agents/ prefix', async () => { +- const copilotApiService = new TestCopilotApiService(); +- copilotApiService.response = 'add-agent-host-config'; +- const generator = new AgentBranchNameGenerator(copilotApiService, new NullLogService()); +- +- assert.deepStrictEqual({ +- withPrefix: await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', message: 'Add agent host config', githubToken: 'token', branchPrefix: 'users/alice/' }), +- emptyPrefix: await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', message: 'Add agent host config', githubToken: 'token', branchPrefix: '' }), +- fallbackWithPrefix: await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', branchPrefix: 'users/alice/' }), +- }, { +- withPrefix: 'users/alice/agents/add-agent-host-config', +- emptyPrefix: 'agents/add-agent-host-config', +- fallbackWithPrefix: 'users/alice/agents/12345678-aaaa-bbbb-cccc-123456789abc', +- }); +- }); +- +- test('keeps generated branch names short', async () => { +- const copilotApiService = new TestCopilotApiService(); +- copilotApiService.response = 'a'.repeat(100); +- const generator = new AgentBranchNameGenerator(copilotApiService, new NullLogService()); +- +- assert.strictEqual( +- (await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', message: 'Add agent host config', githubToken: 'token' })).length, +- 'agents/'.length + 48, +- ); +- }); +- +- test('normalizes generated branch names', () => { +- assert.deepStrictEqual({ +- simple: normalizeAgentBranchName('feature-branch'), +- uppercase: normalizeAgentBranchName('Feature-Branch'), +- special: normalizeAgentBranchName('Fix: Add new feature! (#42)'), +- unicode: normalizeAgentBranchName('café-feature'), +- empty: normalizeAgentBranchName('🚀🎉'), - }, { - simple: 'feature-branch', - uppercase: 'feature-branch', @@ -10682,12 +15325,12 @@ index bd411161..00000000 - - test('derives slug branch hint from first message for fallback', () => { - assert.deepStrictEqual({ -- simple: getCopilotBranchNameHintFromMessage('Add agent host config'), -- punctuation: getCopilotBranchNameHintFromMessage(' Fix: the bug!! '), -- unicode: getCopilotBranchNameHintFromMessage('Refactor café ☕ rendering'), -- words: getCopilotBranchNameHintFromMessage('one two three four five six seven eight nine ten'), -- long: getCopilotBranchNameHintFromMessage('a'.repeat(100))?.length, -- empty: getCopilotBranchNameHintFromMessage('!!! ??? ...'), +- simple: getAgentBranchNameHintFromMessage('Add agent host config'), +- punctuation: getAgentBranchNameHintFromMessage(' Fix: the bug!! '), +- unicode: getAgentBranchNameHintFromMessage('Refactor café ☕ rendering'), +- words: getAgentBranchNameHintFromMessage('one two three four five six seven eight nine ten'), +- long: getAgentBranchNameHintFromMessage('a'.repeat(100))?.length, +- empty: getAgentBranchNameHintFromMessage('!!! ??? ...'), - }, { - simple: 'add-agent-host-config', - punctuation: 'fix-the-bug', @@ -10701,29 +15344,29 @@ index bd411161..00000000 - test('falls back to first-message slug when generated branch name cannot be used', async () => { - const copilotApiService = new TestCopilotApiService(); - copilotApiService.response = '!!! ??? ...'; -- const generator = new CopilotBranchNameGenerator(copilotApiService, new NullLogService()); +- const generator = new AgentBranchNameGenerator(copilotApiService, new NullLogService()); - - assert.strictEqual( - await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', message: 'Add agent host config', githubToken: 'token' }), -- 'agents/add-agent-host-config-12345678', +- 'agents/add-agent-host-config', - ); - }); - - test('falls back to first-message slug when branch name generation fails', async () => { - const copilotApiService = new TestCopilotApiService(); - copilotApiService.error = new Error('failed'); -- const generator = new CopilotBranchNameGenerator(copilotApiService, new NullLogService()); +- const generator = new AgentBranchNameGenerator(copilotApiService, new NullLogService()); - - assert.strictEqual( - await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', message: 'Add agent host config', githubToken: 'token' }), -- 'agents/add-agent-host-config-12345678', +- 'agents/add-agent-host-config', - ); - }); - - test('falls back to session id when no branch name can be derived', async () => { - const copilotApiService = new TestCopilotApiService(); - copilotApiService.response = '!!! ??? ...'; -- const generator = new CopilotBranchNameGenerator(copilotApiService, new NullLogService()); +- const generator = new AgentBranchNameGenerator(copilotApiService, new NullLogService()); - - assert.strictEqual( - await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', message: '!!! ??? ...', githubToken: 'token' }), @@ -10731,6 +15374,30 @@ index bd411161..00000000 - ); - }); - +- test('contributes GHE-aware GitHub and discovered CAPI diagnostics endpoints', async () => { +- const endpointService = createTestGitHubEndpointService('https://github.example.com'); +- const copilotApiService = new TestCopilotApiService(); +- copilotApiService.apiEndpoint = 'https://copilot.example.com'; +- copilotApiService.userLogin = 'octocat'; +- const agent = createTestAgent(disposables, { copilotApiService, gitHubEndpointService: endpointService }); +- try { +- 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); +- } +- }); +- - test('returns empty models and lists sessions before authentication', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const ownedSession = AgentSession.uri('copilotcli', 'owned-before-auth'); @@ -10762,11 +15429,11 @@ index bd411161..00000000 - const session = AgentSession.uri('copilotcli', 'unauth-create'); - const workingDirectory = URI.file('/workspace'); - try { -- const result = await agent.createSession({ session, workingDirectory }); -- assert.ok(result.workingDirectory); +- const result = await agent.createSession({ session, workingDirectories: workingDirectory ? [workingDirectory] : undefined }); +- assert.ok(result.resolvedWorkingDirectory); - assert.deepStrictEqual({ - session: result.session.toString(), -- workingDirectory: result.workingDirectory.toString(), +- workingDirectory: result.resolvedWorkingDirectory.toString(), - provisional: result.provisional, - starts: client.startCallCount, - stops: client.stopCallCount, @@ -10829,9 +15496,125 @@ index bd411161..00000000 - } - }); - -- test('createSession falls back to an empty temp directory when workingDirectory is omitted', async () => { -- const agent = createTestAgent(disposables); -- let createdWorkingDirectory: URI | undefined; +- test('retries refreshing models after a transient failure', async () => { +- const client = new TestCopilotClient([], [{ +- id: 'gpt-4o', +- name: 'GPT-4o', +- }]); +- 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); +- +- assert.deepStrictEqual({ +- modelNames: models.map(m => m.name), +- requestCount: client.modelListRequests.length, +- }, { +- modelNames: ['GPT-4o'], +- requestCount: 2, +- }); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('stops refreshing models after the maximum number of attempts', async () => { +- const client = new TestCopilotClient([], [{ +- id: 'gpt-4o', +- name: 'GPT-4o', +- }]); +- for (let i = 0; i < 10; i++) { +- client.modelListErrors.push(new Error('429 "too many requests"')); +- } +- const agent = createTestAgent(disposables, { copilotClient: client }); +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- for (let i = 0; i < 500 && client.modelListRequests.length < 5; i++) { +- await new Promise(resolve => setTimeout(resolve, 1)); +- } +- // Give any erroneous extra retry a chance to fire before asserting. +- await new Promise(resolve => setTimeout(resolve, 10)); +- +- assert.deepStrictEqual({ +- requestCount: client.modelListRequests.length, +- models: agent.models.get(), +- }, { +- requestCount: 5, +- models: [], +- }); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('keeps the previously loaded models when a later refresh fails', async () => { +- const client = new TestCopilotClient([], [{ +- id: 'gpt-4o', +- name: 'GPT-4o', +- }]); +- const agent = createTestAgent(disposables, { copilotClient: client }); +- try { +- await agent.authenticate('https://api.github.com', 'token-a'); +- await waitForState(agent.models, m => m.length > 0); +- +- // A refresh triggered by the next token change fails on every attempt. +- for (let i = 0; i < 10; i++) { +- client.modelListErrors.push(new Error('429 "too many requests"')); +- } +- const requestsBefore = client.modelListRequests.length; +- await agent.authenticate('https://api.github.com', 'token-b'); +- for (let i = 0; i < 500 && client.modelListRequests.length < requestsBefore + 5; i++) { +- await new Promise(resolve => setTimeout(resolve, 1)); +- } +- await new Promise(resolve => setTimeout(resolve, 10)); +- +- assert.deepStrictEqual({ +- modelNames: agent.models.get().map(m => m.name), +- retriedRequests: client.modelListRequests.length - requestsBefore, +- }, { +- modelNames: ['GPT-4o'], +- retriedRequests: 5, +- }); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('does not refresh models or restart the client after shutdown', async () => { +- const client = new TestCopilotClient([], [{ +- id: 'gpt-4o', +- 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(); +- +- 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); +- +- assert.deepStrictEqual({ +- starts: client.startCallCount, +- requests: client.modelListRequests.length, +- }, { +- starts: startsAfterShutdown, +- requests: requestsAfterShutdown, +- }); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('createSession 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'); - @@ -10840,19 +15623,128 @@ index bd411161..00000000 - }); - - assert.strictEqual(result.provisional, true); -- assert.ok(result.workingDirectory); -- createdWorkingDirectory = result.workingDirectory; -- assert.strictEqual(createdWorkingDirectory.scheme, Schemas.file); -- assert.strictEqual(createdWorkingDirectory.fsPath.toLowerCase().startsWith(os.tmpdir().toLowerCase()), true); -- assert.deepStrictEqual(await fs.readdir(createdWorkingDirectory.fsPath), []); +- 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 { -- if (createdWorkingDirectory) { -- await fs.rm(createdWorkingDirectory.fsPath, { recursive: true, force: true }); -- } +- await fs.rm(userHome.fsPath, { recursive: true, force: true }); - await disposeAgent(agent); - } - }).timeout(30_000); - +- suite('quick chat scratch directory', () => { +- test('resume recreates a reaped quick chat scratch dir (ensure-exists on restore)', async () => { +- const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/qc-home-`)); +- const sessionId = 'qc-resume'; +- const session = AgentSession.uri('copilotcli', sessionId); +- const scratchDir = URI.joinPath(userHome, '.copilot', 'chats', sessionId); +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const db = sessionDataService.openDatabase(session); +- await db.object.setMetadata('copilot.workingDirectory', scratchDir.toString()); +- await db.object.setMetadata('agentHost.workspaceless', 'true'); +- db.dispose(); +- 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) => 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).catch(() => undefined); +- await fs.access(scratchDir.fsPath); +- } finally { +- await fs.rm(userHome.fsPath, { recursive: true, force: true }); +- await disposeAgent(agent); +- } +- }).timeout(30_000); +- +- test('disposeSession 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 agent.createSession({ session }); +- const scratchDir = URI.joinPath(userHome, '.copilot', 'chats', 'qc-dispose'); +- await fs.access(scratchDir.fsPath); +- await agent.disposeSession(result.session); +- await assert.rejects(() => fs.access(scratchDir.fsPath)); +- } finally { +- await fs.rm(userHome.fsPath, { recursive: true, force: true }); +- await disposeAgent(agent); +- } +- }).timeout(30_000); +- }); +- +- suite('working-directory persistence', () => { +- const repoA = URI.file('/repoA'); +- const repoB = URI.file('/repoB'); +- const repoC = URI.file('/repoC'); +- +- async function restore(seed: (db: ReturnType) => Promise, cwd?: string): Promise<{ list: string[] | undefined; meta: string[] | undefined }> { +- const sessionId = 'wd-persist'; +- const session = AgentSession.uri('copilotcli', sessionId); +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const db = sessionDataService.openDatabase(session); +- // Mark the project resolved so the restore path does not probe git. +- await db.object.setMetadata('copilot.project.resolved', 'true'); +- await seed(db); +- db.dispose(); +- const client = new TestCopilotClient([sdkSession(sessionId, cwd)]); +- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); +- try { +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); +- const listed = (await agent.listSessions()).find(s => AgentSession.id(s.session) === sessionId); +- const meta = await agent.getSessionMetadata(session); +- return { +- list: listed?.workingDirectories?.map(d => d.toString()), +- meta: meta?.workingDirectories?.map(d => d.toString()), +- }; +- } finally { +- await disposeAgent(agent); +- } +- } +- +- test('restores the persisted ordered set from copilot.workingDirectories', async () => { +- const result = await restore(async db => { +- await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([repoA, repoB, repoC].map(d => d.toString()))); +- await db.object.setMetadata('copilot.workingDirectory', repoA.toString()); +- }); +- assert.deepStrictEqual(result, { +- list: [repoA.toString(), repoB.toString(), repoC.toString()], +- meta: [repoA.toString(), repoB.toString(), repoC.toString()], +- }); +- }); +- +- test('falls back to the legacy single working directory when the set is absent', async () => { +- const result = await restore(async db => { +- await db.object.setMetadata('copilot.workingDirectory', repoA.toString()); +- }); +- assert.deepStrictEqual(result, { +- list: [repoA.toString()], +- meta: [repoA.toString()], +- }); +- }); +- +- test('falls back to the legacy single working directory when the set is malformed', async () => { +- const result = await restore(async db => { +- await db.object.setMetadata('copilot.workingDirectories', 'not-json'); +- await db.object.setMetadata('copilot.workingDirectory', repoA.toString()); +- }); +- assert.deepStrictEqual(result, { +- list: [repoA.toString()], +- meta: [repoA.toString()], +- }); +- }); +- }); +- - suite('restart on startup config change', () => { - - class StopCountingClient extends TestCopilotClient { @@ -10863,6 +15755,106 @@ index bd411161..00000000 - } - } - +- class MutableLogService extends NullLogService { +- private _level = LogLevel.Info; +- +- override setLevel(level: LogLevel): void { +- this._level = level; +- } +- +- override getLevel(): LogLevel { +- return this._level; +- } +- } +- +- test('resolves the system proxy by default and bypasses it when disabled', async () => { +- const proxyResolver = new TestProxyResolver(); +- proxyResolver.resolvedProxy = 'http://system-proxy.example:8080'; +- const { agent, configurationService } = createTestAgentContext(disposables, { proxyResolver }); +- const resolveProxyForSdk = (env: Record) => (agent as unknown as { +- _resolveProxyForSdk(env: Record): Promise; +- })._resolveProxyForSdk(env); +- try { +- 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); +- } +- }); +- +- 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.listSessions(); +- +- assert.deepStrictEqual(getCreatedClientOptions(agent).map(options => options.logLevel), ['all']); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- 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.listSessions(); +- +- assert.deepStrictEqual(getCreatedClientOptions(agent).map(options => options.logLevel), ['info']); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('uses trace when the agent host log level is trace', async () => { +- const client = new TestCopilotClient([]); +- 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.listSessions(); +- +- assert.deepStrictEqual(getCreatedClientOptions(agent).map(options => options.logLevel), ['all']); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- 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.listSessions(); +- +- configurationService.updateRootConfig({ [CopilotCliConfigKey.CopilotSdkLogLevel]: 'trace' }); +- await Promise.resolve(); +- await agent.listSessions(); +- +- assert.deepStrictEqual({ +- stopCount: client.stopCount, +- logLevel: getCreatedClientOptions(agent).at(-1)?.logLevel, +- }, { +- stopCount: 1, +- logLevel: 'all', +- }); +- } finally { +- await disposeAgent(agent); +- } +- }); +- - test('restarts the idle client when the rubber duck config changes', async () => { - const client = new StopCountingClient([]); - const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client }); @@ -10871,7 +15863,7 @@ index bd411161..00000000 - // Force the client to start so a subsequent config change has something to restart. - await agent.listSessions(); - -- configurationService.updateRootConfig({ [AgentHostConfigKey.RubberDuck]: true }); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: true }); - await Promise.resolve(); - - assert.strictEqual(client.stopCount, 1); @@ -10888,14 +15880,18 @@ index bd411161..00000000 - await agent.listSessions(); - - let disposed = false; -- const sessions = (agent as unknown as { _sessions: { set(k: string, v: { dispose(): void }): void } })._sessions; -- sessions.set('active', { dispose() { disposed = true; } }); +- setDefaultSessionStub(agent, 'active', { dispose() { disposed = true; } }); - -- configurationService.updateRootConfig({ [AgentHostConfigKey.RubberDuck]: true }); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: true }); - await Promise.resolve(); - -- assert.strictEqual(client.stopCount, 1); -- assert.strictEqual(disposed, true); +- assert.deepStrictEqual({ +- stopCount: client.stopCount, +- disposed, +- }, { +- stopCount: 1, +- disposed: true, +- }); - } finally { - await disposeAgent(agent); - } @@ -10903,12 +15899,14 @@ index bd411161..00000000 - - test('does not restart when an unrelated config key changes', async () => { - const client = new StopCountingClient([]); -- const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client }); +- const logService = new MutableLogService(); +- const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client, logService }); - try { - await agent.authenticate('https://api.github.com', 'token'); - await agent.listSessions(); - -- configurationService.updateRootConfig({ [AgentHostConfigKey.EnableCustomTerminalTool]: true }); +- logService.setLevel(LogLevel.Trace); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.EnableCustomTerminalTool]: true }); - await Promise.resolve(); - - assert.strictEqual(client.stopCount, 0); @@ -10916,26 +15914,156 @@ index bd411161..00000000 - await disposeAgent(agent); - } - }); -- }); - -- test('models include billing multiplier metadata when SDK provides it', async () => { -- const agent = createTestAgent(disposables, { -- copilotClient: new TestCopilotClient([], [{ -- id: 'gpt-4o', -- name: 'GPT-4o', -- billing: { multiplier: 1.5 }, -- capabilities: { limits: { max_context_window_tokens: 128000 }, supports: { vision: true } }, -- }]), -- }); -- try { -- await agent.authenticate('https://api.github.com', 'token'); -- const models = await waitForState(agent.models, models => models.length > 0); +- /** +- * Signals the turn-ended hook the agent wires into every chat it creates +- * (`onTurnEnded`), which stub-injected chats bypass. +- */ +- function reportChatTurnEnded(agent: CopilotAgent): void { +- (agent as unknown as { _onChatTurnEnded(): void })._onChatTurnEnded(); +- } - -- assert.deepStrictEqual(models, [{ -- provider: 'copilotcli', +- /** A stub chat whose in-flight turn can be ended by the test. */ +- function busyChatStub(): { hasActiveTurn: boolean; disposed: boolean; dispose(): void; destroySession(): Promise } { +- return { +- hasActiveTurn: true, +- disposed: false, +- dispose() { this.disposed = true; }, +- destroySession: async () => { }, +- }; +- } +- +- 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.listSessions(); +- +- const chat = busyChatStub(); +- setDefaultSessionStub(agent, 'busy', chat); +- +- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: true }); +- await timeout(0); +- const duringTurn = { stopCount: client.stopCount, disposed: chat.disposed }; +- +- 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); +- } +- }); +- +- 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.listSessions(); +- +- const first = busyChatStub(); +- const second = busyChatStub(); +- setDefaultSessionStub(agent, 'busy-1', first); +- setDefaultSessionStub(agent, 'busy-2', second); +- +- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: true }); +- await timeout(0); +- +- first.hasActiveTurn = false; +- reportChatTurnEnded(agent); +- await timeout(0); +- const afterFirst = client.stopCount; +- +- second.hasActiveTurn = false; +- reportChatTurnEnded(agent); +- await timeout(0); +- +- assert.deepStrictEqual({ afterFirst, afterSecond: client.stopCount }, { afterFirst: 0, afterSecond: 1 }); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- 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.listSessions(); +- +- setDefaultSessionStub(agent, 'busy', busyChatStub()); +- +- configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: true }); +- await timeout(0); +- const duringTurn = client.stopCount; +- +- // A disposed session never reports its turn ending, so disposal +- // must drain the parked restart itself. +- await agent.disposeSession(AgentSession.uri('copilotcli', 'busy')); +- +- assert.deepStrictEqual({ duringTurn, afterDispose: client.stopCount }, { duringTurn: 0, afterDispose: 1 }); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- 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.listSessions(); +- +- 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]: true }); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.CopilotSdkLogLevel]: 'trace' }); +- await timeout(0); +- +- chat.hasActiveTurn = false; +- reportChatTurnEnded(agent); +- reportChatTurnEnded(agent); +- await timeout(0); +- +- assert.strictEqual(client.stopCount, 1); +- } finally { +- await disposeAgent(agent); +- } +- }); +- }); +- +- test('models include billing multiplier metadata when SDK provides it', async () => { +- const agent = createTestAgent(disposables, { +- copilotClient: new TestCopilotClient([], [{ +- id: 'gpt-4o', +- name: 'GPT-4o', +- billing: { multiplier: 1.5 }, +- 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); +- +- assert.deepStrictEqual(models, [{ +- provider: 'copilotcli', - id: 'gpt-4o', - name: 'GPT-4o', - maxContextWindow: 128000, +- maxOutputTokens: 16000, +- maxPromptTokens: 112000, - supportsVision: true, - configSchema: undefined, - policyState: undefined, @@ -10946,7 +16074,7 @@ index bd411161..00000000 - } - }); - -- test('models include token-price and price-category metadata when billing provides it', async () => { +- test('models include picker and promo metadata when the SDK provides it', async () => { - const agent = createTestAgent(disposables, { - copilotClient: new TestCopilotClient([], [{ - id: 'claude-sonnet', @@ -10954,14 +16082,22 @@ index bd411161..00000000 - capabilities: { limits: { max_context_window_tokens: 200_000 } }, - billing: { - multiplier: 1, +- promo: { +- id: 'summer-sale', +- discountPercent: 25, +- endsAt: '2026-08-01T00:00:00Z', +- message: 'Save on Claude Sonnet', +- }, - tokenPrices: { -- contextMax: 200_000, -- inputPrice: 3, -- cachePrice: 1, -- outputPrice: 15, -- longContext: { contextMax: 1_000_000, inputPrice: 6, cachePrice: 1, outputPrice: 22.5 }, +- batchSize: 100_000, +- maxPromptTokens: 200_000, +- inputPrice: 0.3, +- cacheReadPrice: 0.1, +- outputPrice: 1.5, +- longContext: { maxPromptTokens: 1_000_000, inputPrice: 0.6, cacheReadPrice: 0.1, outputPrice: 2.25 }, - }, - }, +- modelPickerCategory: 'powerful', - modelPickerPriceCategory: 'medium', - }]), - }); @@ -10973,11 +16109,18 @@ index bd411161..00000000 - multiplierNumeric: 1, - inputCost: 3, - cacheCost: 1, -- cacheWriteCost: 1, - outputCost: 15, - longContextInputCost: 6, +- longContextCacheCost: 1, - longContextOutputCost: 22.5, - priceCategory: 'medium', +- category: 'powerful', +- promo: { +- id: 'summer-sale', +- discountPercent: 25, +- endsAt: '2026-08-01T00:00:00Z', +- message: 'Save on Claude Sonnet', +- }, - }); - } finally { - await disposeAgent(agent); @@ -11001,13 +16144,104 @@ index bd411161..00000000 - 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.contextTier, undefined); +- assert.strictEqual(schema?.properties.contextSize, undefined); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('configSchema falls back to a default thinkingLevel when the model advertises no default', async () => { +- const agent = createTestAgent(disposables, { +- copilotClient: new TestCopilotClient([], [{ +- id: 'gpt-5.6-terra', +- name: 'GPT-5.6 Terra', +- capabilities: { limits: { max_context_window_tokens: 128000 } }, +- supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], +- }, { +- id: 'claude-opus-5', +- name: 'Claude Opus 5', +- capabilities: { limits: { max_context_window_tokens: 200000 } }, +- supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], +- }, { +- id: 'no-preferred', +- name: 'No Preferred', +- capabilities: { limits: { max_context_window_tokens: 128000 } }, +- supportedReasoningEfforts: ['minimal', 'xhigh'], +- }, { +- id: 'unsupported-only', +- name: 'Unsupported Only', +- capabilities: { limits: { max_context_window_tokens: 128000 } }, +- supportedReasoningEfforts: ['minimal', 'none'], +- }]), +- }); +- try { +- 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'], 'high'], +- ['no-preferred', ['xhigh'], 'xhigh'], +- ['unsupported-only', undefined, undefined], +- ]); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('BYOK model configSchema exposes only Copilot-supported reasoning efforts', async () => { +- const byokBridgeRegistry = new ByokLmBridgeRegistry(); +- const agent = createTestAgent(disposables, { byokBridgeRegistry }); +- const modelSnapshots = disposables.add(new Emitter()); +- const connection: IByokLmBridgeConnection = { +- chat: async () => ({ output: [] }), +- onDidChangeModels: modelSnapshots.event, +- }; +- 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: 'unsupported-only', +- name: 'Unsupported Only', +- supportedReasoningEfforts: ['minimal'], +- defaultReasoningEffort: 'minimal', +- }, +- ]); +- const models = await waitForState(agent.models, models => models.length === 3); +- +- 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: ['low', 'high'], default: 'low' } }, +- { id: 'acme/valid-default', thinkingLevel: { enum: ['low', 'medium', 'high'], default: 'medium' } }, +- { id: 'acme/unsupported-only', thinkingLevel: undefined }, +- ]); - } finally { - await disposeAgent(agent); - } - }); - -- test('configSchema emits a contextTier property when long_context tier exceeds default', async () => { +- test('configSchema emits a numeric contextSize property when long_context tier exceeds default', async () => { - const agent = createTestAgent(disposables, { - copilotClient: new TestCopilotClient([], [{ - id: 'claude-sonnet', @@ -11016,8 +16250,8 @@ index bd411161..00000000 - billing: { - multiplier: 1, - tokenPrices: { -- contextMax: 200_000, -- longContext: { contextMax: 1_000_000, inputPrice: 2 }, +- maxPromptTokens: 200_000, +- longContext: { maxPromptTokens: 1_000_000, inputPrice: 2 }, - }, - }, - }]), @@ -11026,16 +16260,17 @@ index bd411161..00000000 - await agent.authenticate('https://api.github.com', 'token'); - const models = await waitForState(agent.models, models => models.length > 0); - -- const contextTier = models[0].configSchema?.properties.contextTier; -- assert.deepStrictEqual(contextTier?.enum, ['default', 'long_context']); -- assert.strictEqual(contextTier?.default, 'default'); -- assert.deepStrictEqual(contextTier?.enumLabels, ['200K', '1M']); +- 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); - } - }); - -- test('configSchema omits contextTier when long_context tier is missing or not larger', async () => { +- test('configSchema omits contextSize when long_context tier is missing or not larger', async () => { - const agent = createTestAgent(disposables, { - copilotClient: new TestCopilotClient([], [ - { @@ -11064,14 +16299,178 @@ index bd411161..00000000 - } - }); - +- test('configSchema shows both context options by default when long_context tier has no surcharge', async () => { +- const agent = createTestAgent(disposables, { +- copilotClient: new TestCopilotClient([], [{ +- id: 'free-long-context', +- name: 'Free Long Context', +- capabilities: { limits: { max_context_window_tokens: 200_000 } }, +- billing: { +- multiplier: 1, +- tokenPrices: { +- contextMax: 200_000, +- longContext: { contextMax: 1_000_000 }, +- }, +- }, +- }]), +- }); +- try { +- 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); +- } +- }); +- +- test('configSchema shows only long context option when long_context tier has no surcharge and preferLongContext is enabled', async () => { +- const { agent, configurationService } = createTestAgentContext(disposables, { +- copilotClient: new TestCopilotClient([], [{ +- id: 'free-long-context', +- name: 'Free Long Context', +- capabilities: { limits: { max_context_window_tokens: 200_000 } }, +- billing: { +- multiplier: 1, +- tokenPrices: { +- contextMax: 200_000, +- longContext: { contextMax: 1_000_000 }, +- }, +- }, +- }]), +- }); +- try { +- configurationService.updateRootConfig({ [AgentHostPreferLongContextEnabledConfigKey]: true }); +- 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, [1_000_000]); +- assert.strictEqual(contextSize?.default, 1_000_000); +- assert.deepStrictEqual(contextSize?.enumLabels, ['1M']); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- suite('contextSize to contextTier mapping', () => { +- const longContextModel: ITestCopilotModelInfo = { +- id: 'claude-sonnet', +- name: 'Claude Sonnet', +- capabilities: { limits: { max_context_window_tokens: 200_000 } }, +- billing: { +- multiplier: 1, +- tokenPrices: { +- contextMax: 200_000, +- longContext: { contextMax: 1_000_000, inputPrice: 2 }, +- }, +- }, +- }; +- +- async function captureSessionConfig(model: ModelSelection | undefined, models: readonly ITestCopilotModelInfo[], preferLongContext?: boolean): Promise { +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const client = new TestCopilotClient([], models); +- let capturedConfig: CopilotCreateSessionOptions | undefined; +- client.createSession = async config => { +- capturedConfig = config; +- return new MockCopilotSession() as unknown as CopilotSession; +- }; +- const { agent, configurationService } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client }); +- try { +- if (preferLongContext) { +- configurationService.updateRootConfig({ [AgentHostPreferLongContextEnabledConfigKey]: true }); +- } +- await agent.authenticate('https://api.github.com', 'token'); +- await waitForState(agent.models, m => m.length > 0); +- const result = await agent.createSession({ +- session: AgentSession.uri('copilotcli', 'ctx-session'), +- workingDirectories: [URI.file('/workspace')], +- ...(model ? { model } : {}), +- }); +- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined); +- return capturedConfig; +- } finally { +- await disposeAgent(agent); +- } +- } +- +- test('maps the largest numeric context size to long_context', async () => { +- const config = await captureSessionConfig({ id: 'claude-sonnet', config: { contextSize: '1000000' } }, [longContextModel]); +- assert.ok(config, 'SDK createSession should be called during materialization'); +- assert.strictEqual(config.contextTier, 'long_context'); +- }); +- +- test('maps the default numeric context size to default', async () => { +- const config = await captureSessionConfig({ id: 'claude-sonnet', config: { contextSize: '200000' } }, [longContextModel]); +- assert.ok(config); +- assert.strictEqual(config.contextTier, 'default'); +- }); +- +- test('drops a numeric context size the model does not offer', async () => { +- const config = await captureSessionConfig( +- { id: 'no-context-picker', config: { contextSize: '1000000' } }, +- [{ id: 'no-context-picker', name: 'No Picker' }], +- ); +- assert.ok(config); +- assert.strictEqual(config.contextTier, undefined); +- }); +- +- test('passes through a legacy resolved tier string under the deprecated contextTier key', async () => { +- const config = await captureSessionConfig({ id: 'claude-sonnet', config: { contextTier: 'long_context' } }, [longContextModel]); +- assert.ok(config); +- assert.strictEqual(config.contextTier, 'long_context'); +- }); +- +- test('leaves the SDK on its default tier when model has no surcharge and no explicit selection', async () => { +- const freeLongContextModel: ITestCopilotModelInfo = { +- id: 'free-long-ctx', +- name: 'Free Long Ctx', +- capabilities: { limits: { max_context_window_tokens: 200_000 } }, +- billing: { +- multiplier: 1, +- tokenPrices: { +- contextMax: 200_000, +- longContext: { contextMax: 1_000_000 }, +- }, +- }, +- }; +- const config = await captureSessionConfig({ id: 'free-long-ctx' }, [freeLongContextModel]); +- assert.ok(config); +- assert.strictEqual(config.contextTier, undefined); +- }); +- +- test('uses long_context when model has no surcharge, no explicit selection and preferLongContext is enabled', async () => { +- const freeLongContextModel: ITestCopilotModelInfo = { +- id: 'free-long-ctx', +- name: 'Free Long Ctx', +- capabilities: { limits: { max_context_window_tokens: 200_000 } }, +- billing: { +- multiplier: 1, +- tokenPrices: { +- contextMax: 200_000, +- longContext: { contextMax: 1_000_000 }, +- }, +- }, +- }; +- const config = await captureSessionConfig({ id: 'free-long-ctx' }, [freeLongContextModel], true); +- assert.ok(config); +- assert.strictEqual(config.contextTier, 'long_context'); +- }); +- }); +- - test('agent-created sessions can resolve session-state paths via INativeEnvironmentService', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const { agent, instantiationService } = createTestAgentContext(disposables, { - environmentServiceRegistration: 'native', - sessionDataService, - }); -- const previousXdgStateHome = process.env['XDG_STATE_HOME']; -- delete process.env['XDG_STATE_HOME']; +- const previousCopilotHome = process.env['COPILOT_HOME']; +- delete process.env['COPILOT_HOME']; - try { - const createdSession = createAgentSessionThroughAgent(agent, instantiationService); - const agentSession = disposables.add(createdSession.session); @@ -11088,56 +16487,331 @@ index bd411161..00000000 - - assert.strictEqual(result.kind, 'approve-once'); - } finally { -- if (previousXdgStateHome === undefined) { -- delete process.env['XDG_STATE_HOME']; +- if (previousCopilotHome === undefined) { +- delete process.env['COPILOT_HOME']; - } else { -- process.env['XDG_STATE_HOME'] = previousXdgStateHome; +- process.env['COPILOT_HOME'] = previousCopilotHome; - } - await disposeAgent(agent); - } - }); - -- test('listSessions only returns sessions with a database', async () => { +- test('client tool call contributor prefers the message sender when it provides the tool', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); -- const ownedSession = AgentSession.uri('copilotcli', 'owned'); -- const ownedDb = sessionDataService.openDatabase(ownedSession); -- ownedDb.dispose(); -- -- const client = new TestCopilotClient([sdkSession('owned'), sdkSession('external')]); -- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); +- const { agent, instantiationService } = createTestAgentContext(disposables, { environmentServiceRegistration: 'native', sessionDataService }); +- const actions: (SessionAction | ChatAction)[] = []; +- disposables.add(agent.onDidSessionProgress(signal => { +- if (signal.kind === 'action') { +- actions.push(signal.action); +- } +- })); +- const activeClientToolSet = new ActiveClientToolSet(); +- const sharedTool: ToolDefinition = { name: 'shared', description: 'Shared tool', inputSchema: { type: 'object', properties: {} } }; +- activeClientToolSet.set('client-A', [sharedTool]); +- activeClientToolSet.set('client-B', [sharedTool]); +- const mockSession = new MockCopilotSession(); +- const createdSession = createAgentSessionThroughAgent(agent, instantiationService, { +- mockSession, +- activeClientToolSet, +- snapshot: { tools: activeClientToolSet.merged(), plugins: [], mcpServers: {} }, +- }); +- const agentSession = disposables.add(createdSession.session); - try { -- await agent.authenticate('https://api.github.com', 'token'); +- await agentSession.initializeSession(); +- agentSession.resetTurnState('turn-1', 'client-B'); - -- assert.deepStrictEqual((await agent.listSessions()).map(s => AgentSession.id(s.session)), ['owned']); +- 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); - } - }); - -- test('listSessions reads stored metadata from sessions with a database', async () => { +- test('client tool completion unblocks a pending permission request', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); -- const legacySession = AgentSession.uri('copilotcli', 'legacy'); -- const legacyDb = sessionDataService.openDatabase(legacySession); -- await legacyDb.object.setMetadata('copilot.workingDirectory', URI.file('/workspace').toString()); -- legacyDb.dispose(); -- -- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([sdkSession('legacy')]) }); +- const { agent, instantiationService, fileService } = createTestAgentContext(disposables, { environmentServiceRegistration: 'native', sessionDataService }); +- disposables.add(registerPendingEditContentProvider(fileService)); +- const createdSession = createAgentSessionThroughAgent(agent, instantiationService); +- const agentSession = disposables.add(createdSession.session); +- const pendingEditContentUri = new DeferredPromise(); +- disposables.add(agent.onDidSessionProgress(signal => { +- if (signal.kind === 'pending_confirmation') { +- const uri = signal.state.edits?.items[0]?.after?.content.uri; +- if (uri) { +- pendingEditContentUri.complete(URI.parse(uri)); +- } +- } +- })); - try { -- await agent.authenticate('https://api.github.com', 'token'); +- await agentSession.initializeSession(); +- const onPermissionRequest = createdSession.createOptions()?.onPermissionRequest; +- assert.ok(onPermissionRequest); - -- assert.deepStrictEqual((await agent.listSessions()).map(withoutUndefinedProperties), [{ -- session: legacySession, -- startTime: 1000, -- modifiedTime: 2000, -- summary: 'SDK legacy', -- workingDirectory: URI.file('/workspace'), -- }]); +- 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: 'approve-once' }, +- pendingEditContentExists: false, +- }); - } finally { - await disposeAgent(agent); - } - }); - -- test('getSessionMetadata reads one SDK session and stored metadata without listing sessions', async () => { +- test('auto-approves one duplicate write permission request after approval', async () => { +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const { agent, instantiationService, fileService } = createTestAgentContext(disposables, { environmentServiceRegistration: 'native', sessionDataService }); +- disposables.add(registerPendingEditContentProvider(fileService)); +- const createdSession = createAgentSessionThroughAgent(agent, instantiationService); +- const agentSession = disposables.add(createdSession.session); +- let nextPendingPermission = new DeferredPromise(); +- let pendingPermissionCount = 0; +- disposables.add(agent.onDidSessionProgress(signal => { +- if (signal.kind === 'pending_confirmation') { +- pendingPermissionCount++; +- 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', +- }; +- +- 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; +- +- assert.deepStrictEqual({ +- results: [firstResult, duplicateResult, thirdResult], +- pendingPermissionCount, +- }, { +- results: [{ kind: 'approve-once' }, { kind: 'approve-once' }, { kind: 'denied-interactively-by-user' }], +- pendingPermissionCount: 2, +- }); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('requires confirmation when a second write permission request differs', async () => { +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const { agent, instantiationService, fileService } = createTestAgentContext(disposables, { environmentServiceRegistration: 'native', sessionDataService }); +- disposables.add(registerPendingEditContentProvider(fileService)); +- const createdSession = createAgentSessionThroughAgent(agent, instantiationService); +- const agentSession = disposables.add(createdSession.session); +- let nextPendingPermission = new DeferredPromise(); +- let pendingPermissionCount = 0; +- disposables.add(agent.onDidSessionProgress(signal => { +- if (signal.kind === 'pending_confirmation') { +- pendingPermissionCount++; +- 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; +- +- 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: 'denied-interactively-by-user' }], +- pendingPermissionCount: 2, +- }); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('auto-approves one duplicate read permission request after approval', async () => { +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const { agent, instantiationService, fileService } = createTestAgentContext(disposables, { environmentServiceRegistration: 'native', sessionDataService }); +- disposables.add(registerPendingEditContentProvider(fileService)); +- const createdSession = createAgentSessionThroughAgent(agent, instantiationService); +- const agentSession = disposables.add(createdSession.session); +- let nextPendingPermission = new DeferredPromise(); +- let pendingPermissionCount = 0; +- disposables.add(agent.onDidSessionProgress(signal => { +- if (signal.kind === 'pending_confirmation') { +- pendingPermissionCount++; +- 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, +- }; +- +- 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; +- +- assert.deepStrictEqual({ +- results: [firstResult, duplicateResult, thirdResult], +- pendingPermissionCount, +- }, { +- results: [{ kind: 'approve-once' }, { kind: 'approve-once' }, { kind: 'denied-interactively-by-user' }], +- pendingPermissionCount: 2, +- }); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('listSessions only returns sessions with a database', async () => { +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const ownedSession = AgentSession.uri('copilotcli', 'owned'); +- const ownedDb = sessionDataService.openDatabase(ownedSession); +- ownedDb.dispose(); +- +- const client = new TestCopilotClient([sdkSession('owned'), sdkSession('external')]); +- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- +- assert.deepStrictEqual((await agent.listSessions()).map(s => AgentSession.id(s.session)), ['owned']); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('listSessions reads stored metadata from sessions with a database', async () => { +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const legacySession = AgentSession.uri('copilotcli', 'legacy'); +- const legacyDb = sessionDataService.openDatabase(legacySession); +- await legacyDb.object.setMetadata('copilot.workingDirectory', URI.file('/workspace').toString()); +- legacyDb.dispose(); +- +- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([sdkSession('legacy')]) }); +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- +- assert.deepStrictEqual((await agent.listSessions()).map(withoutUndefinedProperties), [{ +- session: legacySession, +- startTime: 1000, +- modifiedTime: 2000, +- summary: 'SDK legacy', +- workingDirectories: [URI.file('/workspace')], +- }]); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('listSessions does not itself re-emit the workspaceless tag (AgentService overlays it centrally)', async () => { +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const session = AgentSession.uri('copilotcli', 'quick'); +- const db = sessionDataService.openDatabase(session); +- // A committed quick chat persists a scratch cwd AND the AH-owned +- // workspace-less marker. The marker is surfaced onto `_meta` by +- // `AgentService.listSessions` (see agentService.test.ts), not by the agent +- // itself — the agent only reads it for the resume system prompt / cleanup. +- await db.object.setMetadata('copilot.workingDirectory', URI.file('/scratch/quick').toString()); +- await db.object.setMetadata('agentHost.workspaceless', 'true'); +- db.dispose(); +- +- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([sdkSession('quick')]) }); +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- +- assert.deepStrictEqual((await agent.listSessions()).map(withoutUndefinedProperties), [{ +- session, +- startTime: 1000, +- modifiedTime: 2000, +- summary: 'SDK quick', +- workingDirectories: [URI.file('/scratch/quick')], +- }]); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('getSessionMetadata reads one SDK session and stored metadata without listing sessions', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const session = AgentSession.uri('copilotcli', 'target'); - const db = sessionDataService.openDatabase(session); @@ -11156,7 +16830,7 @@ index bd411161..00000000 - startTime: 1000, - modifiedTime: 2000, - summary: 'SDK target', -- workingDirectory: URI.file('/workspace'), +- workingDirectories: [URI.file('/workspace')], - }); - assert.deepStrictEqual(client.getSessionMetadataCalls, ['target']); - assert.strictEqual(client.listSessionCallCount, 0); @@ -11222,7 +16896,7 @@ index bd411161..00000000 - const customizations: ClientPluginCustomization[] = [{ type: CustomizationType.Plugin, id: customizationId('file:///plugin-a'), uri: 'file:///plugin-a', name: 'Plugin A', enabled: true }]; - const result = await agent.createSession({ - session: AgentSession.uri('copilotcli', 'test-session'), -- workingDirectory: URI.file('/workspace'), +- workingDirectories: [URI.file('/workspace')], - activeClient: { - clientId: 'client-1', - tools: [{ name: 't1', description: 'd', inputSchema: { type: 'object' } }], @@ -11249,7 +16923,7 @@ index bd411161..00000000 - - const result = await agent.createSession({ - session: AgentSession.uri('copilotcli', 'test-session-2'), -- workingDirectory: URI.file('/workspace'), +- workingDirectories: [URI.file('/workspace')], - }); - - assert.strictEqual(result.provisional, true); @@ -11259,6 +16933,101 @@ index bd411161..00000000 - } - }); - +- test('provisional session anchors customization discovery to the additional roots (gated)', async () => { +- const { agent, stateManager } = createTestAgentContext(disposables); +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- const repoA = URI.file('/repo-a'); +- const repoB = URI.file('/repo-b'); +- +- const additionalDirsAfterCreate = async (enabled: boolean, workingDirectories: readonly URI[]): Promise => { +- stateManager.dispatchServerAction(ROOT_STATE_URI, { +- type: ActionType.RootConfigChanged, +- config: { [AgentHostCopilotMultiRootEnabledConfigKey]: enabled }, +- }); +- const uri = AgentSession.uri('copilotcli', `mrp-${enabled}-${workingDirectories.length}`); +- await agent.createSession({ +- 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()); +- }; +- +- // A brand-new (pre-send) provisional chat must anchor discovery to every +- // root when multi-root is on, so its custom-agent picker shows the union. +- const multiRootOn = await additionalDirsAfterCreate(true, [repoA, repoB]); +- const multiRootOff = await additionalDirsAfterCreate(false, [repoA, repoB]); +- const singleRootOn = await additionalDirsAfterCreate(true, [repoA]); +- +- assert.deepStrictEqual({ multiRootOn, multiRootOff, singleRootOn }, { +- multiRootOn: [repoB.toString()], +- multiRootOff: [], +- singleRootOn: [], +- }); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('session plugin enablement is projected from reducer state per session', async () => { +- class PassthroughPluginManager extends TestAgentPluginManager { +- override async syncCustomizations(_clientId: string, customizations: ClientPluginCustomization[]): Promise { +- return customizations.map(customization => ({ customization })); +- } +- } +- +- const { agent, stateManager } = createTestAgentContext(disposables, { pluginManager: new PassthroughPluginManager() }); +- 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, +- }); +- } +- +- const plugin: ClientPluginCustomization = { +- type: CustomizationType.Plugin, +- id: 'file:///plugin-a', +- uri: 'file:///plugin-a', +- name: 'Plugin A', +- enabled: true, +- }; +- agent.getOrCreateActiveClient(firstSession, { clientId: 'client-1' }).customizations = [plugin]; +- agent.getOrCreateActiveClient(secondSession, { clientId: 'client-2' }).customizations = [plugin]; +- +- const [firstInitial, secondInitial] = await Promise.all([ +- agent.getSessionCustomizations(firstSession), +- agent.getSessionCustomizations(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, enabled: false }); +- +- const [first, second] = await Promise.all([ +- agent.getSessionCustomizations(firstSession), +- agent.getSessionCustomizations(secondSession), +- ]); +- assert.deepStrictEqual({ +- first: first.find(customization => customization.id === plugin.id)?.enabled, +- second: second.find(customization => customization.id === plugin.id)?.enabled, +- }, { +- first: false, +- second: true, +- }); +- } finally { +- await disposeAgent(agent); +- } +- }); +- - test('setClientCustomizations publishes parsed agents in SessionCustomizationUpdated', async () => { - const fileService = disposables.add(new FileService(new NullLogService())); - disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); @@ -11295,7 +17064,7 @@ index bd411161..00000000 - await agent.authenticate('https://api.github.com', 'token'); - - const session = AgentSession.uri('copilotcli', 'sync-customizations-test'); -- await agent.setClientCustomizations(session, 'client-1', [{ type: CustomizationType.Plugin, id: customizationId(pluginDir.toString()), uri: pluginDir.toString(), name: 'Plugin A', enabled: true }]); +- agent.getOrCreateActiveClient(session, { clientId: 'client-1' }).customizations = [{ type: CustomizationType.Plugin, id: customizationId(pluginDir.toString()), uri: pluginDir.toString(), name: 'Plugin A', enabled: true }]; - - // Wait for the deferred resolution chain in PluginController.sync. - await new Promise(r => setTimeout(r, 50)); @@ -11360,18 +17129,33 @@ index bd411161..00000000 - const session = AgentSession.uri('copilotcli', 'session-discovery-directories'); - await agent.createSession({ - session, -- workingDirectory: workspace, +- workingDirectories: [workspace], - }); - - const customizations = await agent.getSessionCustomizations(session); - const discoveredDirectories = customizations.filter(customization => customization.type === CustomizationType.Directory); - -- assert.strictEqual(discoveredDirectories.length, 3); -- assert.deepStrictEqual(discoveredDirectories.map(customization => customization.uri).sort(), [ +- // All discovery roots are returned, even if empty or non-existing +- // Workspace root is included because AGENTS.md was created +- assert.strictEqual(discoveredDirectories.length, 13); +- const expectedUris = [ +- // workspace roots - workspace.toString(), - URI.joinPath(workspace, '.github', 'agents').toString(), +- URI.joinPath(workspace, '.claude', 'agents').toString(), +- URI.joinPath(workspace, '.github', 'skills').toString(), +- URI.joinPath(workspace, '.agents', 'skills').toString(), +- URI.joinPath(workspace, '.claude', 'skills').toString(), - URI.joinPath(workspace, '.github', 'instructions').toString(), -- ].sort()); +- URI.joinPath(workspace, '.github', 'hooks').toString(), +- // user home roots +- URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.copilot/agents' }).toString(), +- URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.agents/skills' }).toString(), +- URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.copilot/skills' }).toString(), +- URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.copilot/instructions' }).toString(), +- URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.copilot/hooks' }).toString(), +- ]; +- 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); @@ -11412,6 +17196,46 @@ index bd411161..00000000 - } - }); - +- test('getSessionCustomizations starts initial discovery without debounce', async () => { +- class StatTrackingFileSystemProvider extends InMemoryFileSystemProvider { +- trackStats = false; +- statCalls = 0; +- +- override async stat(resource: URI): Promise { +- if (this.trackStats) { +- this.statCalls++; +- } +- return super.stat(resource); +- } +- } +- +- const fileService = disposables.add(new FileService(new NullLogService())); +- const provider = disposables.add(new StatTrackingFileSystemProvider()); +- disposables.add(fileService.registerProvider(Schemas.inMemory, provider)); +- const workspace = URI.from({ scheme: Schemas.inMemory, path: '/workspace' }); +- await fileService.createFolder(workspace); +- +- const sessionDataService = disposables.add(new TestSessionDataService()); +- 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 agent.createSession({ session, workingDirectories: [workspace] }); +- +- provider.trackStats = true; +- const customizations = agent.getSessionCustomizations(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); +- } +- }); +- - test('getSessionCustomizations clears discovered files when the root disappears', async () => { - const fileService = disposables.add(new FileService(new NullLogService())); - disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); @@ -11431,20 +17255,28 @@ index bd411161..00000000 - const session = AgentSession.uri('copilotcli', 'session-discovery-cleared'); - await agent.createSession({ - session, -- workingDirectory: workspace, +- workingDirectories: [workspace], - }); - - const before = await agent.getSessionCustomizations(session); -- assert.deepStrictEqual(before.filter(customization => customization.type === CustomizationType.Directory).map(customization => customization.uri), [agentsRoot.toString()]); +- 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 - - await fileService.del(agentsRoot, { recursive: true }); - - let after = await agent.getSessionCustomizations(session); -- for (let i = 0; i < 20 && after.filter(customization => customization.type === CustomizationType.Directory).length > 0; i++) { +- let afterDirs = after.filter(customization => customization.type === CustomizationType.Directory); +- for (let i = 0; i < 20 && afterDirs.some(d => d.uri === agentsRoot.toString() && (d.children?.length ?? 0) > 0); i++) { - await new Promise(resolve => setTimeout(resolve, 50)); - after = await agent.getSessionCustomizations(session); +- afterDirs = after.filter(customization => customization.type === CustomizationType.Directory); - } -- assert.deepStrictEqual(after.filter(customization => customization.type === CustomizationType.Directory).map(customization => customization.uri), []); +- // 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); - } @@ -11488,7 +17320,7 @@ index bd411161..00000000 - const session = AgentSession.uri('copilotcli', 'session-discovery-neutral-watcher-change'); - await agent.createSession({ - session, -- workingDirectory: workspace, +- workingDirectories: [workspace], - }); - - await agent.getSessionCustomizations(session); @@ -11504,7 +17336,24 @@ index bd411161..00000000 - } - - const after = await agent.getSessionCustomizations(session); -- assert.deepStrictEqual(after.filter(customization => customization.type === CustomizationType.Directory).map(customization => customization.uri), [agentsRoot.toString()]); +- const afterDirs = after.filter(customization => customization.type === CustomizationType.Directory); +- // All discovery roots are discovered (workspace root only if it has AGENTS.md) +- const expectedUris = [ +- URI.joinPath(workspace, '.github', 'agents').toString(), +- URI.joinPath(workspace, '.claude', 'agents').toString(), +- URI.joinPath(workspace, '.github', 'skills').toString(), +- URI.joinPath(workspace, '.agents', 'skills').toString(), +- URI.joinPath(workspace, '.claude', 'skills').toString(), +- URI.joinPath(workspace, '.github', 'instructions').toString(), +- URI.joinPath(workspace, '.github', 'hooks').toString(), +- // user home roots +- URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.copilot/agents' }).toString(), +- URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.agents/skills' }).toString(), +- URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.copilot/skills' }).toString(), +- URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.copilot/instructions' }).toString(), +- URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.copilot/hooks' }).toString(), +- ]; +- assert.deepStrictEqual(afterDirs.map(customization => customization.uri).sort(), expectedUris.sort()); - } finally { - await disposeAgent(agent); - } @@ -11550,7 +17399,7 @@ index bd411161..00000000 - const session = AgentSession.uri('copilotcli', 'session-discovery-burst-watcher-change'); - await agent.createSession({ - session, -- workingDirectory: workspace, +- workingDirectories: [workspace], - }); - - await agent.getSessionCustomizations(session); @@ -11605,7 +17454,7 @@ index bd411161..00000000 - - const result = await agent.createSession({ - session: AgentSession.uri('copilotcli', 'prov-1'), -- workingDirectory: URI.file('/workspace'), +- workingDirectories: [URI.file('/workspace')], - config: { isolation: 'worktree', branch: 'main' }, - }); - @@ -11617,6 +17466,30 @@ index bd411161..00000000 - } - }); - +- test('sendMessage on the default chat materializes the parent provisional session', async () => { +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const client = new TestCopilotClient([]); +- let capturedConfig: CopilotCreateSessionOptions | undefined; +- client.createSession = async config => { +- capturedConfig = config; +- 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 agent.createSession({ +- session: AgentSession.uri('copilotcli', 'prov-default-chat'), +- workingDirectories: [URI.file('/workspace')], +- }); +- +- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined); +- +- assert.strictEqual(capturedConfig?.sessionId, 'prov-default-chat'); +- } finally { +- await disposeAgent(agent); +- } +- }); +- - test('disposeSession on provisional session does not touch SDK or worktree', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([]); @@ -11631,7 +17504,7 @@ index bd411161..00000000 - - const result = await agent.createSession({ - session: AgentSession.uri('copilotcli', 'prov-2'), -- workingDirectory: URI.file('/workspace'), +- workingDirectories: [URI.file('/workspace')], - }); - - await agent.disposeSession(result.session); @@ -11668,7 +17541,7 @@ index bd411161..00000000 - - const result = await agent.createSession({ - session: AgentSession.uri('copilotcli', 'prov-3'), -- workingDirectory: URI.file('/workspace'), +- workingDirectories: [URI.file('/workspace')], - }); - - await agent.disposeSession(result.session); @@ -11714,15 +17587,18 @@ index bd411161..00000000 - - const result = await agent.createSession({ - session: AgentSession.uri('copilotcli', 'system-message-session'), -- workingDirectory: URI.file('/workspace'), +- workingDirectories: [URI.file('/workspace')], - }); - assert.strictEqual(result.provisional, true); - -- await agent.sendMessage(result.session, 'hello'); +- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined); - - assert.ok(capturedConfig, 'SDK createSession should be called during provisional materialization'); - const systemMessage = capturedConfig.systemMessage; -- assert.deepStrictEqual(systemMessage, COPILOT_AGENT_HOST_SYSTEM_MESSAGE); +- assert.deepStrictEqual(systemMessage, { +- ...COPILOT_AGENT_HOST_SYSTEM_MESSAGE, +- content: COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS, +- }); - if (!systemMessage || systemMessage.mode !== 'customize') { - assert.fail('Expected customize-mode system message'); - } @@ -11740,25 +17616,46 @@ index bd411161..00000000 - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([]); - let capturedConfig: Parameters[0] | undefined; +- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); - client.createSession = async config => { - capturedConfig = config; - return new MockCopilotSession() as unknown as CopilotSession; - }; - -- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); - try { - await agent.authenticate('https://api.github.com', 'gh-token-abc'); - - const result = await agent.createSession({ - session: AgentSession.uri('copilotcli', 'session-level-token'), -- workingDirectory: URI.file('/workspace'), +- workingDirectories: [URI.file('/workspace')], - }); - assert.strictEqual(result.provisional, true); - -- await agent.sendMessage(result.session, 'hello'); +- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined); +- +- assert.deepStrictEqual({ +- configToken: capturedConfig?.gitHubToken, +- }, { +- configToken: 'gh-token-abc', +- }); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('failed materialization surfaces the create error', async () => { +- const sessionDataService = disposables.add(new TestSessionDataService()); +- 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 agent.createSession({ +- session: AgentSession.uri('copilotcli', 'failed-session-token'), +- workingDirectories: [URI.file('/workspace')], +- }); - -- assert.strictEqual(capturedConfig?.gitHubToken, 'gh-token-abc', -- 'createSession should receive the GitHub token at session level so the SDK can resolve a per-session GitHub identity'); +- await assert.rejects(agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined), /create failed/); - } finally { - await disposeAgent(agent); - } @@ -11776,15 +17673,15 @@ index bd411161..00000000 - const { agent, configurationService } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client }); - try { - await agent.authenticate('https://api.github.com', 'token'); -- configurationService.updateRootConfig({ [AgentHostConfigKey.EnableCustomTerminalTool]: false }); +- configurationService.updateRootConfig({ [CopilotCliConfigKey.EnableCustomTerminalTool]: false }); - - const result = await agent.createSession({ - session: AgentSession.uri('copilotcli', 'sdk-terminal-defaults'), -- workingDirectory: URI.file('/workspace'), +- workingDirectories: [URI.file('/workspace')], - }); - assert.strictEqual(result.provisional, true); - -- await agent.sendMessage(result.session, 'hello'); +- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined); - - assert.deepStrictEqual(capturedConfig?.tools?.map(tool => tool.name), []); - } finally { @@ -11808,8 +17705,7 @@ index bd411161..00000000 - }, - dispose() { }, - }; -- const sessions = (agent as unknown as { _sessions: Map })._sessions; -- sessions.set(sessionId, stub); +- setDefaultSessionStub(agent, sessionId, stub); - return { calls }; - } - @@ -11817,10 +17713,11 @@ index bd411161..00000000 - 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)); - - const result: ToolCallResult = { success: true, pastTenseMessage: 'did it' }; -- agent.onClientToolCallComplete(sessionUri, 'tc-top', result); +- agent.onClientToolCallComplete(sessionUri, defaultChat, 'tc-top', result); - - assert.deepStrictEqual(calls, [{ toolCallId: 'tc-top', result }]); - } finally { @@ -11828,54 +17725,13 @@ index bd411161..00000000 - } - }); - -- test('routes a subagent session URI to its parent session entry', async () => { -- // Regression: client-tool completions for tools running inside a -- // subagent are dispatched against the subagent session URI by -- // the renderer. The agent must resolve that to the parent -- // session entry — only the parent owns the SDK session and the -- // pending deferred for the tool call. -- const agent = createTestAgent(disposables); -- try { -- const parentUri = AgentSession.uri('copilotcli', 'session-parent'); -- const { calls } = installStubSession(agent, AgentSession.id(parentUri)); -- -- const subagentUri = URI.parse(buildSubagentSessionUri(parentUri.toString(), 'tc-parent')); -- const result: ToolCallResult = { success: true, pastTenseMessage: 'subagent tool done' }; -- agent.onClientToolCallComplete(subagentUri, 'tc-inner', result); -- -- assert.deepStrictEqual(calls, [{ toolCallId: 'tc-inner', result }]); -- } finally { -- await disposeAgent(agent); -- } -- }); -- -- test('routes a nested subagent session URI (depth > 1) to the root session entry', async () => { -- // Regression for depth > 1: a nested subagent URI like -- // `copilot:/root/subagent/tc1/subagent/tc2` must walk all the way -- // to the root session entry in `_sessions`, not stop at the -- // intermediate parent `copilot:/root/subagent/tc1`. -- const agent = createTestAgent(disposables); -- try { -- const rootUri = AgentSession.uri('copilotcli', 'session-root'); -- const { calls } = installStubSession(agent, AgentSession.id(rootUri)); -- -- const subagentUri = URI.parse(buildSubagentSessionUri(rootUri.toString(), 'tc-parent')); -- const nestedUri = URI.parse(buildSubagentSessionUri(subagentUri.toString(), 'tc-nested')); -- const result: ToolCallResult = { success: true, pastTenseMessage: 'nested done' }; -- agent.onClientToolCallComplete(nestedUri, 'tc-inner', result); -- -- assert.deepStrictEqual(calls, [{ toolCallId: 'tc-inner', result }]); -- } finally { -- await disposeAgent(agent); -- } -- }); -- - 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(sessionUri, 'tc-x', { success: true, pastTenseMessage: 'noop' }); +- agent.onClientToolCallComplete(sessionUri, defaultChat, 'tc-x', { success: true, pastTenseMessage: 'noop' }); - } finally { - await disposeAgent(agent); - } @@ -11883,9 +17739,9 @@ index bd411161..00000000 - - test('routes a peer chat URI to its chat-session entry', async () => { - // Client-tool completions for tools running inside an additional -- // (non-default) chat are dispatched against the chat channel URI. -- // The agent must resolve that to the `_chatSessions` entry, which -- // is keyed by the chat URI string rather than a session id. +- // (non-default) chat carry both the owning session URI and the +- // chat channel URI. The agent must route by the chat URI to the peer +- // chat hosted on the owning session's entry. - const agent = createTestAgent(disposables); - try { - const sessionUri = AgentSession.uri('copilotcli', 'session-with-peer'); @@ -11895,21 +17751,39 @@ index bd411161..00000000 - handleClientToolCallComplete(toolCallId: string, result: ToolCallResult) { calls.push({ toolCallId, result }); }, - dispose() { }, - }; -- (agent as unknown as { _chatSessions: Map })._chatSessions.set(chatUri.toString(), stub); +- setPeerChatStub(agent, chatUri, stub); - - const result: ToolCallResult = { success: true, pastTenseMessage: 'peer done' }; -- agent.onClientToolCallComplete(chatUri, 'tc-peer', result); +- agent.onClientToolCallComplete(sessionUri, chatUri, 'tc-peer', result); - - assert.deepStrictEqual(calls, [{ toolCallId: 'tc-peer', result }]); - } finally { - await disposeAgent(agent); - } - }); +- test('routes the default chat URI to the session entry, not a chat-session', async () => { +- // The default chat is not a peer chat; passing its chat URI must +- // still resolve via `_sessions` by the owning session id. This is +- // the regression that previously hung the agent. +- 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)); +- +- const result: ToolCallResult = { success: true, pastTenseMessage: 'default done' }; +- agent.onClientToolCallComplete(sessionUri, defaultChatUri, 'tc-default', result); +- +- assert.deepStrictEqual(calls, [{ toolCallId: 'tc-default', result }]); +- } finally { +- await disposeAgent(agent); +- } +- }); - }); - - suite('peer chat routing and lifecycle', () => { - -- /** Installs a stub peer chat into `_chatSessions` keyed by the chat URI. */ +- /** Installs a stub peer chat into the owning session's entry, keyed by the chat URI. */ - function installStubChat(agent: CopilotAgent, chatUri: URI, options?: { permissionOwner?: string; inputOwner?: string }) { - const events: string[] = []; - let disposed = false; @@ -11931,7 +17805,7 @@ index bd411161..00000000 - handleClientToolCallComplete() { }, - dispose() { disposed = true; }, - }; -- (agent as unknown as { _chatSessions: Map })._chatSessions.set(chatUri.toString(), stub); +- setPeerChatStub(agent, chatUri, stub); - return { events, isDisposed: () => disposed }; - } - @@ -11965,6 +17839,32 @@ index bd411161..00000000 - } - }); - +- test('setPendingMessages steers the addressed chat, not the session\'s default chat', async () => { +- // Regression for #326244: a steering message submitted in a forked +- // (peer) chat must only reach that chat's SDK session. +- const agent = createTestAgent(disposables); +- try { +- const sessionUri = AgentSession.uri('copilotcli', 'session-steer'); +- const chatUri = URI.parse(buildChatUri(sessionUri, 'peer-steer')); +- const steered: string[] = []; +- setDefaultSessionStub(agent, AgentSession.id(sessionUri), { +- sendSteering: async (msg: { id: string }) => { steered.push(`default:${msg.id}`); }, +- dispose() { }, +- }); +- setPeerChatStub(agent, chatUri, { +- sendSteering: async (msg: { id: string }) => { steered.push(`peer:${msg.id}`); }, +- dispose() { }, +- }); +- +- agent.setPendingMessages(chatUri, { id: 'steer-peer', message: { text: 'stop', origin: { kind: MessageKind.User } } }, []); +- agent.setPendingMessages(URI.parse(buildDefaultChatUri(sessionUri)), { id: 'steer-default', message: { text: 'stop', origin: { kind: MessageKind.User } } }, []); +- +- assert.deepStrictEqual(steered, ['peer:steer-peer', 'default:steer-default']); +- } finally { +- await disposeAgent(agent); +- } +- }); +- - test('disposeSession disposes the session\'s peer chats', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([]); @@ -11974,7 +17874,7 @@ index bd411161..00000000 - - const result = await agent.createSession({ - session: AgentSession.uri('copilotcli', 'parent-with-peers'), -- workingDirectory: URI.file('/workspace'), +- workingDirectories: [URI.file('/workspace')], - }); - const chatUri = URI.parse(buildChatUri(result.session, 'peer-x')); - const chat = installStubChat(agent, chatUri); @@ -11982,5215 +17882,11929 @@ index bd411161..00000000 - await agent.disposeSession(result.session); - - assert.strictEqual(chat.isDisposed(), true, 'peer chat should be disposed with its parent session'); -- const chatSessions = (agent as unknown as { _chatSessions: Map })._chatSessions; -- assert.strictEqual(chatSessions.has(chatUri.toString()), false, 'peer chat entry should be removed'); +- assert.strictEqual(hasPeerChatStub(agent, chatUri), false, 'peer chat entry should be removed'); - } finally { - await disposeAgent(agent); - } - }); - -- test('getChats returns the persisted peer chat catalog', async () => { +- 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 agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); +- const client = new TestCopilotClient([]); +- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); - try { -- const session = AgentSession.uri('copilotcli', 'session-getchats'); +- 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' }, -- 'peer-b': { sdkSessionId: 'sdk-b' }, - })); +- 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, JSON.stringify({ sdkSessionId: 'sdk-a' })); - -- const chats = await agent.getChats(session); +- await agent.chats.disposeChat(chatUri); - -- assert.deepStrictEqual( -- chats.map(c => c.toString()).sort(), -- [buildChatUri(session, 'peer-a'), buildChatUri(session, 'peer-b')].sort(), -- ); +- 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); - } - }); +- }); +- +- suite('peer chat create / fork / model+agent / restore round-trip', () => { - -- test('getChats drops corrupted or invalid persisted entries', async () => { +- /** Internal surface the multi-chat tests reach into to stub the SDK/agent-session seam. */ +- type ChatInternals = { +- _chatBackings: Map; +- _sessions: Map; +- _createAgentSession: (launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: unknown, identity?: { sessionUri: URI; chatChannelUri: URI }) => CopilotAgentSession; +- _sessionSequencer: { queue(key: string, task: () => Promise): Promise }; +- _forkSdkChat: (client: unknown, sourceEntry: unknown, turnId: string, targetDbDir: URI) => Promise<{ sessionId: string; inheritedTurnCount: number }>; +- _resolveAgentName: (snapshot: IActiveClientSnapshot, agent: AgentSelection) => string | undefined; +- }; +- +- interface IFakeChatRecorder { +- initialized: boolean; +- disposed: boolean; +- readonly remapCalls: ReadonlyMap[]; +- readonly sends: { prompt: string; turnId: string | undefined; mode: unknown; senderClientId: string | undefined }[]; +- readonly resets: { turnId: string; senderClientId: string | undefined }[]; +- readonly modelCalls: { id: string }[]; +- readonly agentCalls: (string | undefined)[]; +- } +- +- /** +- * Builds a fake {@link CopilotAgentSession} that records the calls +- * `createChat`/`sendMessage`/`changeModel`/`changeAgent` route to a peer +- * chat, so tests can drive the real agent methods while stubbing only the +- * SDK-backed chat. The `_createAgentSession` seam returns this. +- */ +- function makeFakeChatSession(sessionUri: URI, sdkSessionId: string, getMessages?: () => Promise, owned?: IDisposable): { rec: IFakeChatRecorder; fake: CopilotAgentSession } { +- const rec: IFakeChatRecorder = { +- initialized: false, +- disposed: false, +- remapCalls: [], +- sends: [], +- resets: [], +- modelCalls: [], +- agentCalls: [], +- }; +- const fake = { +- sessionUri, +- sessionId: sdkSessionId, +- appliedSnapshot: { tools: [], plugins: [], mcpServers: {} } satisfies IActiveClientSnapshot, +- async initializeSession(): Promise { rec.initialized = true; }, +- async remapTurnIds(mapping: ReadonlyMap): Promise { rec.remapCalls.push(mapping); }, +- async send(prompt: string, _attachments: unknown, turnId: string | undefined, mode: unknown, senderClientId: string | undefined): Promise { +- rec.sends.push({ prompt, turnId, mode, senderClientId }); +- }, +- resetTurnState(turnId: string, senderClientId: string | undefined): void { rec.resets.push({ turnId, senderClientId }); }, +- async setModel(id: string): Promise { rec.modelCalls.push({ id }); }, +- async setAgent(name: string | undefined): Promise { rec.agentCalls.push(name); }, +- handleClientToolCallComplete(): void { }, +- async getNextTurnEventId(): Promise { return undefined; }, +- getMessages: getMessages ?? (async () => []), +- dispose(): void { rec.disposed = true; owned?.dispose(); }, +- } as unknown as CopilotAgentSession; +- return { rec, fake }; +- } +- +- test('createChat materializes a peer 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 { -- const session = AgentSession.uri('copilotcli', 'session-getchats-invalid'); -- const db = sessionDataService.openDatabase(session); -- await db.object.setMetadata('copilot.chats', JSON.stringify({ -- 'peer-ok': { sdkSessionId: 'sdk-ok' }, -- 'peer-null': null, -- 'peer-missing-id': { model: { id: 'm' } }, -- 'peer-nonstring-id': { sdkSessionId: 42 }, -- 'peer-empty-id': { sdkSessionId: '' }, -- })); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'create-peer'); +- await agent.createSession({ session, workingDirectories: [URI.file('/workspace')] }); - -- const chats = await agent.getChats(session); +- 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?.sessionUri; +- const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); +- rec = built.rec; +- return built.fake; +- }; - -- assert.deepStrictEqual( -- chats.map(c => c.toString()), -- [buildChatUri(session, 'peer-ok')], -- ); +- const model: ModelSelection = { id: 'gpt-x' }; +- const result = await agent.chats.createChat(chatUri, { model }); +- +- const db = sessionDataService.openDatabase(session); +- const raw = await db.object.getMetadata('copilot.chats'); +- assert.deepStrictEqual({ +- tracked: hasPeerChatStub(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); - } - }); - -- test('disposeChat removes the persisted entry and deletes its SDK conversation', async () => { +- test('createChat is a no-op for the default chat URI', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); -- const client = new TestCopilotClient([]); -- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); +- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); - try { - await agent.authenticate('https://api.github.com', 'token'); -- const session = AgentSession.uri('copilotcli', 'session-dispose-chat'); -- const db = sessionDataService.openDatabase(session); -- await db.object.setMetadata('copilot.chats', JSON.stringify({ -- 'peer-a': { sdkSessionId: 'sdk-a' }, -- })); -- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); +- const session = AgentSession.uri('copilotcli', 'create-default'); +- const internals = agent as unknown as ChatInternals; +- internals._createAgentSession = () => { throw new Error('_createAgentSession must not be called for the default chat'); }; - -- await agent.disposeChat(session, chatUri); +- await agent.chats.createChat(URI.parse(buildDefaultChatUri(session)), {}); - -- const remaining = await db.object.getMetadata('copilot.chats'); - assert.deepStrictEqual({ -- remaining: remaining ? JSON.parse(remaining) : {}, -- deleted: client.deletedSessionIds, +- tracked: peerChatCount(agent), - }, { -- remaining: {}, -- deleted: ['sdk-a'], +- tracked: 0, - }); - } finally { - await disposeAgent(agent); - } - }); -- }); - -- // Regression for the #319516 incident: a window reload reconnects with a -- // NEW clientId but an identical tool list. The cached SDK session's -- // staleness check (`ActiveClient.requiresRestart`) must NOT treat a -- // clientId-only change as a config change — otherwise either the session -- // is needlessly restarted, or (the actual bug) the cached session is -- // reused while the live clientId is never updated, so subsequent client -- // tool calls are stamped with the dead window's id and hang forever. -- suite('client tool refresh on reload (#319516)', () => { -- /** Minimal structural view of the agent's private per-session ActiveClient. */ -- type TestActiveClient = { -- readonly state: { readonly clientId: string | undefined }; -- snapshot(): Promise; -- requiresRestart(snap: IActiveClientSnapshot): Promise; -- }; +- test('createChat forks the source chat into a new peer 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 agent.createSession({ session, workingDirectories: [URI.file('/workspace')] }); +- +- const internals = agent as unknown as ChatInternals; +- // Install the default 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); +- +- // 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', inheritedTurnCount: 0 }; +- }; +- let captured: CopilotSessionLaunchPlan | undefined; +- internals._createAgentSession = (launchPlan) => { +- captured = launchPlan; +- return makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager).fake; +- }; - -- function getActiveClient(agent: CopilotAgent, session: URI): TestActiveClient { -- const activeClients = (agent as unknown as { _activeClients: { get(s: URI): TestActiveClient | undefined } })._activeClients; -- const activeClient = activeClients.get(session); -- assert.ok(activeClient, 'expected an ActiveClient to exist after setClientTools'); -- return activeClient; -- } +- const chatUri = URI.parse(buildChatUri(session, 'peer-fork')); +- const result = await agent.chats.fork(chatUri, { source: URI.parse(buildDefaultChatUri(session)), turnId: 't1' }); - -- const tools: ToolDefinition[] = [{ name: 'my_tool', description: 'A test tool', inputSchema: { type: 'object', properties: {} } }]; +- 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: hasPeerChatStub(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); +- } +- }); - -- test('clientId-only change (reload) does NOT require a restart and updates the live clientId', async () => { -- const agent = createTestAgent(disposables); +- test('createChat side chat forks hidden context and filters inherited turns', async () => { +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); - 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.setClientTools(session, 'client-A', tools); -- const activeClient = getActiveClient(agent, session); -- const appliedSnapshot = await activeClient.snapshot(); -- assert.strictEqual(activeClient.state.clientId, 'client-A'); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'side-peer'); +- await agent.createSession({ session, workingDirectories: [URI.file('/workspace')] }); +- const sourceTurn: Turn = { +- id: 't1', +- state: TurnState.Complete, +- message: { text: 'source', origin: { kind: MessageKind.User } }, +- responseParts: [], +- usage: undefined, +- }; +- const partialResponse = 'partial source answer'; +- const sourceContext = 'User request:\nsource\n\nAgent response:\nsource answer\n\n---\n\nUser request:\nactive source'; +- const injectedPrompt = injectSideChatContext('side', partialResponse, sourceContext); +- const sideTurn: Turn = { +- id: 't2', +- state: TurnState.Complete, +- message: { text: injectedPrompt, origin: { kind: MessageKind.User } }, +- responseParts: [], +- usage: undefined, +- }; +- const source = makeFakeChatSession(session, 'source-sdk', async () => [sourceTurn]); +- setDefaultSessionStub(agent, AgentSession.id(session), source.fake); +- const internals = agent as unknown as ChatInternals; +- internals._forkSdkChat = async () => ({ sessionId: 'side-sdk-id', inheritedTurnCount: 1 }); +- let messageReadCount = 0; +- let sideRecorder: IFakeChatRecorder | undefined; +- internals._createAgentSession = launchPlan => { +- const side = makeFakeChatSession(session, launchPlan.sessionId, async () => { +- messageReadCount++; +- return messageReadCount <= 2 ? [sourceTurn] : [sourceTurn, sideTurn]; +- }, launchPlan.shellManager); +- sideRecorder = side.rec; +- return side.fake; +- }; - -- // Window A reloads: window B reconnects with a new clientId but -- // the identical tool list. -- agent.setClientTools(session, 'client-B', [...tools]); +- const chatUri = URI.parse(buildChatUri(session, 'peer-side')); +- const sourceLockEntered = new DeferredPromise(); +- const releaseSourceLock = new DeferredPromise(); +- const sourceLock = internals._sessionSequencer.queue(AgentSession.id(session), 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, { sideChat: { source: URI.parse(buildDefaultChatUri(session)), turnId: 'active-turn', sourceContext, partialResponse } }), +- createTimeout.then(() => { throw new Error('Side chat 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' }); +- const turns = await agent.chats.getMessages(chatUri); - -- // Root-cause assertions: the cached SDK session must be reused -- // (no restart) AND the live clientId must now be window B's, so -- // the next client tool call is stamped with a live owner. -- assert.strictEqual(await activeClient.requiresRestart(appliedSnapshot), false); -- assert.strictEqual(activeClient.state.clientId, 'client-B'); +- assert.deepStrictEqual({ +- hasExplanationGuidance: sideRecorder?.sends[0]?.prompt.includes('Prefer explanation over action'), +- sentPrompts: sideRecorder?.sends.map(send => send.prompt), +- turns: turns.map(turn => turn.id), +- visiblePrompt: turns[0]?.message.text, +- sideChat: result ? JSON.parse(result.providerData!).sideChat : undefined, +- }, { +- hasExplanationGuidance: true, +- sentPrompts: [injectedPrompt, 'follow-up'], +- turns: ['t2'], +- visiblePrompt: 'side', +- sideChat: { source: buildDefaultChatUri(session), turnId: 'active-turn', inheritedTurnCount: 1, context: sourceContext, partialResponse }, +- }); - } finally { - await disposeAgent(agent); - } - }); - -- test('a structural tool change still requires a restart', async () => { -- const agent = createTestAgent(disposables); +- test('createChat side chat preserves a local source turn id while forking from the concrete provider anchor', async () => { +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); - try { -- const session = AgentSession.uri('copilotcli', 'tools-change-session'); -- -- agent.setClientTools(session, 'client-A', tools); -- const activeClient = getActiveClient(agent, session); -- const appliedSnapshot = await activeClient.snapshot(); +- await agent.authenticate('https://api.github.com', 'token'); +- const session = AgentSession.uri('copilotcli', 'side-local-peer'); +- await agent.createSession({ session, workingDirectories: [URI.file('/workspace')] }); +- const sourceTurn: Turn = { +- id: 't1', +- state: TurnState.Complete, +- message: { text: 'source', origin: { kind: MessageKind.User } }, +- responseParts: [], +- usage: undefined, +- }; +- const sourceContext = 'User request:\nsource\n\nAgent response:\nsource answer\n\n---\n\nUser request:\n!command'; +- const injectedPrompt = injectSideChatContext('side', undefined, sourceContext); +- const sideTurn: Turn = { +- id: 't2', +- state: TurnState.Complete, +- message: { text: injectedPrompt, origin: { kind: MessageKind.User } }, +- responseParts: [], +- usage: undefined, +- }; +- const source = makeFakeChatSession(session, 'source-sdk', async () => [sourceTurn]); +- setDefaultSessionStub(agent, AgentSession.id(session), source.fake); +- const internals = agent as unknown as ChatInternals; +- let forkTurnId: string | undefined; +- internals._forkSdkChat = async (_client, _sourceEntry, turnId) => { +- forkTurnId = turnId; +- return { sessionId: 'side-sdk-id', inheritedTurnCount: 1 }; +- }; +- let messageReadCount = 0; +- let sideRecorder: IFakeChatRecorder | undefined; +- internals._createAgentSession = launchPlan => { +- const side = makeFakeChatSession(session, launchPlan.sessionId, async () => { +- messageReadCount++; +- return messageReadCount <= 2 ? [sourceTurn] : [sourceTurn, sideTurn]; +- }, launchPlan.shellManager); +- sideRecorder = side.rec; +- return side.fake; +- }; - -- // A genuinely different tool set (added tool) must restart so the -- // SDK session is rebuilt with the new tools. -- agent.setClientTools(session, 'client-A', [...tools, { name: 'second_tool', description: 'another', inputSchema: { type: 'object', properties: {} } }]); +- const chatUri = URI.parse(buildChatUri(session, 'peer-side-local')); +- const result = await agent.chats.createChat(chatUri, { +- sideChat: { +- source: URI.parse(buildDefaultChatUri(session)), +- turnId: 'local-1', +- providerAnchorTurnId: 't1', +- sourceContext, +- }, +- }); +- 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); - -- assert.strictEqual(await activeClient.requiresRestart(appliedSnapshot), true); +- assert.deepStrictEqual({ +- forkTurnId, +- sentPrompts: sideRecorder?.sends.map(send => send.prompt), +- turns: turns.map(turn => turn.id), +- visiblePrompt: turns[0]?.message.text, +- sideChat: result ? JSON.parse(result.providerData!).sideChat : undefined, +- }, { +- forkTurnId: 't1', +- sentPrompts: [injectedPrompt, 'follow-up'], +- turns: ['t2'], +- visiblePrompt: 'side', +- sideChat: { +- source: buildDefaultChatUri(session), +- turnId: 'local-1', +- providerAnchorTurnId: 't1', +- inheritedTurnCount: 1, +- context: sourceContext, +- }, +- }); - } finally { - await disposeAgent(agent); - } - }); -- }); -- -- suite('_resumeSession dedup', () => { -- // Regression: two concurrent paths (e.g. an outdated-config refresh in -- // `sendMessage` and a `getSessionMessages` subscribe) each calling -- // `_resumeSession(id)` used to construct two `CopilotAgentSession` -- // entries for the same id; the second `_sessions.set(id, …)` on the -- // underlying `DisposableMap` disposed the first one mid -- // `initializeSession()`, producing 'Trying to add a disposable to a -- // DisposableStore that has already been disposed' warnings and a -- // half-initialised session with no event subscriptions. -- -- type AgentInternals = { -- _resumeSession: (id: string) => Promise; -- _doResumeSession: (id: string) => Promise; -- }; -- const makeFakeSession = () => ({ dispose: () => { } } as unknown as CopilotAgentSession); - -- test('dedupes concurrent calls for the same sessionId', async () => { +- test('sendMessage routes a turn to the targeted peer chat only', async () => { - const agent = createTestAgent(disposables); -- const internals = agent as unknown as AgentInternals; -- const deferred = new DeferredPromise(); -- let doResumeCalls = 0; -- internals._doResumeSession = () => { -- doResumeCalls++; -- return deferred.p; -- }; - try { -- const p1 = internals._resumeSession('s1'); -- const p2 = internals._resumeSession('s1'); -- assert.strictEqual(p1, p2); -- assert.strictEqual(doResumeCalls, 1); +- 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); - -- const session = makeFakeSession(); -- deferred.complete(session); -- assert.strictEqual(await p1, session); -- assert.strictEqual(await p2, session); +- 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); - } - }); - -- test('clears inflight entry after resolution so the next call re-invokes _doResumeSession', async () => { +- test('sendMessage throws for a peer chat with no backing chat', async () => { - const agent = createTestAgent(disposables); -- const internals = agent as unknown as AgentInternals; -- let doResumeCalls = 0; -- internals._doResumeSession = async () => { -- doResumeCalls++; -- return makeFakeSession(); -- }; - try { -- await internals._resumeSession('s1'); -- await internals._resumeSession('s1'); -- assert.strictEqual(doResumeCalls, 2); +- const session = AgentSession.uri('copilotcli', 'route-ghost'); +- const chatUri = URI.parse(buildChatUri(session, 'ghost')); +- await assert.rejects( +- () => agent.chats.sendMessage(chatUri, 'hi', undefined), +- /unknown chat/, +- ); - } finally { - await disposeAgent(agent); - } - }); - -- test('clears inflight entry on rejection so the next call retries', async () => { +- test('changeModel applies to the targeted peer chat only', async () => { - const agent = createTestAgent(disposables); -- const internals = agent as unknown as AgentInternals; -- let attempt = 0; -- internals._doResumeSession = async () => { -- attempt++; -- if (attempt === 1) { -- throw new Error('first failed'); -- } -- return makeFakeSession(); -- }; - try { -- await assert.rejects(() => internals._resumeSession('s1'), /first failed/); -- await internals._resumeSession('s1'); -- assert.strictEqual(attempt, 2); +- 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' }); +- +- 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); - } - }); - -- test('does not dedupe across different sessionIds', async () => { +- test('changeAgent resolves and applies the agent to the targeted peer chat, and clears it with undefined', async () => { - const agent = createTestAgent(disposables); -- const internals = agent as unknown as AgentInternals; -- const ids: string[] = []; -- internals._doResumeSession = async (id: string) => { -- ids.push(id); -- return makeFakeSession(); -- }; - try { -- await Promise.all([ -- internals._resumeSession('s1'), -- internals._resumeSession('s2'), -- ]); -- assert.deepStrictEqual([...ids].sort(), ['s1', 's2']); +- 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' }); +- await agent.chats.changeAgent(chatA, undefined); +- +- assert.deepStrictEqual(a.rec.agentCalls, ['Resolved Agent', undefined]); - } finally { - await disposeAgent(agent); - } - }); - -- test('post-init shutdown race: disposes the session and throws CancellationError instead of registering on a disposed _sessions map', async () => { -- // Without this guard an in-flight `_resumeSession` / -- // `_materializeProvisional` whose `initializeSession()` -- // resolves AFTER `dispose()` -> `shutdown()` -> `super.dispose()` -- // has run would call `_sessions.set(...)` on a disposed -- // DisposableMap, leaking the session and reproducing the -- // 'Trying to add a disposable to a DisposableStore that has -- // already been disposed' warning this PR exists to eliminate. -- const agent = createTestAgent(disposables); -- const internals = agent as unknown as { -- _registerInitializedSession: (id: string, s: CopilotAgentSession) => void; -- _shutdownPromise: Promise | undefined; -- }; -- let disposed = 0; -- const fakeSession = { dispose: () => { disposed++; } } as unknown as CopilotAgentSession; -- internals._shutdownPromise = Promise.resolve(); +- test('round-trips peer chats through providerData + materializeChat and resumes per-chat history after a restart', async () => { +- // A single session data service is shared across the two agent +- // instances to model the on-disk store surviving a process restart. +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const session = AgentSession.uri('copilotcli', 'restore-rt'); +- const created: Record = {}; +- const providerData: Record = {}; +- +- // ---- process #1: create two peer chats, capturing the opaque +- // providerData blob the orchestrator would persist for each ---- +- const agent1 = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); - try { -- assert.throws( -- () => internals._registerInitializedSession('s1', fakeSession), -- (err: unknown) => isCancellationError(err), -- ); -- assert.strictEqual(disposed, 1, 'session should be disposed by the guard'); +- await agent1.authenticate('https://api.github.com', 'token'); +- await agent1.createSession({ session, workingDirectories: [URI.file('/workspace')] }); +- const internals1 = agent1 as unknown as ChatInternals; +- internals1._createAgentSession = (launchPlan, _dir, _ac, identity) => { +- if (identity) { +- created[identity.chatChannelUri.authority] = launchPlan.sessionId; +- } +- return makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager).fake; +- }; +- const peerAUri = URI.parse(buildChatUri(session, 'peer-a')); +- const peerBUri = URI.parse(buildChatUri(session, 'peer-b')); +- const resA = await agent1.chats.createChat(peerAUri, {}); +- const resB = await agent1.chats.createChat(peerBUri, {}); +- providerData['peer-a'] = resA!.providerData!; +- providerData['peer-b'] = resB!.providerData!; - } finally { -- // Clear the fake shutdown promise so disposeAgent doesn't -- // short-circuit and leave real state behind. -- internals._shutdownPromise = undefined; -- await disposeAgent(agent); +- await disposeAgent(agent1); - } -- }); -- }); - -- suite('_resumeSession fallback', () => { -- type AgentInternals = { -- _resumeSession: (id: string) => Promise; -- }; +- // ---- 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 agent2.createSession({ 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, providerData['peer-a']); +- await agent2.materializeChat(peerB, providerData['peer-b']); +- +- const peerAHistory: readonly Turn[] = [{ id: 'turn-1' } as unknown as Turn]; +- let resumed: CopilotSessionLaunchPlan | undefined; +- internals2._createAgentSession = (launchPlan) => { +- resumed = launchPlan; +- return makeFakeChatSession(session, launchPlan.sessionId, async () => peerAHistory, launchPlan.shellManager).fake; +- }; - -- function createResumeFailingClient(message: string, code = -32603): { readonly client: TestCopilotClient; readonly getCreateSessionCalls: () => number } { -- let createSessionCalls = 0; -- const client = new TestCopilotClient([sdkSession('s1', '/workspace')]); -- client.resumeSession = async () => { -- throw new TestSdkError(message, code); -- }; -- client.createSession = async () => { -- createSessionCalls++; -- return new MockCopilotSession() as unknown as CopilotSession; -- }; -- return { client, getCreateSessionCalls: () => createSessionCalls }; -- } +- await agent2.chats.sendMessage(peerA, 'after restart', undefined); +- const history = await getPeerChatStub(agent2, peerA)!.getMessages(); - -- test('falls back to createSession after a Start Over truncate leaves the session empty', async () => { -- // Simulates the post-`truncateSession`/"Start Over" case: the on-disk -- // session has zero events, so the SDK's resumeSession refuses to -- // resume it. The exact wording varies across SDK versions, so we -- // assert on the general -32603 + "no events" shape. -- const { client, getCreateSessionCalls } = createResumeFailingClient(`Request session.resume failed with message: LocalRpcSession: 'session.getMessages' returned no events for session s1`); -- const agent = createTestAgent(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService: disposables.add(new TestSessionDataService()) }); -- const internals = agent as unknown as AgentInternals; -- try { -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- await internals._resumeSession('s1'); -- assert.strictEqual(getCreateSessionCalls(), 1); +- 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: hasPeerChatStub(agent2, peerA), +- }, { +- materializedBackings: [{ sdkSessionId: created['peer-a'] }, { sdkSessionId: created['peer-b'] }], +- resumeKind: 'resume', +- resumeSessionId: created['peer-a'], +- expectedSessionId: created['peer-a'], +- historyLen: 1, +- tracked: true, +- }); - } finally { -- await disposeAgent(agent); +- await disposeAgent(agent2); - } - }); - -- test('falls back to createSession for an unknown -32603 from resumeSession', async () => { -- // Defensive: if the SDK starts emitting some other generic -- // "cannot resume this session" message, we should still recover -- // rather than leaving the user with an unopenable session. -- const { client, getCreateSessionCalls } = createResumeFailingClient('Request session.resume failed: something went wrong'); -- const agent = createTestAgent(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService: disposables.add(new TestSessionDataService()) }); -- const internals = agent as unknown as AgentInternals; +- test('materializeChat falls back to the legacy copilot.chats catalog when providerData is undefined', async () => { +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); - try { -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- await internals._resumeSession('s1'); -- assert.strictEqual(getCreateSessionCalls(), 1); +- 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; +- +- // undefined blob -> agent recovers the backing from its own catalog. +- await agent.materializeChat(chatUri, undefined); +- // A corrupt blob is dropped (no backing recorded). +- const corruptUri = URI.parse(buildChatUri(session, 'peer-corrupt')); +- await agent.materializeChat(corruptUri, '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); - } - }); - -- test('does not replace a corrupted session file with an empty session', async () => { -- const { client, getCreateSessionCalls } = createResumeFailingClient('Request session.resume failed with message: Session file is corrupted (line 19567: data.compactionTokensUsed.copilotUsage.tokenDetails.0.batchSize: Number must be greater than 0)'); -- const agent = createTestAgent(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService: disposables.add(new TestSessionDataService()) }); -- const internals = agent as unknown as AgentInternals; +- test('changeModel on a peer chat refreshes its backing and fires onDidChangeChatData', async () => { +- const agent = createTestAgent(disposables); - try { -- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); -- await assert.rejects(() => internals._resumeSession('s1'), /Session file is corrupted/); -- assert.strictEqual(getCreateSessionCalls(), 0); +- 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) }))); +- +- await agent.chats.changeModel(chatUri, { id: 'model-x' }); +- +- 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); - } - }); - }); - -- suite('worktree announcement', () => { -- // Drives a real session through worktree creation (calling the -- // agent's _resolveSessionWorkingDirectory via a test seam so we don't -- // need a full Copilot SDK), then exercises both the live path -- // (sendMessage emits a synthetic delta) and the restore path -- // (getSessionMessages prepends to the first assistant message). A -- // stubbed CopilotAgentSession is injected via overriding _resumeSession -- // because the real one requires a full SDK CopilotSession with ~30 -- // event subscriptions. +- // The chat-addressed surface ({@link IAgent.chats}) is a thin adapter over +- // the legacy `(session, chat?)` methods. These tests verify it resolves a +- // single chat URI back to the right `(session, chat)` target — a peer +- // `ahp-chat` URI keeps its own identity, a session URI maps to the +- // session's default chat — and then delegates to the legacy implementation. +- suite('chat surface (IAgentChats)', () => { +- +- type ConvInternals = { +- _sessions: Map; +- _provisionalSessions: Map; +- _createAgentSession: (launchPlan: CopilotSessionLaunchPlan, dir: URI | undefined, activeClient: unknown, identity?: { sessionUri: URI; chatChannelUri: URI }) => CopilotAgentSession; +- }; - -- let tmpDir: string; +- interface IFakeConvRecorder { +- readonly sends: { prompt: string; turnId: string | undefined; senderClientId: string | undefined }[]; +- readonly resets: { turnId: string; senderClientId: string | undefined }[]; +- readonly modelCalls: string[]; +- readonly agentCalls: (string | undefined)[]; +- aborted: number; +- disposed: boolean; +- } - -- setup(async () => { -- tmpDir = await fs.mkdtemp(`${os.tmpdir()}/copilot-agent-worktree-test-`); -- }); +- /** +- * Installs a recording fake {@link CopilotAgentSession} as a peer chat +- * (hosted on the owning session's entry) or as a session's default chat, +- * keyed as the real agent would, so the chat adapter can drive +- * the real legacy methods. +- */ +- function installFake(agent: CopilotAgent, key: string, target: 'chat' | 'session', sessionUri: URI): IFakeConvRecorder { +- const rec: IFakeConvRecorder = { sends: [], resets: [], modelCalls: [], agentCalls: [], aborted: 0, disposed: false }; +- const fake = { +- sessionUri, +- sessionId: `sdk-${key}`, +- appliedSnapshot: { tools: [], plugins: [], mcpServers: {} } satisfies IActiveClientSnapshot, +- async send(prompt: string, _attachments: unknown, turnId: string | undefined, _mode: unknown, senderClientId: string | undefined): Promise { +- rec.sends.push({ prompt, turnId, senderClientId }); +- }, +- resetTurnState(turnId: string, senderClientId: string | undefined): void { rec.resets.push({ turnId, senderClientId }); }, +- async setModel(id: string): Promise { rec.modelCalls.push(id); }, +- async setAgent(name: string | undefined): Promise { rec.agentCalls.push(name); }, +- async abort(): Promise { rec.aborted++; }, +- async getMessages(): Promise { return [{ id: `turn-${key}` } as unknown as Turn]; }, +- handleClientToolCallComplete(): void { }, +- dispose(): void { rec.disposed = true; }, +- } as unknown as CopilotAgentSession; +- if (target === 'chat') { +- setPeerChatStub(agent, URI.parse(key), fake); +- } else { +- setDefaultSessionStub(agent, key, fake); +- } +- return rec; +- } - -- teardown(async () => { -- await fs.rm(tmpDir, { recursive: true, force: true }); -- }); +- /** +- * Stubs `_createAgentSession` (the SDK-backed launch seam) so peer-chat +- * creation/fork stays in-memory: it returns a minimal fake whose +- * `sessionId` echoes the launch plan, which is what `createChat` records +- * as the chat's backing. +- */ +- function stubBackingSession(agent: CopilotAgent): void { +- (agent as unknown as ConvInternals)._createAgentSession = (launchPlan, _dir, _ac, identity) => { +- return { +- sessionUri: identity?.sessionUri ?? AgentSession.uri('copilotcli', launchPlan.sessionId), +- sessionId: launchPlan.sessionId, +- appliedSnapshot: { tools: [], plugins: [], mcpServers: {} } satisfies IActiveClientSnapshot, +- async initializeSession(): Promise { }, +- async remapTurnIds(): Promise { }, +- async getMessages(): Promise { return []; }, +- handleClientToolCallComplete(): void { }, +- dispose(): void { launchPlan.shellManager?.dispose(); }, +- } as unknown as CopilotAgentSession; +- }; +- } - -- test('emits announcement live as a delta on first sendMessage and persists it for restore via getSessionMessages', async () => { -- const sessionId = 'wt-session'; -- const session = AgentSession.uri('copilotcli', sessionId); -- const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo'); -- await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); +- test('createSession mints a provisional session', async () => { +- const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); +- try { +- const session = AgentSession.uri('copilotcli', 'scope-create'); +- const result = await agent.createSession({ 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); +- } +- }); - -- const gitService = new TestAgentHostGitService(); -- gitService.repositoryRoot = repositoryRoot; +- test('disposeSession tears down a provisional session', async () => { +- const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); +- try { +- const session = AgentSession.uri('copilotcli', 'scope-dispose'); +- await agent.createSession({ session, workingDirectories: [URI.file('/workspace')] }); +- const internals = agent as unknown as ConvInternals; +- assert.strictEqual(internals._provisionalSessions.has(AgentSession.id(session)), true); - -- const copilotApiService = new TestCopilotApiService(); -- copilotApiService.response = 'add-feature'; -- const sessionDataService = disposables.add(new TestSessionDataService()); -- const agent = createTestAgent(disposables, { -- sessionDataService, -- copilotClient: new TestCopilotClient([]), -- gitService, -- copilotApiService, -- }) as TestableCopilotAgent; +- await agent.disposeSession(session); - -- const fakeMessages: Turn[] = [ -- { -- id: 'u1', -- message: { text: 'hi', origin: { kind: MessageKind.User } }, -- responseParts: [ -- { -- kind: ResponsePartKind.ToolCall, -- toolCall: { -- status: ToolCallStatus.Completed, -- toolCallId: 'tc-1', -- toolName: 'view', -- displayName: 'View File', -- invocationMessage: 'Reading file', -- success: true, -- pastTenseMessage: 'Read file', -- confirmed: ToolCallConfirmationReason.NotNeeded, -- }, -- }, -- { kind: ResponsePartKind.Markdown, id: 'a1', content: 'hello back' }, -- ], -- usage: undefined, -- state: TurnState.Complete, -- }, -- ]; -- let sendCalls = 0; -- agent.registerFakeSession(sessionId, { -- send: async () => { sendCalls++; }, -- getMessages: async () => fakeMessages, -- dispose: () => { }, -- }); +- assert.strictEqual(internals._provisionalSessions.has(AgentSession.id(session)), false); +- } finally { +- await disposeAgent(agent); +- } +- }); - +- test('createChat creates a peer chat 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 agent.createSession({ session, workingDirectories: [URI.file('/workspace')] }); +- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); - -- // 1. Drive worktree resolution: this is what createSession does -- // before constructing the SDK session. Verifies that the -- // real production code path persists branch metadata and -- // queues the live announcement. -- const expectedBranchName = `agents/add-feature-${sessionId.substring(0, 8)}`; -- const workingDir = await agent.resolveWorktreeForTest({ -- workingDirectory: repositoryRoot, -- config: { isolation: 'worktree', branch: 'main' }, -- }, sessionId, 'Add feature'); -- assert.ok(workingDir, 'resolveWorktreeForTest must return a worktree URI'); -- assert.deepStrictEqual(gitService.addedWorktrees.length, 1, 'addWorktree must be called once'); -- assert.strictEqual(gitService.addedWorktrees[0].branchName, expectedBranchName); -- -- // 2. Live path: sendMessage must fire a synthetic markdown -- // delta carrying the announcement before delegating to the -- // SDK. The session is responsible for emitting the -- // announcement after resetting partId tracking. -- const signals: AgentSignal[] = []; -- disposables.add(agent.onDidSessionProgress(s => { -- signals.push(s); -- })); -- -- await agent.sendMessage(session, 'hello'); -- assert.strictEqual(sendCalls, 1, 'underlying SDK send must still be called'); +- stubBackingSession(agent); +- const result = await agent.chats.createChat(chatUri, { model: { id: 'gpt-x' } }); - -- const markdownSignals = signals.filter((s): s is IAgentActionSignal => -- s.kind === 'action' && ( -- (s.action.type === ActionType.ChatResponsePart && s.action.part.kind === ResponsePartKind.Markdown) || -- s.action.type === ActionType.ChatDelta -- ) -- ); -- assert.strictEqual(markdownSignals.length, 1, 'exactly one markdown announcement signal should be emitted for the worktree announcement'); -- const announcement = markdownSignals[0]; -- const announcementContent = announcement.action.type === ActionType.ChatResponsePart -- ? (announcement.action.part as MarkdownResponsePart).content -- : (announcement.action as IDeltaAction).content; -- assert.ok(announcementContent.includes(expectedBranchName), `announcement should contain branch name '${expectedBranchName}', got '${announcementContent}'`); -- -- // 3. Live path is one-shot: a second sendMessage must not re-emit. -- signals.length = 0; -- await agent.sendMessage(session, 'follow-up'); -- const reemittedMarkdown = signals.filter(s => -- s.kind === 'action' && ( -- (s.action.type === ActionType.ChatResponsePart && s.action.part.kind === ResponsePartKind.Markdown) || -- s.action.type === ActionType.ChatDelta -- ) -- ); -- assert.strictEqual(reemittedMarkdown.length, 0, 'announcement must not be re-emitted on subsequent sends'); -- -- // 4. Restore path: getSessionMessages must prepend the -- // announcement ahead of every first-turn response part, -- // using the persisted branch metadata. -- const restored = await agent.getSessionMessages(session); -- const md = restored[0]?.responseParts[0] as MarkdownResponsePart | undefined; -- assert.ok(md, 'restored turns should include a markdown response part'); -- assert.strictEqual(md.kind, ResponsePartKind.Markdown, 'worktree announcement must be the first response part'); -- assert.ok(md.content.includes(expectedBranchName), `restored markdown content should include the branch name, got '${md.content}'`); -- assert.strictEqual(restored[0]?.responseParts[1]?.kind, ResponsePartKind.ToolCall, 'existing tool calls must remain after the announcement'); +- assert.deepStrictEqual({ +- tracked: hasPeerChatStub(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); - } - }); - -- test('does not announce or persist branch metadata when isolation is not worktree', async () => { -- const sessionId = 'no-wt-session'; -- const session = AgentSession.uri('copilotcli', sessionId); -- const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo'); -- await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); -- -- const gitService = new TestAgentHostGitService(); -- gitService.repositoryRoot = repositoryRoot; -- +- test('fork delegates to createChat with the fork source', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); -- const agent = createTestAgent(disposables, { -- sessionDataService, -- copilotClient: new TestCopilotClient([]), -- gitService, -- }) as TestableCopilotAgent; -- -- const fakeMessages: Turn[] = [ -- { id: 'u1', message: { text: 'hi', origin: { kind: MessageKind.User } }, responseParts: [{ kind: ResponsePartKind.Markdown, id: 'a1', content: 'untouched reply' }], usage: undefined, state: TurnState.Complete }, -- ]; -- agent.registerFakeSession(sessionId, { -- send: async () => { }, -- getMessages: async () => fakeMessages, -- dispose: () => { }, -- }); -- +- 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 agent.createSession({ 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; inheritedTurnCount: number }> })._forkSdkChat = async (_c, _s, turnId) => { +- forkArgs.push({ turnId }); +- return { sessionId: 'forked-sdk-id', inheritedTurnCount: 0 }; +- }; +- stubBackingSession(agent); - -- await agent.resolveWorktreeForTest({ workingDirectory: repositoryRoot }, sessionId); -- assert.deepStrictEqual(gitService.addedWorktrees, [], 'addWorktree must not be called without worktree isolation'); -- -- const signals: AgentSignal[] = []; -- disposables.add(agent.onDidSessionProgress(s => { -- signals.push(s); -- })); -- await agent.sendMessage(session, 'hello'); -- const markdownSignals = signals.filter(s => -- s.kind === 'action' && ( -- (s.action.type === ActionType.ChatResponsePart && s.action.part.kind === ResponsePartKind.Markdown) || -- s.action.type === ActionType.ChatDelta -- ) -- ); -- assert.deepStrictEqual(markdownSignals, [], 'no announcement should be emitted live'); +- const chatUri = URI.parse(buildChatUri(session, 'peer-fork')); +- const source: IAgentCreateChatForkSource = { source: URI.parse(buildDefaultChatUri(session)), turnId: 't1' }; +- const result = await agent.chats.fork(chatUri, source); - -- const restored = await agent.getSessionMessages(session); -- const md = restored[0]?.responseParts.find((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown); -- assert.strictEqual(md?.content, 'untouched reply', 'restored markdown content must not be modified'); +- assert.deepStrictEqual({ +- forkArgs, +- tracked: hasPeerChatStub(agent, chatUri), +- providerData: result ? JSON.parse(result.providerData!) : undefined, +- }, { +- forkArgs: [{ turnId: 't1' }], +- tracked: true, +- providerData: { sdkSessionId: 'forked-sdk-id' }, +- }); - } finally { - await disposeAgent(agent); - } - }); - -- test('onArchivedChanged removes the worktree on archive and recreates it on unarchive', async () => { -- const sessionId = 'archive-cleanup-session'; -- const session = AgentSession.uri('copilotcli', sessionId); -- const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo'); -- await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); -- -- const gitService = new TestAgentHostGitService(); -- gitService.repositoryRoot = repositoryRoot; -- -- const agent = createTestAgent(disposables, { -- sessionDataService: disposables.add(new TestSessionDataService()), -- copilotClient: new TestCopilotClient([]), -- gitService, -- }) as TestableCopilotAgent; -- +- test('sendMessage routes a peer chat URI to the peer chat', async () => { +- const agent = createTestAgent(disposables); - try { -- await agent.authenticate('https://api.github.com', 'token'); -- const workingDir = await agent.resolveWorktreeForTest({ -- workingDirectory: repositoryRoot, -- config: { isolation: 'worktree', branch: 'main' }, -- }, sessionId); -- assert.ok(workingDir, 'worktree must be created'); -- // Simulate the worktree directory existing on disk so the archive -- // path's existence-check passes; the test git service has no real repo. -- await fs.mkdir(workingDir!.fsPath, { recursive: true }); -- -- await agent.onArchivedChanged(session, true); -- assert.deepStrictEqual( -- gitService.removedWorktrees.map(r => r.worktree.fsPath), -- [workingDir!.fsPath], -- 'archive must remove the worktree once it is clean and the branch is preserved', -- ); +- const session = AgentSession.uri('copilotcli', 'conv-send-peer'); +- const chatUri = URI.parse(buildChatUri(session, 'peer-a')); +- const rec = installFake(agent, chatUri.toString(), 'chat', session); - -- // Simulate that the worktree directory is gone after removal. -- await fs.rm(workingDir!.fsPath, { recursive: true, force: true }); +- await agent.chats.sendMessage(chatUri, 'hello-peer', undefined, undefined, 'turn-1', 'client-1'); - -- await agent.onArchivedChanged(session, false); -- assert.deepStrictEqual( -- gitService.addedExistingWorktrees.map(r => ({ worktree: r.worktree.fsPath, branchName: r.branchName })), -- [{ worktree: workingDir!.fsPath, branchName: gitService.addedWorktrees[0].branchName }], -- 'unarchive must recreate the worktree using the preserved branch', -- ); +- 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); - } - }); - -- test('onArchivedChanged skips removal when worktree has uncommitted changes', async () => { -- const sessionId = 'archive-skip-dirty-session'; -- const session = AgentSession.uri('copilotcli', sessionId); -- const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo-dirty'); -- await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); +- test('sendMessage routes a scope (session) URI to the default chat', async () => { +- const agent = createTestAgent(disposables); +- try { +- const session = AgentSession.uri('copilotcli', 'conv-send-default'); +- const rec = installFake(agent, AgentSession.id(session), 'session', session); - -- const gitService = new TestAgentHostGitService(); -- gitService.repositoryRoot = repositoryRoot; +- await agent.chats.sendMessage(defaultChatUri(session), 'hello-default', undefined, undefined, 'turn-d', 'client-d'); - -- const agent = createTestAgent(disposables, { -- sessionDataService: disposables.add(new TestSessionDataService()), -- copilotClient: new TestCopilotClient([]), -- gitService, -- }) as TestableCopilotAgent; +- assert.deepStrictEqual(rec.sends, [{ prompt: 'hello-default', turnId: 'turn-d', senderClientId: 'client-d' }]); +- } finally { +- await disposeAgent(agent); +- } +- }); - +- test('abort, changeModel, and changeAgent route a peer URI to the peer chat', async () => { +- const agent = createTestAgent(disposables); - try { -- await agent.authenticate('https://api.github.com', 'token'); -- const workingDir = await agent.resolveWorktreeForTest({ -- workingDirectory: repositoryRoot, -- config: { isolation: 'worktree', branch: 'main' }, -- }, sessionId); -- await fs.mkdir(workingDir!.fsPath, { recursive: true }); -- gitService.dirtyWorkingDirectories.add(workingDir!.fsPath); +- 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); +- await agent.chats.changeModel(chatUri, { id: 'model-x' }); +- await agent.chats.changeAgent(chatUri, { uri: 'agent://x' }); +- await agent.chats.changeAgent(chatUri, undefined); - -- await agent.onArchivedChanged(session, true); -- assert.deepStrictEqual(gitService.removedWorktrees, [], 'must not remove a dirty worktree'); +- assert.deepStrictEqual({ +- aborted: rec.aborted, +- modelCalls: rec.modelCalls, +- agentCalls: rec.agentCalls, +- }, { +- aborted: 1, +- modelCalls: ['model-x'], +- agentCalls: ['Resolved Agent', undefined], +- }); - } finally { - await disposeAgent(agent); - } - }); - -- test('onArchivedChanged skips removal when branch is missing', async () => { -- const sessionId = 'archive-skip-no-branch-session'; -- const session = AgentSession.uri('copilotcli', sessionId); -- const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo-nobranch'); -- await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); +- test('getMessages returns the peer 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); - -- const gitService = new TestAgentHostGitService(); -- gitService.repositoryRoot = repositoryRoot; +- const turns = await agent.chats.getMessages(chatUri); - -- const agent = createTestAgent(disposables, { -- sessionDataService: disposables.add(new TestSessionDataService()), -- copilotClient: new TestCopilotClient([]), -- gitService, -- }) as TestableCopilotAgent; +- assert.deepStrictEqual(turns.map(t => t.id), [`turn-${chatUri.toString()}`]); +- } finally { +- await disposeAgent(agent); +- } +- }); - +- test('disposeChat disposes the peer 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 workingDir = await agent.resolveWorktreeForTest({ -- workingDirectory: repositoryRoot, -- config: { isolation: 'worktree', branch: 'main' }, -- }, sessionId); -- await fs.mkdir(workingDir!.fsPath, { recursive: true }); -- // Drop the branch so cleanup must skip. -- gitService.existingBranches.clear(); +- 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.onArchivedChanged(session, true); -- assert.deepStrictEqual(gitService.removedWorktrees, [], 'must not remove a worktree whose branch is missing'); +- await agent.chats.disposeChat(chatUri); +- +- assert.deepStrictEqual({ +- disposed: rec.disposed, +- tracked: hasPeerChatStub(agent, chatUri), +- deleted: client.deletedSessionIds, +- }, { +- disposed: true, +- tracked: false, +- deleted: ['sdk-' + chatUri.toString()], +- }); - } finally { - await disposeAgent(agent); - } - }); +- }); - -- test('onArchivedChanged is a no-op when no worktree metadata is persisted', async () => { -- const sessionId = 'archive-no-meta-session'; -- const session = AgentSession.uri('copilotcli', sessionId); -- const gitService = new TestAgentHostGitService(); -- const agent = createTestAgent(disposables, { -- sessionDataService: disposables.add(new TestSessionDataService()), -- copilotClient: new TestCopilotClient([]), -- gitService, -- }) as TestableCopilotAgent; +- // Regression for the #319516 incident: a window reload reconnects with a +- // NEW clientId but an identical tool list. The cached SDK session's +- // staleness check (`ActiveClient.requiresRestart`) must NOT treat a +- // clientId-only change as a config change — otherwise either the session +- // is needlessly restarted, or (the actual bug) the cached session is +- // reused while the live clientId is never updated, so subsequent client +- // tool calls are stamped with the dead window's id and hang forever. +- suite('client tool refresh on reload (#319516)', () => { +- /** Minimal structural view of the agent's private per-session ActiveClient. */ +- type TestActiveClient = { +- readonly toolSet: { ownerOf(toolName: string): string | undefined }; +- snapshot(): Promise; +- requiresRestart(snap: IActiveClientSnapshot): Promise; +- }; +- +- function getActiveClient(agent: CopilotAgent, session: URI): TestActiveClient { +- const activeClients = (agent as unknown as { _activeClients: { get(s: URI): TestActiveClient | undefined } })._activeClients; +- const activeClient = activeClients.get(session); +- assert.ok(activeClient, 'expected an ActiveClient to exist after registering client tools'); +- return activeClient; +- } - +- const tools: ToolDefinition[] = [{ name: 'my_tool', description: 'A test tool', inputSchema: { type: 'object', properties: {} } }]; +- +- test('clientId-only change (reload) does NOT require a restart and updates the live owner', async () => { +- const agent = createTestAgent(disposables); - try { -- await agent.authenticate('https://api.github.com', 'token'); -- await agent.onArchivedChanged(session, true); -- await agent.onArchivedChanged(session, false); -- assert.deepStrictEqual({ -- removed: gitService.removedWorktrees, -- addedExisting: gitService.addedExistingWorktrees, -- }, { removed: [], addedExisting: [] }); +- 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(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(session, 'client-A'); +- agent.getOrCreateActiveClient(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.requiresRestart(appliedSnapshot), false); +- assert.strictEqual(activeClient.toolSet.ownerOf('my_tool'), 'client-B'); - } finally { - await disposeAgent(agent); - } - }); - -- test('onArchivedChanged unarchive skips when worktree directory already exists', async () => { -- const sessionId = 'unarchive-existing-session'; -- const session = AgentSession.uri('copilotcli', sessionId); -- const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo-exists'); -- await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); +- test('a structural tool change still requires a restart', async () => { +- const agent = createTestAgent(disposables); +- try { +- const session = AgentSession.uri('copilotcli', 'tools-change-session'); - -- const gitService = new TestAgentHostGitService(); -- gitService.repositoryRoot = repositoryRoot; +- agent.getOrCreateActiveClient(session, { clientId: 'client-A' }).tools = tools; +- const activeClient = getActiveClient(agent, session); +- const appliedSnapshot = await activeClient.snapshot(); - -- const agent = createTestAgent(disposables, { -- sessionDataService: disposables.add(new TestSessionDataService()), -- copilotClient: new TestCopilotClient([]), -- gitService, -- }) as TestableCopilotAgent; +- // A genuinely different tool set (added tool) must restart so the +- // SDK session is rebuilt with the new tools. +- agent.getOrCreateActiveClient(session, { clientId: 'client-A' }).tools = [...tools, { name: 'second_tool', description: 'another', inputSchema: { type: 'object', properties: {} } }]; +- +- assert.strictEqual(await activeClient.requiresRestart(appliedSnapshot), true); +- } finally { +- await disposeAgent(agent); +- } +- }); - +- test('multiple active clients merge their tools and removal isolates per client', async () => { +- const agent = createTestAgent(disposables); - try { -- await agent.authenticate('https://api.github.com', 'token'); -- const workingDir = await agent.resolveWorktreeForTest({ -- workingDirectory: repositoryRoot, -- config: { isolation: 'worktree', branch: 'main' }, -- }, sessionId); -- await fs.mkdir(workingDir!.fsPath, { recursive: true }); +- const session = AgentSession.uri('copilotcli', 'multi-client-session'); +- +- // Two clients each contribute their own tool plus a shared one. +- agent.getOrCreateActiveClient(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(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); - -- await agent.onArchivedChanged(session, false); -- assert.deepStrictEqual(gitService.addedExistingWorktrees, [], 'must not recreate a worktree whose directory already exists'); +- // 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(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); - } - }); -- - }); --}); -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 7cff7183..00000000 ---- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts -+++ /dev/null -@@ -1,3982 +0,0 @@ --/*--------------------------------------------------------------------------------------------- -- * Copyright (c) Microsoft Corporation. All rights reserved. -- * Licensed under the MIT License. See License.txt in the project root for license information. +- +- suite('config-driven session refresh', () => { +- test('waits for the previous SDK session to disconnect before resuming', async () => { +- const client = new TestCopilotClient([]); +- const agent = createTestAgent(disposables, { copilotClient: client }); +- const sessionId = 'config-refresh-session'; +- const session = AgentSession.uri('copilotcli', sessionId); +- const disconnectStarted = new DeferredPromise(); +- const allowDisconnect = new DeferredPromise(); +- const order: string[] = []; +- const previousSession = { +- appliedSnapshot: { tools: [], plugins: [], mcpServers: {} }, +- destroySession: async () => { +- order.push('disconnect-started'); +- disconnectStarted.complete(); +- await allowDisconnect.p; +- order.push('disconnect-finished'); +- }, +- dispose: () => order.push('previous-disposed'), +- } as unknown as CopilotAgentSession; +- const resumedSession = { +- send: async () => { order.push('send'); }, +- dispose: () => { }, +- } as unknown as CopilotAgentSession; +- const internals = agent as unknown as { +- _resumeSession: (id: string) => Promise; +- }; +- +- setDefaultSessionStub(agent, sessionId, previousSession); +- agent.getOrCreateActiveClient(session, { clientId: 'client' }).tools = [ +- { name: 'new_tool', description: 'A newly registered tool', inputSchema: { type: 'object', properties: {} } }, +- ]; +- internals._resumeSession = async id => { +- assert.strictEqual(id, sessionId); +- order.push('resume'); +- setDefaultSessionStub(agent, sessionId, resumedSession); +- return resumedSession; +- }; +- +- try { +- const send = agent.chats.sendMessage(defaultChatUri(session), 'hello', undefined); +- await disconnectStarted.p; +- assert.deepStrictEqual(order, ['disconnect-started']); +- +- allowDisconnect.complete(); +- await send; +- assert.deepStrictEqual(order, [ +- 'disconnect-started', +- 'disconnect-finished', +- 'previous-disposed', +- 'resume', +- 'send', +- ]); +- } finally { +- allowDisconnect.complete(); +- await disposeAgent(agent); +- } +- }); +- }); +- +- suite('_resumeSession dedup', () => { +- // Regression: two concurrent paths (e.g. an outdated-config refresh in +- // `sendMessage` and a `getSessionMessages` subscribe) each calling +- // `_resumeSession(id)` used to construct two `CopilotAgentSession` +- // entries for the same id; the second `_sessions.set(id, …)` on the +- // underlying `DisposableMap` disposed the first one mid +- // `initializeSession()`, producing 'Trying to add a disposable to a +- // DisposableStore that has already been disposed' warnings and a +- // half-initialised session with no event subscriptions. +- +- type AgentInternals = { +- _resumeSession: (id: string) => Promise; +- _doResumeSession: (id: string) => Promise; +- }; +- const makeFakeSession = () => ({ dispose: () => { } } as unknown as CopilotAgentSession); +- +- test('dedupes concurrent calls for the same sessionId', async () => { +- const agent = createTestAgent(disposables); +- const internals = agent as unknown as AgentInternals; +- const deferred = new DeferredPromise(); +- let doResumeCalls = 0; +- internals._doResumeSession = () => { +- doResumeCalls++; +- return deferred.p; +- }; +- try { +- const p1 = internals._resumeSession('s1'); +- const p2 = internals._resumeSession('s1'); +- assert.strictEqual(p1, p2); +- assert.strictEqual(doResumeCalls, 1); +- +- const session = makeFakeSession(); +- deferred.complete(session); +- assert.strictEqual(await p1, session); +- assert.strictEqual(await p2, session); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('clears inflight entry after resolution so the next call re-invokes _doResumeSession', async () => { +- const agent = createTestAgent(disposables); +- const internals = agent as unknown as AgentInternals; +- let doResumeCalls = 0; +- internals._doResumeSession = async () => { +- doResumeCalls++; +- return makeFakeSession(); +- }; +- try { +- await internals._resumeSession('s1'); +- await internals._resumeSession('s1'); +- assert.strictEqual(doResumeCalls, 2); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('clears inflight entry on rejection so the next call retries', async () => { +- const agent = createTestAgent(disposables); +- const internals = agent as unknown as AgentInternals; +- let attempt = 0; +- internals._doResumeSession = async () => { +- attempt++; +- if (attempt === 1) { +- throw new Error('first failed'); +- } +- return makeFakeSession(); +- }; +- try { +- await assert.rejects(() => internals._resumeSession('s1'), /first failed/); +- await internals._resumeSession('s1'); +- assert.strictEqual(attempt, 2); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('does not dedupe across different sessionIds', async () => { +- const agent = createTestAgent(disposables); +- const internals = agent as unknown as AgentInternals; +- const ids: string[] = []; +- internals._doResumeSession = async (id: string) => { +- ids.push(id); +- return makeFakeSession(); +- }; +- try { +- await Promise.all([ +- internals._resumeSession('s1'), +- internals._resumeSession('s2'), +- ]); +- assert.deepStrictEqual([...ids].sort(), ['s1', 's2']); +- } finally { +- await disposeAgent(agent); +- } +- }); +- +- test('post-init shutdown race: disposes the session and throws CancellationError instead of registering on a disposed _sessions map', async () => { +- // Without this guard an in-flight `_resumeSession` / +- // `_materializeProvisional` whose `initializeSession()` +- // resolves AFTER `dispose()` -> `shutdown()` -> `super.dispose()` +- // has run would call `_sessions.set(...)` on a disposed +- // DisposableMap, leaking the session and reproducing the +- // 'Trying to add a disposable to a DisposableStore that has +- // already been disposed' warning this PR exists to eliminate. +- const agent = createTestAgent(disposables); +- const internals = agent as unknown as { +- _registerInitializedSession: (id: string, s: CopilotAgentSession) => void; +- _shutdownPromise: Promise | undefined; +- }; +- let disposed = 0; +- const fakeSession = { dispose: () => { disposed++; } } as unknown as CopilotAgentSession; +- internals._shutdownPromise = Promise.resolve(); +- try { +- assert.throws( +- () => internals._registerInitializedSession('s1', fakeSession), +- (err: unknown) => isCancellationError(err), +- ); +- assert.strictEqual(disposed, 1, 'session should be disposed by the guard'); +- } finally { +- // Clear the fake shutdown promise so disposeAgent doesn't +- // short-circuit and leave real state behind. +- internals._shutdownPromise = undefined; +- await disposeAgent(agent); +- } +- }); +- +- test('shutdown during resume cancels the in-flight resume', async () => { +- const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/resume-telemetry-shutdown-`); +- const client = new TestCopilotClient([sdkSession('s1', workingDirectory)]); +- const deferredSession = new DeferredPromise(); +- let resumeCalled = false; +- client.resumeSession = () => { +- resumeCalled = true; +- return deferredSession.p; +- }; +- const agent = createTestAgent(disposables, { +- copilotClient: client, +- useRealResumePath: true, +- sessionDataService: disposables.add(new TestSessionDataService()), +- }); +- const internals = agent as unknown as { _resumeSession: (id: string) => Promise }; +- try { +- await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); +- const resumePromise = internals._resumeSession('s1'); +- for (let i = 0; i < 200 && !resumeCalled; i++) { +- await timeout(0); +- } +- assert.strictEqual(resumeCalled, true); +- +- await agent.shutdown(); +- deferredSession.complete(new MockCopilotSession() as unknown as CopilotSession); +- +- await assert.rejects(resumePromise, (error: unknown) => isCancellationError(error)); +- } finally { +- await fs.rm(workingDirectory, { recursive: true, force: true }); +- await disposeAgent(agent); +- } +- }); +- }); +- +- suite('_resumeSession fallback', () => { +- type AgentInternals = { +- _resumeSession: (id: string) => Promise; +- }; +- +- test('does not restore a persisted custom agent that is absent from the current plugin snapshot', async () => { +- const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/resume-agent-`); +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const session = AgentSession.uri('copilotcli', 's1'); +- const dbRef = sessionDataService.openDatabase(session); +- try { +- await dbRef.object.setMetadata('copilot.workingDirectory', URI.file(workingDirectory).toString()); +- await dbRef.object.setMetadata('copilot.agent', JSON.stringify({ uri: 'file:///old-client/data.md' })); +- } finally { +- dbRef.dispose(); +- } +- +- const client = new TestCopilotClient([sdkSession('s1', workingDirectory)]); +- const resumeAgents: (string | undefined)[] = []; +- client.resumeSession = async (_sessionId, options) => { +- resumeAgents.push(options?.agent); +- return new MockCopilotSession() as unknown as CopilotSession; +- }; +- 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'); +- assert.deepStrictEqual(resumeAgents, [undefined]); +- } finally { +- await fs.rm(workingDirectory, { recursive: true, force: true }); +- await disposeAgent(agent); +- } +- }); +- +- 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.inMemory, disposables.add(new InMemoryFileSystemProvider()))); +- +- const repo = URI.from({ scheme: Schemas.inMemory, path: '/repo' }); +- const dataAgent = URI.joinPath(repo, '.github', 'agents', 'data.md'); +- await fileService.writeFile(dataAgent, VSBuffer.fromString('---\nname: Data\ndescription: data queries\n---\nbody')); +- +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const session = AgentSession.uri('copilotcli', 's1'); +- const dbRef = sessionDataService.openDatabase(session); +- try { +- await dbRef.object.setMetadata('copilot.workingDirectory', repo.toString()); +- await dbRef.object.setMetadata('copilot.agent', JSON.stringify({ uri: dataAgent.toString() })); +- } finally { +- dbRef.dispose(); +- } +- +- const client = new TestCopilotClient([sdkSession('s1')]); +- const resumeAgents: (string | undefined)[] = []; +- 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); +- } +- return new MockCopilotSession() as unknown as CopilotSession; +- }; +- client.createSession = async () => { +- throw new Error('createSession should not be called'); +- }; +- +- 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'); +- assert.deepStrictEqual(resumeAgents, ['Data', undefined]); +- } finally { +- await disposeAgent(agent); +- } +- }); +- }); +- +- suite('customization anchoring', () => { +- +- test('rebaseUnder rebases paths under the source dir and leaves others untouched', () => { +- const original = URI.file('/Users/me/src/vscode'); +- const worktree = URI.file('/Users/me/src/vscode.worktrees/agents-x'); +- assert.strictEqual( +- rebaseUnder(URI.file('/Users/me/src/vscode/.github/skills/sessions'), original, worktree)?.toString(), +- URI.file('/Users/me/src/vscode.worktrees/agents-x/.github/skills/sessions').toString(), +- 'a path under the source dir is rebased onto the target dir', +- ); +- assert.strictEqual( +- rebaseUnder(original, original, worktree)?.toString(), +- worktree.toString(), +- 'the source dir itself maps to the target dir', +- ); +- assert.strictEqual( +- rebaseUnder(URI.file('/Users/me/.copilot/skills/foo'), original, worktree), +- undefined, +- 'a path outside the source dir (e.g. user home) is not rebased', +- ); +- }); +- +- let tmpDir: string; +- +- setup(async () => { +- tmpDir = await fs.mkdtemp(`${os.tmpdir()}/copilot-agent-anchor-test-`); +- }); +- +- teardown(async () => { +- await fs.rm(tmpDir, { recursive: true, force: true }); +- }); +- +- async function materializeAndCaptureAnchor(resolvedWorkingDirectory: URI | undefined): Promise<{ anchor: URI | undefined; sdkWorkingDirectory: string | undefined; originalFolder: URI }> { +- const originalFolder = URI.joinPath(URI.file(tmpDir), 'repo'); +- await fs.mkdir(originalFolder.fsPath, { recursive: true }); +- +- const client = new TestCopilotClient([]); +- let sdkWorkingDirectory: string | undefined; +- client.createSession = async config => { +- sdkWorkingDirectory = config.workingDirectory; +- return new MockCopilotSession() as unknown as CopilotSession; +- }; +- +- const agent = createTestAgent(disposables, { +- sessionDataService: disposables.add(new TestSessionDataService()), +- copilotClient: client, +- }); +- +- // Capture the customization anchor handed to `_createAgentSession`. The +- // host pushes the resolved working directory (the worktree) into the first +- // `sendMessage`, mirroring AgentSideEffects. +- let anchor: URI | undefined; +- const agentInternals = agent as unknown as { +- _createAgentSession: (launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: unknown, identity?: { sessionUri: URI; chatChannelUri: URI }) => CopilotAgentSession; +- }; +- const originalCreateAgentSession = agentInternals._createAgentSession; +- agentInternals._createAgentSession = (launchPlan, customizationDirectory, activeClient, identity) => { +- anchor = customizationDirectory; +- return originalCreateAgentSession.call(agent, launchPlan, customizationDirectory, activeClient, identity); +- }; +- +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- const result = await agent.createSession({ session: AgentSession.uri('copilotcli', 'anchor-session'), workingDirectories: [originalFolder] }); +- assert.strictEqual(result.provisional, true); +- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', resolvedWorkingDirectory ? [resolvedWorkingDirectory] : undefined, undefined, undefined, undefined); +- } finally { +- await disposeAgent(agent); +- } +- return { anchor, sdkWorkingDirectory, originalFolder }; +- } +- +- test('materialization re-anchors customization discovery to the resolved worktree', async () => { +- const worktree = URI.joinPath(URI.file(tmpDir), 'repo.worktrees', 'agents-x'); +- const { anchor, sdkWorkingDirectory, originalFolder } = await materializeAndCaptureAnchor(worktree); +- assert.strictEqual(anchor?.toString(), worktree.toString(), 'customization discovery must be anchored to the worktree, not the original folder'); +- assert.notStrictEqual(anchor?.toString(), originalFolder.toString(), 'the anchor must move off the original folder'); +- assert.strictEqual(sdkWorkingDirectory, worktree.fsPath, 'the SDK working directory must be the worktree'); +- }); +- +- test('materialization without a worktree keeps the anchor on the original folder', async () => { +- const originalFolder = URI.joinPath(URI.file(tmpDir), 'repo'); +- const { anchor } = await materializeAndCaptureAnchor(undefined); +- assert.strictEqual(anchor?.toString(), originalFolder.toString(), 'the anchor stays on the original folder when no worktree is created'); +- }); +- +- test('worktree skill/instruction directories sent to the SDK resolve inside the worktree', async () => { +- const fileService = disposables.add(new FileService(new NullLogService())); +- disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); +- +- const originalFolder = URI.from({ scheme: Schemas.inMemory, path: '/orig' }); +- const worktree = URI.from({ scheme: Schemas.inMemory, path: '/wt' }); +- +- // A skill present only in the ORIGINAL folder must NOT reach the SDK; +- // the worktree's own skill + instruction must. +- await fileService.writeFile(URI.joinPath(originalFolder, '.github', 'skills', 'orig-skill', 'SKILL.md'), VSBuffer.fromString('---\nname: orig-skill\ndescription: from the original repo\n---\nbody')); +- await fileService.writeFile(URI.joinPath(worktree, '.github', 'skills', 'wt-skill', 'SKILL.md'), VSBuffer.fromString('---\nname: wt-skill\ndescription: from the worktree\n---\nbody')); +- await fileService.writeFile(URI.joinPath(worktree, '.github', 'instructions', 'wt.instructions.md'), VSBuffer.fromString('---\napplyTo: "**/*.ts"\ndescription: worktree instruction\n---\nbody')); +- +- const client = new TestCopilotClient([]); +- let capturedConfig: Parameters[0] | undefined; +- client.createSession = async config => { +- capturedConfig = config; +- return new MockCopilotSession() as unknown as CopilotSession; +- }; +- +- const { agent } = createTestAgentContext(disposables, { +- sessionDataService: disposables.add(new TestSessionDataService()), +- copilotClient: client, +- fileService, +- }); +- +- // The active-client claim anchors the provisional plugin controller to +- // the ORIGINAL folder first; the host pushes the worktree into the first +- // send, so this exercises the re-anchor at materialization. +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- const result = await agent.createSession({ +- session: AgentSession.uri('copilotcli', 'wt-dirs-session'), +- workingDirectories: [originalFolder], +- activeClient: { clientId: 'c1', tools: [] }, +- }); +- assert.strictEqual(result.provisional, true); +- await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', [worktree], undefined, undefined, undefined); +- } finally { +- await disposeAgent(agent); +- } +- +- assert.ok(capturedConfig, 'the SDK createSession must run during materialization'); +- assert.deepStrictEqual( +- { +- workingDirectory: capturedConfig.workingDirectory, +- skillDirectories: capturedConfig.skillDirectories, +- instructionDirectories: capturedConfig.instructionDirectories, +- }, +- { +- workingDirectory: worktree.fsPath, +- skillDirectories: [URI.joinPath(worktree, '.github', 'skills', 'wt-skill').fsPath], +- instructionDirectories: [URI.joinPath(worktree, '.github', 'instructions').fsPath], +- }, +- 'skill/instruction directories sent to the SDK must resolve inside the worktree, never the original folder', +- ); +- }); +- +- }); +- +- suite('custom agent worktree translation', () => { +- +- // The new methods under test are private; reach in the same way the +- // surrounding suites do (e.g. `customization anchoring`). +- type AgentInternals = { +- _getAlternativeAgentForWorktree(provisional: unknown, workingDirectory: URI | undefined): AgentSelection | undefined; +- _resolveAgentWhenMaterializing(provisional: unknown, snapshot: IActiveClientSnapshot, workingDirectory: URI | undefined): Promise<{ agent: AgentSelection; name: string } | undefined>; +- _resolveAgentName(snapshot: IActiveClientSnapshot, agent: AgentSelection): string | undefined; +- _createAgentSession(launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: unknown, identity?: { sessionUri: URI; chatChannelUri: URI }): CopilotAgentSession; +- _readSessionMetadata(session: URI): Promise<{ agent?: AgentSelection }>; +- }; +- +- const repo = URI.file('/repo'); +- const worktree = URI.joinPath(URI.file('/repo.worktrees'), 'agents-x'); +- const repoAgentUri = URI.joinPath(repo, '.github', 'agents', 'agent.md').toString(); +- const worktreeAgentUri = URI.joinPath(worktree, '.github', 'agents', 'agent.md').toString(); +- const emptySnapshot: IActiveClientSnapshot = { tools: [], plugins: [], mcpServers: {} }; +- +- function provisional(workingDirectory: URI | undefined, agent: AgentSelection | undefined): unknown { +- const sessionUri = AgentSession.uri('copilotcli', 'prov-agent'); +- return { sessionId: AgentSession.id(sessionUri), sessionUri, workingDirectory, model: undefined, agent, project: 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); +- } +- }); +- +- 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); +- } +- }); +- +- 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); +- } +- }); +- +- 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); +- } +- }); +- +- test('materialization rewrites a repo agent to its worktree copy and persists it (no resolution stubbing)', async () => { +- // End-to-end through real customization discovery: the same custom +- // agent file exists in both the original repo and the worktree. The +- // user selects the repo copy, but once the worktree is materialized +- // discovery re-anchors there, so the persisted/launched agent must be +- // the worktree copy — proving the translation against real resolution +- // rather than a stubbed `_resolveAgentName`. +- const fileService = disposables.add(new FileService(new NullLogService())); +- disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); +- +- const repoFolder = URI.from({ scheme: Schemas.inMemory, path: '/repo' }); +- const worktreeFolder = URI.from({ scheme: Schemas.inMemory, path: '/repo.worktrees/agents-x' }); +- const repoAgentFile = URI.joinPath(repoFolder, '.github', 'agents', 'agent.md'); +- const worktreeAgentFile = URI.joinPath(worktreeFolder, '.github', 'agents', 'agent.md'); +- const agentContents = VSBuffer.fromString('---\nname: My Agent\ndescription: a custom agent\n---\nbody'); +- await fileService.writeFile(repoAgentFile, agentContents); +- await fileService.writeFile(worktreeAgentFile, agentContents); +- +- const client = new TestCopilotClient([]); +- client.createSession = async () => new MockCopilotSession() as unknown as CopilotSession; +- +- const sessionDataService = disposables.add(new TestSessionDataService()); +- const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, fileService }); +- +- let launchAgentName: string | undefined; +- const internals = agent as unknown as AgentInternals; +- const originalCreateAgentSession = internals._createAgentSession; +- internals._createAgentSession = (launchPlan, customizationDirectory, activeClient, identity) => { +- launchAgentName = launchPlan.resolvedAgentName; +- return originalCreateAgentSession.call(agent, launchPlan, customizationDirectory, activeClient, identity); +- }; +- +- try { +- await agent.authenticate('https://api.github.com', 'token'); +- const result = await agent.createSession({ +- 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); +- +- // `_readSessionMetadata` reads back the exact agent field the +- // resume path consumes, so asserting it stands in for restore. +- const stored = await internals._readSessionMetadata(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); +- } +- }); +- +- }); +-}); +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 84d85c93..00000000 +--- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts ++++ /dev/null +@@ -1,8825 +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 { CopilotSession, SessionEvent, SessionEventPayload, SessionEventType, Tool, ToolResultObject, TypedSessionEventHandler } from '@github/copilot-sdk'; --import assert from 'assert'; --import { DeferredPromise, timeout } from '../../../../base/common/async.js'; --import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; --import { Emitter } from '../../../../base/common/event.js'; --import { DisposableStore } from '../../../../base/common/lifecycle.js'; --import { join, sep } from '../../../../base/common/path.js'; --import { URI } from '../../../../base/common/uri.js'; --import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; --import { INativeEnvironmentService } from '../../../environment/common/environment.js'; --import { IFileService } from '../../../files/common/files.js'; --import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; --import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; --import { ILogService, NullLogService } from '../../../log/common/log.js'; --import type { ClassifiedEvent, IGDPRProperty, OmitMetadata, StrictPropertyCheck } from '../../../telemetry/common/gdprTypings.js'; --import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; --import { NullTelemetryServiceShape } from '../../../telemetry/common/telemetryUtils.js'; --import { AgentSession, type AgentSignal, type IAgentActionSignal, type IAgentToolPendingConfirmationSignal } from '../../common/agentService.js'; --import { AgentFeedbackAttachmentDisplayKind } from '../../common/meta/agentFeedbackAttachments.js'; --import { IDiffComputeService } from '../../common/diffComputeService.js'; --import { ISessionDataService } from '../../common/sessionDataService.js'; --import { ActionType, type ChatDeltaAction, type ChatErrorAction, type ChatInputRequestedAction, type ChatResponsePartAction, type ChatToolCallCompleteAction, type ChatToolCallReadyAction, type ChatToolCallStartAction, type ChatTurnCompleteAction } from '../../common/state/sessionActions.js'; --import { MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, type ToolDefinition, type ToolResultContent, type ToolResultFileEditContent } from '../../common/state/sessionState.js'; --import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; --import { ActiveClientState } from '../../node/activeClientState.js'; --import { type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from '../../node/copilot/copilotSessionLauncher.js'; --import { CopilotSessionWrapper } from '../../node/copilot/copilotSessionWrapper.js'; --import { buildCopilotSystemNotification } from '../../node/copilot/copilotSystemNotification.js'; --import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; --import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; --import { createSessionDataService, createZeroDiffComputeService } from '../common/sessionTestHelpers.js'; --import { IAgentServerToolHost } from '../../common/agentServerTools.js'; +-import type Anthropic from '@anthropic-ai/sdk'; +-import type { CopilotSession, CurrentToolMetadata, PermissionAllowAllMode, SessionEvent, SessionEventHandler, SessionEventPayload, SessionEventType, Tool, ToolResultObject, TypedSessionEventHandler } from '@github/copilot-sdk'; +-import type { CCAModel } from '@vscode/copilot-api'; +-import assert from 'assert'; +-import { DeferredPromise, timeout } from '../../../../base/common/async.js'; +-import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; +-import { Emitter } from '../../../../base/common/event.js'; +-import { DisposableStore } from '../../../../base/common/lifecycle.js'; +-import { join, sep } from '../../../../base/common/path.js'; +-import { URI } from '../../../../base/common/uri.js'; +-import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +-import { INativeEnvironmentService } from '../../../environment/common/environment.js'; +-import { IFileService } from '../../../files/common/files.js'; +-import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; +-import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; +-import { ILogService, NullLogService } from '../../../log/common/log.js'; +-import type { ClassifiedEvent, IGDPRProperty, OmitMetadata, StrictPropertyCheck } from '../../../telemetry/common/gdprTypings.js'; +-import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; +-import { NullTelemetryServiceShape } from '../../../telemetry/common/telemetryUtils.js'; +-import { AgentSession, type AgentSignal, type IAgentActionSignal, type IAgentToolPendingConfirmationSignal } from '../../common/agentService.js'; +-import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; +-import type { ChatInputRequestWithPlanReview } from '../../common/agentHostPlanReview.js'; +-import { AgentFeedbackAttachmentDisplayKind } from '../../common/meta/agentFeedbackAttachments.js'; +-import { readToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; +-import { IDiffComputeService } from '../../common/diffComputeService.js'; +-import { ISessionDataService, type ISessionDatabase } from '../../common/sessionDataService.js'; +-import { ActionType, 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 { MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, createSessionState, mergeSessionWithDefaultChat, readSessionPromptCacheState, readUsageInfoMeta, SessionStatus, withSessionPromptCacheState, type ToolDefinition, type ToolResultContent, type ToolResultFileEditContent, type ToolResultTerminalContent, type UsageInfoMeta } from '../../common/state/sessionState.js'; +-import { TerminalClaimKind } from '../../common/state/protocol/state.js'; +-import { STREAMING_TOOL_DISPLAY_INTERVAL_MS } from '../../common/streamingToolCallDisplay.js'; +-import { CustomizationType, McpAuthRequiredReason, McpServerStatus, type Customization } from '../../common/state/protocol/channels-session/state.js'; +-import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; +-import { buildNonPtyShellTerminalUri } from '../../node/copilot/copilotNonPtyShellTerminals.js'; +-import { ActiveClientToolSet } from '../../node/activeClientState.js'; +-import { type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from '../../node/copilot/copilotSessionLauncher.js'; +-import { CopilotSessionWrapper } from '../../node/copilot/copilotSessionWrapper.js'; +-import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentHostStateManager.js'; +-import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; +-import { TestAgentHostTerminalManager } from './testAgentHostTerminalManager.js'; +-import { buildCopilotSystemNotification } from '../../node/copilot/copilotSystemNotification.js'; +-import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; +-import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +-import { AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey } from '../../common/agentHostSchema.js'; +-import { CopilotCliConfigKey } from '../../common/copilotCliConfig.js'; +-import { AgentHostSandboxConfigKey, AgentHostSandboxKey } from '../../common/sandboxConfigSchema.js'; +-import { AgentSandboxEnabledValue } from '../../../sandbox/common/settings.js'; +-import { createNoopGitService, createSessionDataService, createZeroDiffComputeService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; +-import { OtelData } from '../../common/otlp/otlpLogEmitter.js'; +-import { IAgentServerToolHost } from '../../common/agentServerTools.js'; +-import { IAgentHostGitService } from '../../common/agentHostGitService.js'; +-import { ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest, type IRestrictedTelemetryContext } from '../../node/shared/copilotApiService.js'; +-import { IAgentHostGitHubEndpointService } from '../../node/agentHostGitHubEndpointService.js'; +-import type { IAgentHostRestrictedTelemetry, IAgentHostRestrictedTelemetryContext, IAgentHostInternalTelemetryContext, TelemetryMeasurements, TelemetryProps } from '../../node/agentHostRestrictedTelemetry.js'; +-import { createTestGitHubEndpointService } from './testGitHubEndpointService.js'; +- +-// ---- Mock CopilotSession (SDK level) ---------------------------------------- +- +-/** +- * Minimal mock of the SDK's {@link CopilotSession}. Implements `on()` to +- * store typed handlers, and exposes `fire()` so tests can push events +- * through the real {@link CopilotSessionWrapper} event pipeline. +- */ +-class MockCopilotSession { +- readonly sessionId = 'test-session-1'; +- readonly sendRequests: unknown[] = []; +- readonly modeSetCalls: Array<{ mode: 'interactive' | 'plan' | 'autopilot' }> = []; +- readonly permissionModeSetCalls: PermissionAllowAllMode[] = []; +- permissionModeSetSuccess = true; +- readonly experimentalModeUpdates: boolean[] = []; +- experimentalModeUpdateSuccess = true; +- abortCalls = 0; +- abortGate: Promise | undefined; +- readonly compactCalls: unknown[] = []; +- readonly commandListCalls: unknown[] = []; +- readonly commandInvokeCalls: Array<{ name: string; input?: string }> = []; +- readonly mcpEnableCalls: Array<{ serverName: string }> = []; +- readonly mcpDisableCalls: Array<{ serverName: string }> = []; +- readonly mcpStartServerCalls: Array<{ serverName: string }> = []; +- readonly mcpStopServerCalls: Array<{ serverName: string }> = []; +- mcpDisableGate: Promise | undefined; +- compactResult: { success: boolean; tokensRemoved: number; messagesRemoved: number; contextWindow?: { currentTokens: number; tokenLimit: number; messagesLength: number } } = { success: true, tokensRemoved: 0, messagesRemoved: 0 }; +- compactError: unknown = undefined; +- /** Invoked inside `rpc.history.compact` before it settles, so tests can emit the SDK's in-flight compaction events. */ +- onCompact: (() => void) | undefined = undefined; +- commandListResult: { +- commands: Array<{ +- name: string; +- kind: 'builtin' | 'skill' | 'client'; +- description: string; +- allowDuringAgentExecution: boolean; +- aliases?: string[]; +- input?: { hint: string; required?: boolean; preserveMultilineInput?: boolean }; +- }>; +- } = { commands: [] }; +- commandInvokeResult: { kind: 'text'; text: string; markdown?: boolean } | { kind: 'completed'; message?: string } | { kind: 'agent-prompt'; prompt: string; displayPrompt: string; mode?: 'interactive' | 'plan' | 'autopilot' } = { kind: 'text', text: '' }; +- messages: SessionEvent[] = []; +- usageMetricsResult = { +- totalPremiumRequestCost: 0, +- totalUserRequests: 0, +- totalApiDurationMs: 0, +- totalNanoAiu: 0, +- sessionStartTime: new Date().toISOString(), +- codeChanges: { linesAdded: 0, linesRemoved: 0, filesModifiedCount: 0, filesModified: [] }, +- modelMetrics: {} as Record, +- currentModel: undefined as string | undefined, +- lastCallInputTokens: 0, +- lastCallOutputTokens: 0, +- }; +- /** Rejects the next `usage.getMetrics` call, then clears itself. */ +- usageMetricsError: unknown = undefined; +- usageMetricsCalls = 0; +- /** Awaited inside `usage.getMetrics` so tests can hold a refresh in flight. */ +- usageMetricsGate: Promise | undefined; +- disconnectCalls = 0; +- disconnectGate: Promise | undefined; +- disconnectHook: (() => void) | undefined; +- /** +- * Per-call gates, consumed in call order, for holding individual reads in flight. +- * Lets a test make an earlier-issued read resolve after a later one. +- */ +- readonly usageMetricsGates: Array> = []; +- +- private readonly _handlers = new Map void>>(); +- private readonly _allHandlers = new Set(); +- planReadResult: { exists: boolean; content: string | null; path: string | null } = { exists: false, content: null, path: null }; +- planReadPromise: Promise<{ exists: boolean; content: string | null; path: string | null }> | undefined; +- +- getInstructionSourcesResult: { sources: Array<{ id: string; label: string; sourcePath: string; content: string; type: string; location: string; applyTo?: string[] }> } = { sources: [] }; +- getInstructionSourcesError: unknown = undefined; +- getInstructionSourcesCallCount = 0; +- +- on(handler: SessionEventHandler): () => void; +- on(eventType: K, handler: TypedSessionEventHandler): () => void; +- on(eventTypeOrHandler: K | SessionEventHandler, handler?: TypedSessionEventHandler): () => void { +- if (typeof eventTypeOrHandler === 'function') { +- this._allHandlers.add(eventTypeOrHandler); +- return () => { this._allHandlers.delete(eventTypeOrHandler); }; +- } +- +- const eventType = eventTypeOrHandler; +- let set = this._handlers.get(eventType); +- if (!set) { +- set = new Set(); +- this._handlers.set(eventType, set); +- } +- assert.ok(handler); +- set.add(handler as (event: SessionEvent) => void); +- return () => { set.delete(handler as (event: SessionEvent) => void); }; +- } +- +- /** Push an event through to all registered handlers of the given type. */ +- fire(type: K, data: SessionEventPayload['data'], overrides?: Partial, 'type' | 'data'>>): void { +- const event = { type, data, id: 'evt-1', timestamp: new Date().toISOString(), parentId: null, ...overrides } as SessionEventPayload; +- this._accumulateUsageMetrics(type, data); +- const set = this._handlers.get(type); +- if (set) { +- for (const handler of set) { +- handler(event); +- } +- } +- for (const handler of this._allHandlers) { +- handler(event); +- } +- } +- +- /** +- * Mirrors the SDK's own usage tracker, which folds the `copilotUsage` billed on +- * `assistant.usage` (including sub-agent calls) and `session.compaction_complete` +- * into the session-wide total that `usage.getMetrics` reports. +- */ +- private _accumulateUsageMetrics(type: SessionEventType, data: unknown): void { +- if (type === 'session.model_change') { +- const modelChange = data as { newModel?: string }; +- if (modelChange.newModel) { +- this.usageMetricsResult.currentModel = modelChange.newModel; +- } +- } +- if (type === 'assistant.usage') { +- const usage = data as { model?: string; cacheExpiresAt?: string; parentToolCallId?: string }; +- if (!usage.parentToolCallId && usage.model) { +- this.usageMetricsResult.currentModel = usage.model; +- if (usage.cacheExpiresAt) { +- this.usageMetricsResult.modelMetrics[usage.model] = { cacheExpiresAt: usage.cacheExpiresAt }; +- } +- } +- } +- const billed = type === 'assistant.usage' +- ? data +- : type === 'session.compaction_complete' +- ? (data as { compactionTokensUsed?: unknown } | undefined)?.compactionTokensUsed +- : undefined; +- const totalNanoAiu = (billed as { copilotUsage?: { totalNanoAiu?: number } } | undefined)?.copilotUsage?.totalNanoAiu; +- if (typeof totalNanoAiu === 'number') { +- this.usageMetricsResult.totalNanoAiu += totalNanoAiu; +- } +- } +- +- // Stubs for methods the wrapper / session class calls +- async send(request: unknown) { +- this.sendRequests.push(request); +- return `message-${this.sendRequests.length}`; +- } +- async abort() { +- this.abortCalls++; +- await this.abortGate; +- } +- async setModel() { } +- async getEvents(): Promise { return this.messages; } +- async disconnect() { +- this.disconnectCalls++; +- this.disconnectHook?.(); +- await this.disconnectGate; +- } +- +- readonly rpc = { +- mode: { +- get: async () => ({ mode: 'interactive' as const }), +- set: async (params: { mode: 'interactive' | 'plan' | 'autopilot' }) => { +- this.modeSetCalls.push({ mode: params.mode }); +- }, +- }, +- permissions: { +- setAllowAll: async (params: { mode?: PermissionAllowAllMode }) => { +- const mode = params.mode ?? 'off'; +- this.permissionModeSetCalls.push(mode); +- return { success: this.permissionModeSetSuccess, enabled: mode === 'on', mode }; +- }, +- }, +- plan: { +- read: async () => this.planReadPromise ?? this.planReadResult, +- update: async (_params: { content: string }) => { /* no-op */ }, +- delete: async () => { /* no-op */ }, +- }, +- history: { +- compact: async (params?: unknown) => { +- this.compactCalls.push(params ?? null); +- this.onCompact?.(); +- if (this.compactError !== undefined) { +- throw this.compactError; +- } +- return this.compactResult; +- }, +- }, +- commands: { +- list: async (params?: unknown) => { +- this.commandListCalls.push(params ?? null); +- return this.commandListResult; +- }, +- invoke: async (params: { name: string; input?: string }) => { +- this.commandInvokeCalls.push(params); +- return this.commandInvokeResult; +- }, +- }, +- mcp: { +- list: async () => { +- if (this.mcpListError !== undefined) { +- throw this.mcpListError; +- } +- return this.mcpListResult; +- }, +- enable: async (params: { serverName: string }) => { +- this.mcpEnableCalls.push(params); +- if (this.mcpEnableError !== undefined) { +- throw this.mcpEnableError; +- } +- this.mcpListResult = { +- servers: this.mcpListResult.servers.map(server => server.name === params.serverName ? { ...server, status: 'pending' } : server), +- }; +- }, +- listTools: async (_params: { serverName: string }) => { +- return { tools: [] }; +- }, +- disable: async (params: { serverName: string }) => { +- this.mcpDisableCalls.push(params); +- await this.mcpDisableGate; +- this.mcpListResult = { +- servers: this.mcpListResult.servers.map(server => server.name === params.serverName ? { ...server, status: 'disabled' } : server), +- }; +- }, +- startServer: async (params: { serverName: string }) => { +- this.mcpStartServerCalls.push(params); +- if (this.mcpStartServerError !== undefined) { +- throw this.mcpStartServerError; +- } +- this.mcpListResult = { +- servers: this.mcpListResult.servers.map(server => server.name === params.serverName ? { ...server, status: 'pending' } : server), +- }; +- }, +- stopServer: async (params: { serverName: string }) => { +- this.mcpStopServerCalls.push(params); +- this.mcpListResult = { +- servers: this.mcpListResult.servers.map(server => server.name === params.serverName ? { ...server, status: 'not_configured' } : server), +- }; +- }, +- executeSampling: async () => ({ status: 'completed' as const, result: undefined }), +- cancelSamplingExecution: async () => { /* no-op */ }, +- }, +- options: { +- update: async (params: { sandboxConfig?: unknown; isExperimentalMode?: boolean }) => { +- if (params.sandboxConfig !== undefined) { +- this.sandboxConfigUpdates.push(params.sandboxConfig); +- } +- if (params.isExperimentalMode !== undefined) { +- this.experimentalModeUpdates.push(params.isExperimentalMode); +- } +- return { success: this.experimentalModeUpdateSuccess }; +- }, +- }, +- instructions: { +- getSources: async () => { +- this.getInstructionSourcesCallCount++; +- if (this.getInstructionSourcesError !== undefined) { +- throw this.getInstructionSourcesError; +- } +- return this.getInstructionSourcesResult; +- }, +- }, +- usage: { +- getMetrics: async () => { +- this.usageMetricsCalls++; +- if (this.usageMetricsError !== undefined) { +- const err = this.usageMetricsError; +- this.usageMetricsError = undefined; +- throw err; +- } +- // Snapshot at call time, like a real RPC whose result reflects the state +- // when the request was served rather than when the caller observes it. +- const snapshot = { ...this.usageMetricsResult }; +- await (this.usageMetricsGates.shift() ?? this.usageMetricsGate); +- return snapshot; +- }, +- }, +- }; +- +- readonly sandboxConfigUpdates: unknown[] = []; +- +- mcpListResult: { servers: ReadonlyArray<{ name: string; status: 'connected' | 'failed' | 'needs-auth' | 'pending' | 'disabled' | 'not_configured'; error?: string }> } = { servers: [] }; +- mcpListError: unknown = undefined; +- mcpEnableError: unknown = undefined; +- mcpStartServerError: unknown = undefined; +-} +- +-class TestCopilotApiService implements ICopilotApiService { +- declare readonly _serviceBrand: undefined; +- +- apiEndpoint: string | undefined; +- restrictedTelemetryContext: IRestrictedTelemetryContext = { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; +- restrictedTelemetryContextError: Error | undefined; +- +- messages(_githubToken: string, _request: Anthropic.MessageCreateParamsStreaming, _options?: ICopilotApiServiceRequestOptions): AsyncGenerator; +- messages(_githubToken: string, _request: Anthropic.MessageCreateParamsNonStreaming, _options?: ICopilotApiServiceRequestOptions): Promise; +- messages(): AsyncGenerator | Promise { throw new Error('not used'); } +- async countTokens(): Promise { throw new Error('not used'); } +- async models(): Promise { return []; } +- async responses(): Promise { throw new Error('not used'); } +- async utilityChatCompletion(_githubToken: string, _request: ICopilotUtilityChatCompletionRequest): Promise { throw new Error('not used'); } +- async resolveRestrictedTelemetryContext() { +- if (this.restrictedTelemetryContextError) { +- throw this.restrictedTelemetryContextError; +- } +- return this.restrictedTelemetryContext; +- } +- async resolveApiEndpoint() { return this.apiEndpoint; } +-} +- +-class CapturingRestrictedTelemetryService implements ITelemetryService, IAgentHostRestrictedTelemetry { +- declare readonly _serviceBrand: undefined; +- readonly telemetryLevel = TelemetryLevel.USAGE; +- readonly sendErrorTelemetry = true; +- readonly sessionId = 'sessionId'; +- readonly machineId = 'machineId'; +- readonly sqmId = 'sqmId'; +- readonly devDeviceId = 'devDeviceId'; +- readonly firstSessionDate = 'firstSessionDate'; +- readonly events: Array<{ destination: 'enhanced' | 'internal'; eventName: string; properties: TelemetryProps | undefined }> = []; +- +- publicLog(): void { } +- publicLog2(): void { } +- publicLogError(): void { } +- publicLogError2(): void { } +- setExperimentProperty(): void { } +- setCommonProperty(): void { } +- sendGHTelemetryEvent(): void { } +- sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryProps, _measurements?: TelemetryMeasurements): void { +- this.events.push({ destination: 'enhanced', eventName, properties }); +- } +- sendEnhancedGHTelemetryEventForContext(_context: IAgentHostRestrictedTelemetryContext, eventName: string, properties?: TelemetryProps): void { +- this.events.push({ destination: 'enhanced', eventName, properties }); +- } +- sendInternalMSFTTelemetryEvent(eventName: string, properties?: TelemetryProps, _measurements?: TelemetryMeasurements): void { +- this.events.push({ destination: 'internal', eventName, properties }); +- } +- sendInternalMSFTTelemetryEventForContext(_context: IAgentHostInternalTelemetryContext, eventName: string, properties?: TelemetryProps): void { +- this.events.push({ destination: 'internal', eventName, properties }); +- } +- setCopilotTrackingId(): void { } +- setRestrictedTelemetryEndpoint(): void { } +- setRestrictedTelemetryEnabled(): void { } +- setInternalTelemetryContext(): void { } +-} +- +-class CapturingLogService extends NullLogService { +- readonly errors: Array<{ first: string | Error; args: unknown[] }> = []; +- readonly warnings: Array<{ message: string; args: unknown[] }> = []; +- readonly traces: Array<{ message: string; args: unknown[] }> = []; +- readonly infos: Array<{ message: string; args: unknown[] }> = []; +- +- override trace(message: string, ...args: unknown[]): void { +- this.traces.push({ message, args }); +- super.trace(message, ...args); +- } +- +- override info(message: string, ...args: unknown[]): void { +- this.infos.push({ message, args }); +- super.info(message, ...args); +- } +- +- override error(message: string | Error, ...args: unknown[]): void { +- this.errors.push({ first: message, args }); +- super.error(message, ...args); +- } +- +- override warn(message: string, ...args: unknown[]): void { +- this.warnings.push({ message, args }); +- super.warn(message, ...args); +- } +-} +- +-class CapturingTelemetryService implements ITelemetryService { +- declare readonly _serviceBrand: undefined; +- readonly telemetryLevel = TelemetryLevel.USAGE; +- readonly sendErrorTelemetry = true; +- readonly sessionId = 'sessionId'; +- readonly machineId = 'machineId'; +- readonly sqmId = 'sqmId'; +- readonly devDeviceId = 'devDeviceId'; +- readonly firstSessionDate = 'firstSessionDate'; +- readonly events: Array<{ eventName: string; data: unknown }> = []; +- +- publicLog(): void { } +- publicLog2> = never, T extends IGDPRProperty = never>(eventName: string, data?: StrictPropertyCheck): void { +- this.events.push({ eventName, data }); +- } +- publicLogError(): void { } +- publicLogError2(): void { } +- setExperimentProperty(): void { } +- setCommonProperty(): void { } +-} +- +-// ---- Helpers ---------------------------------------------------------------- +- +-/** +- * Invokes a client-SDK tool's handler with the minimal fields the SDK +- * contract requires, and narrows the `unknown` return type to +- * {@link ToolResultObject} — which is what {@link CopilotAgentSession}'s +- * handler implementation actually returns. +- */ +-function invokeClientToolHandler(tool: Pick, toolCallId: string, args: Record = {}, availableTools?: CurrentToolMetadata[]): Promise { +- return Promise.resolve(tool.handler!(args, { +- sessionId: 'test-session-1', +- toolCallId, +- toolName: tool.name, +- arguments: args, +- availableTools, +- })) as Promise; +-} +- +-type ISessionInternalsForTest = { +- _onDidSessionProgress: { fire(event: AgentSignal): void }; +- _editTracker: { +- trackEditStart(path: string): Promise; +- completeEdit(path: string): Promise; +- takeCompletedEdit(turnId: string, toolCallId: string, path: string, toolName: string, toolInput: unknown, modelId: string | undefined): Promise; +- }; +- _pendingClientToolCalls: { +- register(toolCallId: string): Promise; +- respondOrBuffer(toolCallId: string, value: ToolResultObject): void; +- }; +-}; +- +-function isAction(s: AgentSignal, type: ActionType): s is IAgentActionSignal { +- return s.kind === 'action' && s.action.type === type; +-} +- +-function getActions(signals: readonly AgentSignal[]) { +- return signals +- .filter((s): s is IAgentActionSignal => s.kind === 'action') +- .map(s => s.action); +-} +- +-function getInputRequest(signal: AgentSignal): ChatInputRequestedAction['request'] { +- assert.strictEqual(signal.kind, 'action'); +- if (signal.kind !== 'action') { throw new Error('unreachable'); } +- assert.strictEqual(signal.action.type, ActionType.ChatInputRequested); +- return (signal.action as ChatInputRequestedAction).request; +-} +- +-async function createAgentSession(disposables: DisposableStore, options?: { +- clientSnapshot?: IActiveClientSnapshot; +- activeClientToolSet?: ActiveClientToolSet; +- environmentServiceRegistration?: 'native' | 'none'; +- logService?: ILogService; +- telemetryService?: ITelemetryService; +- captureRuntime?: { current?: ICopilotSessionRuntime }; +- workingDirectory?: URI; +- /** Per-key effective config values returned by the fake configuration service. */ +- configValues?: Record; +- /** Per-key root config values returned by the fake configuration service's `getRootValue`. */ +- rootValues?: Record; +- fileContents?: Record; +- fileReadErrors?: readonly string[]; +- sessionDatabase?: ISessionDatabase; +- /** Configure the mock session before {@link CopilotAgentSession.initializeSession} runs. */ +- configureMockSession?: (session: MockCopilotSession) => void; +- sessionCustomizations?: () => readonly Customization[]; +- initialSessionMeta?: Record; +- sessionUri?: URI; +- chatChannelUri?: URI; +- resolveMcpChildId?: (serverName: string) => string | undefined; +- /** Optional server-tool host wired into the session. */ +- serverToolHost?: IAgentServerToolHost; +- /** Platform used to compute the SDK sandbox policy. Defaults to `'linux'` so sandbox tests are deterministic. */ +- platform?: NodeJS.Platform; +- githubToken?: string; +- copilotApiEndpoint?: string; +- gitService?: IAgentHostGitService; +- gitHubEndpointService?: IAgentHostGitHubEndpointService; +- restrictedTelemetryContext?: IRestrictedTelemetryContext; +- restrictedTelemetryContextError?: Error; +- isLaunchTokenCurrent?: () => boolean; +- onTurnEnded?: () => void; +- modelId?: string; +- resume?: boolean; +-}): Promise<{ +- session: CopilotAgentSession; +- runtime: ICopilotSessionRuntime; +- mockSession: MockCopilotSession; +- signals: AgentSignal[]; +- waitForSignal: (predicate: (signal: AgentSignal) => boolean) => Promise; +- terminalManager: TestAgentHostTerminalManager; +- dispatchedActions: readonly StateAction[]; +- sessionConfigUpdates: ReadonlyArray<{ session: string; patch: Record }>; +- setConfigValue: (key: string, value: unknown) => void; +- setRootValue: (key: string, value: unknown) => void; +- fireRootConfigChange: () => void; +- fireSessionConfigChange: (config: Record, session?: string) => void; +-}> { +- const progressEmitter = disposables.add(new Emitter()); +- const signals: AgentSignal[] = []; +- const waiters: { predicate: (signal: AgentSignal) => boolean; deferred: DeferredPromise }[] = []; +- +- disposables.add(progressEmitter.event(signal => { +- signals.push(signal); +- for (let i = waiters.length - 1; i >= 0; i--) { +- if (waiters[i].predicate(signal)) { +- const { deferred } = waiters[i]; +- waiters.splice(i, 1); +- deferred.complete(signal); +- } +- } +- })); +- +- const waitForSignal = (predicate: (signal: AgentSignal) => boolean): Promise => { +- const existing = signals.find(predicate); +- if (existing) { +- return Promise.resolve(existing); +- } +- const deferred = new DeferredPromise(); +- waiters.push({ predicate, deferred }); +- return deferred.p; +- }; +- +- const parentSessionUri = AgentSession.uri('copilot', 'test-session-1'); +- const sessionUri = options?.sessionUri ?? parentSessionUri; +- const chatChannelUri = options?.chatChannelUri ?? URI.parse(buildDefaultChatUri(parentSessionUri)); +- const mockSession = new MockCopilotSession(); +- options?.configureMockSession?.(mockSession); +- +- const launchPlanBase = { +- client: { +- createSession: async () => mockSession as unknown as CopilotSession, +- resumeSession: async () => mockSession as unknown as CopilotSession, +- }, +- activeClientToolSet: new ActiveClientToolSet(), +- sessionId: 'test-session-1', +- workingDirectory: options?.workingDirectory, +- resolvedAgentName: undefined, +- snapshot: options?.clientSnapshot ?? { tools: [], plugins: [], mcpServers: {} }, +- shellManager: undefined, +- githubToken: options?.githubToken, +- }; +- const model = options?.modelId ? { id: options.modelId } : undefined; +- const launchPlan: CopilotSessionLaunchPlan = options?.resume +- ? { +- ...launchPlanBase, +- kind: 'resume', +- workingDirectory: options.workingDirectory ?? URI.file('/workspace'), +- fallback: { model }, +- } +- : { +- ...launchPlanBase, +- kind: 'create', +- model, +- }; +- let launchedRuntime: ICopilotSessionRuntime | undefined; +- const sessionLauncher: ICopilotSessionLauncher = { +- launch: async (_plan, runtime) => { +- launchedRuntime = runtime; +- if (options?.captureRuntime) { +- options.captureRuntime.current = runtime; +- } +- return new CopilotSessionWrapper(mockSession as unknown as CopilotSession); +- } +- }; +- +- const services = new ServiceCollection(); +- services.set(ILogService, options?.logService ?? new NullLogService()); +- services.set(ITelemetryService, options?.telemetryService ?? new NullTelemetryServiceShape()); +- services.set(IAgentHostGitService, options?.gitService ?? createNoopGitService()); +- services.set(IAgentHostGitHubEndpointService, options?.gitHubEndpointService ?? createTestGitHubEndpointService()); +- const copilotApiService = new TestCopilotApiService(); +- copilotApiService.apiEndpoint = options?.copilotApiEndpoint; +- if (options?.restrictedTelemetryContext) { +- copilotApiService.restrictedTelemetryContext = options.restrictedTelemetryContext; +- } +- copilotApiService.restrictedTelemetryContextError = options?.restrictedTelemetryContextError; +- services.set(ICopilotApiService, copilotApiService); +- const storedFileContents = new Map(Object.entries(options?.fileContents ?? {})); +- services.set(IFileService, { +- _serviceBrand: undefined, +- readFile: async (resource: URI) => { +- if (options?.fileReadErrors?.includes(resource.toString()) || options?.fileReadErrors?.includes(resource.fsPath)) { +- throw new Error('read failed'); +- } +- return { value: VSBuffer.fromString(storedFileContents.get(resource.toString()) ?? storedFileContents.get(resource.fsPath) ?? '') }; +- }, +- exists: async (resource: URI) => storedFileContents.has(resource.toString()) || storedFileContents.has(resource.fsPath), +- writeFile: async (resource: URI, content: VSBuffer) => { +- storedFileContents.set(resource.toString(), content.toString()); +- return { resource } as Awaited>; +- }, +- del: async (resource: URI) => { +- storedFileContents.delete(resource.toString()); +- storedFileContents.delete(resource.fsPath); +- }, +- } as Partial as IFileService); +- services.set(ISessionDataService, createSessionDataService(options?.sessionDatabase)); +- services.set(IDiffComputeService, createZeroDiffComputeService()); +- const sessionConfigUpdates: Array<{ session: string; patch: Record }> = []; +- const configValues = options?.configValues ?? {}; +- const rootValues = options?.rootValues ?? {}; +- const rootConfigEmitter = disposables.add(new Emitter()); +- const sessionConfigEmitter = disposables.add(new Emitter<{ session: string; config: Record }>()); +- const fakeConfigurationService: IAgentConfigurationService = { +- _serviceBrand: undefined, +- onDidRootConfigChange: rootConfigEmitter.event, +- onDidSessionConfigChange: sessionConfigEmitter.event, +- // Simple per-key map suffices for tests; the real service walks +- // session → parent → host and validates against the schema, but +- // neither matters here — we just need to surface a value the +- // session class will read. +- getEffectiveValue: ((_session: string, _schema: unknown, key: string) => configValues[key]) as IAgentConfigurationService['getEffectiveValue'], +- getEffectiveWorkingDirectory: () => undefined, +- getEffectiveWorkingDirectories: () => undefined, +- isWorkingDirectoryPending: () => false, +- resolveWorkingDirectoryForResume: async (_session, workingDirectory) => workingDirectory, +- getSessionConfigValues: () => undefined, +- updateSessionConfig: (session, patch) => { sessionConfigUpdates.push({ session, patch }); }, +- getRootValue: ((_schema: unknown, key: string) => rootValues[key]) as IAgentConfigurationService['getRootValue'], +- updateRootConfig: () => { /* no-op */ }, +- persistRootConfig: () => { /* no-op */ }, +- whenIdle: async () => { /* no-op */ }, +- }; +- services.set(IAgentConfigurationService, fakeConfigurationService); +- const stateManager = disposables.add(new class extends AgentHostStateManager { +- readonly dispatchedActions: StateAction[] = []; +- override dispatchServerAction(channel: string, action: StateAction): void { +- this.dispatchedActions.push(action); +- super.dispatchServerAction(channel, action); +- } +- override getSessionState(session: string) { +- if ((!options?.sessionCustomizations && !options?.initialSessionMeta) || session !== sessionUri.toString()) { +- return undefined; +- } +- const state = createSessionState({ +- resource: sessionUri.toString(), +- provider: 'copilot', +- title: 'Test session', +- status: SessionStatus.Idle, +- createdAt: new Date().toISOString(), +- modifiedAt: new Date().toISOString(), +- }); +- return mergeSessionWithDefaultChat({ +- ...state, +- ...(options.initialSessionMeta ? { _meta: options.initialSessionMeta } : {}), +- ...(options.sessionCustomizations ? { customizations: [...options.sessionCustomizations()] } : {}), +- }, undefined); +- } +- override getSessionSummary(session: string) { +- if (options?.initialSessionMeta && session === sessionUri.toString()) { +- const now = new Date().toISOString(); +- return { +- resource: session, +- provider: 'copilot', +- title: 'Test session', +- status: SessionStatus.Idle, +- createdAt: now, +- modifiedAt: now, +- _meta: options.initialSessionMeta, +- }; +- } +- return super.getSessionSummary(session); +- } +- }(new NullLogService())); +- services.set(IAgentHostStateManager, stateManager); +- const environmentService = { +- _serviceBrand: undefined, +- userHome: URI.file('/mock-home'), +- tmpDir: URI.file('/mock-tmp'), +- } as INativeEnvironmentService; +- if (options?.environmentServiceRegistration !== 'none') { +- services.set(INativeEnvironmentService, environmentService); +- } +- const terminalManager = disposables.add(new TestAgentHostTerminalManager()); +- services.set(IAgentHostTerminalManager, terminalManager); +- const instantiationService = disposables.add(new InstantiationService(services)); +- +- const session = disposables.add(instantiationService.createInstance( +- CopilotAgentSession, +- { +- sessionUri, +- chatChannelUri, +- rawSessionId: 'test-session-1', +- onDidSessionProgress: progressEmitter, +- sessionLauncher, +- launchPlan, +- shellManager: undefined, +- clientSnapshot: options?.clientSnapshot, +- activeClientToolSet: options?.activeClientToolSet, +- resolveMcpChildId: options?.resolveMcpChildId ?? (() => undefined), +- workingDirectory: options?.workingDirectory, +- serverToolHost: options?.serverToolHost, +- platform: options?.platform ?? 'linux', +- isLaunchTokenCurrent: options?.isLaunchTokenCurrent, +- onTurnEnded: options?.onTurnEnded, +- }, +- )); +- +- await session.initializeSession(); +- assert.ok(launchedRuntime); +- +- return { +- session, +- runtime: launchedRuntime, +- mockSession, +- signals, +- waitForSignal, +- terminalManager, +- dispatchedActions: stateManager.dispatchedActions, +- sessionConfigUpdates, +- setConfigValue: (key, value) => { configValues[key] = value; }, +- setRootValue: (key, value) => { rootValues[key] = value; }, +- fireRootConfigChange: () => rootConfigEmitter.fire(), +- fireSessionConfigChange: (config, session = sessionUri.toString()) => sessionConfigEmitter.fire({ session, config }), +- }; +-} +- +-// ---- Tests ------------------------------------------------------------------ +- +-suite('CopilotAgentSession', () => { +- +- const disposables = new DisposableStore(); +- +- teardown(() => disposables.clear()); +- ensureNoDisposablesAreLeakedInTestSuite(); +- +- suite('CopilotSessionWrapper', () => { +- test('fires unhandled events when no wrapped listener is registered', () => { +- const mockSession = new MockCopilotSession(); +- const wrapper = disposables.add(new CopilotSessionWrapper(mockSession as unknown as CopilotSession)); +- const events: string[] = []; +- disposables.add(wrapper.onUnhandledEvent(e => events.push(e.type))); +- +- mockSession.fire('session.compaction_start', {} as SessionEventPayload<'session.compaction_start'>['data']); +- +- assert.deepStrictEqual(events, ['session.compaction_start']); +- }); +- +- test('tracks wrapped listener registrations dynamically', () => { +- const mockSession = new MockCopilotSession(); +- const wrapper = disposables.add(new CopilotSessionWrapper(mockSession as unknown as CopilotSession)); +- const events: string[] = []; +- disposables.add(wrapper.onUnhandledEvent(e => events.push(e.type))); +- const handledListener = wrapper.onSessionCompactionStart(() => { }); +- +- mockSession.fire('session.compaction_start', {} as SessionEventPayload<'session.compaction_start'>['data']); +- handledListener.dispose(); +- mockSession.fire('session.compaction_start', {} as SessionEventPayload<'session.compaction_start'>['data']); +- +- assert.deepStrictEqual(events, ['session.compaction_start']); +- }); +- }); +- +- test('destroySession completes when shutdown arrives before the response', async () => { +- const disconnectStarted = new DeferredPromise(); +- const disconnectGate = new DeferredPromise(); +- const { session, mockSession } = await createAgentSession(disposables, { +- configureMockSession: mockSession => { +- mockSession.disconnectGate = disconnectGate.p; +- mockSession.disconnectHook = () => { void disconnectStarted.complete(); }; +- }, +- }); +- const destroy = session.destroySession(); +- +- await disconnectStarted.p; +- try { +- mockSession.fire('session.shutdown', { +- shutdownType: 'normal', +- totalApiDurationMs: 0, +- } as SessionEventPayload<'session.shutdown'>['data']); +- await destroy; +- session.dispose(); +- +- assert.strictEqual(mockSession.disconnectCalls, 1); +- } finally { +- disconnectGate.complete(); +- } +- }); +- +- test('logs SDK events without wrapped handlers', async () => { +- const logService = new CapturingLogService(); +- const { mockSession } = await createAgentSession(disposables, { logService }); +- +- mockSession.fire('session.title_changed', { title: 'A new title' } as SessionEventPayload<'session.title_changed'>['data'], { +- ephemeral: true, +- id: 'evt-title', +- timestamp: '2026-06-24T00:00:00.000Z', +- }); +- +- assert.deepStrictEqual( +- logService.traces.filter(t => t.message.includes('Unhandled SDK event')).map(t => t.message), +- ['[Copilot:test-session-1] Unhandled SDK event: {"type":"session.title_changed","data":{"title":"A new title"},"id":"evt-title","timestamp":"2026-06-24T00:00:00.000Z","parentId":null,"ephemeral":true}'] +- ); +- }); +- +- test('logs managed settings resolution and enforcement', async () => { +- const logService = new CapturingLogService(); +- const { mockSession } = await createAgentSession(disposables, { logService }); +- +- mockSession.fire('session.managed_settings_resolved', { +- source: 'server', +- serverManaged: true, +- deviceManaged: false, +- managedKeys: ['permissions'], +- bypassPermissionsDisabled: true, +- failClosed: false, +- } as SessionEventPayload<'session.managed_settings_resolved'>['data']); +- mockSession.fire('session.managed_settings_enforced', { +- action: 'bypass_permissions_blocked', +- escalation: 'allow_all', +- setting: 'permissions.disableBypassPermissionsMode', +- failClosed: false, +- message: 'Bypass permissions mode is disabled by enterprise policy.', +- } as SessionEventPayload<'session.managed_settings_enforced'>['data']); +- +- assert.deepStrictEqual({ +- infos: logService.infos.map(entry => entry.message).filter(message => message.includes('Managed settings')), +- warnings: logService.warnings.map(entry => entry.message).filter(message => message.includes('Managed settings')), +- }, { +- infos: ['[Copilot:test-session-1] Managed settings resolved: source=server, managedKeys=permissions, bypassPermissionsDisabled=true, failClosed=false'], +- warnings: ['[Copilot:test-session-1] Managed settings enforced: action=bypass_permissions_blocked, setting=permissions.disableBypassPermissionsMode, escalation=allow_all, failClosed=false, message=Bypass permissions mode is disabled by enterprise policy.'], +- }); +- }); +- +- test('maps internal attachment URIs to Copilot SDK path fields', async () => { +- const fileUri = URI.file('/workspace/file.ts'); +- const selectionUri = URI.file('/workspace/selection.ts'); +- const { session, mockSession } = await createAgentSession(disposables, { +- fileContents: { +- [selectionUri.toString()]: 'alpha\nbeta\n012selected text345\nomega', +- }, +- }); +- +- await session.send('hello', [ +- { type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'file.ts', displayKind: 'document' }, +- { +- type: MessageAttachmentKind.Resource, +- uri: selectionUri.toString(), +- label: 'selection.ts', +- displayKind: 'selection', +- selection: { +- range: { +- start: { line: 2, character: 3 }, +- end: { line: 2, character: 16 }, +- }, +- }, +- }, +- ]); +- +- assert.deepStrictEqual(mockSession.sendRequests, [{ +- prompt: 'hello', +- attachments: [ +- { type: 'file', path: fileUri.fsPath, displayName: 'file.ts' }, +- { +- type: 'selection', +- filePath: selectionUri.fsPath, +- displayName: 'selection.ts', +- text: 'selected text', +- selection: { +- start: { line: 2, character: 3 }, +- end: { line: 2, character: 16 }, +- }, +- }, +- ], +- }]); +- }); +- +- test('maps symbol Resource attachments to SDK selection so the range survives (#315193)', async () => { +- // Symbols arrive as a Resource with displayKind 'symbol' AND a populated selection.range. Keying the selection +- // branch off the `selection` field (not displayKind === 'selection') keeps the range instead of degrading the +- // symbol to a plain file reference. +- const symbolUri = URI.file('/workspace/sym.ts'); +- const { session, mockSession } = await createAgentSession(disposables, { +- fileContents: { +- [symbolUri.toString()]: 'line0\nline1\nfunction foo() {}\nline3', +- }, +- }); +- +- await session.send('explain this', [ +- { +- type: MessageAttachmentKind.Resource, +- uri: symbolUri.toString(), +- label: 'foo', +- displayKind: 'symbol', +- selection: { +- range: { +- start: { line: 2, character: 9 }, +- end: { line: 2, character: 12 }, +- }, +- }, +- }, +- ]); +- +- assert.deepStrictEqual(mockSession.sendRequests, [{ +- prompt: 'explain this', +- attachments: [ +- { +- type: 'selection', +- filePath: symbolUri.fsPath, +- displayName: 'foo', +- text: 'foo', +- selection: { +- start: { line: 2, character: 9 }, +- end: { line: 2, character: 12 }, +- }, +- }, +- ], +- }]); +- }); +- +- test('memoizes the event reconstruction across getMessages/getSubagentMessages and invalidates on log changes', async () => { +- const { session, mockSession } = await createAgentSession(disposables); +- let getEventsCalls = 0; +- mockSession.getEvents = async () => { getEventsCalls++; return mockSession.messages; }; +- +- // A single resume wave reads + reconstructs the event log once, shared +- // by the parent turns and every subagent lookup. +- await session.getMessages(); +- await session.getSubagentMessages('tc-x'); +- await session.getMessages(); +- assert.strictEqual(getEventsCalls, 1, 'event log should be read once for the whole resume wave'); +- +- // A log-mutating event drops the memo so a later read rebuilds from +- // fresh events instead of serving stale turns. +- mockSession.fire('assistant.turn_end', { turnId: 'sdk-0' } as SessionEventPayload<'assistant.turn_end'>['data']); +- await session.getMessages(); +- assert.strictEqual(getEventsCalls, 2, 'memo should be invalidated after the event log changes'); +- }); +- +- test('falls back to file reference when reading a symbol Resource attachment fails', async () => { +- const symbolUri = URI.file('/workspace/missing.ts'); +- const { session, mockSession } = await createAgentSession(disposables, { +- fileReadErrors: [symbolUri.toString()], +- }); +- +- await session.send('explain this', [ +- { +- type: MessageAttachmentKind.Resource, +- uri: symbolUri.toString(), +- label: 'foo', +- displayKind: 'symbol', +- selection: { +- range: { +- start: { line: 2, character: 9 }, +- end: { line: 2, character: 12 }, +- }, +- }, +- }, +- ]); +- +- assert.deepStrictEqual(mockSession.sendRequests, [{ +- prompt: 'explain this', +- attachments: [ +- { type: 'file', path: symbolUri.fsPath, displayName: 'foo' }, +- ], +- }]); +- }); +- +- test('sends agent feedback annotations attachments as text blobs', async () => { +- const { session, mockSession } = await createAgentSession(disposables); +- +- await session.send('/act-on-feedback', [{ +- type: MessageAttachmentKind.Annotations, +- label: '1 comment', +- displayKind: AgentFeedbackAttachmentDisplayKind, +- resource: 'ahp-session:/s/annotations', +- annotationIds: ['feedback-1'], +- }]); +- +- const expectedText = +- 'The user attached specific feedback comments to act on (comment ids):\n' + +- '- feedback-1\n\n' + +- 'Use the `listComments` tool to read their content and focus on these comments.'; +- assert.deepStrictEqual(mockSession.sendRequests, [{ +- prompt: '/act-on-feedback', +- attachments: [{ +- type: 'blob', +- data: encodeBase64(VSBuffer.fromString(expectedText)), +- mimeType: 'text/plain', +- displayName: '1 comment', +- }], +- }]); +- }); +- +- test('sends simple attachments as text blobs and restores them from SDK blobs', async () => { +- const { session, mockSession } = await createAgentSession(disposables); +- +- await session.send('/act-on-feedback', [{ +- type: MessageAttachmentKind.Simple, +- label: 'Feedback', +- modelRepresentation: 'Feedback text for the model', +- }]); +- +- const expectedAttachment = { +- type: MessageAttachmentKind.Simple, +- label: 'Feedback', +- modelRepresentation: 'Feedback text for the model', +- }; +- assert.deepStrictEqual(mockSession.sendRequests, [{ +- prompt: '/act-on-feedback', +- attachments: [{ +- type: 'blob', +- data: encodeBase64(VSBuffer.fromString('Feedback text for the model')), +- mimeType: 'text/plain', +- displayName: 'Feedback', +- }], +- }]); +- +- mockSession.messages = [{ +- type: 'user.message', +- id: 'event-1', +- parentId: null, +- timestamp: '2026-07-29T10:00:00.000Z', +- data: { +- interactionId: 'message-1', +- content: '/act-on-feedback', +- attachments: [{ +- type: 'blob', +- data: encodeBase64(VSBuffer.fromString('Feedback text for the model')), +- mimeType: 'text/plain', +- displayName: 'Feedback', +- }], +- }, +- }]; +- +- assert.deepStrictEqual(await session.getMessages(), [{ +- id: 'event-1', +- startedAt: '2026-07-29T10:00:00.000Z', +- duration: 0, +- message: { +- text: '/act-on-feedback', +- origin: { kind: MessageKind.User }, +- attachments: [expectedAttachment], +- }, +- responseParts: [], +- usage: undefined, +- state: 'cancelled', +- }]); +- }); +- +- test('sends display-kind simple attachments as inline text SDK blobs', async () => { +- const { session, mockSession } = await createAgentSession(disposables); +- const attachment = { +- type: MessageAttachmentKind.Simple, +- label: 'Browser Pages', +- displayKind: 'workspace', +- modelRepresentation: 'No browser pages are currently shared with you.', +- } as const; +- +- await session.send('hello', [attachment]); +- const sdkAttachment = { +- type: 'blob' as const, +- data: encodeBase64(VSBuffer.fromString(attachment.modelRepresentation)), +- mimeType: 'text/x-vscode-simple-attachment; x-vscode-display-kind=workspace', +- displayName: attachment.label, +- }; +- assert.deepStrictEqual(mockSession.sendRequests, [{ +- prompt: 'hello', +- attachments: [sdkAttachment], +- }]); +- +- mockSession.messages = [{ +- type: 'user.message', +- id: 'event-1', +- parentId: null, +- timestamp: new Date().toISOString(), +- data: { +- interactionId: 'message-1', +- content: 'hello', +- attachments: [{ +- ...sdkAttachment, +- mimeType: 'text/plain; x-vscode-display-kind=workspace', +- }], +- }, +- }]; +- +- assert.deepStrictEqual((await session.getMessages())[0].message.attachments, [attachment]); +- }); +- +- test('forwards an embedded resource with a selection as its already-sliced inline blob', async () => { +- const { session, mockSession } = await createAgentSession(disposables); +- +- // The handler inlines only the selected text into `data`, so the adapter forwards it verbatim (no re-slicing). +- await session.send('what is the selected word?', [{ +- type: MessageAttachmentKind.EmbeddedResource, +- label: 'file:test.js', +- displayKind: 'selection', +- data: encodeBase64(VSBuffer.fromString('world')), +- contentType: 'text/plain', +- selection: { range: { start: { line: 1, character: 6 }, end: { line: 1, character: 11 } } }, +- }]); +- +- assert.deepStrictEqual(mockSession.sendRequests, [{ +- prompt: 'what is the selected word?', +- attachments: [{ +- type: 'blob', +- data: encodeBase64(VSBuffer.fromString('world')), +- mimeType: 'text/plain', +- displayName: 'file:test.js', +- }], +- }]); +- }); +- +- test('sends an embedded resource without a selection as the full blob', async () => { +- const { session, mockSession } = await createAgentSession(disposables); +- +- await session.send('what is in this file?', [{ +- type: MessageAttachmentKind.EmbeddedResource, +- label: 'file:test.js', +- displayKind: 'document', +- data: encodeBase64(VSBuffer.fromString('line0\nhello world\nline2')), +- contentType: 'text/plain', +- }]); +- +- assert.deepStrictEqual(mockSession.sendRequests, [{ +- prompt: 'what is in this file?', +- attachments: [{ +- type: 'blob', +- data: encodeBase64(VSBuffer.fromString('line0\nhello world\nline2')), +- mimeType: 'text/plain', +- displayName: 'file:test.js', +- }], +- }]); +- }); +- +- test('sends paste simple attachments as text blobs', async () => { +- const { session, mockSession } = await createAgentSession(disposables); +- +- await session.send('continue', [{ +- type: MessageAttachmentKind.Simple, +- label: 'Previous conversation', +- displayKind: 'paste', +- modelRepresentation: 'Transcript text', +- }]); +- +- assert.deepStrictEqual(mockSession.sendRequests, [{ +- prompt: 'continue', +- attachments: [{ +- type: 'blob', +- data: encodeBase64(VSBuffer.fromString('Transcript text')), +- mimeType: 'text/x-vscode-simple-attachment; x-vscode-display-kind=paste', +- displayName: 'Previous conversation', +- }], +- }]); +- +- mockSession.messages = [{ +- type: 'user.message', +- id: 'event-1', +- parentId: null, +- timestamp: new Date().toISOString(), +- data: { +- interactionId: 'message-1', +- content: 'continue', +- attachments: [{ +- type: 'blob', +- data: encodeBase64(VSBuffer.fromString('Transcript text')), +- mimeType: 'text/x-vscode-simple-attachment; x-vscode-display-kind=paste', +- displayName: 'Previous conversation', +- }], +- }, +- }]; +- +- assert.deepStrictEqual((await session.getMessages())[0].message.attachments, [{ +- type: MessageAttachmentKind.Simple, +- label: 'Previous conversation', +- displayKind: 'paste', +- modelRepresentation: 'Transcript text', +- }]); +- }); +- +- test('`/compact` runs the history compact RPC and completes the turn with output', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- +- await session.send('/compact', undefined, 'turn-compact'); +- +- // The compact command is handled inline via the history RPC and must +- // not fall through to a normal SDK `send()` turn. +- assert.strictEqual(mockSession.compactCalls.length, 1); +- assert.deepStrictEqual(mockSession.sendRequests, []); +- +- // The turn opened by the server is closed inline (the SDK never fires +- // `onIdle` for the compact path) after emitting the completion message. +- const actions = getActions(signals); +- const responseParts = actions.filter((a): a is ChatResponsePartAction => a.type === ActionType.ChatResponsePart); +- assert.strictEqual(responseParts.length, 1); +- const responsePart = responseParts[0]; +- assert.strictEqual(responsePart.part.kind, ResponsePartKind.Markdown); +- if (responsePart.part.kind !== ResponsePartKind.Markdown) { +- throw new Error('unreachable'); +- } +- assert.deepStrictEqual({ +- turnId: responsePart.turnId, +- kind: responsePart.part.kind, +- content: responsePart.part.content, +- }, { +- turnId: 'turn-compact', +- kind: ResponsePartKind.Markdown, +- content: 'Compaction completed', +- }); +- const turnComplete = actions.find(a => a.type === ActionType.ChatTurnComplete); +- assert.ok(turnComplete, 'expected the turn to complete'); +- assert.strictEqual((turnComplete as ChatTurnCompleteAction).turnId, 'turn-compact'); +- }); +- +- test('`/compact` reports post-compaction context window usage before completing the turn', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- mockSession.compactResult = { success: true, tokensRemoved: 1200, messagesRemoved: 3, contextWindow: { currentTokens: 4500, tokenLimit: 128000, messagesLength: 7 } }; +- +- await session.send('/compact', undefined, 'turn-compact'); +- +- const actions = getActions(signals); +- const usage = actions.find(a => a.type === ActionType.ChatUsage) as ChatUsageAction | undefined; +- assert.ok(usage, 'expected a usage action reporting the shrunken context window'); +- assert.deepStrictEqual({ turnId: usage.turnId, usage: usage.usage }, { +- turnId: 'turn-compact', +- usage: { inputTokens: 4500, outputTokens: 0, model: undefined }, +- }); +- // Usage must precede the turn completion so the reducer accepts it. +- const usageIndex = actions.findIndex(a => a.type === ActionType.ChatUsage); +- const completeIndex = actions.findIndex(a => a.type === ActionType.ChatTurnComplete); +- assert.ok(usageIndex >= 0 && completeIndex > usageIndex, 'usage emitted before turn complete'); +- }); +- +- test('`/compact` skips usage when the SDK omits the context window', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- mockSession.compactResult = { success: true, tokensRemoved: 0, messagesRemoved: 0 }; +- +- await session.send('/compact', undefined, 'turn-compact'); +- +- const usage = getActions(signals).find(a => a.type === ActionType.ChatUsage); +- assert.strictEqual(usage, undefined, 'no usage action without a context window'); +- }); +- +- test('`/compact` carries the last-seen model id on the post-compaction usage', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- // A prior model call sets `_lastSeenModelId`, which the compaction usage +- // reports so the context-usage widget can resolve the context window. +- mockSession.fire('assistant.usage', { +- inputTokens: 10, +- outputTokens: 20, +- model: 'claude-sonnet-4.6', +- } as unknown as SessionEventPayload<'assistant.usage'>['data']); +- mockSession.compactResult = { success: true, tokensRemoved: 1200, messagesRemoved: 3, contextWindow: { currentTokens: 4500, tokenLimit: 128000, messagesLength: 7 } }; +- +- await session.send('/compact', undefined, 'turn-compact'); +- +- const usage = getActions(signals).reverse().find(a => a.type === ActionType.ChatUsage) as ChatUsageAction | undefined; +- assert.deepStrictEqual(usage?.usage, { inputTokens: 4500, outputTokens: 0, model: 'claude-sonnet-4.6' }); +- }); +- +- test('a resumed session does not bill its restored history to the first new turn', async () => { +- // The SDK re-folds usage from its durable event log on resume, so `getMetrics` +- // opens at the accumulated total of everything already billed. +- const { session, mockSession, signals } = await createAgentSession(disposables, { +- configureMockSession: mock => { mock.usageMetricsResult.totalNanoAiu = 40_000_000_000; }, +- }); +- +- session.resetTurnState('turn-after-resume'); +- mockSession.fire('assistant.usage', { +- model: 'claude-opus-4.6', +- inputTokens: 10, +- outputTokens: 20, +- copilotUsage: { totalNanoAiu: 500_000_000 }, +- } as unknown as SessionEventPayload<'assistant.usage'>['data']); +- await timeout(0); +- +- const usageActions = getActions(signals).filter(a => a.type === ActionType.ChatUsage) as ChatUsageAction[]; +- // The new turn bills only its own call, while the session total carries the history. +- assert.deepStrictEqual(usageActions.at(-1)?.usage._meta?.copilotUsage, { +- totalNanoAiu: 500_000_000, +- sessionTotalNanoAiu: 40_500_000_000, +- }); +- }); +- +- test('a failed usage read leaves the turn cost intact', async () => { +- // The turn's own cost comes from the events, so a metrics outage costs only +- // the session total's freshness rather than the turn's reported cost. +- const { session, mockSession, signals } = await createAgentSession(disposables, { +- configureMockSession: mock => { +- mock.usageMetricsResult.totalNanoAiu = 40_000_000_000; +- mock.usageMetricsError = new Error('rpc unavailable'); +- }, +- }); +- +- session.resetTurnState('turn-with-failed-read'); +- mockSession.fire('assistant.usage', { +- model: 'claude-opus-4.6', +- inputTokens: 10, +- outputTokens: 20, +- copilotUsage: { totalNanoAiu: 500_000_000 }, +- } as unknown as SessionEventPayload<'assistant.usage'>['data']); +- await timeout(0); +- mockSession.fire('assistant.usage', { +- model: 'claude-opus-4.6', +- inputTokens: 10, +- outputTokens: 20, +- copilotUsage: { totalNanoAiu: 250_000_000 }, +- } as unknown as SessionEventPayload<'assistant.usage'>['data']); +- await timeout(0); +- +- const usageActions = getActions(signals).filter(a => a.type === ActionType.ChatUsage) as ChatUsageAction[]; +- // Both calls counted toward the turn even though the first metrics read failed. +- assert.deepStrictEqual(usageActions.at(-1)?.usage._meta?.copilotUsage, { +- totalNanoAiu: 750_000_000, +- sessionTotalNanoAiu: 40_750_000_000, +- }); +- }); +- +- test('a session total that drops after truncation is adopted rather than treated as stale', async () => { +- // `history.truncate` (checkpoint restore, editing an earlier message) makes the +- // SDK re-fold usage from the surviving events, so its authoritative total +- // legitimately decreases. Treating that as a stale read would freeze the +- // reported cost until billing climbed back past the pre-truncation figure. +- const { session, mockSession, signals } = await createAgentSession(disposables); +- +- session.resetTurnState('turn-before-truncate'); +- mockSession.fire('assistant.usage', { +- model: 'claude-opus-4.6', +- inputTokens: 10, +- outputTokens: 20, +- copilotUsage: { totalNanoAiu: 10_000_000_000 }, +- } as unknown as SessionEventPayload<'assistant.usage'>['data']); +- await timeout(0); +- +- // Truncation rewinds the SDK's total from 10 down to 3; the next call brings it +- // to 4. A high-water guard would reject everything below 10 and freeze the +- // reported cost, so the drop must be adopted. +- mockSession.usageMetricsResult.totalNanoAiu = 3_000_000_000; +- session.resetTurnState('turn-after-truncate'); +- mockSession.fire('assistant.usage', { +- model: 'claude-opus-4.6', +- inputTokens: 10, +- outputTokens: 20, +- copilotUsage: { totalNanoAiu: 1_000_000_000 }, +- } as unknown as SessionEventPayload<'assistant.usage'>['data']); +- await timeout(0); +- +- const usageActions = getActions(signals).filter(a => a.type === ActionType.ChatUsage) as ChatUsageAction[]; +- assert.deepStrictEqual(usageActions.at(-1)?.usage._meta?.copilotUsage, { +- totalNanoAiu: 1_000_000_000, +- sessionTotalNanoAiu: 4_000_000_000, +- }); +- }); +- +- test('overlapping usage events issue one metrics read at a time and converge on the newest', async () => { +- // `getMetrics` is a real RPC round trip. Letting several overlap means an older +- // one can resolve last and publish a stale session cost, and a high-water guard +- // can't reject it because the total legitimately drops after a truncation. +- // Serializing the reads removes the interleaving entirely and coalesces the +- // redundant reads a burst of usage events would otherwise issue. +- const { session, mockSession, signals } = await createAgentSession(disposables); +- +- session.resetTurnState('turn-overlapping'); +- const slowFirstRead = new DeferredPromise(); +- mockSession.usageMetricsGates.push(slowFirstRead.p); +- +- const fireUsage = (totalNanoAiu: number) => mockSession.fire('assistant.usage', { +- model: 'claude-opus-4.6', +- inputTokens: 10, +- outputTokens: 20, +- copilotUsage: { totalNanoAiu }, +- } as unknown as SessionEventPayload<'assistant.usage'>['data']); +- +- fireUsage(500_000_000); +- await timeout(0); +- fireUsage(1_000_000_000); +- await timeout(0); +- fireUsage(500_000_000); +- await timeout(0); +- // The first read is still in flight, so the two later events have not started +- // their own — they are waiting behind it and collapse into a single follow-up. +- assert.strictEqual(mockSession.usageMetricsCalls, 1); +- +- slowFirstRead.complete(); +- for (let i = 0; i < 5; i++) { +- await timeout(0); +- } +- +- // One follow-up read for the three events that queued behind the first, and it +- // observed the newest total rather than any earlier snapshot. +- assert.strictEqual(mockSession.usageMetricsCalls, 2); +- session.resetTurnState('turn-after-overlap'); +- fireUsage(250_000_000); +- for (let i = 0; i < 5; i++) { +- await timeout(0); +- } +- const usageActions = getActions(signals).filter(a => a.type === ActionType.ChatUsage) as ChatUsageAction[]; +- assert.strictEqual( +- (usageActions.at(-1)?.usage._meta as UsageInfoMeta | undefined)?.copilotUsage?.sessionTotalNanoAiu, +- 2_250_000_000, +- ); +- }); +- +- test('a turn ending while its usage refresh is in flight still bills that turn', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- +- session.resetTurnState('turn-racing-idle'); +- const gate = new DeferredPromise(); +- mockSession.usageMetricsGate = gate.p; +- mockSession.fire('assistant.usage', { +- model: 'claude-opus-4.6', +- inputTokens: 10, +- outputTokens: 20, +- copilotUsage: { totalNanoAiu: 500_000_000 }, +- } as unknown as SessionEventPayload<'assistant.usage'>['data']); +- // The SDK's terminal `session.idle` lands before the metrics RPC resolves — the +- // common case, since idle follows a turn's last usage event almost immediately. +- mockSession.fire('session.idle', { aborted: false } as SessionEventPayload<'session.idle'>['data']); +- gate.complete(); +- await timeout(0); +- +- const usageActions = getActions(signals).filter(a => a.type === ActionType.ChatUsage) as ChatUsageAction[]; +- // The cost belongs to the turn that incurred it, so it must survive the turn +- // ending mid-refresh. The session total may lag here — the re-emit carrying it +- // is dropped once the turn is no longer active — which `ChatModel.sessionCost` +- // absorbs by taking the larger of the reported total and the summed turns. +- assert.strictEqual((usageActions.at(-1)?.usage._meta as UsageInfoMeta | undefined)?.copilotUsage?.totalNanoAiu, 500_000_000); +- }); +- +- test('`/compact` reports the compaction call credits on the post-compaction usage', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- mockSession.compactResult = { success: true, tokensRemoved: 1200, messagesRemoved: 3, contextWindow: { currentTokens: 4500, tokenLimit: 128000, messagesLength: 7 } }; +- // The SDK bills the summarization call on `session.compaction_complete` (emitted while the +- // history RPC is still in flight) rather than as an `assistant.usage` event. +- mockSession.onCompact = () => mockSession.fire('session.compaction_complete', { +- success: true, +- tokensRemoved: 1200, +- // `copilotUsage` is `asInternal` in the SDK schema, so it is absent from the public type but present at runtime. +- compactionTokensUsed: { model: 'claude-sonnet-4.6', inputTokens: 9000, outputTokens: 400, copilotUsage: { totalNanoAiu: 250_000_000 } }, +- } as unknown as SessionEventPayload<'session.compaction_complete'>['data']); +- +- await session.send('/compact', undefined, 'turn-compact'); +- await timeout(0); +- +- const usageActions = getActions(signals).filter(a => a.type === ActionType.ChatUsage) as ChatUsageAction[]; +- assert.deepStrictEqual(usageActions.at(-1), { +- type: ActionType.ChatUsage, +- turnId: 'turn-compact', +- usage: { +- inputTokens: 4500, +- outputTokens: 0, +- model: undefined, +- _meta: { copilotUsage: { totalNanoAiu: 250_000_000, sessionTotalNanoAiu: 250_000_000 } }, +- }, +- }); +- }); +- +- test('automatic compaction folds its credits into the turn running total', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- +- session.resetTurnState('turn-auto-compact'); +- mockSession.fire('assistant.usage', { +- model: 'claude-sonnet-4.6', +- inputTokens: 10, +- outputTokens: 20, +- copilotUsage: { totalNanoAiu: 500_000_000 }, +- } as unknown as SessionEventPayload<'assistant.usage'>['data']); +- mockSession.fire('session.compaction_complete', { +- success: true, +- tokensRemoved: 1200, +- compactionTokensUsed: { model: 'claude-sonnet-4.6', copilotUsage: { totalNanoAiu: 250_000_000 } }, +- } as unknown as SessionEventPayload<'session.compaction_complete'>['data']); +- await timeout(0); +- +- const usageActions = getActions(signals).filter(a => a.type === ActionType.ChatUsage) as ChatUsageAction[]; +- // The compaction credits add to the turn total while the parent turn's own model and +- // context tokens are preserved, so the response footer shows the full turn cost. +- assert.deepStrictEqual(usageActions.at(-1)?.usage, { +- inputTokens: 10, +- outputTokens: 20, +- model: 'claude-sonnet-4.6', +- cacheReadTokens: undefined, +- _meta: { copilotUsage: { totalNanoAiu: 750_000_000, sessionTotalNanoAiu: 750_000_000 } }, +- }); +- }); +- +- test('failed compaction does not report credits', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- +- session.resetTurnState('turn-failed-compact'); +- mockSession.fire('session.compaction_complete', { +- success: false, +- error: 'boom', +- compactionTokensUsed: { copilotUsage: { totalNanoAiu: 250_000_000 } }, +- } as unknown as SessionEventPayload<'session.compaction_complete'>['data']); +- await timeout(0); +- +- assert.deepStrictEqual(getActions(signals).filter(a => a.type === ActionType.ChatUsage), []); +- }); +- +- test('compaction billed outside a turn shows in the session total, not on the next turn', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- +- // Automatic compaction can run with no turn active (e.g. after an abort). It is +- // nobody's turn cost — and an out-of-turn compaction usually finds a cold prompt +- // cache and pays the ~12x cache-write rate, so billing it to an unrelated next +- // turn would dominate that turn's footer. +- mockSession.fire('session.compaction_complete', { +- success: true, +- compactionTokensUsed: { model: 'claude-opus-4.6', copilotUsage: { totalNanoAiu: 133_468_375_000 } }, +- } as unknown as SessionEventPayload<'session.compaction_complete'>['data']); +- await timeout(0); +- assert.deepStrictEqual(getActions(signals).filter(a => a.type === ActionType.ChatUsage), []); +- +- session.resetTurnState('turn-after-compact'); +- mockSession.fire('assistant.usage', { +- model: 'claude-opus-4.6', +- inputTokens: 10, +- outputTokens: 20, +- copilotUsage: { totalNanoAiu: 500_000_000 }, +- } as unknown as SessionEventPayload<'assistant.usage'>['data']); +- await timeout(0); +- +- const usageActions = getActions(signals).filter(a => a.type === ActionType.ChatUsage) as ChatUsageAction[]; +- assert.deepStrictEqual(usageActions.at(-1)?.usage, { +- inputTokens: 10, +- outputTokens: 20, +- model: 'claude-opus-4.6', +- cacheReadTokens: undefined, +- // The turn bills only its own call; the compaction is visible in the session total. +- _meta: { copilotUsage: { totalNanoAiu: 500_000_000, sessionTotalNanoAiu: 133_968_375_000 } }, +- }); +- }); +- +- test('a turn that never reports usage does not inherit out-of-turn compaction cost', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- +- mockSession.fire('session.compaction_complete', { +- success: true, +- compactionTokensUsed: { copilotUsage: { totalNanoAiu: 2_000_000_000 } }, +- } as unknown as SessionEventPayload<'session.compaction_complete'>['data']); +- await timeout(0); +- +- session.resetTurnState('turn-1'); +- session.resetTurnState('turn-2'); +- mockSession.fire('assistant.usage', { +- model: 'claude-opus-4.6', +- inputTokens: 1, +- outputTokens: 1, +- copilotUsage: { totalNanoAiu: 1_000_000_000 }, +- } as unknown as SessionEventPayload<'assistant.usage'>['data']); +- await timeout(0); +- +- const usageActions = getActions(signals).filter(a => a.type === ActionType.ChatUsage) as ChatUsageAction[]; +- assert.deepStrictEqual(usageActions.at(-1)?.usage._meta, { +- copilotUsage: { totalNanoAiu: 1_000_000_000, sessionTotalNanoAiu: 3_000_000_000 }, +- }); +- }); +- +- test('each turn bills only its own calls while the session total accumulates', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- +- mockSession.fire('session.compaction_complete', { +- success: true, +- compactionTokensUsed: { copilotUsage: { totalNanoAiu: 2_000_000_000 } }, +- } as unknown as SessionEventPayload<'session.compaction_complete'>['data']); +- await timeout(0); +- +- session.resetTurnState('turn-1'); +- mockSession.fire('assistant.usage', { +- model: 'claude-opus-4.6', +- inputTokens: 1, +- outputTokens: 1, +- copilotUsage: { totalNanoAiu: 1_000_000_000 }, +- } as unknown as SessionEventPayload<'assistant.usage'>['data']); +- await timeout(0); +- session.resetTurnState('turn-2'); +- mockSession.fire('assistant.usage', { +- model: 'claude-opus-4.6', +- inputTokens: 1, +- outputTokens: 1, +- copilotUsage: { totalNanoAiu: 1_000_000_000 }, +- } as unknown as SessionEventPayload<'assistant.usage'>['data']); +- await timeout(0); +- +- const usageActions = getActions(signals).filter(a => a.type === ActionType.ChatUsage) as ChatUsageAction[]; +- // Each turn reports its own single call; the session total carries the +- // out-of-turn compaction plus both turns. +- assert.deepStrictEqual(usageActions.map(a => ({ turnId: a.turnId, copilotUsage: a.usage._meta?.copilotUsage })), [ +- { turnId: 'turn-1', copilotUsage: { totalNanoAiu: 1_000_000_000, sessionTotalNanoAiu: 2_000_000_000 } }, +- { turnId: 'turn-1', copilotUsage: { totalNanoAiu: 1_000_000_000, sessionTotalNanoAiu: 3_000_000_000 } }, +- { turnId: 'turn-2', copilotUsage: { totalNanoAiu: 1_000_000_000, sessionTotalNanoAiu: 3_000_000_000 } }, +- { turnId: 'turn-2', copilotUsage: { totalNanoAiu: 1_000_000_000, sessionTotalNanoAiu: 4_000_000_000 } }, +- ]); +- }); +- +- test('`/compact` completes the turn even when compaction reports failure', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- mockSession.compactResult = { success: false, tokensRemoved: 0, messagesRemoved: 0 }; +- +- await session.send('/compact', undefined, 'turn-compact'); +- +- assert.strictEqual(mockSession.compactCalls.length, 1); +- assert.deepStrictEqual(mockSession.sendRequests, []); +- const turnComplete = getActions(signals).find(a => a.type === ActionType.ChatTurnComplete); +- assert.ok(turnComplete, 'expected the turn to complete on a failed compaction'); +- }); +- +- test('`/compact` treats nothing-to-compact errors as completed', async () => { +- const logService = new CapturingLogService(); +- const { session, mockSession, signals } = await createAgentSession(disposables, { logService }); +- mockSession.compactError = new Error('NOTHING TO COMPACT for this conversation'); +- +- await session.send('/compact', undefined, 'turn-compact'); +- +- const actions = getActions(signals); +- assert.deepStrictEqual({ +- compactCalls: mockSession.compactCalls.length, +- sendRequests: mockSession.sendRequests, +- errors: logService.errors, +- responseParts: actions +- .filter(a => a.type === ActionType.ChatResponsePart) +- .map(a => { +- const part = (a as ChatResponsePartAction).part; +- return part.kind === ResponsePartKind.Markdown ? { turnId: a.turnId, kind: part.kind, content: part.content } : { turnId: a.turnId, kind: part.kind }; +- }), +- turnComplete: actions +- .filter(a => a.type === ActionType.ChatTurnComplete) +- .map(a => (a as ChatTurnCompleteAction).turnId), +- }, { +- compactCalls: 1, +- sendRequests: [], +- errors: [], +- responseParts: [{ turnId: 'turn-compact', kind: ResponsePartKind.Markdown, content: 'Compaction completed' }], +- turnComplete: ['turn-compact'], +- }); +- }); +- +- test('a failed send releases the turn so the chat does not look busy forever', async () => { +- // Nothing closes a turn whose send rejected: the SDK loop never starts, +- // so no `session.idle` arrives. The host finalizes the protocol turn with +- // a ChatError, and the session must drop its handle to match — a chat +- // stuck `busy` blocks idle eviction and parks deferred client restarts. +- let turnEndCount = 0; +- const { session, mockSession } = await createAgentSession(disposables, { onTurnEnded: () => turnEndCount++ }); +- mockSession.send = async () => { throw new Error('send failed'); }; +- +- await assert.rejects(() => session.send('hello', undefined, 'turn-failed'), /send failed/); +- +- assert.deepStrictEqual({ hasActiveTurn: session.hasActiveTurn, turnEndCount }, { hasActiveTurn: false, turnEndCount: 1 }); +- }); +- +- test('`/env` runs the runtime command when listed and emits markdown output', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- mockSession.commandListResult = { +- commands: [{ +- name: 'env', +- kind: 'builtin', +- description: 'Show loaded environment details', +- allowDuringAgentExecution: true, +- }], +- }; +- mockSession.commandInvokeResult = { kind: 'text', text: '## Environment\n\nLoaded.', markdown: true }; +- +- await session.send('/env', undefined, 'turn-env'); +- +- const actions = getActions(signals); +- assert.deepStrictEqual({ +- commandListCalls: mockSession.commandListCalls, +- commandInvokeCalls: mockSession.commandInvokeCalls, +- sendRequests: mockSession.sendRequests, +- responseParts: actions +- .filter(a => a.type === ActionType.ChatResponsePart) +- .map(a => { +- const part = (a as ChatResponsePartAction).part; +- return part.kind === ResponsePartKind.Markdown ? { kind: part.kind, content: part.content } : { kind: part.kind }; +- }), +- turnComplete: actions +- .filter(a => a.type === ActionType.ChatTurnComplete) +- .map(a => (a as ChatTurnCompleteAction).turnId), +- }, { +- commandListCalls: [{ includeBuiltins: true, includeSkills: true, includeClientCommands: true }], +- commandInvokeCalls: [{ name: 'env' }], +- sendRequests: [], +- responseParts: [{ kind: ResponsePartKind.Markdown, content: '## Environment\n\nLoaded.' }], +- turnComplete: ['turn-env'], +- }); +- }); +- +- test('`/env` escapes plain text runtime command output before emitting markdown', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- mockSession.commandListResult = { +- commands: [{ +- name: 'env', +- kind: 'builtin', +- description: 'Show loaded environment details', +- allowDuringAgentExecution: true, +- }], +- }; +- mockSession.commandInvokeResult = { kind: 'text', text: '*plain*\n- item', markdown: false }; +- +- await session.send('/env', undefined, 'turn-env'); +- +- const responsePart = getActions(signals).find(a => a.type === ActionType.ChatResponsePart) as ChatResponsePartAction | undefined; +- assert.strictEqual(responsePart?.part.kind, ResponsePartKind.Markdown); +- if (responsePart?.part.kind === ResponsePartKind.Markdown) { +- assert.strictEqual(responsePart.part.content, '\\*plain\\*\n\\- item'); +- } +- }); +- +- test('`/env` falls through to a normal SDK send when not listed', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- mockSession.commandListResult = { commands: [] }; +- +- await session.send('/env', undefined, 'turn-env'); +- +- assert.deepStrictEqual({ +- commandListCalls: mockSession.commandListCalls, +- commandInvokeCalls: mockSession.commandInvokeCalls, +- sendRequests: mockSession.sendRequests, +- responseParts: getActions(signals).filter(a => a.type === ActionType.ChatResponsePart), +- turnComplete: getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete), +- }, { +- commandListCalls: [{ includeBuiltins: true, includeSkills: true, includeClientCommands: true }], +- commandInvokeCalls: [], +- sendRequests: [{ prompt: '/env', attachments: undefined }], +- responseParts: [], +- turnComplete: [], +- }); +- }); +- +- test('`/env` forwards trailing text as runtime command input', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- mockSession.commandListResult = { +- commands: [{ +- name: 'env', +- kind: 'builtin', +- description: 'Show loaded environment details', +- allowDuringAgentExecution: true, +- }], +- }; +- mockSession.commandInvokeResult = { kind: 'completed', message: 'done' }; +- +- await session.send('/env details please', undefined, 'turn-env-input'); +- +- const actions = getActions(signals); +- assert.deepStrictEqual({ +- commandListCalls: mockSession.commandListCalls, +- commandInvokeCalls: mockSession.commandInvokeCalls, +- sendRequests: mockSession.sendRequests, +- responseParts: actions +- .filter(a => a.type === ActionType.ChatResponsePart) +- .map(a => { +- const part = (a as ChatResponsePartAction).part; +- return part.kind === ResponsePartKind.Markdown ? { kind: part.kind, content: part.content } : { kind: part.kind }; +- }), +- turnComplete: actions +- .filter(a => a.type === ActionType.ChatTurnComplete) +- .map(a => (a as ChatTurnCompleteAction).turnId), +- }, { +- commandListCalls: [{ includeBuiltins: true, includeSkills: true, includeClientCommands: true }], +- commandInvokeCalls: [{ name: 'env', input: 'details please' }], +- sendRequests: [], +- responseParts: [{ kind: ResponsePartKind.Markdown, content: 'done' }], +- turnComplete: ['turn-env-input'], +- }); +- }); +- +- test('invokes non-local runtime slash commands via commands API', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- mockSession.commandListResult = { +- commands: [{ +- name: 'focus', +- aliases: ['f'], +- kind: 'builtin', +- description: 'Focus on a scope', +- allowDuringAgentExecution: true, +- input: { hint: 'scope' }, +- }], +- }; +- mockSession.commandInvokeResult = { kind: 'completed', message: 'Focus done' }; +- +- await session.send('/f src/vs/platform', undefined, 'turn-focus'); +- +- const actions = getActions(signals); +- assert.deepStrictEqual({ +- commandListCalls: mockSession.commandListCalls, +- commandInvokeCalls: mockSession.commandInvokeCalls, +- sendRequests: mockSession.sendRequests, +- responseParts: actions +- .filter(a => a.type === ActionType.ChatResponsePart) +- .map(a => { +- const part = (a as ChatResponsePartAction).part; +- return part.kind === ResponsePartKind.Markdown ? { kind: part.kind, content: part.content } : { kind: part.kind }; +- }), +- turnComplete: actions +- .filter(a => a.type === ActionType.ChatTurnComplete) +- .map(a => (a as ChatTurnCompleteAction).turnId), +- }, { +- commandListCalls: [{ includeBuiltins: true, includeSkills: true, includeClientCommands: true }], +- commandInvokeCalls: [{ name: 'focus', input: 'src/vs/platform' }], +- sendRequests: [], +- responseParts: [{ kind: ResponsePartKind.Markdown, content: 'Focus done' }], +- turnComplete: ['turn-focus'], +- }); +- }); +- +- test('caches runtime slash command availability across checks', async () => { +- const { session, mockSession } = await createAgentSession(disposables); +- mockSession.commandListResult = { +- commands: [ +- { +- name: 'env', +- kind: 'builtin', +- description: 'Show loaded environment details', +- allowDuringAgentExecution: true, +- }, +- { +- name: 'review', +- kind: 'builtin', +- description: 'Run code review agent to analyze changes', +- allowDuringAgentExecution: false, +- }, +- { +- name: 'not-a-builtin', +- kind: 'skill', +- description: 'Skill command', +- allowDuringAgentExecution: false, +- }, +- ], +- }; +- +- assert.deepStrictEqual({ +- env: await session.hasRuntimeSlashCommand('env'), +- review: await session.hasRuntimeSlashCommand('review'), +- skill: await session.hasRuntimeSlashCommand('not-a-builtin'), +- commandListCalls: mockSession.commandListCalls, +- }, { +- env: true, +- review: true, +- skill: true, +- commandListCalls: [{ includeBuiltins: true, includeSkills: true, includeClientCommands: true }], +- }); +- }); +- +- test('`/review` invokes runtime command when listed', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- mockSession.commandListResult = { +- commands: [{ +- name: 'review', +- kind: 'builtin', +- description: 'Run code review agent to analyze changes', +- allowDuringAgentExecution: true, +- input: { hint: 'scope' }, +- }], +- }; +- mockSession.commandInvokeResult = { kind: 'completed', message: 'Review done' }; +- +- await session.send('/review focus on tests', undefined, 'turn-review'); +- +- assert.deepStrictEqual({ +- commandListCalls: mockSession.commandListCalls, +- commandInvokeCalls: mockSession.commandInvokeCalls, +- sendRequests: mockSession.sendRequests, +- responseParts: getActions(signals) +- .filter(a => a.type === ActionType.ChatResponsePart) +- .map(a => { +- const part = (a as ChatResponsePartAction).part; +- return part.kind === ResponsePartKind.Markdown ? { kind: part.kind, content: part.content } : { kind: part.kind }; +- }), +- turnComplete: getActions(signals) +- .filter(a => a.type === ActionType.ChatTurnComplete) +- .map(a => (a as ChatTurnCompleteAction).turnId), +- }, { +- commandListCalls: [{ includeBuiltins: true, includeSkills: true, includeClientCommands: true }], +- commandInvokeCalls: [{ name: 'review', input: 'focus on tests' }], +- sendRequests: [], +- responseParts: [{ kind: ResponsePartKind.Markdown, content: 'Review done' }], +- turnComplete: ['turn-review'], +- }); +- }); +- +- test('`/security-review` falls through to normal send when runtime command is unavailable', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- mockSession.commandListResult = { commands: [] }; +- +- await session.send('/security-review', undefined, 'turn-security-review'); +- +- assert.deepStrictEqual({ +- commandListCalls: mockSession.commandListCalls, +- commandInvokeCalls: mockSession.commandInvokeCalls, +- sendRequests: mockSession.sendRequests, +- responseParts: getActions(signals).filter(a => a.type === ActionType.ChatResponsePart), +- turnComplete: getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete), +- }, { +- commandListCalls: [{ includeBuiltins: true, includeSkills: true, includeClientCommands: true }], +- commandInvokeCalls: [], +- sendRequests: [{ prompt: '/security-review', attachments: undefined }], +- responseParts: [], +- turnComplete: [], +- }); +- }); +- +- test('emits accumulated Copilot usage metadata', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- +- session.resetTurnState('turn-usage'); +- mockSession.fire('assistant.usage', { +- model: 'claude-sonnet-4.6', +- inputTokens: 10, +- outputTokens: 20, +- cacheReadTokens: 5, +- cost: 2, +- // `copilotUsage` is marked `asInternal` in the SDK schema so it is not on the public type, but is present at runtime. +- copilotUsage: { totalNanoAiu: 500_000_000, tokenDetails: [] }, +- } as unknown as SessionEventPayload<'assistant.usage'>['data']); +- await timeout(0); +- mockSession.fire('assistant.usage', { +- model: 'claude-sonnet-4.6', +- inputTokens: 30, +- outputTokens: 40, +- cost: 2, +- copilotUsage: { totalNanoAiu: 750_000_000, tokenDetails: [] }, +- } as unknown as SessionEventPayload<'assistant.usage'>['data']); +- await timeout(0); +- +- const usageActions = signals +- .filter((s): s is IAgentActionSignal => s.kind === 'action') +- .map(s => s.action) +- .filter(a => a.type === ActionType.ChatUsage); +- +- // The turn's running total and the session total both come from the SDK's usage +- // metrics, so they are reported on the enrichment re-emit that follows each event. +- assert.deepStrictEqual(usageActions.at(-1)?.usage, { +- inputTokens: 30, +- outputTokens: 40, +- model: 'claude-sonnet-4.6', +- cacheReadTokens: undefined, +- _meta: { +- cost: 2, +- copilotUsage: { totalNanoAiu: 1_250_000_000, sessionTotalNanoAiu: 1_250_000_000 }, +- }, +- }); +- }); +- +- test('restores non-Opus prompt cache expiration from usage metrics on initialize', async () => { +- const cacheExpiresAt = '2026-07-24T12:00:00.000Z'; +- const { dispatchedActions } = await createAgentSession(disposables, { +- resume: true, +- configureMockSession: session => { +- session.usageMetricsResult.currentModel = 'gpt-5.4'; +- session.usageMetricsResult.modelMetrics['gpt-5.4'] = { cacheExpiresAt }; +- }, +- }); +- +- const promptCaches = dispatchedActions +- .filter(action => action.type === ActionType.SessionMetaChanged) +- .map(action => readSessionPromptCacheState(action._meta)) +- .filter(cache => cache !== undefined); +- assert.deepStrictEqual(promptCaches, [{ +- modelId: 'gpt-5.4', +- cacheExpiresAt, +- }]); +- }); +- +- test('updates prompt cache expiration from main-agent usage only', async () => { +- const { mockSession, dispatchedActions } = await createAgentSession(disposables); +- mockSession.fire('assistant.usage', { +- model: 'claude-opus-4.8', +- inputTokens: 100, +- outputTokens: 10, +- cacheExpiresAt: '2026-07-24T12:00:00.000Z', +- }); +- mockSession.fire('assistant.usage', { +- model: 'claude-haiku-4.5', +- inputTokens: 50, +- outputTokens: 5, +- cacheExpiresAt: '2026-07-24T12:10:00.000Z', +- parentToolCallId: 'subagent-tool-call', +- }); +- await timeout(0); +- +- const promptCaches = dispatchedActions +- .filter(action => action.type === ActionType.SessionMetaChanged) +- .map(action => readSessionPromptCacheState(action._meta)) +- .filter(cache => cache !== undefined); +- assert.deepStrictEqual(promptCaches, [{ +- modelId: 'claude-opus-4.8', +- cacheExpiresAt: '2026-07-24T12:00:00.000Z', +- }]); +- }); +- +- test('preserves prompt cache expiration when later usage omits a cache update', async () => { +- const { mockSession, dispatchedActions } = await createAgentSession(disposables); +- mockSession.fire('assistant.usage', { +- model: 'claude-sonnet-4.6', +- inputTokens: 100, +- outputTokens: 10, +- cacheExpiresAt: '2026-07-24T12:00:00.000Z', +- }); +- await timeout(0); +- mockSession.fire('assistant.usage', { +- model: 'claude-sonnet-4.6', +- inputTokens: 50, +- outputTokens: 5, +- }); +- await timeout(0); +- +- const promptCaches = dispatchedActions +- .filter(action => action.type === ActionType.SessionMetaChanged) +- .map(action => readSessionPromptCacheState(action._meta)) +- .filter(cache => cache !== undefined); +- assert.deepStrictEqual(promptCaches, [{ +- modelId: 'claude-sonnet-4.6', +- cacheExpiresAt: '2026-07-24T12:00:00.000Z', +- }]); +- }); +- +- test('preserves restored prompt cache metadata after a resume metrics failure', async () => { +- const cacheExpiresAt = '2026-07-24T12:00:00.000Z'; +- const initialSessionMeta = withSessionPromptCacheState(undefined, { modelId: 'claude-sonnet-4.6', cacheExpiresAt }); +- assert.ok(initialSessionMeta); +- const { mockSession, dispatchedActions } = await createAgentSession(disposables, { +- resume: true, +- initialSessionMeta, +- configureMockSession: session => { +- session.usageMetricsResult.currentModel = 'claude-sonnet-4.6'; +- session.usageMetricsResult.modelMetrics['claude-sonnet-4.6'] = { cacheExpiresAt }; +- session.usageMetricsError = new Error('rpc unavailable'); +- }, +- }); +- mockSession.fire('assistant.usage', { +- model: 'claude-sonnet-4.6', +- inputTokens: 50, +- outputTokens: 5, +- }); +- await timeout(0); +- +- assert.deepStrictEqual(dispatchedActions.filter(action => action.type === ActionType.SessionMetaChanged), []); +- }); +- +- test('clears prompt cache expiration when switching to a model without cached state', async () => { +- const { mockSession, dispatchedActions } = await createAgentSession(disposables); +- mockSession.fire('assistant.usage', { +- model: 'claude-opus-4.8', +- inputTokens: 100, +- outputTokens: 10, +- cacheExpiresAt: '2026-07-24T12:00:00.000Z', +- }); +- mockSession.fire('session.model_change', { +- previousModel: 'claude-opus-4.8', +- newModel: 'gpt-5.4', +- }); +- await timeout(0); +- +- const promptCaches = dispatchedActions +- .filter(action => action.type === ActionType.SessionMetaChanged) +- .map(action => readSessionPromptCacheState(action._meta)); +- assert.deepStrictEqual(promptCaches, [{ +- modelId: 'claude-opus-4.8', +- cacheExpiresAt: '2026-07-24T12:00:00.000Z', +- }, undefined]); +- }); +- +- test('preserves prompt cache expiration for same-model configuration changes', async () => { +- const { mockSession, dispatchedActions } = await createAgentSession(disposables); +- mockSession.fire('assistant.usage', { +- model: 'claude-opus-4.8', +- inputTokens: 100, +- outputTokens: 10, +- cacheExpiresAt: '2026-07-24T12:00:00.000Z', +- }); +- await timeout(0); +- mockSession.fire('session.model_change', { +- previousModel: 'claude-opus-4.8', +- newModel: 'claude-opus-4.8', +- reasoningEffort: 'high', +- }); +- await timeout(0); +- +- const promptCaches = dispatchedActions +- .filter(action => action.type === ActionType.SessionMetaChanged) +- .map(action => readSessionPromptCacheState(action._meta)); +- assert.deepStrictEqual(promptCaches, [{ +- modelId: 'claude-opus-4.8', +- cacheExpiresAt: '2026-07-24T12:00:00.000Z', +- }]); +- }); +- +- test('clears another model cache when a usage metrics refresh fails', async () => { +- const { mockSession, dispatchedActions } = await createAgentSession(disposables); +- mockSession.fire('assistant.usage', { +- model: 'claude-opus-4.8', +- inputTokens: 100, +- outputTokens: 10, +- cacheExpiresAt: '2026-07-24T12:00:00.000Z', +- }); +- await timeout(0); +- mockSession.usageMetricsError = new Error('rpc unavailable'); +- mockSession.fire('assistant.usage', { +- model: 'gpt-5.4', +- inputTokens: 50, +- outputTokens: 5, +- }); +- await timeout(0); +- +- const promptCaches = dispatchedActions +- .filter(action => action.type === ActionType.SessionMetaChanged) +- .map(action => readSessionPromptCacheState(action._meta)); +- assert.deepStrictEqual(promptCaches, [{ +- modelId: 'claude-opus-4.8', +- cacheExpiresAt: '2026-07-24T12:00:00.000Z', +- }, undefined]); +- }); +- +- test('forwards Auto model resolution on live usage metadata', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- const autoModeResolved = { +- chosenModel: 'claude-opus-4.8', +- reasoningBucket: 'high' as const, +- categoryScores: { reasoning: 0.91, code_gen: 0.72 }, +- predictedLabel: 'needs_reasoning', +- confidence: 0.93, +- candidateModels: ['claude-opus-4.8', 'claude-sonnet-4.6'], +- }; +- +- session.resetTurnState('turn-before-auto'); +- mockSession.fire('assistant.usage', { +- model: 'gpt-5.5', +- inputTokens: 100, +- outputTokens: 200, +- cost: 5, +- } as SessionEventPayload<'assistant.usage'>['data']); +- session.resetTurnState('turn-auto'); +- mockSession.fire('session.auto_mode_resolved', autoModeResolved); +- mockSession.fire('assistant.usage', { +- model: 'claude-opus-4.8', +- inputTokens: 10, +- outputTokens: 20, +- cost: 2, +- } as SessionEventPayload<'assistant.usage'>['data']); +- +- const usageActions = signals +- .filter((signal): signal is IAgentActionSignal => signal.kind === 'action') +- .map(signal => signal.action) +- .filter((action): action is ChatUsageAction => action.type === ActionType.ChatUsage && action.turnId === 'turn-auto'); +- +- assert.deepStrictEqual({ +- usages: usageActions.map(action => action.usage), +- parsed: readUsageInfoMeta(usageActions.at(-1)?.usage).autoModeResolved, +- }, { +- usages: [ +- { +- model: 'claude-opus-4.8', +- _meta: { autoModeResolved }, +- }, +- { +- inputTokens: 10, +- outputTokens: 20, +- model: 'claude-opus-4.8', +- cacheReadTokens: undefined, +- _meta: { cost: 2, autoModeResolved }, +- }, +- ], +- parsed: autoModeResolved, +- }); +- }); +- +- test('reports the parent turn aggregate and additionally the per-subagent component', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- +- session.resetTurnState('turn-1'); +- +- // Map the subagent's agentId to its parent tool call id. +- mockSession.fire('subagent.started', { +- toolCallId: 'tc-subagent', +- agentName: 'explore', +- agentDisplayName: 'Explore', +- agentDescription: 'Explore tests', +- } as SessionEventPayload<'subagent.started'>['data'], { agentId: 'agent-1' }); +- +- // Parent agent usage (no agentId) only contributes to the parent aggregate. +- mockSession.fire('assistant.usage', { +- model: 'claude-opus-4.8', +- inputTokens: 10, +- outputTokens: 20, +- copilotUsage: { totalNanoAiu: 500_000_000, tokenDetails: [] }, +- } as unknown as SessionEventPayload<'assistant.usage'>['data']); +- await timeout(0); +- +- // Subagent usage (its agentId) is emitted to the subagent's child session as +- // its own component; the parent aggregate grows via the SDK's session metrics. +- mockSession.fire('assistant.usage', { +- model: 'gpt-5.5', +- inputTokens: 5, +- outputTokens: 7, +- copilotUsage: { totalNanoAiu: 200_000_000, tokenDetails: [] }, +- } as unknown as SessionEventPayload<'assistant.usage'>['data'], { agentId: 'agent-1' }); +- await timeout(0); +- +- mockSession.fire('assistant.usage', { +- model: 'gpt-5.5', +- inputTokens: 6, +- outputTokens: 8, +- copilotUsage: { totalNanoAiu: 300_000_000, tokenDetails: [] }, +- } as unknown as SessionEventPayload<'assistant.usage'>['data'], { agentId: 'agent-1' }); +- await timeout(0); +- +- const usageSignals = signals.flatMap(signal => { +- if (signal.kind !== 'action' || signal.action.type !== ActionType.ChatUsage) { +- return []; +- } +- return [{ +- parentToolCallId: signal.parentToolCallId, +- model: signal.action.usage.model, +- inputTokens: signal.action.usage.inputTokens, +- outputTokens: signal.action.usage.outputTokens, +- totalNanoAiu: (signal.action.usage._meta as UsageInfoMeta | undefined)?.copilotUsage?.totalNanoAiu, +- }]; +- }); +- +- // The parent aggregate always keeps the parent's own model/context tokens, and +- // its credits cover every call the turn caused (its own plus every subagent's). +- // They land on the synchronous emit, so a turn ending mid-refresh cannot lose them. +- assert.deepStrictEqual(usageSignals, [ +- { parentToolCallId: undefined, model: 'claude-opus-4.8', inputTokens: 10, outputTokens: 20, totalNanoAiu: 500_000_000 }, +- { parentToolCallId: undefined, model: 'claude-opus-4.8', inputTokens: 10, outputTokens: 20, totalNanoAiu: 500_000_000 }, +- { parentToolCallId: undefined, model: 'claude-opus-4.8', inputTokens: 10, outputTokens: 20, totalNanoAiu: 700_000_000 }, +- { parentToolCallId: 'tc-subagent', model: 'gpt-5.5', inputTokens: 5, outputTokens: 7, totalNanoAiu: 200_000_000 }, +- { parentToolCallId: undefined, model: 'claude-opus-4.8', inputTokens: 10, outputTokens: 20, totalNanoAiu: 700_000_000 }, +- { parentToolCallId: undefined, model: 'claude-opus-4.8', inputTokens: 10, outputTokens: 20, totalNanoAiu: 1_000_000_000 }, +- { parentToolCallId: 'tc-subagent', model: 'gpt-5.5', inputTokens: 6, outputTokens: 8, totalNanoAiu: 500_000_000 }, +- { parentToolCallId: undefined, model: 'claude-opus-4.8', inputTokens: 10, outputTokens: 20, totalNanoAiu: 1_000_000_000 }, +- ]); +- }); +- +- test('forwards account quota snapshots on usage metadata', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- +- session.resetTurnState('turn-quota'); +- mockSession.fire('assistant.usage', { +- model: 'claude-sonnet-4.6', +- inputTokens: 10, +- outputTokens: 20, +- // `quotaSnapshots` is marked `asInternal` in the SDK schema so it is not on the public type, but is present at runtime. +- quotaSnapshots: { +- premium_interactions: { +- isUnlimitedEntitlement: false, +- entitlementRequests: 300, +- usedRequests: 75, +- usageAllowedWithExhaustedQuota: true, +- remainingPercentage: 75, +- overage: 1.5, +- overageAllowedWithExhaustedQuota: true, +- resetDate: '2026-07-01T00:00:00.000Z', +- }, +- }, +- } as unknown as SessionEventPayload<'assistant.usage'>['data']); +- +- const usageActions = signals +- .filter((s): s is IAgentActionSignal => s.kind === 'action') +- .map(s => s.action) +- .filter(a => a.type === ActionType.ChatUsage); +- +- assert.deepStrictEqual(usageActions.map(a => a.usage._meta?.quotaSnapshots), [ +- { +- premium_interactions: { +- isUnlimitedEntitlement: false, +- entitlementRequests: 300, +- usedRequests: 75, +- remainingPercentage: 75, +- overage: 1.5, +- overageAllowedWithExhaustedQuota: true, +- resetDate: '2026-07-01T00:00:00.000Z', +- }, +- }, +- ]); +- }); +- +- test('extracts selected text from file contents for different line endings and bounds', async () => { +- const testCases = [ +- { +- name: 'lf multiline', +- contents: 'zero\none\ntwo\nthree', +- selection: { start: { line: 1, character: 1 }, end: { line: 2, character: 2 } }, +- expectedText: 'ne\ntw', +- }, +- { +- name: 'crlf multiline', +- contents: 'zero\r\none\r\ntwo\r\nthree', +- selection: { start: { line: 1, character: 1 }, end: { line: 2, character: 2 } }, +- expectedText: 'ne\r\ntw', +- }, +- { +- name: 'clamps past eof', +- contents: 'zero\none', +- selection: { start: { line: 1, character: 1 }, end: { line: 42, character: 99 } }, +- expectedText: 'ne', +- }, +- { +- name: 'empty when end is before start', +- contents: 'zero\none', +- selection: { start: { line: 1, character: 3 }, end: { line: 1, character: 1 } }, +- expectedText: '', +- }, +- ] satisfies ReadonlyArray<{ +- name: string; +- contents: string; +- selection: { +- start: { line: number; character: number }; +- end: { line: number; character: number }; +- }; +- expectedText: string; +- }>; +- +- for (const testCase of testCases) { +- const selectionUri = URI.file(`/workspace/${testCase.name}.ts`); +- const { session, mockSession } = await createAgentSession(disposables, { +- fileContents: { +- [selectionUri.toString()]: testCase.contents, +- }, +- }); +- +- await session.send('hello', [{ +- type: MessageAttachmentKind.Resource, +- uri: selectionUri.toString(), +- label: `${testCase.name}.ts`, +- displayKind: 'selection', +- selection: { range: testCase.selection }, +- }]); +- +- assert.deepStrictEqual(mockSession.sendRequests, [{ +- prompt: 'hello', +- attachments: [{ +- type: 'selection', +- filePath: selectionUri.fsPath, +- displayName: `${testCase.name}.ts`, +- text: testCase.expectedText, +- selection: testCase.selection, +- }], +- }], testCase.name); +- disposables.clear(); +- } +- }); +- +- test('falls back to file attachment when selection text cannot be read', async () => { +- const selectionUri = URI.file('/workspace/missing.ts'); +- const logService = new CapturingLogService(); +- const { session, mockSession } = await createAgentSession(disposables, { +- fileReadErrors: [selectionUri.toString()], +- logService, +- }); +- +- await session.send('hello', [{ +- type: MessageAttachmentKind.Resource, +- uri: selectionUri.toString(), +- label: 'missing.ts', +- displayKind: 'selection', +- selection: { +- range: { +- start: { line: 0, character: 0 }, +- end: { line: 0, character: 5 }, +- }, +- }, +- }]); +- +- assert.deepStrictEqual(mockSession.sendRequests, [{ +- prompt: 'hello', +- attachments: [ +- { type: 'file', path: selectionUri.fsPath, displayName: 'missing.ts' }, +- ], +- }]); +- assert.strictEqual(logService.warnings.length, 1); +- assert.match(logService.warnings[0].message, /Failed to read selected text/); +- }); +- +- // ---- permission handling ---- +- +- suite('permission handling', () => { +- +- test('read permission fires tool_ready (deferred to side effects)', async () => { +- const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables); +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'read', +- path: '/workspace/src/file.ts', +- toolCallId: 'tc-1', +- }); +- +- await waitForSignal(s => s.kind === 'pending_confirmation'); +- assert.strictEqual(signals.length, 1); +- +- assert.ok(session.respondToPermissionRequest('tc-1', true)); +- const result = await resultPromise; +- assert.strictEqual(result.kind, 'approve-once'); +- }); +- +- test('auto-approves read permission for session-state plan files', async () => { +- const previousCopilotHome = process.env['COPILOT_HOME']; +- process.env['COPILOT_HOME'] = '/mock-state-home/.copilot'; +- try { +- const { runtime, signals } = await createAgentSession(disposables); +- const result = await runtime.handlePermissionRequest({ +- kind: 'read', +- path: join('/mock-state-home', '.copilot', 'session-state', 'test-session-1', 'plan.md'), +- toolCallId: 'tc-read-plan', +- }); +- +- assert.strictEqual(result.kind, 'approve-once'); +- assert.strictEqual(signals.length, 0); +- } finally { +- if (previousCopilotHome === undefined) { +- delete process.env['COPILOT_HOME']; +- } else { +- process.env['COPILOT_HOME'] = previousCopilotHome; +- } +- } +- }); +- +- test('resolves native environment through INativeEnvironmentService registration', async () => { +- const previousCopilotHome = process.env['COPILOT_HOME']; +- delete process.env['COPILOT_HOME']; +- try { +- const { runtime, signals } = await createAgentSession(disposables, { environmentServiceRegistration: 'native' }); +- const result = await runtime.handlePermissionRequest({ +- kind: 'read', +- path: join('/mock-home', '.copilot', 'session-state', 'test-session-1', 'plan.md'), +- toolCallId: 'tc-read-plan-native-env', +- }); +- +- assert.strictEqual(result.kind, 'approve-once'); +- assert.strictEqual(signals.length, 0); +- } finally { +- if (previousCopilotHome === undefined) { +- delete process.env['COPILOT_HOME']; +- } else { +- process.env['COPILOT_HOME'] = previousCopilotHome; +- } +- } +- }); +- +- test('logs and rethrows permission failures', async () => { +- const previousCopilotHome = process.env['COPILOT_HOME']; +- delete process.env['COPILOT_HOME']; +- const logService = new CapturingLogService(); +- try { +- const { runtime } = await createAgentSession(disposables, { +- environmentServiceRegistration: 'none', +- logService, +- }); +- +- await assert.rejects( +- runtime.handlePermissionRequest({ +- kind: 'read', +- path: join('/mock-home', '.copilot', 'session-state', 'test-session-1', 'plan.md'), +- toolCallId: 'tc-read-plan-missing-env', +- }), +- ); +- +- assert.strictEqual(logService.errors.length, 1); +- const [entry] = logService.errors; +- assert.ok(entry.first instanceof TypeError); +- assert.strictEqual(entry.args[0], '[Copilot:test-session-1] Failed to handle permission request: kind=read, toolCallId=tc-read-plan-missing-env'); +- } finally { +- if (previousCopilotHome === undefined) { +- delete process.env['COPILOT_HOME']; +- } else { +- process.env['COPILOT_HOME'] = previousCopilotHome; +- } +- } +- }); +- +- test('write permission fires tool_ready (deferred to side effects)', async () => { +- const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables); +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'write', +- fileName: '/workspace/src/file.ts', +- toolCallId: 'tc-1', +- }); +- +- await waitForSignal(s => s.kind === 'pending_confirmation'); +- assert.strictEqual(signals.length, 1); +- +- assert.ok(session.respondToPermissionRequest('tc-1', true)); +- const result = await resultPromise; +- assert.strictEqual(result.kind, 'approve-once'); +- }); +- +- test('new-file write permission includes proposed content and create wording', async () => { +- const { session, runtime, waitForSignal } = await createAgentSession(disposables); +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'write', +- fileName: '/workspace/package.json', +- newFileContents: '{"name":"example"}\n', +- toolCallId: 'tc-create', +- }); +- +- const signal = await waitForSignal(s => s.kind === 'pending_confirmation'); +- assert.strictEqual(signal.kind, 'pending_confirmation'); +- if (signal.kind !== 'pending_confirmation') { +- throw new Error('Expected a pending confirmation'); +- } +- const edit = signal.state.edits?.items[0]; +- assert.deepStrictEqual({ +- title: signal.state.confirmationTitle, +- message: signal.state.invocationMessage, +- before: edit?.before, +- afterUri: edit?.after?.uri, +- contentScheme: edit?.after?.content.uri ? URI.parse(edit.after.content.uri).scheme : undefined, +- }, { +- title: 'Create file?', +- message: { markdown: 'Creating [package.json](file:///workspace/package.json)' }, +- before: undefined, +- afterUri: 'file:///workspace/package.json', +- contentScheme: 'pending-edit-content', +- }); +- +- assert.ok(session.respondToPermissionRequest('tc-create', false)); +- assert.strictEqual((await resultPromise).kind, 'denied-interactively-by-user'); +- }); +- +- test('auto-approves write permission for session-state plan files', async () => { +- const previousCopilotHome = process.env['COPILOT_HOME']; +- process.env['COPILOT_HOME'] = '/mock-state-home/.copilot'; +- try { +- const { runtime, signals } = await createAgentSession(disposables); +- const result = await runtime.handlePermissionRequest({ +- kind: 'write', +- fileName: join('/mock-state-home', '.copilot', 'session-state', 'test-session-1', 'plan.md'), +- toolCallId: 'tc-write-plan', +- }); +- +- assert.strictEqual(result.kind, 'approve-once'); +- assert.strictEqual(signals.length, 0); +- } finally { +- if (previousCopilotHome === undefined) { +- delete process.env['COPILOT_HOME']; +- } else { +- process.env['COPILOT_HOME'] = previousCopilotHome; +- } +- } +- }); +- +- test('does not auto-approve session-state files from another session', async () => { +- const previousCopilotHome = process.env['COPILOT_HOME']; +- process.env['COPILOT_HOME'] = '/mock-state-home/.copilot'; +- try { +- const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables); +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'write', +- fileName: join('/mock-state-home', '.copilot', 'session-state', 'different-session', 'plan.md'), +- toolCallId: 'tc-write-other-plan', +- }); +- +- await waitForSignal(s => s.kind === 'pending_confirmation'); +- assert.strictEqual(signals.length, 1); +- +- assert.ok(session.respondToPermissionRequest('tc-write-other-plan', true)); +- const result = await resultPromise; +- assert.strictEqual(result.kind, 'approve-once'); +- } finally { +- if (previousCopilotHome === undefined) { +- delete process.env['COPILOT_HOME']; +- } else { +- process.env['COPILOT_HOME'] = previousCopilotHome; +- } +- } +- }); +- +- test('does not auto-approve traversal paths that escape the session-state directory', async () => { +- const previousCopilotHome = process.env['COPILOT_HOME']; +- process.env['COPILOT_HOME'] = '/mock-state-home/.copilot'; +- try { +- const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables); +- const sessionDir = join('/mock-state-home', '.copilot', 'session-state', 'test-session-1'); +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'write', +- fileName: `${sessionDir}${sep}..${sep}outside.md`, +- toolCallId: 'tc-write-traversal', +- }); +- +- await waitForSignal(s => s.kind === 'pending_confirmation'); +- assert.strictEqual(signals.length, 1); +- +- assert.ok(session.respondToPermissionRequest('tc-write-traversal', true)); +- const result = await resultPromise; +- assert.strictEqual(result.kind, 'approve-once'); +- } finally { +- if (previousCopilotHome === undefined) { +- delete process.env['COPILOT_HOME']; +- } else { +- process.env['COPILOT_HOME'] = previousCopilotHome; +- } +- } +- }); +- +- test('auto-approves read of Copilot SDK large-tool-output temp files', async () => { +- const { runtime, signals } = await createAgentSession(disposables); +- +- // Layout 1: -copilot-tool-output-.txt +- const result1 = await runtime.handlePermissionRequest({ +- kind: 'read', +- path: join('/mock-tmp', '1730000000000-copilot-tool-output-abc123.txt'), +- toolCallId: 'tc-tool-output-1', +- }); +- assert.strictEqual(result1.kind, 'approve-once'); +- +- // Layout 2: copilot-tool-output--.txt +- const result2 = await runtime.handlePermissionRequest({ +- kind: 'read', +- path: join('/mock-tmp', 'copilot-tool-output-1730000000000-abc123.txt'), +- toolCallId: 'tc-tool-output-2', +- }); +- assert.strictEqual(result2.kind, 'approve-once'); +- +- assert.strictEqual(signals.length, 0); +- }); +- +- test('does not auto-approve tool-output-named files outside tmpdir', async () => { +- const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables); +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'read', +- path: join('/some/other/dir', 'copilot-tool-output-1730000000000-abc123.txt'), +- toolCallId: 'tc-tool-output-outside', +- }); +- +- await waitForSignal(s => s.kind === 'pending_confirmation'); +- assert.strictEqual(signals.length, 1); +- +- assert.ok(session.respondToPermissionRequest('tc-tool-output-outside', true)); +- const result = await resultPromise; +- assert.strictEqual(result.kind, 'approve-once'); +- }); +- +- test('does not auto-approve unrelated files inside tmpdir', async () => { +- const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables); +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'read', +- path: join('/mock-tmp', 'something-else.txt'), +- toolCallId: 'tc-tmp-other', +- }); +- +- await waitForSignal(s => s.kind === 'pending_confirmation'); +- assert.strictEqual(signals.length, 1); +- +- assert.ok(session.respondToPermissionRequest('tc-tmp-other', true)); +- const result = await resultPromise; +- assert.strictEqual(result.kind, 'approve-once'); +- }); +- +- test('does not auto-approve write to a tool-output temp path', async () => { +- const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables); +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'write', +- fileName: join('/mock-tmp', 'copilot-tool-output-1730000000000-abc123.txt'), +- toolCallId: 'tc-tool-output-write', +- }); +- +- await waitForSignal(s => s.kind === 'pending_confirmation'); +- assert.strictEqual(signals.length, 1); +- +- assert.ok(session.respondToPermissionRequest('tc-tool-output-write', true)); +- const result = await resultPromise; +- assert.strictEqual(result.kind, 'approve-once'); +- }); +- +- test('write permission outside working directory fires tool_ready', async () => { +- const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables); +- +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'write', +- fileName: '/other/file.ts', +- toolCallId: 'tc-write-outside', +- }); +- +- await waitForSignal(s => s.kind === 'pending_confirmation'); +- assert.strictEqual(signals.length, 1); +- +- assert.ok(session.respondToPermissionRequest('tc-write-outside', true)); +- const result = await resultPromise; +- assert.strictEqual(result.kind, 'approve-once'); +- }); +- +- test('read permission outside working directory fires tool_ready', async () => { +- const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables); +- +- // Kick off permission request but don't await — it will block +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'read', +- path: '/other/file.ts', +- toolCallId: 'tc-2', +- }); +- +- // Should have fired a pending_confirmation signal +- await waitForSignal(s => s.kind === 'pending_confirmation'); +- assert.strictEqual(signals.length, 1); +- +- // Respond to it +- assert.ok(session.respondToPermissionRequest('tc-2', true)); +- const result = await resultPromise; +- assert.strictEqual(result.kind, 'approve-once'); +- }); +- +- test('denies permission when no toolCallId', async () => { +- const { runtime } = await createAgentSession(disposables); +- const result = await runtime.handlePermissionRequest({ kind: 'write' }); +- assert.strictEqual(result.kind, 'reject'); +- }); +- +- test('denied-interactively when user denies', async () => { +- const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables); +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'shell', +- toolCallId: 'tc-3', +- }); +- +- await waitForSignal(s => s.kind === 'pending_confirmation'); +- assert.strictEqual(signals.length, 1); +- session.respondToPermissionRequest('tc-3', false); +- const result = await resultPromise; +- assert.strictEqual(result.kind, 'denied-interactively-by-user'); +- }); +- +- test('shell permissions carry the tracked shell language when known', async () => { +- const cases = [ +- { toolCallId: 'tc-powershell-language', trackedToolName: 'powershell', requestToolName: undefined, expected: 'powershell' }, +- { toolCallId: 'tc-bash-language', trackedToolName: 'bash', requestToolName: undefined, expected: 'bash' }, +- { toolCallId: 'tc-unrecognized-language', trackedToolName: 'unexpected-shell-tool', requestToolName: undefined, expected: undefined }, +- { toolCallId: 'tc-missing-language', trackedToolName: undefined, requestToolName: undefined, expected: undefined }, +- // `PermissionRequestShell` has no toolName. If a loose or synthetic +- // request includes one anyway, the tracked runtime tool remains authoritative. +- { toolCallId: 'tc-conflicting-language', trackedToolName: 'powershell', requestToolName: 'bash', expected: 'powershell' }, +- ] as const; +- const actual: string[] = []; +- +- for (const { toolCallId, trackedToolName, requestToolName } of cases) { +- const { session, runtime, mockSession, waitForSignal } = await createAgentSession(disposables); +- if (trackedToolName) { +- mockSession.fire('tool.execution_start', { +- toolCallId, +- toolName: trackedToolName, +- arguments: { command: 'Get-ChildItem' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- } +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'shell', +- toolCallId, +- fullCommandText: 'Get-ChildItem', +- toolName: requestToolName, +- }); +- const signal = await waitForSignal(s => s.kind === 'pending_confirmation' && s.state.toolCallId === toolCallId); +- assert.strictEqual(signal.kind, 'pending_confirmation'); +- if (signal.kind !== 'pending_confirmation') { +- throw new Error('Expected a pending confirmation'); +- } +- actual.push(`${toolCallId}=${signal.shellLanguage}`); +- assert.ok(session.respondToPermissionRequest(toolCallId, true)); +- assert.strictEqual((await resultPromise).kind, 'approve-once'); +- } +- +- assert.deepStrictEqual(actual, cases.map(({ toolCallId, expected }) => `${toolCallId}=${expected}`)); +- }); +- +- test('custom terminal permissions are normalized for shell auto-approval', async () => { +- const cases = [ +- { toolCallId: 'tc-custom-bash', toolName: 'bash', command: 'git status' }, +- { toolCallId: 'tc-custom-powershell', toolName: 'powershell', command: 'Get-ChildItem' }, +- ] as const; +- const actual: Array<{ permissionKind: string | undefined; toolInput: string | undefined; shellLanguage: string | undefined }> = []; +- +- for (const { toolCallId, toolName, command } of cases) { +- const { session, runtime, waitForSignal } = await createAgentSession(disposables); +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'custom-tool', +- toolCallId, +- toolName, +- args: { command }, +- }); +- const signal = await waitForSignal(s => s.kind === 'pending_confirmation' && s.state.toolCallId === toolCallId); +- assert.strictEqual(signal.kind, 'pending_confirmation'); +- if (signal.kind !== 'pending_confirmation') { +- throw new Error('Expected a pending confirmation'); +- } +- actual.push({ +- permissionKind: signal.permissionKind, +- toolInput: signal.state.toolInput, +- shellLanguage: signal.shellLanguage, +- }); +- assert.ok(session.respondToPermissionRequest(toolCallId, true)); +- assert.strictEqual((await resultPromise).kind, 'approve-once'); +- disposables.clear(); +- } +- +- assert.deepStrictEqual(actual, cases.map(({ toolName, command }) => ({ +- permissionKind: 'shell', +- toolInput: command, +- shellLanguage: toolName, +- }))); +- }); +- +- test('auto-approves sandboxed-by-default shell command without prompting', async () => { +- const { runtime, signals } = await createAgentSession(disposables, { +- rootValues: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On } }, +- }); +- +- const result = await runtime.handlePermissionRequest({ +- kind: 'shell', +- toolCallId: 'tc-sandboxed', +- fullCommandText: 'cat ~/something.txt', +- }); +- +- assert.strictEqual(result.kind, 'approve-once'); +- assert.strictEqual(signals.length, 0); +- }); +- +- test('does not auto-approve a shell command that opted out of the sandbox', async () => { +- const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables, { +- rootValues: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On } }, +- }); +- +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'shell', +- toolCallId: 'tc-sandboxbypass', +- fullCommandText: 'cat ~/something.txt', +- requestSandboxBypass: true, +- }); +- +- // Must fall through to the normal confirmation flow rather than +- // auto-approving, since the command escapes the sandbox. +- await waitForSignal(s => s.kind === 'pending_confirmation'); +- assert.strictEqual(signals.length, 1); +- assert.ok(session.respondToPermissionRequest('tc-sandboxbypass', true)); +- const result = await resultPromise; +- assert.strictEqual(result.kind, 'approve-once'); +- }); +- +- test('per-request sandbox: applies the configured policy under default permissions', async () => { +- const { session, mockSession } = await createAgentSession(disposables, { +- rootValues: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On } }, +- }); +- +- await session.send('hello', undefined, 'turn-1'); +- +- assert.deepStrictEqual(mockSession.sandboxConfigUpdates.at(-1), { +- enabled: true, +- allowBypass: true, +- userPolicy: { filesystem: {}, network: { allowOutbound: false } }, +- }); +- assert.deepStrictEqual(mockSession.permissionModeSetCalls, ['off']); +- }); +- +- test('per-request sandbox: disabled under session bypass approvals', async () => { +- const { session, mockSession } = await createAgentSession(disposables, { +- rootValues: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On } }, +- configValues: { [SessionConfigKey.AutoApprove]: 'autoApprove' }, +- }); +- +- await session.send('hello', undefined, 'turn-1'); +- +- assert.deepStrictEqual(mockSession.sandboxConfigUpdates.at(-1), { enabled: false }); +- assert.deepStrictEqual(mockSession.permissionModeSetCalls, ['on']); +- }); +- +- test('per-request permissions: delegates approvals to the SDK under Approve When Safe', async () => { +- const { session, mockSession } = await createAgentSession(disposables, { +- configValues: { [SessionConfigKey.AutoApprove]: 'assisted' }, +- }); +- +- await session.send('hello', undefined, 'turn-1'); +- +- assert.deepStrictEqual({ +- experimentalModeUpdates: mockSession.experimentalModeUpdates, +- permissionModes: mockSession.permissionModeSetCalls, +- }, { +- experimentalModeUpdates: [true], +- permissionModes: ['auto'], +- }); +- }); +- +- test('does not send when the SDK rejects experimental mode for Approve When Safe', async () => { +- const { session, mockSession } = await createAgentSession(disposables, { +- configValues: { [SessionConfigKey.AutoApprove]: 'assisted' }, +- }); +- mockSession.experimentalModeUpdateSuccess = false; +- +- await assert.rejects(() => session.send('hello', undefined, 'turn-1'), /rejected experimental mode/); +- +- assert.deepStrictEqual({ +- permissionModes: mockSession.permissionModeSetCalls, +- sends: mockSession.sendRequests, +- }, { +- permissionModes: [], +- sends: [], +- }); +- }); +- +- test('switching from Bypass to Default immediately disables SDK allow-all mode', async () => { +- const configValues: Record = { [SessionConfigKey.AutoApprove]: 'autoApprove' }; +- const logService = new CapturingLogService(); +- const { session, mockSession } = await createAgentSession(disposables, { configValues, logService }); +- +- await session.send('hello', undefined, 'turn-1'); +- configValues[SessionConfigKey.AutoApprove] = 'default'; +- await session.syncPermissionMode('config-change'); +- +- assert.deepStrictEqual({ +- modes: mockSession.permissionModeSetCalls, +- logs: logService.infos +- .map(entry => entry.message) +- .filter(message => message.includes('Syncing permission mode')), +- }, { +- modes: ['on', 'off'], +- logs: [ +- '[Copilot:test-session-1] Syncing permission mode: source=turn-start, agentMode=interactive, configuredLevel=autoApprove, sdkMode=on, previousSdkMode=unknown, globalAutoApprove=false', +- '[Copilot:test-session-1] Syncing permission mode: source=config-change, agentMode=interactive, configuredLevel=default, sdkMode=off, previousSdkMode=on, globalAutoApprove=false', +- ], +- }); +- }); +- +- test('switching from Approve When Safe to Ask When Needed disables SDK experimental mode', async () => { +- const configValues: Record = { [SessionConfigKey.AutoApprove]: 'assisted' }; +- const { session, mockSession } = await createAgentSession(disposables, { configValues }); +- +- await session.syncPermissionMode('turn-start'); +- configValues[SessionConfigKey.AutoApprove] = 'default'; +- await session.syncPermissionMode('config-change'); +- +- assert.deepStrictEqual({ +- experimentalModeUpdates: mockSession.experimentalModeUpdates, +- permissionModes: mockSession.permissionModeSetCalls, +- }, { +- experimentalModeUpdates: [true, false], +- permissionModes: ['auto', 'off'], +- }); +- }); +- +- test('Approve When Safe honors approve recommendations without prompting', async () => { +- const { session, runtime, mockSession, signals } = await createAgentSession(disposables, { +- configValues: { [SessionConfigKey.AutoApprove]: 'assisted' }, +- }); +- await session.syncPermissionMode('turn-start'); +- mockSession.fire('permission.requested', { +- requestId: 'request-1', +- permissionRequest: { kind: 'read', path: '/workspace/src/file.ts', intention: 'Read the file', toolCallId: 'tc-assisted' }, +- promptRequest: { +- kind: 'path', +- accessKind: 'read', +- paths: ['/workspace/src/file.ts'], +- toolCallId: 'tc-assisted', +- autoApproval: { recommendation: 'approve', reason: 'Low risk' }, +- }, +- }); +- +- const result = await runtime.handlePermissionRequest({ +- kind: 'read', +- path: '/workspace/src/file.ts', +- toolCallId: 'tc-assisted', +- }); +- +- assert.deepStrictEqual({ result, signals }, { +- result: { kind: 'approve-once' }, +- signals: [], +- }); +- }); +- +- test('managed approval requires confirmation despite an approve recommendation', async () => { +- const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { +- configValues: { [SessionConfigKey.AutoApprove]: 'assisted' }, +- }); +- await session.syncPermissionMode('turn-start'); +- mockSession.fire('permission.requested', { +- requestId: 'request-managed', +- permissionRequest: { +- kind: 'read', +- path: '/workspace/src/file.ts', +- intention: 'Read the file', +- toolCallId: 'tc-managed', +- managedApprovalRequired: true, +- }, +- promptRequest: { +- kind: 'path', +- accessKind: 'read', +- paths: ['/workspace/src/file.ts'], +- toolCallId: 'tc-managed', +- managedApprovalRequired: true, +- autoApproval: { recommendation: 'approve', reason: 'Low risk' }, +- }, +- }); +- +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'read', +- path: '/workspace/src/file.ts', +- toolCallId: 'tc-managed', +- managedApprovalRequired: true, +- }); +- +- await waitForSignal(signal => signal.kind === 'pending_confirmation'); +- assert.strictEqual(signals.length, 1); +- assert.strictEqual(signals[0].kind === 'pending_confirmation' && signals[0].managedApprovalRequired, true); +- assert.ok(session.respondToPermissionRequest('tc-managed', true)); +- assert.strictEqual((await resultPromise).kind, 'approve-once'); +- }); +- +- test('managed approval requires confirmation under global and session allow-all modes', async () => { +- const testCases = [ +- { +- name: 'global', +- options: { rootValues: { [AgentHostGlobalAutoApproveEnabledConfigKey]: true } }, +- }, +- { +- name: 'session', +- options: { configValues: { [SessionConfigKey.AutoApprove]: 'autoApprove' } }, +- }, +- ]; +- +- for (const testCase of testCases) { +- const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables, testCase.options); +- await session.syncPermissionMode('turn-start'); +- const toolCallId = `tc-managed-${testCase.name}`; +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'read', +- path: '/workspace/src/file.ts', +- toolCallId, +- managedApprovalRequired: true, +- }); +- +- await waitForSignal(signal => signal.kind === 'pending_confirmation'); +- assert.deepStrictEqual({ +- managedApprovalRequired: signals[0].kind === 'pending_confirmation' ? signals[0].managedApprovalRequired : undefined, +- responded: session.respondToPermissionRequest(toolCallId, false), +- result: await resultPromise, +- }, { +- managedApprovalRequired: true, +- responded: true, +- result: { kind: 'denied-interactively-by-user' }, +- }, testCase.name); +- disposables.clear(); +- } +- }); +- +- test('managed read and write approvals do not auto-approve duplicate requests', async () => { +- const testCases = [ +- { +- name: 'read', +- request: { +- kind: 'read' as const, +- path: '/workspace/src/file.ts', +- toolCallId: 'tc-managed-duplicate-read', +- managedApprovalRequired: true, +- }, +- }, +- { +- name: 'write', +- request: { +- kind: 'write' as const, +- fileName: '/workspace/src/file.ts', +- toolCallId: 'tc-managed-duplicate-write', +- managedApprovalRequired: true, +- }, +- }, +- ]; +- +- for (const testCase of testCases) { +- const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables); +- const firstResultPromise = runtime.handlePermissionRequest(testCase.request); +- await waitForSignal(signal => signal.kind === 'pending_confirmation'); +- assert.ok(session.respondToPermissionRequest(testCase.request.toolCallId, true)); +- const firstResult = await firstResultPromise; +- +- const duplicateResultPromise = runtime.handlePermissionRequest({ ...testCase.request }); +- await timeout(0); +- const pendingConfirmationCount = signals.filter(signal => signal.kind === 'pending_confirmation').length; +- assert.ok(session.respondToPermissionRequest(testCase.request.toolCallId, false)); +- +- assert.deepStrictEqual({ +- results: [firstResult, await duplicateResultPromise], +- pendingConfirmationCount, +- }, { +- results: [{ kind: 'approve-once' }, { kind: 'denied-interactively-by-user' }], +- pendingConfirmationCount: 2, +- }, testCase.name); +- disposables.clear(); +- } +- }); +- +- test('Approve When Safe correlates a recommendation event that arrives after the permission callback', async () => { +- const { session, runtime, mockSession, signals } = await createAgentSession(disposables, { +- configValues: { [SessionConfigKey.AutoApprove]: 'assisted' }, +- }); +- await session.syncPermissionMode('turn-start'); +- +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'write', +- fileName: '/workspace/package.json', +- intention: 'Edit file', +- diff: 'diff', +- toolCallId: 'tc-assisted-late-event', +- }); +- mockSession.fire('permission.requested', { +- requestId: 'request-late-event', +- permissionRequest: { +- kind: 'write', +- fileName: '/workspace/package.json', +- intention: 'Edit file', +- diff: 'diff', +- canOfferSessionApproval: true, +- toolCallId: 'tc-assisted-late-event', +- }, +- promptRequest: { +- kind: 'write', +- fileName: '/workspace/package.json', +- intention: 'Edit file', +- diff: 'diff', +- canOfferSessionApproval: true, +- toolCallId: 'tc-assisted-late-event', +- autoApproval: { recommendation: 'approve', reason: 'Matches the request' }, +- }, +- }); +- +- assert.deepStrictEqual({ +- result: await resultPromise, +- signals, +- }, { +- result: { kind: 'approve-once' }, +- signals: [], +- }); +- }); +- +- test('Approve When Safe prompts when the model requires approval', async () => { +- const { session, runtime, mockSession, waitForSignal } = await createAgentSession(disposables, { +- configValues: { [SessionConfigKey.AutoApprove]: 'assisted' }, +- }); +- await session.syncPermissionMode('turn-start'); +- mockSession.fire('permission.requested', { +- requestId: 'request-2', +- permissionRequest: { kind: 'read', path: '/workspace/src/file.ts', intention: 'Read the file', toolCallId: 'tc-assisted-prompt' }, +- promptRequest: { +- kind: 'path', +- accessKind: 'read', +- paths: ['/workspace/src/file.ts'], +- toolCallId: 'tc-assisted-prompt', +- autoApproval: { recommendation: 'requireApproval', reason: 'Needs confirmation' }, +- }, +- }); +- +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'read', +- path: '/workspace/src/file.ts', +- toolCallId: 'tc-assisted-prompt', +- }); +- const confirmation = await waitForSignal(signal => signal.kind === 'pending_confirmation'); +- assert.deepStrictEqual(confirmation.kind === 'pending_confirmation' ? confirmation.state.riskAssessment : undefined, { +- kind: ToolCallRiskAssessmentKind.Judge, +- status: ToolCallRiskAssessmentStatus.Complete, +- reason: 'Needs confirmation', +- safety: 0, +- }); +- assert.ok(session.respondToPermissionRequest('tc-assisted-prompt', true)); +- +- assert.strictEqual((await resultPromise).kind, 'approve-once'); +- }); +- +- test('Approve When Safe never bypasses sandbox-escape confirmation', async () => { +- const { session, runtime, mockSession, waitForSignal } = await createAgentSession(disposables, { +- configValues: { [SessionConfigKey.AutoApprove]: 'assisted' }, +- }); +- await session.syncPermissionMode('turn-start'); +- mockSession.fire('permission.requested', { +- requestId: 'request-3', +- permissionRequest: { +- kind: 'shell', +- canOfferSessionApproval: false, +- commands: [], +- fullCommandText: 'curl https://example.com', +- hasWriteFileRedirection: false, +- intention: 'Access the network', +- possiblePaths: [], +- possibleUrls: [{ url: 'https://example.com' }], +- requestSandboxBypass: true, +- toolCallId: 'tc-assisted-bypass', +- }, +- promptRequest: { +- kind: 'commands', +- canOfferSessionApproval: false, +- commandIdentifiers: ['curl'], +- fullCommandText: 'curl https://example.com', +- intention: 'Access the network', +- toolCallId: 'tc-assisted-bypass', +- autoApproval: { recommendation: 'approve', reason: 'Incorrect recommendation' }, +- }, +- }); +- +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'shell', +- toolCallId: 'tc-assisted-bypass', +- fullCommandText: 'curl https://example.com', +- requestSandboxBypass: true, +- }); +- const confirmation = await waitForSignal(signal => signal.kind === 'pending_confirmation'); +- assert.deepStrictEqual(confirmation.kind === 'pending_confirmation' ? confirmation.state.riskAssessment : undefined, { +- kind: ToolCallRiskAssessmentKind.Judge, +- status: ToolCallRiskAssessmentStatus.Complete, +- reason: 'Incorrect recommendation', +- safety: 1, +- }); +- assert.ok(session.respondToPermissionRequest('tc-assisted-bypass', false)); +- +- assert.strictEqual((await resultPromise).kind, 'denied-interactively-by-user'); +- }); +- +- test('does not send when the SDK rejects the requested permission mode', async () => { +- const { session, mockSession } = await createAgentSession(disposables, { +- configValues: { [SessionConfigKey.AutoApprove]: 'assisted' }, +- }); +- mockSession.permissionModeSetSuccess = false; +- +- await assert.rejects(() => session.send('hello', undefined, 'turn-1'), /rejected permission mode 'auto'/); +- +- assert.deepStrictEqual(mockSession.sendRequests, []); +- }); +- +- test('syncs permission mode when the session approval level changes', async () => { +- const { session, mockSession, setConfigValue, fireSessionConfigChange } = await createAgentSession(disposables, { +- configValues: { [SessionConfigKey.AutoApprove]: 'assisted' }, +- }); +- await session.syncPermissionMode('turn-start'); +- setConfigValue(SessionConfigKey.AutoApprove, 'default'); +- +- fireSessionConfigChange({ [SessionConfigKey.AutoApprove]: 'default' }); +- await timeout(0); +- +- assert.deepStrictEqual(mockSession.permissionModeSetCalls, ['auto', 'off']); +- }); +- +- test('ignores approval changes for other sessions', async () => { +- const { session, mockSession, setConfigValue, fireSessionConfigChange } = await createAgentSession(disposables, { +- configValues: { [SessionConfigKey.AutoApprove]: 'assisted' }, +- }); +- await session.syncPermissionMode('turn-start'); +- setConfigValue(SessionConfigKey.AutoApprove, 'default'); +- +- fireSessionConfigChange({ [SessionConfigKey.AutoApprove]: 'default' }, AgentSession.uri('copilot', 'other-session').toString()); +- await timeout(0); +- +- assert.deepStrictEqual(mockSession.permissionModeSetCalls, ['auto']); +- }); +- +- test('syncs permission mode when root approval configuration changes', async () => { +- const { session, mockSession, setRootValue, fireRootConfigChange } = await createAgentSession(disposables); +- await session.syncPermissionMode('turn-start'); +- setRootValue(AgentHostGlobalAutoApproveEnabledConfigKey, true); +- +- fireRootConfigChange(); +- await timeout(0); +- +- assert.deepStrictEqual(mockSession.permissionModeSetCalls, ['off', 'on']); +- }); +- +- test('aborts when a live permission mode update fails', async () => { +- const { session, mockSession, setConfigValue, fireSessionConfigChange } = await createAgentSession(disposables, { +- configValues: { [SessionConfigKey.AutoApprove]: 'assisted' }, +- }); +- await session.syncPermissionMode('turn-start'); +- mockSession.permissionModeSetSuccess = false; +- setConfigValue(SessionConfigKey.AutoApprove, 'default'); +- +- fireSessionConfigChange({ [SessionConfigKey.AutoApprove]: 'default' }); +- await timeout(0); +- +- assert.strictEqual(mockSession.abortCalls, 1); +- }); +- +- test('per-request permissions: Autopilot with Ask When Needed keeps SDK approval mode off', async () => { +- const { session, mockSession } = await createAgentSession(disposables, { +- rootValues: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On } }, +- configValues: { +- [SessionConfigKey.Mode]: 'autopilot', +- [SessionConfigKey.AutoApprove]: 'default', +- }, +- }); +- +- await session.send('hello', undefined, 'turn-1'); +- +- assert.deepStrictEqual({ +- permissionModes: mockSession.permissionModeSetCalls, +- sandbox: mockSession.sandboxConfigUpdates.at(-1), +- }, { +- permissionModes: ['off'], +- sandbox: { +- enabled: true, +- allowBypass: true, +- userPolicy: { filesystem: {}, network: { allowOutbound: false } }, +- }, +- }); +- }); +- +- test('per-request sandbox: disabled under global auto-approve', async () => { +- const { session, mockSession } = await createAgentSession(disposables, { +- rootValues: { +- [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On }, +- [AgentHostGlobalAutoApproveEnabledConfigKey]: true, +- }, +- }); +- +- await session.send('hello', undefined, 'turn-1'); +- +- assert.deepStrictEqual(mockSession.sandboxConfigUpdates.at(-1), { enabled: false }); +- }); +- +- test('per-request sandbox: explicitly disabled on Windows', async () => { +- const { session, mockSession } = await createAgentSession(disposables, { +- rootValues: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On } }, +- platform: 'win32', +- }); +- +- await session.send('hello', undefined, 'turn-1'); +- +- assert.deepStrictEqual(mockSession.sandboxConfigUpdates.at(-1), { enabled: false }); +- }); +- +- test('per-request sandbox: explicitly disabled when the sandbox setting is off', async () => { +- const { session, mockSession } = await createAgentSession(disposables); +- +- await session.send('hello', undefined, 'turn-1'); +- +- assert.deepStrictEqual(mockSession.sandboxConfigUpdates.at(-1), { enabled: false }); +- }); +- +- test('per-request sandbox: left untouched when the custom terminal tool is enabled', async () => { +- const { session, mockSession } = await createAgentSession(disposables, { +- rootValues: { +- [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On }, +- [CopilotCliConfigKey.EnableCustomTerminalTool]: true, +- }, +- }); +- +- await session.send('hello', undefined, 'turn-1'); +- +- // The host's own terminal sandbox engine handles containment, so the +- // SDK sandbox config is not managed in this mode. +- assert.deepStrictEqual(mockSession.sandboxConfigUpdates, []); +- }); +- +- test('pending permissions are denied on dispose', async () => { +- const { session, runtime } = await createAgentSession(disposables); +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'write', +- toolCallId: 'tc-4', +- }); +- +- session.dispose(); +- const result = await resultPromise; +- assert.strictEqual(result.kind, 'reject'); +- }); +- +- test('late permission requests are rejected after dispose', async () => { +- const { session, runtime } = await createAgentSession(disposables); +- session.dispose(); +- +- assert.deepStrictEqual(await runtime.handlePermissionRequest({ +- kind: 'write', +- toolCallId: 'tc-after-dispose', +- }), { kind: 'reject' }); +- }); +- +- test('pending permissions are denied on abort', async () => { +- const { session, runtime } = await createAgentSession(disposables); +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'write', +- toolCallId: 'tc-5', +- }); +- +- await session.abort(); +- const result = await resultPromise; +- assert.strictEqual(result.kind, 'reject'); +- }); +- +- test('interactive callbacks are cancelled while abort is in progress', async () => { +- const { session, runtime, mockSession } = await createAgentSession(disposables); +- session.resetTurnState('turn-aborting'); +- const pendingUserInput = runtime.handleUserInputRequest( +- { question: 'Existing question' }, +- { sessionId: 'test-session-1' }, +- ); +- const pendingElicitation = runtime.handleElicitationRequest({ +- sessionId: 'test-session-1', +- message: 'Existing elicitation', +- mode: 'form', +- requestedSchema: { type: 'object', properties: {} }, +- }); +- const abortGate = new DeferredPromise(); +- mockSession.abortGate = abortGate.p; +- +- const abortPromise = session.abort(); +- assert.strictEqual(mockSession.abortCalls, 1); +- +- const permission = await runtime.handlePermissionRequest({ +- kind: 'write', +- toolCallId: 'tc-during-abort', +- }); +- const userInput = await runtime.handleUserInputRequest( +- { question: 'New question' }, +- { sessionId: 'test-session-1' }, +- ); +- const elicitation = await runtime.handleElicitationRequest({ +- sessionId: 'test-session-1', +- message: 'New elicitation', +- mode: 'form', +- requestedSchema: { type: 'object', properties: {} }, +- }); +- const planReview = await runtime.handleExitPlanModeRequest({ +- actions: ['interactive'], +- recommendedAction: 'interactive', +- summary: 'Plan', +- }, { sessionId: 'test-session-1' }); +- const mcpAuth = await runtime.handleMcpAuthRequest({ +- requestId: 'auth-during-abort', +- serverName: 'test-server', +- serverUrl: 'https://mcp.example.com', +- reason: 'initial', +- }, { sessionId: 'test-session-1' }); +- +- abortGate.complete(); +- await abortPromise; +- assert.deepStrictEqual({ +- pendingUserInput: await pendingUserInput, +- pendingElicitation: await pendingElicitation, +- permission, +- userInput, +- elicitation, +- planReview, +- mcpAuth, +- }, { +- pendingUserInput: { answer: '', wasFreeform: true }, +- pendingElicitation: { action: 'cancel' }, +- permission: { kind: 'reject' }, +- userInput: { answer: '', wasFreeform: true }, +- elicitation: { action: 'cancel' }, +- planReview: { approved: false }, +- mcpAuth: { kind: 'cancelled' }, +- }); +- }); +- +- test('respondToPermissionRequest returns false for unknown id', async () => { +- const { session } = await createAgentSession(disposables); +- assert.strictEqual(session.respondToPermissionRequest('unknown-id', true), false); +- }); +- +- test('rejects permission requests for unroutable subagent tools', async () => { +- const logService = new CapturingLogService(); +- const { runtime, mockSession, signals } = await createAgentSession(disposables, { logService }); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-orphaned-subagent-tool', +- toolName: 'powershell', +- arguments: { command: 'echo test' }, +- } as SessionEventPayload<'tool.execution_start'>['data'], { agentId: 'unknown-agent' }); +- +- const result = await runtime.handlePermissionRequest({ +- kind: 'shell', +- toolCallId: 'tc-orphaned-subagent-tool', +- fullCommandText: 'echo test', +- }); +- +- assert.deepStrictEqual({ +- result, +- pendingConfirmations: signals.filter(signal => signal.kind === 'pending_confirmation').length, +- errors: logService.errors.map(entry => entry.first).filter(message => typeof message === 'string' && message.includes('unroutable subagent tool call')), +- }, { +- result: { kind: 'reject' }, +- pendingConfirmations: 0, +- errors: ['[Copilot:test-session-1] Rejecting permission request for unroutable subagent tool call: toolCallId=tc-orphaned-subagent-tool, kind=shell'], +- }); +- }); +- }); +- +- // ---- sendSteering ---- +- +- suite('sendSteering', () => { +- +- test('promotes steering to its own turn when the SDK echoes the user message', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-original'); +- +- await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); +- +- // Sending the steering must not flip turns until the SDK has +- // echoed the user message back through the event stream. +- assert.strictEqual(signals.find(s => s.kind === 'action' && (s as IAgentActionSignal).action.type === ActionType.ChatTurnStarted), undefined); +- +- mockSession.fire('user.message', { +- content: 'focus on tests', +- interactionId: 'interaction-steer', +- } as SessionEventPayload<'user.message'>['data']); +- +- const actions = signals.filter(s => s.kind === 'action').map(s => (s as IAgentActionSignal).action); +- const turnComplete = actions.find(a => a.type === ActionType.ChatTurnComplete); +- const turnStarted = actions.find(a => a.type === ActionType.ChatTurnStarted); +- assert.ok(turnComplete, 'should complete the in-flight turn before promoting steering'); +- assert.strictEqual(turnComplete.turnId, 'turn-original'); +- assert.ok(turnStarted, 'should start a new turn for the steering message'); +- assert.notStrictEqual(turnStarted.turnId, 'turn-original'); +- assert.deepStrictEqual(turnStarted.message, { text: 'focus on tests', origin: { kind: MessageKind.User } }); +- assert.strictEqual(turnStarted.queuedMessageId, 'steer-1'); +- }); +- +- test('promotes steering when the SDK echoes before send resolves', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-original'); +- const sendGate = new DeferredPromise(); +- mockSession.send = async request => { +- mockSession.sendRequests.push(request); +- return sendGate.p; +- }; +- +- const steeringPromise = session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); +- await timeout(0); +- assert.strictEqual(mockSession.sendRequests.length, 1); +- +- mockSession.fire('user.message', { +- content: 'focus on tests', +- interactionId: 'interaction-steer', +- } as SessionEventPayload<'user.message'>['data']); +- +- const turnStarted = signals +- .filter(s => s.kind === 'action') +- .map(s => (s as IAgentActionSignal).action) +- .find(a => a.type === ActionType.ChatTurnStarted); +- assert.deepStrictEqual(turnStarted && { +- message: turnStarted.message, +- queuedMessageId: turnStarted.queuedMessageId, +- }, { +- message: { text: 'focus on tests', origin: { kind: MessageKind.User } }, +- queuedMessageId: 'steer-1', +- }); +- sendGate.complete('message-1'); +- await steeringPromise; +- }); +- +- test('promotes steering when the SDK idles before echoing it', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-original'); +- const sendGate = new DeferredPromise(); +- mockSession.send = async request => { +- mockSession.sendRequests.push(request); +- return sendGate.p; +- }; +- +- const steeringPromise = session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); +- await timeout(0); +- assert.strictEqual(mockSession.sendRequests.length, 1); +- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); +- mockSession.fire('user.message', { +- content: 'focus on tests', +- interactionId: 'interaction-steer', +- } as SessionEventPayload<'user.message'>['data']); +- +- const actions = signals.filter(s => s.kind === 'action').map(s => (s as IAgentActionSignal).action); +- assert.deepStrictEqual(actions +- .filter(action => action.type === ActionType.ChatTurnComplete || action.type === ActionType.ChatTurnStarted) +- .map(action => action.type === ActionType.ChatTurnComplete +- ? { type: action.type, turnId: action.turnId } +- : { type: action.type, message: action.message, queuedMessageId: action.queuedMessageId }), [ +- { type: ActionType.ChatTurnComplete, turnId: 'turn-original' }, +- { +- type: ActionType.ChatTurnStarted, +- message: { text: 'focus on tests', origin: { kind: MessageKind.User } }, +- queuedMessageId: 'steer-1', +- }, +- ]); +- sendGate.complete('message-1'); +- await steeringPromise; +- }); +- +- test('routes subsequent SDK events into the steering turn', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-original'); +- +- await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); +- mockSession.fire('user.message', { +- content: 'focus on tests', +- interactionId: 'interaction-steer', +- } as SessionEventPayload<'user.message'>['data']); +- +- const turnStarted = signals +- .filter(s => s.kind === 'action') +- .map(s => (s as IAgentActionSignal).action) +- .find(a => a.type === ActionType.ChatTurnStarted)!; +- +- mockSession.fire('assistant.message_delta', { +- deltaContent: 'No problem', +- } as SessionEventPayload<'assistant.message_delta'>['data']); +- +- const responseParts = signals +- .filter(s => s.kind === 'action') +- .map(s => (s as IAgentActionSignal).action) +- .filter(a => a.type === ActionType.ChatResponsePart); +- assert.ok(responseParts.length > 0, 'expected delta to allocate a response part'); +- assert.strictEqual(responseParts[0].turnId, turnStarted.turnId, 'response part should land in the steering turn, not the original'); +- }); +- +- test('reports tool-call details for the turn completed by steering', async () => { +- const telemetryService = new CapturingTelemetryService(); +- const { session, mockSession } = await createAgentSession(disposables, { +- telemetryService, +- clientSnapshot: { tools: [{ name: 'grep' }], plugins: [], mcpServers: {} }, +- }); +- session.resetTurnState('turn-original'); +- await session.send('hello agent', undefined, 'turn-original'); +- mockSession.fire('user.message', { content: 'hello agent' } as SessionEventPayload<'user.message'>['data']); +- mockSession.fire('assistant.message', { +- messageId: 'msg-tools', +- content: '', +- model: 'gpt-x', +- toolRequests: [{ toolCallId: 'tc-1', name: 'grep', arguments: {} }], +- } as SessionEventPayload<'assistant.message'>['data']); +- +- await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); +- mockSession.fire('user.message', { +- content: 'focus on tests', +- interactionId: 'interaction-steer', +- } as SessionEventPayload<'user.message'>['data']); +- +- assert.deepStrictEqual(telemetryService.events +- .filter(event => event.eventName === 'toolCallDetails') +- .map(event => { +- const data = event.data as Record; +- return { +- requestId: data.requestId, +- responseType: data.responseType, +- toolCounts: data.toolCounts, +- model: data.model, +- numRequests: data.numRequests, +- messageCharLen: data.messageCharLen, +- totalToolCalls: data.totalToolCalls, +- }; +- }), [{ +- requestId: 'turn-original', +- responseType: 'success', +- toolCounts: JSON.stringify({ grep: 1 }), +- model: 'gpt-x', +- numRequests: 1, +- messageCharLen: 11, +- totalToolCalls: 1, +- }]); +- }); +- +- test('does not flip turns for SDK-injected user messages (non-user source)', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-original'); +- +- await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); +- +- // SDK injects an unrelated user.message (e.g. skill content) +- // with the steering's exact text but a non-'user' source. +- // Even if the text happened to match, the synthetic-source +- // guard MUST skip the flip. +- mockSession.fire('user.message', { +- content: 'focus on tests', +- source: 'skill-pdf', +- } as SessionEventPayload<'user.message'>['data']); +- +- const turnStarted = signals.find(s => s.kind === 'action' && (s as IAgentActionSignal).action.type === ActionType.ChatTurnStarted); +- assert.strictEqual(turnStarted, undefined, 'synthetic user messages should not promote steering to a turn'); +- }); +- +- test('does not flip turns for subagent user messages', async () => { +- const sessionDatabase = new TestSessionDatabase(); +- const { session, mockSession, signals } = await createAgentSession(disposables, { sessionDatabase }); +- session.resetTurnState('turn-original'); +- +- await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); +- mockSession.fire('user.message', { +- content: 'focus on tests', +- } as SessionEventPayload<'user.message'>['data'], { agentId: 'agent-1', id: 'evt-subagent' }); +- await timeout(0); +- +- const turnStarted = signals.find(s => s.kind === 'action' && (s as IAgentActionSignal).action.type === ActionType.ChatTurnStarted); +- assert.deepStrictEqual({ +- turnStarted, +- setTurnEventIdCalls: sessionDatabase.setTurnEventIdCalls, +- }, { +- turnStarted: undefined, +- setTurnEventIdCalls: [], +- }); +- }); +- +- test('does not flip turns when the user.message content does not match', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-original'); +- +- await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); +- mockSession.fire('user.message', { +- content: 'something completely different', +- } as SessionEventPayload<'user.message'>['data']); +- +- const turnStarted = signals.find(s => s.kind === 'action' && (s as IAgentActionSignal).action.type === ActionType.ChatTurnStarted); +- assert.strictEqual(turnStarted, undefined, 'unrelated user messages should not consume the pending steering'); +- }); +- +- test('does not send the same steering message again before it is flipped', async () => { +- const { session, mockSession } = await createAgentSession(disposables); +- +- await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); +- await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); +- +- assert.strictEqual(mockSession.sendRequests.length, 1); +- }); +- +- test('fires steering_consumed on abort when the steering never reached its turn', async () => { +- const { session, signals } = await createAgentSession(disposables); +- +- await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); +- await session.abort(); +- +- const consumed = signals.find(s => s.kind === 'steering_consumed'); +- assert.ok(consumed, 'abort should clean up pending steering UI state'); +- assert.strictEqual((consumed as { id: string }).id, 'steer-1'); +- }); +- +- test('an abort during a steering turn tears it down without completing it', async () => { +- // A steering turn is promoted mid-loop while the SDK is actively +- // producing its response, so it must be `running` (not `pending`). +- // Otherwise an abort's terminal idle would treat it as a not-yet- +- // started queued turn and leave it open, and a later idle would +- // orphan-complete it. +- const { session, mockSession, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-original'); +- mockSession.fire('assistant.turn_start', { turnId: 'sdk-0' } as SessionEventPayload<'assistant.turn_start'>['data']); +- +- await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); +- mockSession.fire('user.message', { +- content: 'focus on tests', +- interactionId: 'interaction-steer', +- } as SessionEventPayload<'user.message'>['data']); +- +- const steeringTurnId = getActions(signals).find(a => a.type === ActionType.ChatTurnStarted)?.turnId; +- assert.ok(steeringTurnId && steeringTurnId !== 'turn-original', 'steering should start its own turn'); +- +- // Abort: the running steering turn is finalized by the client's +- // ChatTurnCancelled, so its terminal idle must tear it down rather +- // than complete it — and a subsequent stray idle must find no turn. +- mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); +- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); +- +- const steeringCompletions = getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete && a.turnId === steeringTurnId); +- assert.strictEqual(steeringCompletions.length, 0, 'an aborted steering turn must not be completed'); +- }); +- +- test('does not signal cleanup when send fails', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- +- mockSession.send = async () => { throw new Error('send failed'); }; +- +- await session.sendSteering({ id: 'steer-fail', message: { text: 'will fail', origin: { kind: MessageKind.User } } }); +- +- const consumed = signals.find(s => s.kind === 'steering_consumed'); +- const turnStarted = signals.find(s => s.kind === 'action' && (s as IAgentActionSignal).action.type === ActionType.ChatTurnStarted); +- assert.strictEqual(consumed, undefined, 'should not fire steering_consumed on failure'); +- assert.strictEqual(turnStarted, undefined, 'should not start a new turn on failure'); +- }); +- }); +- +- // ---- system.notification ---- +- +- suite('system.notification', () => { +- +- test('translator handles every notification kind and ignores empty content', () => { +- const base: Omit, 'data'> = { +- id: 'evt-system', +- parentId: null, +- timestamp: new Date().toISOString(), +- type: 'system.notification' as const, +- }; +- +- assert.deepStrictEqual(buildCopilotSystemNotification({ +- ...base, +- data: { +- content: '\nShell done\n', +- kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' }, +- }, +- }), { +- messageText: '`sleep 6` completed', +- startsTurn: true, +- }); +- +- const shellNotificationWithoutDescription = buildCopilotSystemNotification({ +- ...base, +- data: { +- content: 'Shell done', +- kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0 }, +- }, +- }); +- assert.ok(shellNotificationWithoutDescription); +- assert.deepStrictEqual(shellNotificationWithoutDescription, { +- messageText: 'Shell completed', +- startsTurn: true, +- }); +- assert.ok(!shellNotificationWithoutDescription.messageText.includes('shell-a')); +- +- const detachedShellNotification = buildCopilotSystemNotification({ +- ...base, +- data: { +- content: 'Detached done', +- kind: { type: 'shell_detached_completed', shellId: 'detached-a' }, +- }, +- }); +- assert.ok(detachedShellNotification); +- assert.deepStrictEqual(detachedShellNotification, { +- messageText: 'Shell completed', +- startsTurn: true, +- }); +- assert.ok(!detachedShellNotification.messageText.includes('detached-a')); +- +- assert.deepStrictEqual(buildCopilotSystemNotification({ +- ...base, +- data: { +- content: 'Agent done', +- kind: { type: 'agent_completed', agentId: 'agent-a', agentType: 'task', status: 'completed' }, +- }, +- }), { +- messageText: 'Background agent agent-a completed', +- startsTurn: true, +- }); +- +- assert.deepStrictEqual(buildCopilotSystemNotification({ +- ...base, +- data: { +- content: 'Agent failed', +- kind: { type: 'agent_completed', agentId: 'agent-b', agentType: 'task', status: 'failed' }, +- }, +- }), { +- messageText: 'Background agent agent-b failed', +- startsTurn: true, +- }); +- +- assert.deepStrictEqual(buildCopilotSystemNotification({ +- ...base, +- data: { +- content: '\nAgent idle\n', +- kind: { type: 'agent_idle', agentId: 'agent-a', agentType: 'task' }, +- }, +- }), { +- messageText: 'Background agent agent-a is complete', +- startsTurn: true, +- }); +- +- assert.deepStrictEqual(buildCopilotSystemNotification({ +- ...base, +- data: { +- content: 'Inbox message', +- kind: { type: 'new_inbox_message', entryId: 'entry-a', senderName: 'sidekick', senderType: 'sidekick-agent', summary: 'New message' }, +- }, +- }), { +- messageText: 'New inbox message from sidekick', +- startsTurn: false, +- }); +- +- assert.deepStrictEqual(buildCopilotSystemNotification({ +- ...base, +- data: { +- content: 'Discovered instruction', +- kind: { type: 'instruction_discovered', sourcePath: 'packages/billing/AGENTS.md', triggerFile: 'packages/billing/src/index.ts', triggerTool: 'view', description: 'AGENTS.md from packages/billing/' }, +- }, +- }), { +- messageText: 'Instruction discovered: AGENTS.md from packages/billing/', +- startsTurn: false, +- }); +- +- assert.deepStrictEqual(buildCopilotSystemNotification({ +- ...base, +- data: { +- content: '\nExternal host ping\n', +- kind: { type: 'unclassified', metadata: { source: 'host' } }, +- }, +- }), { +- messageText: 'External host ping', +- startsTurn: true, +- }); +- +- assert.strictEqual(buildCopilotSystemNotification({ +- ...base, +- data: { +- content: ' ', +- kind: { type: 'agent_idle', agentId: 'agent-a', agentType: 'task' }, +- }, +- }), undefined); +- }); +- +- test('idle notification starts a system-initiated turn without sending another SDK message', async () => { +- const { mockSession, signals } = await createAgentSession(disposables); - --// ---- Mock CopilotSession (SDK level) ---------------------------------------- +- mockSession.fire('system.notification', { +- content: '\nShell command completed\n', +- kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' }, +- } as SessionEventPayload<'system.notification'>['data']); - --/** -- * Minimal mock of the SDK's {@link CopilotSession}. Implements `on()` to -- * store typed handlers, and exposes `fire()` so tests can push events -- * through the real {@link CopilotSessionWrapper} event pipeline. -- */ --class MockCopilotSession { -- readonly sessionId = 'test-session-1'; -- readonly sendRequests: unknown[] = []; -- readonly modeSetCalls: Array<{ mode: 'interactive' | 'plan' | 'autopilot' }> = []; -- readonly compactCalls: unknown[] = []; -- readonly commandListCalls: unknown[] = []; -- readonly commandInvokeCalls: Array<{ name: string; input?: string }> = []; -- compactResult: { success: boolean; tokensRemoved: number; messagesRemoved: number } = { success: true, tokensRemoved: 0, messagesRemoved: 0 }; -- compactError: unknown = undefined; -- commandListResult: { commands: Array<{ name: string; kind: 'builtin' | 'skill' | 'client'; description: string; allowDuringAgentExecution: boolean }> } = { commands: [] }; -- commandInvokeResult: { kind: 'text'; text: string; markdown?: boolean } | { kind: 'completed'; message?: string } | { kind: 'agent-prompt'; prompt: string; displayPrompt: string; mode?: 'interactive' | 'plan' | 'autopilot' } = { kind: 'text', text: '' }; -- messages: SessionEvent[] = []; +- assert.strictEqual(mockSession.sendRequests.length, 0, 'system notification should not call session.send'); +- const actions = getActions(signals); +- const turnStarted = actions.find(a => a.type === ActionType.ChatTurnStarted); +- assert.ok(turnStarted, 'should synthesize a fresh turn'); +- assert.deepStrictEqual(turnStarted.message, { text: '`sleep 6` completed', origin: { kind: MessageKind.SystemNotification } }); +- }); - -- private readonly _handlers = new Map void>>(); -- planReadResult: { exists: boolean; content: string | null; path: string | null } = { exists: false, content: null, path: null }; +- test('agent idle notification routes resumed SDK events into a generated system turn', async () => { +- const { mockSession, signals } = await createAgentSession(disposables); - -- on(eventType: K, handler: TypedSessionEventHandler): () => void { -- let set = this._handlers.get(eventType); -- if (!set) { -- set = new Set(); -- this._handlers.set(eventType, set); -- } -- set.add(handler as (event: SessionEvent) => void); -- return () => { set.delete(handler as (event: SessionEvent) => void); }; -- } +- mockSession.fire('system.notification', { +- content: '\nAgent "agent-a" has finished processing and is now idle.\n', +- kind: { type: 'agent_idle', agentId: 'agent-a', agentType: 'general-purpose', description: 'Investigate the issue' }, +- } as SessionEventPayload<'system.notification'>['data']); +- const turnStarted = getActions(signals).find(a => a.type === ActionType.ChatTurnStarted)!; - -- /** Push an event through to all registered handlers of the given type. */ -- fire(type: K, data: SessionEventPayload['data'], overrides?: Partial, 'type' | 'data'>>): void { -- const event = { type, data, id: 'evt-1', timestamp: new Date().toISOString(), parentId: null, ...overrides } as SessionEventPayload; -- const set = this._handlers.get(type); -- if (set) { -- for (const handler of set) { -- handler(event); +- mockSession.fire('assistant.message_delta', { +- deltaContent: 'Reading the background agent result now.', +- } as SessionEventPayload<'assistant.message_delta'>['data']); +- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); +- +- assert.deepStrictEqual({ +- message: turnStarted.message, +- responseTurnId: (getActions(signals).find(a => a.type === ActionType.ChatResponsePart && a.part.kind === ResponsePartKind.Markdown) as ChatResponsePartAction | undefined)?.turnId, +- completedTurnId: (getActions(signals).find(a => a.type === ActionType.ChatTurnComplete) as ChatTurnCompleteAction | undefined)?.turnId, +- }, { +- message: { text: 'Background agent agent-a is complete', origin: { kind: MessageKind.SystemNotification } }, +- responseTurnId: turnStarted.turnId, +- completedTurnId: turnStarted.turnId, +- }); +- }); +- +- test('agent idle notification during an active turn appends a SystemNotification response part', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-active'); +- +- mockSession.fire('system.notification', { +- content: 'Agent "agent-a" has finished processing and is now idle.', +- kind: { type: 'agent_idle', agentId: 'agent-a', agentType: 'general-purpose' }, +- } as SessionEventPayload<'system.notification'>['data']); +- +- const actions = getActions(signals); +- const systemPart = actions.find(a => a.type === ActionType.ChatResponsePart && a.part.kind === ResponsePartKind.SystemNotification) as ChatResponsePartAction | undefined; +- assert.deepStrictEqual({ +- turnStarted: actions.find(a => a.type === ActionType.ChatTurnStarted), +- turnId: systemPart?.turnId, +- part: systemPart?.part, +- }, { +- turnStarted: undefined, +- turnId: 'turn-active', +- part: { +- kind: ResponsePartKind.SystemNotification, +- content: 'Background agent agent-a is complete', +- }, +- }); +- }); +- +- test('passive notifications render only within an active turn', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- +- mockSession.fire('system.notification', { +- content: 'Inbox from sidekick', +- kind: { type: 'new_inbox_message', entryId: 'entry-a', senderName: 'sidekick', senderType: 'sidekick-agent', summary: 'New message' }, +- } as SessionEventPayload<'system.notification'>['data']); +- assert.deepStrictEqual(getActions(signals), []); +- +- session.resetTurnState('turn-active'); +- mockSession.fire('system.notification', { +- content: 'Inbox from sidekick', +- kind: { type: 'new_inbox_message', entryId: 'entry-a', senderName: 'sidekick', senderType: 'sidekick-agent', summary: 'New message' }, +- } as SessionEventPayload<'system.notification'>['data']); +- mockSession.fire('system.notification', { +- content: 'Discovered instruction', +- kind: { type: 'instruction_discovered', sourcePath: 'packages/billing/AGENTS.md', triggerFile: 'packages/billing/src/index.ts', triggerTool: 'view', description: 'AGENTS.md from packages/billing/' }, +- } as SessionEventPayload<'system.notification'>['data']); +- +- assert.deepStrictEqual(getActions(signals) +- .filter(action => action.type === ActionType.ChatResponsePart) +- .map(action => action.part), [ +- { kind: ResponsePartKind.SystemNotification, content: 'New inbox message from sidekick' }, +- { kind: ResponsePartKind.SystemNotification, content: 'Instruction discovered: AGENTS.md from packages/billing/' }, +- ]); +- }); +- +- test('generated system turn completes on session.idle', async () => { +- const { mockSession, signals } = await createAgentSession(disposables); +- +- mockSession.fire('system.notification', { +- content: 'Shell command completed', +- kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' }, +- } as SessionEventPayload<'system.notification'>['data']); +- const turnStarted = getActions(signals).find(a => a.type === ActionType.ChatTurnStarted)!; +- +- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); +- +- const turnComplete = getActions(signals).find(a => a.type === ActionType.ChatTurnComplete); +- assert.ok(turnComplete, 'expected idle to complete the generated turn'); +- assert.strictEqual((turnComplete as { turnId: string }).turnId, turnStarted.turnId); +- }); +- +- test('a late event after a completed turn is dropped and logged (never targets the stale turn id)', async () => { +- const logService = new CapturingLogService(); +- const { session, mockSession, signals } = await createAgentSession(disposables, { logService }); +- session.resetTurnState('turn-old'); +- +- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); +- mockSession.fire('assistant.message_delta', { +- deltaContent: 'late text', +- } as SessionEventPayload<'assistant.message_delta'>['data']); +- +- // With no active turn, the late delta is dropped (not emitted even as a +- // no-op), so it can never be attributed to the stale 'turn-old', and the +- // unexpected state is surfaced via an error log. +- const markdownActions = getActions(signals) +- .filter(a => a.type === ActionType.ChatResponsePart && a.part.kind === ResponsePartKind.Markdown); +- assert.strictEqual(markdownActions.length, 0, 'the late delta must be dropped, not emitted'); +- assert.ok(logService.errors.some(e => /no active turn/i.test(String(e.first))), 'the dropped delta should be logged'); +- }); +- }); +- +- // ---- event mapping ---- +- +- suite('event mapping', () => { +- +- test('tool_start event is mapped for non-hidden tools', async () => { +- const { mockSession, signals } = await createAgentSession(disposables); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-10', +- toolName: 'bash', +- arguments: { command: 'echo hello' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- +- assert.strictEqual(signals.length, 2); +- const toolStart = signals[0]; +- assert.ok(isAction(toolStart, ActionType.ChatToolCallStart)); +- if (isAction(toolStart, ActionType.ChatToolCallStart)) { +- const action = toolStart.action as ChatToolCallStartAction; +- assert.strictEqual(action.toolCallId, 'tc-10'); +- assert.strictEqual(action.toolName, 'bash'); +- } +- }); +- +- test('tool call deltas start once, accumulate buffered input, and finalize at tool start', async () => { +- const { mockSession, signals } = await createAgentSession(disposables); +- mockSession.fire('assistant.tool_call_delta', { +- toolCallId: 'tc-stream', +- inputDelta: '{"command":"npm ', +- }); +- assert.strictEqual(signals.length, 0); +- +- mockSession.fire('assistant.tool_call_delta', { +- toolCallId: 'tc-stream', +- toolName: 'bash', +- inputDelta: 'test","description":"Run', +- }); +- await timeout(STREAMING_TOOL_DISPLAY_INTERVAL_MS + 10); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-stream', +- toolName: 'bash', +- arguments: { command: 'npm test', description: 'Run all tests' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- +- const actions = getActions(signals); +- const starts = actions.filter(action => action.type === ActionType.ChatToolCallStart) as ChatToolCallStartAction[]; +- const deltas = actions.filter(action => action.type === ActionType.ChatToolCallDelta) as ChatToolCallDeltaAction[]; +- const ready = actions.find(action => action.type === ActionType.ChatToolCallReady) as ChatToolCallReadyAction | undefined; +- assert.deepStrictEqual({ +- starts: starts.map(action => ({ toolCallId: action.toolCallId, toolName: action.toolName })), +- deltas: deltas.map(action => ({ +- content: action.content, +- hasInvocationMessage: action.invocationMessage !== undefined, +- })), +- ready: ready && { toolCallId: ready.toolCallId, toolInput: ready.toolInput, intention: ready.intention }, +- }, { +- starts: [{ toolCallId: 'tc-stream', toolName: 'bash' }], +- deltas: [{ content: '', hasInvocationMessage: true }], +- ready: { toolCallId: 'tc-stream', toolInput: 'npm test', intention: 'Run all tests' }, +- }); +- }); +- +- test('edit tool deltas progressively refine file and line-count details', async () => { +- const { mockSession, signals } = await createAgentSession(disposables); +- mockSession.fire('assistant.tool_call_delta', { +- toolCallId: 'tc-edit-stream', +- toolName: 'edit', +- inputDelta: '{"path":"/repo/file.ts","old_str":"one\\ntwo"', +- }); +- mockSession.fire('assistant.tool_call_delta', { +- toolCallId: 'tc-edit-stream', +- toolName: 'edit', +- inputDelta: ',"new_str":"one\\nupdated\\nthree"', +- }); +- await timeout(STREAMING_TOOL_DISPLAY_INTERVAL_MS + 10); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-edit-stream', +- toolName: 'edit', +- arguments: { +- path: '/repo/file.ts', +- old_str: 'one\ntwo', +- new_str: 'one\nupdated\nthree', +- }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- +- const actions = getActions(signals); +- const deltas = actions.filter(action => action.type === ActionType.ChatToolCallDelta) as ChatToolCallDeltaAction[]; +- const ready = actions.find(action => action.type === ActionType.ChatToolCallReady) as ChatToolCallReadyAction | undefined; +- assert.deepStrictEqual({ +- deltas: deltas.flatMap(action => { +- const message = action.invocationMessage; +- const text = typeof message === 'string' ? message : message?.markdown; +- return text ? [text] : []; +- }), +- ready: typeof ready?.invocationMessage === 'string' ? ready.invocationMessage : ready?.invocationMessage.markdown, +- }, { +- deltas: [ +- 'Replacing 2 lines in [file.ts](file:///repo/file.ts)', +- 'Replacing 2 lines with 3 lines in [file.ts](file:///repo/file.ts)', +- ], +- ready: 'Editing [file.ts](file:///repo/file.ts)', +- }); +- }); +- +- test('raw apply_patch deltas stream line counts and resolved files', async () => { +- const { mockSession, signals } = await createAgentSession(disposables, { +- workingDirectory: URI.file('/workspace'), +- }); +- mockSession.fire('assistant.tool_call_delta', { +- toolCallId: 'tc-patch-stream', +- toolName: 'apply_patch', +- inputDelta: [ +- '*** Begin Patch', +- '*** Update File: src/file.ts', +- '@@', +- '-old', +- '+new', +- '*** End Patch', +- ].join('\n'), +- }); +- await timeout(STREAMING_TOOL_DISPLAY_INTERVAL_MS + 10); +- +- const delta = getActions(signals).find(action => action.type === ActionType.ChatToolCallDelta) as ChatToolCallDeltaAction | undefined; +- const message = delta?.invocationMessage; +- assert.strictEqual( +- typeof message === 'string' ? message : message?.markdown, +- 'Generating patch (6 lines) in [file.ts](file:///workspace/src/file.ts)', +- ); +- }); +- +- test('MCP tool deltas stream before final contributor metadata arrives', async () => { +- const { mockSession, signals } = await createAgentSession(disposables, { +- configureMockSession: mock => { +- mock.mcpListResult = { servers: [{ name: 'docs', status: 'connected' }] }; +- }, +- }); +- mockSession.fire('session.mcp_server_status_changed', { +- serverName: 'docs', +- status: 'connected', +- } as SessionEventPayload<'session.mcp_server_status_changed'>['data']); +- mockSession.fire('assistant.tool_call_delta', { +- toolCallId: 'tc-stream-mcp', +- toolName: 'mcp_tool', +- inputDelta: '{"topic":"metadata"}', +- }); +- await timeout(STREAMING_TOOL_DISPLAY_INTERVAL_MS + 10); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-stream-mcp', +- toolName: 'mcp_tool', +- mcpServerName: 'docs', +- arguments: { topic: 'metadata' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- +- const actions = getActions(signals); +- const starts = actions.filter(action => action.type === ActionType.ChatToolCallStart) as ChatToolCallStartAction[]; +- const deltas = actions.filter(action => action.type === ActionType.ChatToolCallDelta) as ChatToolCallDeltaAction[]; +- const ready = actions.find(action => action.type === ActionType.ChatToolCallReady) as ChatToolCallReadyAction | undefined; +- assert.deepStrictEqual({ +- startCount: starts.length, +- startContributor: starts[0]?.contributor, +- deltas: deltas.map(action => ({ +- content: action.content, +- hasInvocationMessage: action.invocationMessage !== undefined, +- })), +- readyContributor: ready?.contributor, +- }, { +- startCount: 1, +- startContributor: undefined, +- deltas: [{ content: '', hasInvocationMessage: true }], +- readyContributor: { +- kind: ToolCallContributorKind.MCP, +- customizationId: 'mcp-top-level:copilot:test-session-1:docs', +- }, +- }); +- }); +- +- test('full assistant message does not duplicate markdown emitted before a tool delta', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-stream-dedup'); +- mockSession.fire('assistant.message_delta', { +- deltaContent: 'I will inspect the file.', +- } as SessionEventPayload<'assistant.message_delta'>['data']); +- mockSession.fire('assistant.tool_call_delta', { +- toolCallId: 'tc-dedup', +- toolName: 'view', +- inputDelta: '{"path":"/workspace/file.ts"}', +- }); +- mockSession.fire('assistant.message', { +- messageId: 'msg-dedup', +- content: 'I will inspect the file.', +- toolRequests: [{ +- toolCallId: 'tc-dedup', +- name: 'view', +- arguments: { path: '/workspace/file.ts' }, +- type: 'function', +- }], +- } as SessionEventPayload<'assistant.message'>['data']); +- +- const markdownParts = getActions(signals).flatMap(action => +- action.type === ActionType.ChatResponsePart && action.part.kind === ResponsePartKind.Markdown +- ? [{ kind: action.part.kind, content: action.part.content }] +- : []); +- assert.deepStrictEqual(markdownParts, [{ +- kind: ResponsePartKind.Markdown, +- content: 'I will inspect the file.', +- }]); +- }); +- +- test('tool_start carries MCP App UI metadata from the SDK', async () => { +- const { mockSession, signals } = await createAgentSession(disposables); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-app', +- toolName: 'mcp_tool', +- mcpServerName: 'docs', +- toolDescription: { +- name: 'mcp_tool', +- _meta: { +- ui: { resourceUri: 'ui://docs' }, +- }, +- }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- +- const toolStart = signals.find(s => isAction(s, ActionType.ChatToolCallStart)); +- assert.ok(toolStart && isAction(toolStart, ActionType.ChatToolCallStart)); +- if (toolStart && isAction(toolStart, ActionType.ChatToolCallStart)) { +- const action = toolStart.action as ChatToolCallStartAction; +- assert.deepStrictEqual(action._meta, { +- mcpServerName: 'docs', +- ui: { resourceUri: 'ui://docs' }, +- }); +- } +- }); +- +- test('tool_start carries the MCP App channel when the server is already ready', async () => { +- // A tool call cannot begin until its MCP server is Ready, so the +- // AHP `mcp://` channel is always available at `tool.execution_start` +- // and is published as part of the initial `_meta.ui` payload. +- const { mockSession, signals } = await createAgentSession(disposables, { +- configureMockSession: m => { +- m.mcpListResult = { servers: [{ name: 'docs', status: 'connected' }] }; +- }, +- }); +- mockSession.fire('session.mcp_server_status_changed', { +- serverName: 'docs', +- status: 'connected', +- } as SessionEventPayload<'session.mcp_server_status_changed'>['data']); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-app-channel', +- toolName: 'mcp_tool', +- mcpServerName: 'docs', +- arguments: { topic: 'metadata' }, +- toolDescription: { +- name: 'mcp_tool', +- _meta: { +- ui: { resourceUri: 'ui://docs' }, +- }, +- }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- +- const toolStart = signals.find(s => isAction(s, ActionType.ChatToolCallStart)); +- assert.ok(toolStart && isAction(toolStart, ActionType.ChatToolCallStart)); +- if (toolStart && isAction(toolStart, ActionType.ChatToolCallStart)) { +- const action = toolStart.action as ChatToolCallStartAction; +- assert.deepStrictEqual({ +- contributor: action.contributor, +- meta: action._meta, +- }, { +- contributor: { +- kind: ToolCallContributorKind.MCP, +- customizationId: 'mcp-top-level:copilot:test-session-1:docs', +- }, +- meta: { +- mcpServerName: 'docs', +- ui: { +- resourceUri: 'ui://docs', +- channel: 'mcp://copilot/test-session-1/docs', +- }, +- }, +- }); - } -- } -- } +- }); - -- // Stubs for methods the wrapper / session class calls -- async send(request: unknown) { -- this.sendRequests.push(request); -- return `message-${this.sendRequests.length}`; -- } -- async abort() { } -- async setModel() { } -- async getEvents(): Promise { return this.messages; } -- async disconnect() { } +- test('tool_start derives intention from a shell tool description argument', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-intent'); - -- readonly rpc = { -- mode: { -- get: async () => ({ mode: 'interactive' as const }), -- set: async (params: { mode: 'interactive' | 'plan' | 'autopilot' }) => { -- this.modeSetCalls.push({ mode: params.mode }); -- }, -- }, -- plan: { -- read: async () => this.planReadResult, -- update: async (_params: { content: string }) => { /* no-op */ }, -- delete: async () => { /* no-op */ }, -- }, -- history: { -- compact: async (params?: unknown) => { -- this.compactCalls.push(params ?? null); -- if (this.compactError !== undefined) { -- throw this.compactError; -- } -- return this.compactResult; -- }, -- }, -- commands: { -- list: async (params?: unknown) => { -- this.commandListCalls.push(params ?? null); -- return this.commandListResult; -- }, -- invoke: async (params: { name: string; input?: string }) => { -- this.commandInvokeCalls.push(params); -- return this.commandInvokeResult; -- }, -- }, -- mcp: { -- list: async () => { -- if (this.mcpListError !== undefined) { -- throw this.mcpListError; -- } -- return this.mcpListResult; -- }, -- executeSampling: async () => ({ status: 'completed' as const, result: undefined }), -- cancelSamplingExecution: async () => { /* no-op */ }, -- }, -- }; +- // The shell tool's own `description` argument carries the intention. +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-intent', +- toolName: 'bash', +- arguments: { command: 'ls', description: 'List files in the repo root' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); - -- mcpListResult: { servers: ReadonlyArray<{ name: string; status: 'connected' | 'failed' | 'pending'; error?: string }> } = { servers: [] }; -- mcpListError: unknown = undefined; --} +- const toolStart = signals.find(s => isAction(s, ActionType.ChatToolCallStart)); +- assert.ok(toolStart && isAction(toolStart, ActionType.ChatToolCallStart)); +- assert.strictEqual((toolStart.action as ChatToolCallStartAction).intention, 'List files in the repo root'); +- }); - --class CapturingLogService extends NullLogService { -- readonly errors: Array<{ first: string | Error; args: unknown[] }> = []; -- readonly warnings: Array<{ message: string; args: unknown[] }> = []; +- test('non-pty shell terminal URIs are scoped by session and tool call', () => { +- assert.deepStrictEqual([ +- buildNonPtyShellTerminalUri(AgentSession.uri('copilot', 'session-1'), 'tool-call-1'), +- buildNonPtyShellTerminalUri(AgentSession.uri('copilot', 'session-1'), 'tool-call-2'), +- buildNonPtyShellTerminalUri(AgentSession.uri('copilot', 'session-2'), 'tool-call-1'), +- ], [ +- 'agenthost-terminal://shell/session-1/tool-call-1', +- 'agenthost-terminal://shell/session-1/tool-call-2', +- 'agenthost-terminal://shell/session-2/tool-call-1', +- ]); +- }); - -- override error(message: string | Error, ...args: unknown[]): void { -- this.errors.push({ first: message, args }); -- super.error(message, ...args); -- } +- test('completed non-pty shell calls retire their distinct live output resources', async () => { +- const { session, mockSession, signals, terminalManager } = await createAgentSession(disposables); +- const terminalUris = ['tc-retire-1', 'tc-retire-2', 'tc-retire-3'] +- .map(toolCallId => buildNonPtyShellTerminalUri(session.sessionUri, toolCallId)); - -- override warn(message: string, ...args: unknown[]): void { -- this.warnings.push({ message, args }); -- super.warn(message, ...args); -- } --} +- for (let i = 0; i < terminalUris.length; i++) { +- const toolCallId = `tc-retire-${i + 1}`; +- const output = `output ${i + 1}\n`; +- mockSession.fire('tool.execution_start', { +- toolCallId, +- toolName: 'bash', +- arguments: { command: `command-${i + 1}` }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- mockSession.fire('tool.execution_partial_result', { +- toolCallId, +- partialOutput: output, +- } as SessionEventPayload<'tool.execution_partial_result'>['data']); +- mockSession.fire('tool.execution_complete', { +- toolCallId, +- success: true, +- result: { +- content: output, +- contents: [{ type: 'shell_exit', shellId: `${i + 1}`, exitCode: 0, outputPreview: output }], +- }, +- } as SessionEventPayload<'tool.execution_complete'>['data']); +- } - --class RecordingTelemetryService implements ITelemetryService { -- declare readonly _serviceBrand: undefined; -- readonly telemetryLevel = TelemetryLevel.NONE; -- readonly sessionId = 'someValue.sessionId'; -- readonly machineId = 'someValue.machineId'; -- readonly sqmId = 'someValue.sqmId'; -- readonly devDeviceId = 'someValue.devDeviceId'; -- readonly firstSessionDate = 'someValue.firstSessionDate'; -- readonly sendErrorTelemetry = false; -- readonly events: Array<{ eventName: string; data: unknown }> = []; +- const completions = getActions(signals) +- .filter((action): action is ChatToolCallCompleteAction => action.type === ActionType.ChatToolCallComplete); +- assert.deepStrictEqual({ +- terminalResults: completions.map(action => { +- const terminal = action.result.content?.find(content => content.type === ToolResultContentType.Terminal) as ToolResultTerminalContent | undefined; +- return { +- resource: terminal?.resource, +- preview: terminal?.result?.preview, +- }; +- }), +- disposed: terminalManager.disposedTerminals, +- }, { +- terminalResults: terminalUris.map((resource, i) => ({ +- resource, +- preview: `output ${i + 1}\n`, +- })), +- disposed: terminalUris, +- }); - -- publicLog(): void { } +- session.dispose(); +- assert.deepStrictEqual(terminalManager.disposedTerminals, terminalUris); +- }); - -- publicLog2> = never, T extends IGDPRProperty = never>(eventName: string, data?: StrictPropertyCheck): void { -- this.events.push({ eventName, data }); -- } +- test('tool partial results stream into an output-only terminal channel', async () => { +- const { session, mockSession, signals, waitForSignal, terminalManager } = await createAgentSession(disposables); +- session.resetTurnState('turn-stream'); - -- publicLogError(): void { } +- const terminalUri = 'agenthost-terminal://shell/test-session-1/tc-stream'; +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-stream', +- toolName: 'bash', +- arguments: { command: 'print ticks', description: 'Print ticks' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- mockSession.fire('tool.execution_partial_result', { +- toolCallId: 'tc-stream', +- partialOutput: 'tick 1\n', +- } as SessionEventPayload<'tool.execution_partial_result'>['data']); +- mockSession.fire('tool.execution_partial_result', { +- toolCallId: 'tc-stream', +- partialOutput: 'tick 1\ntick 2\n', +- } as SessionEventPayload<'tool.execution_partial_result'>['data']); +- mockSession.fire('tool.execution_complete', { +- toolCallId: 'tc-stream', +- success: true, +- result: { +- content: 'tick 1\ntick 2\n', +- contents: [{ type: 'shell_exit', shellId: '0', exitCode: 0, outputPreview: 'tick 1\ntick 2\n' }], +- }, +- } as SessionEventPayload<'tool.execution_complete'>['data']); +- await waitForSignal(signal => isAction(signal, ActionType.ChatToolCallComplete)); +- +- // The first partial result creates the channel and attaches the +- // terminal block once; later partials only append channel data. +- assert.deepStrictEqual(getActions(signals) +- .filter(action => action.type === ActionType.ChatToolCallContentChanged) +- .map(action => ({ +- turnId: action.turnId, +- toolCallId: action.toolCallId, +- content: action.content, +- })), [ +- { +- turnId: 'turn-stream', +- toolCallId: 'tc-stream', +- content: [{ type: ToolResultContentType.Terminal, resource: terminalUri, title: 'Run Shell Command', isPty: false }], +- }, +- ]); +- assert.deepStrictEqual(terminalManager.outputTerminalsCreated, [{ +- uri: terminalUri, +- title: 'Run Shell Command', +- claim: { kind: TerminalClaimKind.Session, session: AgentSession.uri('copilot', 'test-session-1').toString(), toolCallId: 'tc-stream' }, +- }]); +- assert.deepStrictEqual(terminalManager.outputTerminalData, [ +- { uri: terminalUri, data: 'tick 1\n' }, +- { uri: terminalUri, data: 'tick 2\n' }, +- ]); +- assert.deepStrictEqual(terminalManager.outputTerminalsFinalized, [{ uri: terminalUri, exitCode: 0 }]); +- assert.deepStrictEqual(terminalManager.disposedTerminals, [terminalUri]); - -- publicLogError2(): void { } +- // shell_exit completion data lands on the streamed terminal block. +- const completed = getActions(signals).find(action => action.type === ActionType.ChatToolCallComplete) as ChatToolCallCompleteAction; +- assert.deepStrictEqual(completed.result.content, [ +- { +- type: ToolResultContentType.Terminal, +- resource: terminalUri, +- title: 'Run Shell Command', +- isPty: false, +- result: { exitCode: 0, preview: 'tick 1\ntick 2\n' }, +- }, +- { type: ToolResultContentType.Text, text: 'tick 1\ntick 2\n' }, +- ]); +- }); - -- setExperimentProperty(): void { } +- test('truncated shell output streams through marker, rolling-tail, and completion transitions', async () => { +- const { session, mockSession, signals, waitForSignal, terminalManager } = await createAgentSession(disposables); +- session.resetTurnState('turn-truncated-stream'); - -- setCommonProperty(): void { } --} +- const terminalUri = 'agenthost-terminal://shell/test-session-1/tc-rewrite'; +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-rewrite', +- toolName: 'bash', +- arguments: { command: 'yes' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- mockSession.fire('tool.execution_partial_result', { +- toolCallId: 'tc-rewrite', +- partialOutput: 'line 1\nline 498\nline 499\n', +- } as SessionEventPayload<'tool.execution_partial_result'>['data']); +- mockSession.fire('tool.execution_partial_result', { +- toolCallId: 'tc-rewrite', +- partialOutput: 'line 1\nline 498\nline 499\n\n', +- } as SessionEventPayload<'tool.execution_partial_result'>['data']); +- mockSession.fire('tool.execution_partial_result', { +- toolCallId: 'tc-rewrite', +- partialOutput: 'line 1\nline 498\nline 499\n\n', +- } as SessionEventPayload<'tool.execution_partial_result'>['data']); +- mockSession.fire('tool.execution_partial_result', { +- toolCallId: 'tc-rewrite', +- partialOutput: 'line 498\nline 499\nline 500\n', +- } as SessionEventPayload<'tool.execution_partial_result'>['data']); +- mockSession.fire('tool.execution_partial_result', { +- toolCallId: 'tc-rewrite', +- partialOutput: 'line 499\nline 500\nline 501\n', +- } as SessionEventPayload<'tool.execution_partial_result'>['data']); +- mockSession.fire('tool.execution_complete', { +- toolCallId: 'tc-rewrite', +- success: true, +- result: { +- content: 'Output too large', +- contents: [{ +- type: 'shell_exit', +- shellId: '0', +- exitCode: 0, +- outputPreview: 'line 1\nline 2\n', +- outputTruncated: true, +- }], +- }, +- } as SessionEventPayload<'tool.execution_complete'>['data']); +- await waitForSignal(signal => isAction(signal, ActionType.ChatToolCallComplete)); - --// ---- Helpers ---------------------------------------------------------------- +- const completed = getActions(signals).find(action => action.type === ActionType.ChatToolCallComplete) as ChatToolCallCompleteAction; +- const terminalResult = completed.result.content?.find(content => content.type === ToolResultContentType.Terminal) as ToolResultTerminalContent | undefined; +- assert.deepStrictEqual({ +- data: terminalManager.outputTerminalData, +- resets: terminalManager.outputTerminalResets, +- finalized: terminalManager.outputTerminalsFinalized, +- disposed: terminalManager.disposedTerminals, +- result: terminalResult?.result, +- }, { +- data: [ +- { uri: terminalUri, data: 'line 1\nline 498\nline 499\n' }, +- { uri: terminalUri, data: '\n' }, +- { uri: terminalUri, data: 'line 500\n' }, +- { uri: terminalUri, data: 'line 501\n' }, +- ], +- resets: [], +- finalized: [{ uri: terminalUri, exitCode: 0 }], +- disposed: [terminalUri], +- result: { exitCode: 0, preview: 'line 1\nline 2\n', truncated: true }, +- }); +- }); - --/** -- * Invokes a client-SDK tool's handler with the minimal fields the SDK -- * contract requires, and narrows the `unknown` return type to -- * {@link ToolResultObject} — which is what {@link CopilotAgentSession}'s -- * handler implementation actually returns. -- */ --function invokeClientToolHandler(tool: Pick, toolCallId: string, args: Record = {}): Promise { -- return Promise.resolve(tool.handler!(args, { -- sessionId: 'test-session-1', -- toolCallId, -- toolName: tool.name, -- arguments: args, -- })) as Promise; --} +- test('zero-partial shell completion creates, seeds, and finalizes the output channel', async () => { +- const { mockSession, signals, terminalManager } = await createAgentSession(disposables); - --type ISessionInternalsForTest = { -- _onDidSessionProgress: { fire(event: AgentSignal): void }; -- _editTracker: { -- trackEditStart(path: string): Promise; -- completeEdit(path: string): Promise; -- takeCompletedEdit(turnId: string, toolCallId: string, path: string, toolName: string, toolInput: unknown, modelId: string | undefined): Promise; -- }; -- _pendingClientToolCalls: { -- register(toolCallId: string): Promise; -- respondOrBuffer(toolCallId: string, value: ToolResultObject): void; -- }; --}; +- const terminalUri = 'agenthost-terminal://shell/test-session-1/tc-quiet'; +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-quiet', +- toolName: 'bash', +- arguments: { command: 'true' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- mockSession.fire('tool.execution_complete', { +- toolCallId: 'tc-quiet', +- success: true, +- result: { +- content: 'ok\n', +- contents: [{ type: 'shell_exit', shellId: '0', exitCode: 0, outputPreview: 'ok\n' }], +- }, +- } as SessionEventPayload<'tool.execution_complete'>['data']); - --function isAction(s: AgentSignal, type: ActionType): s is IAgentActionSignal { -- return s.kind === 'action' && s.action.type === type; --} +- // Completion creates, seeds, and finalizes the channel before the +- // static result is published and the live resource is retired. +- assert.deepStrictEqual({ +- created: terminalManager.outputTerminalsCreated.map(t => t.uri), +- data: terminalManager.outputTerminalData, +- finalized: terminalManager.outputTerminalsFinalized, +- disposed: terminalManager.disposedTerminals, +- }, { +- created: [terminalUri], +- data: [{ uri: terminalUri, data: 'ok\n' }], +- finalized: [{ uri: terminalUri, exitCode: 0 }], +- disposed: [terminalUri], +- }); +- const completed = getActions(signals).find(action => action.type === ActionType.ChatToolCallComplete) as ChatToolCallCompleteAction; +- assert.ok(completed.result.content?.some(c => c.type === ToolResultContentType.Terminal && c.resource === terminalUri)); +- }); - --function getActions(signals: readonly AgentSignal[]) { -- return signals -- .filter((s): s is IAgentActionSignal => s.kind === 'action') -- .map(s => s.action); --} +- test('empty shell preview still retires the completed output channel', async () => { +- const { mockSession, terminalManager } = await createAgentSession(disposables); +- const terminalUri = 'agenthost-terminal://shell/test-session-1/tc-empty-preview'; - --function getInputRequest(signal: AgentSignal): ChatInputRequestedAction['request'] { -- assert.strictEqual(signal.kind, 'action'); -- if (signal.kind !== 'action') { throw new Error('unreachable'); } -- assert.strictEqual(signal.action.type, ActionType.ChatInputRequested); -- return (signal.action as ChatInputRequestedAction).request; --} +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-empty-preview', +- toolName: 'bash', +- arguments: { command: 'true' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- mockSession.fire('tool.execution_complete', { +- toolCallId: 'tc-empty-preview', +- success: true, +- result: { +- content: '', +- contents: [{ type: 'shell_exit', shellId: '0', exitCode: 0, outputPreview: '' }], +- }, +- } as SessionEventPayload<'tool.execution_complete'>['data']); - --async function createAgentSession(disposables: DisposableStore, options?: { -- clientSnapshot?: IActiveClientSnapshot; -- activeClientState?: ActiveClientState; -- environmentServiceRegistration?: 'native' | 'none'; -- logService?: ILogService; -- telemetryService?: ITelemetryService; -- captureRuntime?: { current?: ICopilotSessionRuntime }; -- workingDirectory?: URI; -- /** Per-key effective config values returned by the fake configuration service. */ -- configValues?: Record; -- fileContents?: Record; -- fileReadErrors?: readonly string[]; -- /** Configure the mock session before {@link CopilotAgentSession.initializeSession} runs. */ -- configureMockSession?: (session: MockCopilotSession) => void; -- /** Optional server-tool host wired into the session. */ -- serverToolHost?: IAgentServerToolHost; --}): Promise<{ -- session: CopilotAgentSession; -- runtime: ICopilotSessionRuntime; -- mockSession: MockCopilotSession; -- signals: AgentSignal[]; -- waitForSignal: (predicate: (signal: AgentSignal) => boolean) => Promise; -- sessionConfigUpdates: ReadonlyArray<{ session: string; patch: Record }>; --}> { -- const progressEmitter = disposables.add(new Emitter()); -- const signals: AgentSignal[] = []; -- const waiters: { predicate: (signal: AgentSignal) => boolean; deferred: DeferredPromise }[] = []; +- assert.deepStrictEqual({ +- finalized: terminalManager.outputTerminalsFinalized, +- disposed: terminalManager.disposedTerminals, +- }, { +- finalized: [{ uri: terminalUri, exitCode: 0 }], +- disposed: [terminalUri], +- }); +- }); - -- disposables.add(progressEmitter.event(signal => { -- signals.push(signal); -- for (let i = waiters.length - 1; i >= 0; i--) { -- if (waiters[i].predicate(signal)) { -- const { deferred } = waiters[i]; -- waiters.splice(i, 1); -- deferred.complete(signal); -- } -- } -- })); +- test('tool success without shell_exit does not fabricate a process exit', async () => { +- const { session, mockSession, terminalManager } = await createAgentSession(disposables); - -- const waitForSignal = (predicate: (signal: AgentSignal) => boolean): Promise => { -- const existing = signals.find(predicate); -- if (existing) { -- return Promise.resolve(existing); -- } -- const deferred = new DeferredPromise(); -- waiters.push({ predicate, deferred }); -- return deferred.p; -- }; +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-err', +- toolName: 'bash', +- arguments: { command: 'boom' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- mockSession.fire('tool.execution_partial_result', { +- toolCallId: 'tc-err', +- partialOutput: 'boom\n', +- } as SessionEventPayload<'tool.execution_partial_result'>['data']); +- mockSession.fire('tool.execution_complete', { +- toolCallId: 'tc-err', +- success: false, +- error: { message: 'failed' }, +- } as SessionEventPayload<'tool.execution_complete'>['data']); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-ok', +- toolName: 'bash', +- arguments: { command: 'fine' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- mockSession.fire('tool.execution_partial_result', { +- toolCallId: 'tc-ok', +- partialOutput: 'fine\n', +- } as SessionEventPayload<'tool.execution_partial_result'>['data']); +- mockSession.fire('tool.execution_complete', { +- toolCallId: 'tc-ok', +- success: true, +- result: { content: 'fine\n' }, +- } as SessionEventPayload<'tool.execution_complete'>['data']); - -- const sessionUri = AgentSession.uri('copilot', 'test-session-1'); -- const mockSession = new MockCopilotSession(); -- options?.configureMockSession?.(mockSession); +- // Tool completion and process completion are separate lifecycles. +- assert.deepStrictEqual({ +- data: terminalManager.outputTerminalData, +- finalized: terminalManager.outputTerminalsFinalized, +- disposed: terminalManager.disposedTerminals, +- }, { +- data: [ +- { uri: 'agenthost-terminal://shell/test-session-1/tc-err', data: 'boom\n' }, +- { uri: 'agenthost-terminal://shell/test-session-1/tc-ok', data: 'fine\n' }, +- ], +- finalized: [], +- disposed: [], +- }); +- session.dispose(); +- assert.deepStrictEqual(terminalManager.disposedTerminals, [ +- 'agenthost-terminal://shell/test-session-1/tc-err', +- 'agenthost-terminal://shell/test-session-1/tc-ok', +- ]); +- }); - -- const launchPlan: CopilotSessionLaunchPlan = { -- kind: 'create', -- client: { -- createSession: async () => mockSession as unknown as CopilotSession, -- resumeSession: async () => mockSession as unknown as CopilotSession, -- }, -- activeClientState: new ActiveClientState(), -- sessionId: 'test-session-1', -- workingDirectory: options?.workingDirectory, -- resolvedAgentName: undefined, -- snapshot: options?.clientSnapshot ?? { tools: [], plugins: [], mcpServers: {} }, -- shellManager: undefined, -- githubToken: undefined, -- model: undefined, -- }; -- let launchedRuntime: ICopilotSessionRuntime | undefined; -- const sessionLauncher: ICopilotSessionLauncher = { -- launch: async (_plan, runtime) => { -- launchedRuntime = runtime; -- if (options?.captureRuntime) { -- options.captureRuntime.current = runtime; -- } -- return new CopilotSessionWrapper(mockSession as unknown as CopilotSession); -- } -- }; +- test('stable shell completion fallback finalizes when the SDK strips shell_exit', async () => { +- const { mockSession, signals, waitForSignal, terminalManager } = await createAgentSession(disposables); +- const terminalUri = 'agenthost-terminal://shell/test-session-1/tc-exit-fallback'; - -- const services = new ServiceCollection(); -- services.set(ILogService, options?.logService ?? new NullLogService()); -- services.set(ITelemetryService, options?.telemetryService ?? new NullTelemetryServiceShape()); -- services.set(IFileService, { -- _serviceBrand: undefined, -- readFile: async (resource: URI) => { -- if (options?.fileReadErrors?.includes(resource.toString()) || options?.fileReadErrors?.includes(resource.fsPath)) { -- throw new Error('read failed'); -- } -- return { value: VSBuffer.fromString(options?.fileContents?.[resource.toString()] ?? options?.fileContents?.[resource.fsPath] ?? '') }; -- }, -- } as Partial as IFileService); -- services.set(ISessionDataService, createSessionDataService()); -- services.set(IDiffComputeService, createZeroDiffComputeService()); -- const sessionConfigUpdates: Array<{ session: string; patch: Record }> = []; -- const configValues = options?.configValues ?? {}; -- const fakeConfigurationService: IAgentConfigurationService = { -- _serviceBrand: undefined, -- onDidRootConfigChange: new Emitter().event, -- // Simple per-key map suffices for tests; the real service walks -- // session → parent → host and validates against the schema, but -- // neither matters here — we just need to surface a value the -- // session class will read. -- getEffectiveValue: ((_session: string, _schema: unknown, key: string) => configValues[key]) as IAgentConfigurationService['getEffectiveValue'], -- getEffectiveWorkingDirectory: () => undefined, -- getSessionConfigValues: () => undefined, -- updateSessionConfig: (session, patch) => { sessionConfigUpdates.push({ session, patch }); }, -- getRootValue: () => undefined, -- updateRootConfig: () => { /* no-op */ }, -- persistRootConfig: () => { /* no-op */ }, -- whenIdle: async () => { /* no-op */ }, -- }; -- services.set(IAgentConfigurationService, fakeConfigurationService); -- const environmentService = { -- _serviceBrand: undefined, -- userHome: URI.file('/mock-home'), -- tmpDir: URI.file('/mock-tmp'), -- } as INativeEnvironmentService; -- if (options?.environmentServiceRegistration !== 'none') { -- services.set(INativeEnvironmentService, environmentService); -- } -- const instantiationService = disposables.add(new InstantiationService(services)); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-exit-fallback', +- toolName: 'bash', +- arguments: { command: 'eci hi' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- mockSession.fire('tool.execution_partial_result', { +- toolCallId: 'tc-exit-fallback', +- partialOutput: '/bin/bash: eci: command not found\n', +- } as SessionEventPayload<'tool.execution_partial_result'>['data']); +- mockSession.fire('tool.execution_complete', { +- toolCallId: 'tc-exit-fallback', +- success: true, +- result: { +- content: '/bin/bash: eci: command not found\n', +- }, +- } as SessionEventPayload<'tool.execution_complete'>['data']); +- await waitForSignal(signal => isAction(signal, ActionType.ChatToolCallComplete)); - -- const session = disposables.add(instantiationService.createInstance( -- CopilotAgentSession, -- { -- sessionUri, -- rawSessionId: 'test-session-1', -- onDidSessionProgress: progressEmitter, -- sessionLauncher, -- launchPlan, -- shellManager: undefined, -- clientSnapshot: options?.clientSnapshot, -- activeClientState: options?.activeClientState, -- resolveMcpChildId: () => undefined, -- workingDirectory: options?.workingDirectory, -- serverToolHost: options?.serverToolHost, -- }, -- )); +- assert.deepStrictEqual({ +- finalized: terminalManager.outputTerminalsFinalized, +- disposed: terminalManager.disposedTerminals, +- }, { +- finalized: [{ uri: terminalUri, exitCode: 127 }], +- disposed: [terminalUri], +- }); +- const completed = getActions(signals).find(action => action.type === ActionType.ChatToolCallComplete) as ChatToolCallCompleteAction; +- assert.ok(completed.result.content?.some(content => +- content.type === ToolResultContentType.Terminal +- && content.resource === terminalUri +- && content.isPty === false +- && content.result?.exitCode === 127 +- && content.result.preview === '/bin/bash: eci: command not found\n' +- )); +- }); - -- await session.initializeSession(); -- assert.ok(launchedRuntime); +- test('background shell does not create an output-only terminal', async () => { +- const { mockSession, signals, waitForSignal, terminalManager } = await createAgentSession(disposables); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-background', +- toolName: 'bash', +- arguments: { command: 'long-running-command' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- mockSession.fire('tool.execution_complete', { +- toolCallId: 'tc-background', +- success: true, +- result: { content: '' }, +- } as SessionEventPayload<'tool.execution_complete'>['data']); +- await waitForSignal(signal => isAction(signal, ActionType.ChatToolCallComplete)); - -- return { session, runtime: launchedRuntime, mockSession, signals, waitForSignal, sessionConfigUpdates }; --} +- const completed = getActions(signals).find(action => action.type === ActionType.ChatToolCallComplete) as ChatToolCallCompleteAction; +- assert.ok(!completed.result.content?.some(content => content.type === ToolResultContentType.Terminal)); +- assert.deepStrictEqual(terminalManager.outputTerminalsCreated, []); - --// ---- Tests ------------------------------------------------------------------ +- mockSession.fire('tool.execution_partial_result', { +- toolCallId: 'tc-background', +- partialOutput: 'late output\n', +- } as SessionEventPayload<'tool.execution_partial_result'>['data']); - --suite('CopilotAgentSession', () => { +- mockSession.fire('system.notification', { +- content: 'Shell command completed', +- kind: { type: 'shell_completed', shellId: 'shell-bg', exitCode: 7, description: 'long-running-command' }, +- } as SessionEventPayload<'system.notification'>['data']); +- assert.deepStrictEqual({ +- created: terminalManager.outputTerminalsCreated, +- data: terminalManager.outputTerminalData, +- finalized: terminalManager.outputTerminalsFinalized, +- }, { created: [], data: [], finalized: [] }); +- }); - -- const disposables = new DisposableStore(); +- test('background shell output remains live until its session is disposed', async () => { +- const { session, mockSession, terminalManager } = await createAgentSession(disposables); +- const terminalUri = 'agenthost-terminal://shell/test-session-1/tc-background-stream'; - -- teardown(() => disposables.clear()); -- ensureNoDisposablesAreLeakedInTestSuite(); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-background-stream', +- toolName: 'bash', +- arguments: { command: 'long-running-command' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- mockSession.fire('tool.execution_partial_result', { +- toolCallId: 'tc-background-stream', +- partialOutput: 'started\n', +- } as SessionEventPayload<'tool.execution_partial_result'>['data']); +- mockSession.fire('tool.execution_complete', { +- toolCallId: 'tc-background-stream', +- success: true, +- result: { content: '' }, +- } as SessionEventPayload<'tool.execution_complete'>['data']); +- mockSession.fire('system.notification', { +- content: 'Shell command completed', +- kind: { type: 'shell_completed', shellId: 'shell-bg', exitCode: 0, description: 'long-running-command' }, +- } as SessionEventPayload<'system.notification'>['data']); - -- test('maps internal attachment URIs to Copilot SDK path fields', async () => { -- const fileUri = URI.file('/workspace/file.ts'); -- const selectionUri = URI.file('/workspace/selection.ts'); -- const { session, mockSession } = await createAgentSession(disposables, { -- fileContents: { -- [selectionUri.toString()]: 'alpha\nbeta\n012selected text345\nomega', -- }, +- assert.deepStrictEqual(terminalManager.disposedTerminals, []); +- session.dispose(); +- assert.deepStrictEqual(terminalManager.disposedTerminals, [terminalUri]); - }); - -- await session.send('hello', [ -- { type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'file.ts', displayKind: 'document' }, -- { -- type: MessageAttachmentKind.Resource, -- uri: selectionUri.toString(), -- label: 'selection.ts', -- displayKind: 'selection', -- selection: { -- range: { -- start: { line: 2, character: 3 }, -- end: { line: 2, character: 16 }, -- }, -- }, -- }, -- ]); +- test('completions without partials or shell_exit never create output channels', async () => { +- const { mockSession, terminalManager } = await createAgentSession(disposables); - -- assert.deepStrictEqual(mockSession.sendRequests, [{ -- prompt: 'hello', -- attachments: [ -- { type: 'file', path: fileUri.fsPath, displayName: 'file.ts' }, -- { -- type: 'selection', -- filePath: selectionUri.fsPath, -- displayName: 'selection.ts', -- text: 'selected text', -- selection: { -- start: { line: 2, character: 3 }, -- end: { line: 2, character: 16 }, -- }, -- }, -- ], -- }]); -- }); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-grep', +- toolName: 'grep', +- arguments: { pattern: 'x' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- mockSession.fire('tool.execution_complete', { +- toolCallId: 'tc-grep', +- success: true, +- result: { content: 'match' }, +- } as SessionEventPayload<'tool.execution_complete'>['data']); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-silent', +- toolName: 'bash', +- arguments: { command: 'true' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- mockSession.fire('tool.execution_complete', { +- toolCallId: 'tc-silent', +- success: true, +- result: { content: '' }, +- } as SessionEventPayload<'tool.execution_complete'>['data']); - -- test('maps symbol Resource attachments to SDK selection so the range survives (#315193)', async () => { -- // Symbols arrive as a Resource with displayKind 'symbol' AND a populated selection.range. Keying the selection -- // branch off the `selection` field (not displayKind === 'selection') keeps the range instead of degrading the -- // symbol to a plain file reference. -- const symbolUri = URI.file('/workspace/sym.ts'); -- const { session, mockSession } = await createAgentSession(disposables, { -- fileContents: { -- [symbolUri.toString()]: 'line0\nline1\nfunction foo() {}\nline3', -- }, +- assert.deepStrictEqual({ +- created: terminalManager.outputTerminalsCreated, +- finalized: terminalManager.outputTerminalsFinalized, +- }, { created: [], finalized: [] }); - }); - -- await session.send('explain this', [ -- { -- type: MessageAttachmentKind.Resource, -- uri: symbolUri.toString(), -- label: 'foo', -- displayKind: 'symbol', -- selection: { -- range: { -- start: { line: 2, character: 9 }, -- end: { line: 2, character: 12 }, -- }, -- }, -- }, -- ]); +- test('tool partial results for untracked tools are ignored', async () => { +- const { mockSession, signals } = await createAgentSession(disposables); - -- assert.deepStrictEqual(mockSession.sendRequests, [{ -- prompt: 'explain this', -- attachments: [ -- { -- type: 'selection', -- filePath: symbolUri.fsPath, -- displayName: 'foo', -- text: 'foo', -- selection: { -- start: { line: 2, character: 9 }, -- end: { line: 2, character: 12 }, -- }, -- }, -- ], -- }]); -- }); +- mockSession.fire('tool.execution_partial_result', { +- toolCallId: 'tc-untracked', +- partialOutput: 'orphaned output', +- } as SessionEventPayload<'tool.execution_partial_result'>['data']); - -- test('falls back to file reference when reading a symbol Resource attachment fails', async () => { -- const symbolUri = URI.file('/workspace/missing.ts'); -- const { session, mockSession } = await createAgentSession(disposables, { -- fileReadErrors: [symbolUri.toString()], +- assert.deepStrictEqual(getActions(signals), []); - }); - -- await session.send('explain this', [ -- { -- type: MessageAttachmentKind.Resource, -- uri: symbolUri.toString(), -- label: 'foo', -- displayKind: 'symbol', -- selection: { -- range: { -- start: { line: 2, character: 9 }, -- end: { line: 2, character: 12 }, -- }, -- }, -- }, -- ]); +- test('tool partial results for tracked non-shell tools are ignored', async () => { +- const { mockSession, signals } = await createAgentSession(disposables); - -- assert.deepStrictEqual(mockSession.sendRequests, [{ -- prompt: 'explain this', -- attachments: [ -- { type: 'file', path: symbolUri.fsPath, displayName: 'foo' }, -- ], -- }]); -- }); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-non-shell', +- toolName: 'grep', +- arguments: { pattern: 'needle' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- mockSession.fire('tool.execution_partial_result', { +- toolCallId: 'tc-non-shell', +- partialOutput: 'unexpected partial output', +- } as SessionEventPayload<'tool.execution_partial_result'>['data']); +- +- assert.deepStrictEqual(getActions(signals) +- .filter(action => action.type === ActionType.ChatToolCallContentChanged), []); +- }); +- +- test('live tool_start strips redundant cd prefix matching workingDirectory', async () => { +- const wd = URI.file('/repo/project'); +- const { mockSession, signals } = await createAgentSession(disposables, { workingDirectory: wd }); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-cd', +- toolName: 'bash', +- arguments: { command: 'cd /repo/project && npm test' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); - -- test('sends agent feedback annotations attachments as text blobs', async () => { -- const { session, mockSession } = await createAgentSession(disposables); +- assert.strictEqual(signals.length, 2); +- // toolInput on the auto-ready signal (signals[1]) +- const readySignal = signals[1]; +- assert.ok(isAction(readySignal, ActionType.ChatToolCallReady)); +- if (isAction(readySignal, ActionType.ChatToolCallReady)) { +- const action = readySignal.action as ChatToolCallReadyAction; +- assert.strictEqual(action.toolInput, 'npm test'); +- } +- }); - -- await session.send('/act-on-feedback', [{ -- type: MessageAttachmentKind.Annotations, -- label: '1 comment', -- displayKind: AgentFeedbackAttachmentDisplayKind, -- resource: 'ahp-session:/s/annotations', -- annotationIds: ['feedback-1'], -- }]); +- test('live tool_complete past-tense message reflects the rewritten command', async () => { +- const wd = URI.file('/repo/project'); +- const { mockSession, signals } = await createAgentSession(disposables, { workingDirectory: wd }); - -- const expectedText = -- 'The user attached specific feedback comments to act on (comment ids):\n' + -- '- feedback-1\n\n' + -- 'Use the `listComments` tool to read their content and focus on these comments.'; -- assert.deepStrictEqual(mockSession.sendRequests, [{ -- prompt: '/act-on-feedback', -- attachments: [{ -- type: 'blob', -- data: encodeBase64(VSBuffer.fromString(expectedText)), -- mimeType: 'text/plain', -- displayName: '1 comment', -- }], -- }]); -- }); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-cd-complete', +- toolName: 'bash', +- arguments: { command: 'cd /repo/project && npm test' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); - -- test('sends simple attachments as text blobs and restores them from SDK blobs', async () => { -- const { session, mockSession } = await createAgentSession(disposables); +- mockSession.fire('tool.execution_complete', { +- toolCallId: 'tc-cd-complete', +- success: true, +- result: { content: 'all tests passed' }, +- } as SessionEventPayload<'tool.execution_complete'>['data']); - -- await session.send('/act-on-feedback', [{ -- type: MessageAttachmentKind.Simple, -- label: 'Feedback', -- modelRepresentation: 'Feedback text for the model', -- }]); +- assert.strictEqual(signals.length, 3); +- const completeSignal = signals[2]; +- assert.ok(isAction(completeSignal, ActionType.ChatToolCallComplete)); +- if (isAction(completeSignal, ActionType.ChatToolCallComplete)) { +- const action = completeSignal.action as ChatToolCallCompleteAction; +- const past = action.result.pastTenseMessage; +- const pastStr = typeof past === 'string' ? past : (past?.markdown ?? ''); +- assert.ok(!pastStr.includes('cd /repo/project'), `past-tense message should not contain stripped prefix, got: ${pastStr}`); +- assert.ok(pastStr.includes('npm test'), `past-tense message should contain the rewritten command, got: ${pastStr}`); +- } +- }); - -- const expectedAttachment = { -- type: MessageAttachmentKind.Simple, -- label: 'Feedback', -- modelRepresentation: 'Feedback text for the model', -- }; -- assert.deepStrictEqual(mockSession.sendRequests, [{ -- prompt: '/act-on-feedback', -- attachments: [{ -- type: 'blob', -- data: encodeBase64(VSBuffer.fromString('Feedback text for the model')), -- mimeType: 'text/plain', -- displayName: 'Feedback', -- }], -- }]); +- test('live tool_complete maps SDK shell_exit content to terminal completion', async () => { +- const { session, mockSession, signals, terminalManager } = await createAgentSession(disposables); - -- mockSession.messages = [{ -- type: 'user.message', -- id: 'event-1', -- parentId: null, -- timestamp: new Date().toISOString(), -- data: { -- interactionId: 'message-1', -- content: '/act-on-feedback', -- attachments: [{ -- type: 'blob', -- data: encodeBase64(VSBuffer.fromString('Feedback text for the model')), -- mimeType: 'text/plain', -- displayName: 'Feedback', -- }], -- }, -- }]; +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-shell-exit', +- toolName: 'bash', +- arguments: { command: 'gti status' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- mockSession.fire('tool.execution_complete', { +- toolCallId: 'tc-shell-exit', +- success: true, +- result: { +- content: 'command not found\n', +- contents: [{ type: 'shell_exit', shellId: '0', exitCode: 127, cwd: '/repo' }], +- }, +- } as SessionEventPayload<'tool.execution_complete'>['data']); - -- assert.deepStrictEqual(await session.getMessages(), [{ -- id: 'event-1', -- message: { -- text: '/act-on-feedback', -- origin: { kind: MessageKind.User }, -- attachments: [expectedAttachment], -- }, -- responseParts: [], -- usage: undefined, -- state: 'cancelled', -- }]); -- }); +- assert.strictEqual(signals.length, 3); +- const completeSignal = signals[2]; +- assert.ok(isAction(completeSignal, ActionType.ChatToolCallComplete)); +- if (isAction(completeSignal, ActionType.ChatToolCallComplete)) { +- const action = completeSignal.action as ChatToolCallCompleteAction; +- assert.strictEqual(action.result.success, true); +- assert.deepStrictEqual(action.result.content, [ +- { type: ToolResultContentType.Text, text: 'command not found\n' }, +- { +- type: ToolResultContentType.Terminal, +- resource: 'agenthost-terminal://shell/test-session-1/tc-shell-exit', +- title: 'Run Shell Command', +- isPty: false, +- result: { exitCode: 127 }, +- }, +- ]); +- } +- // The advertised channel exists and is terminated; with no preview +- // there is nothing to seed. +- assert.deepStrictEqual({ +- created: terminalManager.outputTerminalsCreated.map(t => t.uri), +- data: terminalManager.outputTerminalData, +- finalized: terminalManager.outputTerminalsFinalized, +- disposed: terminalManager.disposedTerminals, +- }, { +- created: ['agenthost-terminal://shell/test-session-1/tc-shell-exit'], +- data: [], +- finalized: [{ uri: 'agenthost-terminal://shell/test-session-1/tc-shell-exit', exitCode: 127 }], +- disposed: [], +- }); +- session.dispose(); +- assert.deepStrictEqual(terminalManager.disposedTerminals, ['agenthost-terminal://shell/test-session-1/tc-shell-exit']); +- }); - -- test('sends paste simple attachments as text blobs', async () => { -- const { session, mockSession } = await createAgentSession(disposables); +- test('live task_complete emits root markdown instead of a tool call', async () => { +- const { mockSession, signals } = await createAgentSession(disposables); - -- await session.send('continue', [{ -- type: MessageAttachmentKind.Simple, -- label: 'Previous conversation', -- displayKind: 'paste', -- modelRepresentation: 'Transcript text', -- }]); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-task-complete', +- toolName: 'task_complete', +- arguments: { summary: 'Completed the requested work.' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- mockSession.fire('tool.execution_complete', { +- toolCallId: 'tc-task-complete', +- success: true, +- result: { content: 'Completed the requested work.' }, +- } as SessionEventPayload<'tool.execution_complete'>['data']); - -- assert.deepStrictEqual(mockSession.sendRequests, [{ -- prompt: 'continue', -- attachments: [{ -- type: 'blob', -- data: encodeBase64(VSBuffer.fromString('Transcript text')), -- mimeType: 'text/plain', -- displayName: 'Previous conversation', -- }], -- }]); +- const actions = getActions(signals); +- assert.deepStrictEqual(actions.map(a => a.type), [ActionType.ChatResponsePart]); +- const responsePart = actions[0] as ChatResponsePartAction; +- assert.strictEqual(responsePart.part.kind, ResponsePartKind.Markdown); +- if (responsePart.part.kind !== ResponsePartKind.Markdown) { +- return; +- } +- assert.deepStrictEqual(responsePart.part, { +- kind: ResponsePartKind.Markdown, +- id: responsePart.part.id, +- content: '\n\n**Task completed:** Completed the requested work.', +- }); +- }); - -- mockSession.messages = [{ -- type: 'user.message', -- id: 'event-1', -- parentId: null, -- timestamp: new Date().toISOString(), -- data: { -- interactionId: 'message-1', -- content: 'continue', -- attachments: [{ -- type: 'blob', -- data: encodeBase64(VSBuffer.fromString('Transcript text')), -- mimeType: 'text/plain', -- displayName: 'Previous conversation', -- }], -- }, -- }]; +- test('live tool_start does not rewrite when cd target differs from workingDirectory', async () => { +- const wd = URI.file('/repo/project'); +- const { mockSession, signals } = await createAgentSession(disposables, { workingDirectory: wd }); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-cd-other', +- toolName: 'bash', +- arguments: { command: 'cd /tmp && ls' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); - -- assert.deepStrictEqual((await session.getMessages())[0].message.attachments, [{ -- type: MessageAttachmentKind.Simple, -- label: 'Previous conversation', -- modelRepresentation: 'Transcript text', -- }]); -- }); +- assert.strictEqual(signals.length, 2); +- const readySignal = signals[1]; +- assert.ok(isAction(readySignal, ActionType.ChatToolCallReady)); +- if (isAction(readySignal, ActionType.ChatToolCallReady)) { +- assert.strictEqual((readySignal.action as ChatToolCallReadyAction).toolInput, 'cd /tmp && ls'); +- } +- }); - -- test('`/compact` runs the history compact RPC and completes the turn with output', async () => { -- const { session, mockSession, signals } = await createAgentSession(disposables); +- test('live tool_start without workingDirectory passes command through', async () => { +- const { mockSession, signals } = await createAgentSession(disposables); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-cd-nowd', +- toolName: 'bash', +- arguments: { command: 'cd /repo/project && npm test' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); - -- await session.send('/compact', undefined, 'turn-compact'); +- assert.strictEqual(signals.length, 2); +- const readySignal = signals[1]; +- assert.ok(isAction(readySignal, ActionType.ChatToolCallReady)); +- if (isAction(readySignal, ActionType.ChatToolCallReady)) { +- assert.strictEqual((readySignal.action as ChatToolCallReadyAction).toolInput, 'cd /repo/project && npm test'); +- } +- }); - -- // The compact command is handled inline via the history RPC and must -- // not fall through to a normal SDK `send()` turn. -- assert.strictEqual(mockSession.compactCalls.length, 1); -- assert.deepStrictEqual(mockSession.sendRequests, []); +- test('edit hooks resolve relative apply_patch file paths against workingDirectory', async () => { +- const capturedRuntime: { current?: ICopilotSessionRuntime } = {}; +- const workingDirectory = URI.file('/repo/project'); +- const absolutePath = URI.file('/tmp/absolute.ts').fsPath; +- const { session } = await createAgentSession(disposables, { workingDirectory, captureRuntime: capturedRuntime }); +- const sessionInternals = session as unknown as ISessionInternalsForTest; +- const started: string[] = []; +- const completed: string[] = []; +- sessionInternals._editTracker.trackEditStart = async path => { started.push(path); }; +- sessionInternals._editTracker.completeEdit = async path => { completed.push(path); }; +- const patch = [ +- '*** Begin Patch', +- '*** Update File: foo.ts', +- '@@', +- '+new', +- '*** Update File: src/bar.ts', +- '@@', +- '+new', +- `*** Update File: ${absolutePath}`, +- '@@', +- '+new', +- '*** End Patch', +- ].join('\n'); - -- // The turn opened by the server is closed inline (the SDK never fires -- // `onIdle` for the compact path) after emitting the completion message. -- const actions = getActions(signals); -- const responseParts = actions.filter((a): a is ChatResponsePartAction => a.type === ActionType.ChatResponsePart); -- assert.strictEqual(responseParts.length, 1); -- const responsePart = responseParts[0]; -- assert.strictEqual(responsePart.part.kind, ResponsePartKind.Markdown); -- if (responsePart.part.kind !== ResponsePartKind.Markdown) { -- throw new Error('unreachable'); -- } -- assert.deepStrictEqual({ -- turnId: responsePart.turnId, -- kind: responsePart.part.kind, -- content: responsePart.part.content, -- }, { -- turnId: 'turn-compact', -- kind: ResponsePartKind.Markdown, -- content: 'Compaction completed', -- }); -- const turnComplete = actions.find(a => a.type === ActionType.ChatTurnComplete); -- assert.ok(turnComplete, 'expected the turn to complete'); -- assert.strictEqual((turnComplete as ChatTurnCompleteAction).turnId, 'turn-compact'); -- }); +- await capturedRuntime.current!.handlePreToolUse({ +- sessionId: 'test-session-1', +- timestamp: new Date(0), +- workingDirectory: '/repo/project', +- toolName: 'apply_patch', +- toolArgs: patch, +- }); +- await capturedRuntime.current!.handlePostToolUse({ +- sessionId: 'test-session-1', +- timestamp: new Date(0), +- workingDirectory: '/repo/project', +- toolName: 'apply_patch', +- toolArgs: patch, +- toolResult: { textResultForLlm: '', resultType: 'success' }, +- }); - -- test('`/compact` completes the turn even when compaction reports failure', async () => { -- const { session, mockSession, signals } = await createAgentSession(disposables); -- mockSession.compactResult = { success: false, tokensRemoved: 0, messagesRemoved: 0 }; +- assert.deepStrictEqual({ started, completed }, { +- started: [join(workingDirectory.fsPath, 'foo.ts'), join(workingDirectory.fsPath, 'src/bar.ts'), absolutePath], +- completed: [join(workingDirectory.fsPath, 'foo.ts'), join(workingDirectory.fsPath, 'src/bar.ts'), absolutePath], +- }); +- }); - -- await session.send('/compact', undefined, 'turn-compact'); +- test('tool_complete resolves relative apply_patch file paths before taking completed edits', async () => { +- const workingDirectory = URI.file('/repo/project'); +- const { session, mockSession, waitForSignal } = await createAgentSession(disposables, { workingDirectory }); +- const sessionInternals = session as unknown as ISessionInternalsForTest; +- const taken: string[] = []; +- sessionInternals._editTracker.takeCompletedEdit = async (_turnId, _toolCallId, path, _toolName, _toolInput, _modelId) => { +- taken.push(path); +- return undefined; +- }; +- session.resetTurnState('turn-apply-patch'); +- const patch = [ +- '*** Begin Patch', +- '*** Update File: foo.ts', +- '@@', +- '+new', +- '*** Update File: src/bar.ts', +- '@@', +- '+new', +- '*** End Patch', +- ].join('\n'); - -- assert.strictEqual(mockSession.compactCalls.length, 1); -- assert.deepStrictEqual(mockSession.sendRequests, []); -- const turnComplete = getActions(signals).find(a => a.type === ActionType.ChatTurnComplete); -- assert.ok(turnComplete, 'expected the turn to complete on a failed compaction'); -- }); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-apply-patch', +- toolName: 'apply_patch', +- arguments: patch, +- } as unknown as SessionEventPayload<'tool.execution_start'>['data']); - -- test('`/compact` treats nothing-to-compact errors as completed', async () => { -- const logService = new CapturingLogService(); -- const { session, mockSession, signals } = await createAgentSession(disposables, { logService }); -- mockSession.compactError = new Error('NOTHING TO COMPACT for this conversation'); +- mockSession.fire('tool.execution_complete', { +- toolCallId: 'tc-apply-patch', +- success: true, +- } as SessionEventPayload<'tool.execution_complete'>['data']); - -- await session.send('/compact', undefined, 'turn-compact'); +- await waitForSignal(s => isAction(s, ActionType.ChatToolCallComplete)); - -- const actions = getActions(signals); -- assert.deepStrictEqual({ -- compactCalls: mockSession.compactCalls.length, -- sendRequests: mockSession.sendRequests, -- errors: logService.errors, -- responseParts: actions -- .filter(a => a.type === ActionType.ChatResponsePart) -- .map(a => { -- const part = (a as ChatResponsePartAction).part; -- return part.kind === ResponsePartKind.Markdown ? { turnId: a.turnId, kind: part.kind, content: part.content } : { turnId: a.turnId, kind: part.kind }; -- }), -- turnComplete: actions -- .filter(a => a.type === ActionType.ChatTurnComplete) -- .map(a => (a as ChatTurnCompleteAction).turnId), -- }, { -- compactCalls: 1, -- sendRequests: [], -- errors: [], -- responseParts: [{ turnId: 'turn-compact', kind: ResponsePartKind.Markdown, content: 'Compaction completed' }], -- turnComplete: ['turn-compact'], +- assert.deepStrictEqual(taken, [join(workingDirectory.fsPath, 'foo.ts'), join(workingDirectory.fsPath, 'src/bar.ts')]); - }); -- }); -- -- test('`/env` runs the runtime command when listed and emits markdown output', async () => { -- const { session, mockSession, signals } = await createAgentSession(disposables); -- mockSession.commandListResult = { -- commands: [{ -- name: 'env', -- kind: 'builtin', -- description: 'Show loaded environment details', -- allowDuringAgentExecution: true, -- }], -- }; -- mockSession.commandInvokeResult = { kind: 'text', text: '## Environment\n\nLoaded.', markdown: true }; - -- await session.send('/env', undefined, 'turn-env'); +- test('hidden tools are not emitted as tool_start', async () => { +- const { mockSession, signals } = await createAgentSession(disposables); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-11', +- toolName: 'report_intent', +- } as SessionEventPayload<'tool.execution_start'>['data']); - -- const actions = getActions(signals); -- assert.deepStrictEqual({ -- commandListCalls: mockSession.commandListCalls, -- commandInvokeCalls: mockSession.commandInvokeCalls, -- sendRequests: mockSession.sendRequests, -- responseParts: actions -- .filter(a => a.type === ActionType.ChatResponsePart) -- .map(a => { -- const part = (a as ChatResponsePartAction).part; -- return part.kind === ResponsePartKind.Markdown ? { kind: part.kind, content: part.content } : { kind: part.kind }; -- }), -- turnComplete: actions -- .filter(a => a.type === ActionType.ChatTurnComplete) -- .map(a => (a as ChatTurnCompleteAction).turnId), -- }, { -- commandListCalls: [{ includeBuiltins: true, includeSkills: false, includeClientCommands: false }], -- commandInvokeCalls: [{ name: 'env' }], -- sendRequests: [], -- responseParts: [{ kind: ResponsePartKind.Markdown, content: '## Environment\n\nLoaded.' }], -- turnComplete: ['turn-env'], +- assert.strictEqual(signals.length, 0); - }); -- }); -- -- test('`/env` escapes plain text runtime command output before emitting markdown', async () => { -- const { session, mockSession, signals } = await createAgentSession(disposables); -- mockSession.commandListResult = { -- commands: [{ -- name: 'env', -- kind: 'builtin', -- description: 'Show loaded environment details', -- allowDuringAgentExecution: true, -- }], -- }; -- mockSession.commandInvokeResult = { kind: 'text', text: '*plain*\n- item', markdown: false }; - -- await session.send('/env', undefined, 'turn-env'); +- test('assistant.intent surfaces as session activity', async () => { +- const { mockSession, signals } = await createAgentSession(disposables); +- mockSession.fire('assistant.intent', { intent: 'Reading repo docs' }); +- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); - -- const responsePart = getActions(signals).find(a => a.type === ActionType.ChatResponsePart) as ChatResponsePartAction | undefined; -- assert.strictEqual(responsePart?.part.kind, ResponsePartKind.Markdown); -- if (responsePart?.part.kind === ResponsePartKind.Markdown) { -- assert.strictEqual(responsePart.part.content, '\\*plain\\*\n\\- item'); -- } -- }); +- assert.deepStrictEqual(signals +- .filter(signal => isAction(signal, ActionType.SessionActivityChanged)) +- .map(signal => (signal.action as { activity: string | undefined }).activity), [ +- 'Reading repo docs', +- undefined, +- ]); +- }); - -- test('`/env` falls through to a normal SDK send when not listed', async () => { -- const { session, mockSession, signals } = await createAgentSession(disposables); -- mockSession.commandListResult = { commands: [] }; +- test('assistant.intent clears session activity', async () => { +- const { mockSession, signals } = await createAgentSession(disposables); +- mockSession.fire('assistant.intent', { intent: 'Reading repo docs' }); +- mockSession.fire('assistant.intent', { intent: '' }); +- +- assert.deepStrictEqual(signals +- .filter(signal => isAction(signal, ActionType.SessionActivityChanged)) +- .map(signal => (signal.action as { activity: string | undefined }).activity), [ +- 'Reading repo docs', +- undefined, +- ]); +- }); - -- await session.send('/env', undefined, 'turn-env'); +- test('tool_complete event produces past-tense message', async () => { +- const { mockSession, signals } = await createAgentSession(disposables); - -- assert.deepStrictEqual({ -- commandListCalls: mockSession.commandListCalls, -- commandInvokeCalls: mockSession.commandInvokeCalls, -- sendRequests: mockSession.sendRequests, -- responseParts: getActions(signals).filter(a => a.type === ActionType.ChatResponsePart), -- turnComplete: getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete), -- }, { -- commandListCalls: [{ includeBuiltins: true, includeSkills: false, includeClientCommands: false }], -- commandInvokeCalls: [], -- sendRequests: [{ prompt: '/env', attachments: undefined }], -- responseParts: [], -- turnComplete: [], -- }); -- }); +- // First fire tool_start so it's tracked +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-12', +- toolName: 'bash', +- arguments: { command: 'ls' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); - -- test('caches runtime slash command availability across checks', async () => { -- const { session, mockSession } = await createAgentSession(disposables); -- mockSession.commandListResult = { -- commands: [ -- { -- name: 'env', -- kind: 'builtin', -- description: 'Show loaded environment details', -- allowDuringAgentExecution: true, -- }, -- { -- name: 'review', -- kind: 'builtin', -- description: 'Run code review agent to analyze changes', -- allowDuringAgentExecution: false, -- }, -- { -- name: 'not-a-builtin', -- kind: 'skill', -- description: 'Skill command', -- allowDuringAgentExecution: false, -- }, -- ], -- }; +- // Then fire complete +- mockSession.fire('tool.execution_complete', { +- toolCallId: 'tc-12', +- success: true, +- result: { content: 'file1.ts\nfile2.ts' }, +- } as SessionEventPayload<'tool.execution_complete'>['data']); - -- assert.deepStrictEqual({ -- env: await session.hasRuntimeSlashCommand('env'), -- review: await session.hasRuntimeSlashCommand('review'), -- skill: await session.hasRuntimeSlashCommand('not-a-builtin'), -- commandListCalls: mockSession.commandListCalls, -- }, { -- env: true, -- review: true, -- skill: false, -- commandListCalls: [{ includeBuiltins: true, includeSkills: false, includeClientCommands: false }], +- assert.strictEqual(signals.length, 3); +- const completeSignal = signals[2]; +- assert.ok(isAction(completeSignal, ActionType.ChatToolCallComplete)); +- if (isAction(completeSignal, ActionType.ChatToolCallComplete)) { +- const action = completeSignal.action as ChatToolCallCompleteAction; +- assert.strictEqual(action.toolCallId, 'tc-12'); +- assert.ok(action.result.success); +- assert.ok(action.result.pastTenseMessage); +- } - }); -- }); - -- test('`/review` forwards as a prompt-invoked command', async () => { -- const { session, mockSession, signals } = await createAgentSession(disposables); +- test('tool_complete for untracked tool is ignored', async () => { +- const { mockSession, signals } = await createAgentSession(disposables); +- mockSession.fire('tool.execution_complete', { +- toolCallId: 'tc-untracked', +- success: true, +- } as SessionEventPayload<'tool.execution_complete'>['data']); +- +- assert.strictEqual(signals.length, 0); +- }); - -- await session.send('/review focus on tests', undefined, 'turn-review'); +- test('idle event completes the active turn', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-idle'); +- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); - -- assert.deepStrictEqual({ -- commandListCalls: mockSession.commandListCalls, -- commandInvokeCalls: mockSession.commandInvokeCalls, -- sendRequests: mockSession.sendRequests, -- responseParts: getActions(signals).filter(a => a.type === ActionType.ChatResponsePart), -- turnComplete: getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete), -- }, { -- commandListCalls: [], -- commandInvokeCalls: [], -- sendRequests: [{ prompt: '/review focus on tests', attachments: undefined }], -- responseParts: [], -- turnComplete: [], +- assert.strictEqual(signals.length, 1); +- assert.ok(isAction(signals[0], ActionType.ChatTurnComplete)); - }); -- }); -- -- test('`/security-review` forwards as a prompt-invoked command', async () => { -- const { session, mockSession, signals } = await createAgentSession(disposables); - -- await session.send('/security-review', undefined, 'turn-security-review'); +- test('tool-call aggregate emits once with cancelled result across abort and idle', async () => { +- const telemetryService = new CapturingTelemetryService(); +- const { session, mockSession } = await createAgentSession(disposables, { +- telemetryService, +- clientSnapshot: { tools: [{ name: 'grep' }, { name: 'edit' }], plugins: [], mcpServers: {} }, +- }); +- session.resetTurnState('turn-tool-details'); +- await session.send('hello agent', undefined, 'turn-tool-details'); +- mockSession.fire('user.message', { content: 'hello agent' } as SessionEventPayload<'user.message'>['data']); +- mockSession.fire('assistant.message', { +- messageId: 'msg-tools', +- content: '', +- model: 'gpt-x', +- toolRequests: [ +- { toolCallId: 'tc-1', name: 'grep', arguments: {} }, +- { toolCallId: 'tc-2', name: 'edit', arguments: {} }, +- ], +- } as SessionEventPayload<'assistant.message'>['data']); +- mockSession.fire('assistant.message', { +- messageId: 'msg-final', +- content: 'done', +- model: 'gpt-x', +- } as SessionEventPayload<'assistant.message'>['data']); +- mockSession.fire('abort', { reason: 'user_abort' } as SessionEventPayload<'abort'>['data']); +- mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); - -- assert.deepStrictEqual({ -- commandListCalls: mockSession.commandListCalls, -- commandInvokeCalls: mockSession.commandInvokeCalls, -- sendRequests: mockSession.sendRequests, -- responseParts: getActions(signals).filter(a => a.type === ActionType.ChatResponsePart), -- turnComplete: getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete), -- }, { -- commandListCalls: [], -- commandInvokeCalls: [], -- sendRequests: [{ prompt: '/security-review', attachments: undefined }], -- responseParts: [], -- turnComplete: [], +- assert.deepStrictEqual(telemetryService.events.map(event => { +- const data = event.data as Record; +- return { +- eventName: event.eventName, +- provider: data.provider, +- requestId: data.requestId, +- responseType: data.responseType, +- toolCounts: data.toolCounts, +- model: data.model, +- numRequests: data.numRequests, +- turnIndex: data.turnIndex, +- messageCharLen: data.messageCharLen, +- availableToolCount: data.availableToolCount, +- totalToolCalls: data.totalToolCalls, +- parallelToolCallRounds: data.parallelToolCallRounds, +- parallelToolCallsTotal: data.parallelToolCallsTotal, +- }; +- }), [{ +- eventName: 'toolCallDetails', +- provider: 'copilot', +- requestId: 'turn-tool-details', +- responseType: 'cancelled', +- toolCounts: JSON.stringify({ grep: 1, edit: 1 }), +- model: 'gpt-x', +- numRequests: 2, +- turnIndex: 0, +- messageCharLen: 11, +- availableToolCount: 2, +- totalToolCalls: 2, +- parallelToolCallRounds: 1, +- parallelToolCallsTotal: 2, +- }]); - }); -- }); - -- test('emits accumulated Copilot usage metadata', async () => { -- const { session, mockSession, signals } = await createAgentSession(disposables); +- test('tool approval waits for permission outcome and falls back only at completion', async () => { +- const telemetryService = new CapturingTelemetryService(); +- const { session, mockSession } = await createAgentSession(disposables, { telemetryService }); +- session.resetTurnState('turn-approval'); - -- session.resetTurnState('turn-usage'); -- mockSession.fire('assistant.usage', { -- model: 'claude-sonnet-4.6', -- inputTokens: 10, -- outputTokens: 20, -- cacheReadTokens: 5, -- cost: 2, -- // `copilotUsage` is marked `asInternal` in the SDK schema so it is not on the public type, but is present at runtime. -- copilotUsage: { totalNanoAiu: 500_000_000, tokenDetails: [] }, -- } as unknown as SessionEventPayload<'assistant.usage'>['data']); -- mockSession.fire('assistant.usage', { -- model: 'claude-sonnet-4.6', -- inputTokens: 30, -- outputTokens: 40, -- cost: 2, -- copilotUsage: { totalNanoAiu: 750_000_000, tokenDetails: [] }, -- } as unknown as SessionEventPayload<'assistant.usage'>['data']); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-approved', toolName: 'bash', arguments: {}, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- assert.strictEqual(telemetryService.events.length, 0); +- mockSession.fire('permission.requested', { +- requestId: 'permission-approved', +- permissionRequest: { kind: 'custom-tool', toolCallId: 'tc-approved', toolName: 'bash' }, +- } as SessionEventPayload<'permission.requested'>['data']); +- assert.strictEqual(telemetryService.events.length, 0); +- mockSession.fire('permission.completed', { +- requestId: 'permission-approved', toolCallId: 'tc-approved', result: { kind: 'approved' }, +- } as SessionEventPayload<'permission.completed'>['data']); +- mockSession.fire('tool.execution_complete', { +- toolCallId: 'tc-approved', success: true, result: { content: 'done' }, +- } as SessionEventPayload<'tool.execution_complete'>['data']); - -- const usageActions = signals -- .filter((s): s is IAgentActionSignal => s.kind === 'action') -- .map(s => s.action) -- .filter(a => a.type === ActionType.ChatUsage); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-denied', toolName: 'edit', arguments: {}, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- mockSession.fire('permission.requested', { +- requestId: 'permission-denied', +- permissionRequest: { kind: 'custom-tool', toolCallId: 'tc-denied', toolName: 'edit' }, +- } as SessionEventPayload<'permission.requested'>['data']); +- mockSession.fire('permission.completed', { +- requestId: 'permission-denied', toolCallId: 'tc-denied', result: { kind: 'denied-interactively-by-user' }, +- } as SessionEventPayload<'permission.completed'>['data']); - -- assert.deepStrictEqual(usageActions.map(a => a.usage), [ -- { -- inputTokens: 10, -- outputTokens: 20, -- model: 'claude-sonnet-4.6', -- cacheReadTokens: 5, -- _meta: { -- cost: 2, -- copilotUsage: { totalNanoAiu: 500_000_000, tokenDetails: [] }, -- }, -- }, -- { -- inputTokens: 30, -- outputTokens: 40, -- model: 'claude-sonnet-4.6', -- cacheReadTokens: undefined, -- _meta: { -- cost: 2, -- copilotUsage: { totalNanoAiu: 1_250_000_000, tokenDetails: [] }, -- }, -- }, -- ]); -- }); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-no-permission', toolName: 'grep', arguments: {}, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- assert.strictEqual(telemetryService.events.filter(event => event.eventName === 'chat.toolApproval').length, 2); +- mockSession.fire('tool.execution_complete', { +- toolCallId: 'tc-no-permission', success: true, result: { content: 'done' }, +- } as SessionEventPayload<'tool.execution_complete'>['data']); - -- test('forwards account quota snapshots on usage metadata', async () => { -- const { session, mockSession, signals } = await createAgentSession(disposables); +- assert.deepStrictEqual(telemetryService.events.filter(event => event.eventName === 'chat.toolApproval').map(event => { +- const data = event.data as Record; +- return { +- toolId: data.toolId, +- confirmKind: data.confirmKind, +- confirmationNotNeededReason: data.confirmationNotNeededReason, +- }; +- }), [{ +- toolId: 'bash', confirmKind: 'userAction', confirmationNotNeededReason: undefined, +- }, { +- toolId: 'edit', confirmKind: 'denied', confirmationNotNeededReason: undefined, +- }, { +- toolId: 'grep', confirmKind: 'confirmationNotNeeded', confirmationNotNeededReason: undefined, +- }]); +- }); +- test('idle event without an active turn is ignored', async () => { +- const { mockSession, signals } = await createAgentSession(disposables); +- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); - -- session.resetTurnState('turn-quota'); -- mockSession.fire('assistant.usage', { -- model: 'claude-sonnet-4.6', -- inputTokens: 10, -- outputTokens: 20, -- // `quotaSnapshots` is marked `asInternal` in the SDK schema so it is not on the public type, but is present at runtime. -- quotaSnapshots: { -- premium_interactions: { -- isUnlimitedEntitlement: false, -- entitlementRequests: 300, -- usedRequests: 75, -- usageAllowedWithExhaustedQuota: true, -- remainingPercentage: 75, -- overage: 1.5, -- overageAllowedWithExhaustedQuota: true, -- resetDate: '2026-07-01T00:00:00.000Z', -- }, -- }, -- } as unknown as SessionEventPayload<'assistant.usage'>['data']); +- assert.strictEqual(signals.length, 0); +- }); - -- const usageActions = signals -- .filter((s): s is IAgentActionSignal => s.kind === 'action') -- .map(s => s.action) -- .filter(a => a.type === ActionType.ChatUsage); +- test('reports the turn ending on both normal completion and abort', async () => { +- // The agent parks work that must not interrupt a live turn (notably a +- // CLI client restart) until this fires, so every path out of an +- // in-flight turn has to report it — otherwise that work is stranded +- // and the session spins forever. +- let turnEndCount = 0; +- const { session, mockSession } = await createAgentSession(disposables, { onTurnEnded: () => turnEndCount++ }); - -- assert.deepStrictEqual(usageActions.map(a => a.usage._meta?.quotaSnapshots), [ -- { -- premium_interactions: { -- isUnlimitedEntitlement: false, -- entitlementRequests: 300, -- usedRequests: 75, -- remainingPercentage: 75, -- overage: 1.5, -- overageAllowedWithExhaustedQuota: true, -- resetDate: '2026-07-01T00:00:00.000Z', -- }, -- }, -- ]); -- }); +- session.resetTurnState('turn-completed'); +- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); +- const afterCompletion = turnEndCount; - -- test('extracts selected text from file contents for different line endings and bounds', async () => { -- const testCases = [ -- { -- name: 'lf multiline', -- contents: 'zero\none\ntwo\nthree', -- selection: { start: { line: 1, character: 1 }, end: { line: 2, character: 2 } }, -- expectedText: 'ne\ntw', -- }, -- { -- name: 'crlf multiline', -- contents: 'zero\r\none\r\ntwo\r\nthree', -- selection: { start: { line: 1, character: 1 }, end: { line: 2, character: 2 } }, -- expectedText: 'ne\r\ntw', -- }, -- { -- name: 'clamps past eof', -- contents: 'zero\none', -- selection: { start: { line: 1, character: 1 }, end: { line: 42, character: 99 } }, -- expectedText: 'ne', -- }, -- { -- name: 'empty when end is before start', -- contents: 'zero\none', -- selection: { start: { line: 1, character: 3 }, end: { line: 1, character: 1 } }, -- expectedText: '', -- }, -- ] satisfies ReadonlyArray<{ -- name: string; -- contents: string; -- selection: { -- start: { line: number; character: number }; -- end: { line: number; character: number }; -- }; -- expectedText: string; -- }>; +- session.resetTurnState('turn-aborted'); +- mockSession.fire('assistant.turn_start', { turnId: 'sdk-0' } as SessionEventPayload<'assistant.turn_start'>['data']); +- mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); - -- for (const testCase of testCases) { -- const selectionUri = URI.file(`/workspace/${testCase.name}.ts`); -- const { session, mockSession } = await createAgentSession(disposables, { -- fileContents: { -- [selectionUri.toString()]: testCase.contents, -- }, +- assert.deepStrictEqual({ +- afterCompletion, +- afterAbort: turnEndCount, +- hasActiveTurn: session.hasActiveTurn, +- }, { +- afterCompletion: 1, +- afterAbort: 2, +- hasActiveTurn: false, - }); +- }); - -- await session.send('hello', [{ -- type: MessageAttachmentKind.Resource, -- uri: selectionUri.toString(), -- label: `${testCase.name}.ts`, -- displayKind: 'selection', -- selection: { range: testCase.selection }, -- }]); +- test('drops and logs a markdown delta emitted with no active turn', async () => { +- // A delta should only arrive while a turn is active. With none, we +- // can't persist the part id (so every delta would allocate a fresh +- // part) and the action would carry an empty turnId — drop it and log. +- const logService = new CapturingLogService(); +- const { mockSession, signals } = await createAgentSession(disposables, { logService }); - -- assert.deepStrictEqual(mockSession.sendRequests, [{ -- prompt: 'hello', -- attachments: [{ -- type: 'selection', -- filePath: selectionUri.fsPath, -- displayName: `${testCase.name}.ts`, -- text: testCase.expectedText, -- selection: testCase.selection, -- }], -- }], testCase.name); -- disposables.clear(); -- } -- }); +- // No resetTurnState → no active turn. +- mockSession.fire('assistant.message_delta', { +- deltaContent: 'orphan text', +- } as SessionEventPayload<'assistant.message_delta'>['data']); - -- test('falls back to file attachment when selection text cannot be read', async () => { -- const selectionUri = URI.file('/workspace/missing.ts'); -- const logService = new CapturingLogService(); -- const { session, mockSession } = await createAgentSession(disposables, { -- fileReadErrors: [selectionUri.toString()], -- logService, +- const parts = getActions(signals).filter(a => a.type === ActionType.ChatResponsePart || a.type === ActionType.ChatDelta); +- assert.strictEqual(parts.length, 0, 'no response part/delta should be emitted without an active turn'); +- assert.strictEqual(logService.errors.length, 1, 'should log an error'); +- assert.match(String(logService.errors[0].first), /no active turn/i); - }); - -- await session.send('hello', [{ -- type: MessageAttachmentKind.Resource, -- uri: selectionUri.toString(), -- label: 'missing.ts', -- displayKind: 'selection', -- selection: { -- range: { -- start: { line: 0, character: 0 }, -- end: { line: 0, character: 5 }, -- }, -- }, -- }]); -- -- assert.deepStrictEqual(mockSession.sendRequests, [{ -- prompt: 'hello', -- attachments: [ -- { type: 'file', path: selectionUri.fsPath, displayName: 'missing.ts' }, -- ], -- }]); -- assert.strictEqual(logService.warnings.length, 1); -- assert.match(logService.warnings[0].message, /Failed to read selected text/); -- }); -- -- // ---- permission handling ---- +- test('abort-induced idle does not complete a pending queued turn', async () => { +- // Repro for the blank-response-after-abort race: a running turn is +- // aborted while a queued message exists. The queued message's +- // `send()` creates a fresh (pending) turn before the abort's +- // terminal `session.idle` is delivered. That idle must not complete +- // the queued turn — structurally, because it has not started running +- // yet, and because the idle carries `aborted: true`. +- const { session, mockSession, signals } = await createAgentSession(disposables); - -- suite('permission handling', () => { +- // The queued message has started its (pending) turn by the time the +- // abort's terminal idle arrives — no SDK event has run it yet. +- session.resetTurnState('turn-queued'); +- mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); - -- test('read permission fires tool_ready (deferred to side effects)', async () => { -- const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables); -- const resultPromise = runtime.handlePermissionRequest({ -- kind: 'read', -- path: '/workspace/src/file.ts', -- toolCallId: 'tc-1', -- }); +- assert.strictEqual( +- getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete).length, +- 0, +- 'abort-induced idle must not complete the pending queued turn', +- ); - -- await waitForSignal(s => s.kind === 'pending_confirmation'); -- assert.strictEqual(signals.length, 1); +- // The queued turn now actually runs (first SDK event) and then +- // completes on its own (non-abort) idle. +- mockSession.fire('assistant.turn_start', { turnId: 'sdk-0' } as SessionEventPayload<'assistant.turn_start'>['data']); +- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); - -- assert.ok(session.respondToPermissionRequest('tc-1', true)); -- const result = await resultPromise; -- assert.strictEqual(result.kind, 'approve-once'); +- const completions = getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete); +- assert.strictEqual(completions.length, 1, 'the queued turn should complete on its real idle'); +- assert.strictEqual((completions[0] as ChatTurnCompleteAction).turnId, 'turn-queued'); - }); - -- test('auto-approves read permission for session-state plan files', async () => { -- const previousXdgStateHome = process.env['XDG_STATE_HOME']; -- process.env['XDG_STATE_HOME'] = '/mock-state-home'; -- try { -- const { runtime, signals } = await createAgentSession(disposables); -- const result = await runtime.handlePermissionRequest({ -- kind: 'read', -- path: join('/mock-state-home', '.copilot', 'session-state', 'test-session-1', 'plan.md'), -- toolCallId: 'tc-read-plan', -- }); +- test('abort-induced idle tears down a running turn without completing it', async () => { +- // Plain abort (no queued message): the running turn is finalized by +- // the client-dispatched ChatTurnCancelled, so the abort's idle must +- // not also emit a ChatTurnComplete. The turn handle is dropped so a +- // later stray idle cannot complete it. +- const { session, mockSession, signals } = await createAgentSession(disposables); - -- assert.strictEqual(result.kind, 'approve-once'); -- assert.strictEqual(signals.length, 0); -- } finally { -- if (previousXdgStateHome === undefined) { -- delete process.env['XDG_STATE_HOME']; -- } else { -- process.env['XDG_STATE_HOME'] = previousXdgStateHome; -- } -- } -- }); +- session.resetTurnState('turn-aborted'); +- mockSession.fire('assistant.turn_start', { turnId: 'sdk-0' } as SessionEventPayload<'assistant.turn_start'>['data']); +- mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); - -- test('resolves native environment through INativeEnvironmentService registration', async () => { -- const previousXdgStateHome = process.env['XDG_STATE_HOME']; -- delete process.env['XDG_STATE_HOME']; -- try { -- const { runtime, signals } = await createAgentSession(disposables, { environmentServiceRegistration: 'native' }); -- const result = await runtime.handlePermissionRequest({ -- kind: 'read', -- path: join('/mock-home', '.copilot', 'session-state', 'test-session-1', 'plan.md'), -- toolCallId: 'tc-read-plan-native-env', -- }); +- assert.strictEqual( +- getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete).length, +- 0, +- 'abort-induced idle must not complete the running aborted turn', +- ); - -- assert.strictEqual(result.kind, 'approve-once'); -- assert.strictEqual(signals.length, 0); -- } finally { -- if (previousXdgStateHome === undefined) { -- delete process.env['XDG_STATE_HOME']; -- } else { -- process.env['XDG_STATE_HOME'] = previousXdgStateHome; -- } -- } +- // A subsequent stray idle has no turn to act on. +- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); +- assert.strictEqual(getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete).length, 0); - }); - -- test('logs and rethrows permission failures', async () => { -- const previousXdgStateHome = process.env['XDG_STATE_HOME']; -- delete process.env['XDG_STATE_HOME']; -- const logService = new CapturingLogService(); -- try { -- const { runtime } = await createAgentSession(disposables, { -- environmentServiceRegistration: 'none', -- logService, -- }); +- test('a running turn after a prior abort still completes on its idle', async () => { +- // No lingering state across turns: the next turn completes normally. +- const { session, mockSession, signals } = await createAgentSession(disposables); - -- await assert.rejects( -- runtime.handlePermissionRequest({ -- kind: 'read', -- path: join('/mock-home', '.copilot', 'session-state', 'test-session-1', 'plan.md'), -- toolCallId: 'tc-read-plan-missing-env', -- }), -- ); +- session.resetTurnState('turn-aborted'); +- mockSession.fire('assistant.turn_start', { turnId: 'sdk-0' } as SessionEventPayload<'assistant.turn_start'>['data']); +- mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); +- assert.strictEqual(getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete).length, 0); - -- assert.strictEqual(logService.errors.length, 1); -- const [entry] = logService.errors; -- assert.ok(entry.first instanceof TypeError); -- assert.strictEqual(entry.args[0], '[Copilot:test-session-1] Failed to handle permission request: kind=read, toolCallId=tc-read-plan-missing-env'); -- } finally { -- if (previousXdgStateHome === undefined) { -- delete process.env['XDG_STATE_HOME']; -- } else { -- process.env['XDG_STATE_HOME'] = previousXdgStateHome; -- } -- } +- session.resetTurnState('turn-next'); +- mockSession.fire('assistant.turn_start', { turnId: 'sdk-1' } as SessionEventPayload<'assistant.turn_start'>['data']); +- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); +- +- const completions = getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete); +- assert.strictEqual(completions.length, 1); +- assert.strictEqual((completions[0] as ChatTurnCompleteAction).turnId, 'turn-next'); - }); - -- test('write permission fires tool_ready (deferred to side effects)', async () => { -- const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables); -- const resultPromise = runtime.handlePermissionRequest({ -- kind: 'write', -- fileName: '/workspace/src/file.ts', -- toolCallId: 'tc-1', -- }); +- test('error event is forwarded', async () => { +- const { mockSession, signals } = await createAgentSession(disposables); +- mockSession.fire('session.error', { +- errorType: 'TestError', +- message: 'something went wrong', +- stack: 'Error: something went wrong', +- } as SessionEventPayload<'session.error'>['data']); - -- await waitForSignal(s => s.kind === 'pending_confirmation'); - assert.strictEqual(signals.length, 1); -- -- assert.ok(session.respondToPermissionRequest('tc-1', true)); -- const result = await resultPromise; -- assert.strictEqual(result.kind, 'approve-once'); +- assert.ok(isAction(signals[0], ActionType.ChatError)); +- if (isAction(signals[0], ActionType.ChatError)) { +- const action = signals[0].action as ChatErrorAction; +- assert.strictEqual(action.error.errorType, 'TestError'); +- assert.strictEqual(action.error.message, 'something went wrong'); +- } - }); - -- test('auto-approves write permission for session-state plan files', async () => { -- const previousXdgStateHome = process.env['XDG_STATE_HOME']; -- process.env['XDG_STATE_HOME'] = '/mock-state-home'; -- try { -- const { runtime, signals } = await createAgentSession(disposables); -- const result = await runtime.handlePermissionRequest({ -- kind: 'write', -- fileName: join('/mock-state-home', '.copilot', 'session-state', 'test-session-1', 'plan.md'), -- toolCallId: 'tc-write-plan', -- }); +- test('message delta is forwarded', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-1'); +- mockSession.fire('assistant.message_delta', { +- messageId: 'msg-1', +- deltaContent: 'Hello ', +- } as SessionEventPayload<'assistant.message_delta'>['data']); - -- assert.strictEqual(result.kind, 'approve-once'); -- assert.strictEqual(signals.length, 0); -- } finally { -- if (previousXdgStateHome === undefined) { -- delete process.env['XDG_STATE_HOME']; -- } else { -- process.env['XDG_STATE_HOME'] = previousXdgStateHome; +- assert.ok(signals.length >= 1); +- const hasDelta = signals.some(s => { +- if (s.kind !== 'action') { return false; } +- if (s.action.type === ActionType.ChatResponsePart) { +- const part = (s.action as ChatResponsePartAction).part; +- return part.kind === ResponsePartKind.Markdown && part.content === 'Hello '; - } -- } +- if (s.action.type === ActionType.ChatDelta) { +- return (s.action as ChatDeltaAction).content === 'Hello '; +- } +- return false; +- }); +- assert.ok(hasDelta, 'should have forwarded the delta content'); - }); - -- test('does not auto-approve session-state files from another session', async () => { -- const previousXdgStateHome = process.env['XDG_STATE_HOME']; -- process.env['XDG_STATE_HOME'] = '/mock-state-home'; -- try { -- const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables); -- const resultPromise = runtime.handlePermissionRequest({ -- kind: 'write', -- fileName: join('/mock-state-home', '.copilot', 'session-state', 'different-session', 'plan.md'), -- toolCallId: 'tc-write-other-plan', -- }); -- -- await waitForSignal(s => s.kind === 'pending_confirmation'); -- assert.strictEqual(signals.length, 1); +- test('complete assistant message without preceding deltas surfaces a markdown response part', async () => { +- const { mockSession, signals } = await createAgentSession(disposables); +- mockSession.fire('assistant.message', { +- messageId: 'msg-2', +- content: 'Let me help you.', +- toolRequests: [{ +- toolCallId: 'tc-20', +- name: 'bash', +- arguments: { command: 'ls' }, +- type: 'function', +- }], +- } as SessionEventPayload<'assistant.message'>['data']); - -- assert.ok(session.respondToPermissionRequest('tc-write-other-plan', true)); -- const result = await resultPromise; -- assert.strictEqual(result.kind, 'approve-once'); -- } finally { -- if (previousXdgStateHome === undefined) { -- delete process.env['XDG_STATE_HOME']; -- } else { -- process.env['XDG_STATE_HOME'] = previousXdgStateHome; +- // The session emits a fresh markdown response part for the +- // content. Tool calls fire their own events, so +- // `toolRequests` on the assistant message are not forwarded +- // during live streaming. +- assert.ok(signals.length >= 1); +- const hasPart = signals.some(s => { +- if (s.kind !== 'action') { return false; } +- if (s.action.type === ActionType.ChatResponsePart) { +- const part = (s.action as ChatResponsePartAction).part; +- return part.kind === ResponsePartKind.Markdown && part.content === 'Let me help you.'; - } -- } +- if (s.action.type === ActionType.ChatDelta) { +- return (s.action as ChatDeltaAction).content === 'Let me help you.'; +- } +- return false; +- }); +- assert.ok(hasPart, 'should have surfaced the message content'); - }); - -- test('does not auto-approve traversal paths that escape the session-state directory', async () => { -- const previousXdgStateHome = process.env['XDG_STATE_HOME']; -- process.env['XDG_STATE_HOME'] = '/mock-state-home'; -- try { -- const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables); -- const sessionDir = join('/mock-state-home', '.copilot', 'session-state', 'test-session-1'); -- const resultPromise = runtime.handlePermissionRequest({ -- kind: 'write', -- fileName: `${sessionDir}${sep}..${sep}outside.md`, -- toolCallId: 'tc-write-traversal', -- }); +- test('history replay renders assistant tool requests when lifecycle events are missing', async () => { +- const { session, mockSession } = await createAgentSession(disposables); +- mockSession.getEvents = async () => [ +- { +- type: 'user.message', +- data: { messageId: 'turn-1', content: 'inspect the workspace' }, +- }, +- { +- type: 'assistant.message', +- data: { +- messageId: 'msg-1', +- content: 'I will inspect the workspace.', +- toolRequests: [ +- { +- toolCallId: 'tc-view', +- name: 'view', +- arguments: { path: '/workspace/file.ts' }, +- type: 'function', +- }, +- { +- toolCallId: 'tc-bash', +- name: 'bash', +- arguments: { command: 'npm test' }, +- type: 'function', +- }, +- { +- toolCallId: 'tc-intent', +- name: 'report_intent', +- arguments: { intent: 'Inspecting files' }, +- type: 'function', +- }, +- ], +- }, +- }, +- { +- type: 'tool.execution_complete', +- data: { +- toolCallId: 'tc-bash', +- success: false, +- error: { message: 'tests failed' }, +- }, +- }, +- { +- type: 'assistant.message', +- data: { messageId: 'msg-2', content: 'Done.' }, +- }, +- ] as SessionEvent[]; - -- await waitForSignal(s => s.kind === 'pending_confirmation'); -- assert.strictEqual(signals.length, 1); +- const turns = await session.getMessages(); - -- assert.ok(session.respondToPermissionRequest('tc-write-traversal', true)); -- const result = await resultPromise; -- assert.strictEqual(result.kind, 'approve-once'); -- } finally { -- if (previousXdgStateHome === undefined) { -- delete process.env['XDG_STATE_HOME']; -- } else { -- process.env['XDG_STATE_HOME'] = previousXdgStateHome; +- const actual = turns.map(turn => { +- const parts: Array> = []; +- for (const part of turn.responseParts) { +- switch (part.kind) { +- case ResponsePartKind.ToolCall: +- parts.push({ +- kind: part.kind, +- toolCallId: part.toolCall.toolCallId, +- toolName: part.toolCall.toolName, +- status: part.toolCall.status, +- success: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.success : undefined, +- content: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.content : undefined, +- }); +- break; +- case ResponsePartKind.Markdown: +- parts.push({ kind: part.kind, content: part.content }); +- break; +- default: +- parts.push({ kind: part.kind }); +- } - } -- } -- }); -- -- test('auto-approves read of Copilot SDK large-tool-output temp files', async () => { -- const { runtime, signals } = await createAgentSession(disposables); -- -- // Layout 1: -copilot-tool-output-.txt -- const result1 = await runtime.handlePermissionRequest({ -- kind: 'read', -- path: join('/mock-tmp', '1730000000000-copilot-tool-output-abc123.txt'), -- toolCallId: 'tc-tool-output-1', -- }); -- assert.strictEqual(result1.kind, 'approve-once'); -- -- // Layout 2: copilot-tool-output--.txt -- const result2 = await runtime.handlePermissionRequest({ -- kind: 'read', -- path: join('/mock-tmp', 'copilot-tool-output-1730000000000-abc123.txt'), -- toolCallId: 'tc-tool-output-2', +- return { message: turn.message.text, parts }; - }); -- assert.strictEqual(result2.kind, 'approve-once'); - -- assert.strictEqual(signals.length, 0); +- assert.deepStrictEqual(actual, [{ +- message: 'inspect the workspace', +- parts: [ +- { kind: ResponsePartKind.Markdown, content: 'I will inspect the workspace.' }, +- { kind: ResponsePartKind.ToolCall, toolCallId: 'tc-view', toolName: 'view', status: ToolCallStatus.Completed, success: true, content: undefined }, +- { kind: ResponsePartKind.ToolCall, toolCallId: 'tc-bash', toolName: 'bash', status: ToolCallStatus.Completed, success: false, content: [{ type: ToolResultContentType.Text, text: 'tests failed' }] }, +- { kind: ResponsePartKind.Markdown, content: 'Done.' }, +- ], +- }]); - }); - -- test('does not auto-approve tool-output-named files outside tmpdir', async () => { -- const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables); -- const resultPromise = runtime.handlePermissionRequest({ -- kind: 'read', -- path: join('/some/other/dir', 'copilot-tool-output-1730000000000-abc123.txt'), -- toolCallId: 'tc-tool-output-outside', -- }); +- test('history replay does not duplicate assistant tool requests with lifecycle events', async () => { +- const { session, mockSession } = await createAgentSession(disposables); +- mockSession.getEvents = async () => [ +- { +- type: 'user.message', +- data: { messageId: 'turn-1', content: 'run tests' }, +- }, +- { +- type: 'assistant.message', +- data: { +- messageId: 'msg-1', +- content: '', +- toolRequests: [{ +- toolCallId: 'tc-bash', +- name: 'bash', +- arguments: { command: 'npm test' }, +- type: 'function', +- }], +- }, +- }, +- { +- type: 'tool.execution_start', +- data: { +- toolCallId: 'tc-bash', +- toolName: 'bash', +- arguments: { command: 'npm test' }, +- }, +- }, +- { +- type: 'tool.execution_complete', +- data: { +- toolCallId: 'tc-bash', +- success: true, +- result: { content: 'passed' }, +- }, +- }, +- { +- type: 'assistant.message', +- data: { messageId: 'msg-2', content: 'Done.' }, +- }, +- ] as SessionEvent[]; - -- await waitForSignal(s => s.kind === 'pending_confirmation'); -- assert.strictEqual(signals.length, 1); +- const turns = await session.getMessages(); +- const toolCalls = turns[0].responseParts.flatMap(part => part.kind === ResponsePartKind.ToolCall ? [part.toolCall] : []); - -- assert.ok(session.respondToPermissionRequest('tc-tool-output-outside', true)); -- const result = await resultPromise; -- assert.strictEqual(result.kind, 'approve-once'); +- assert.deepStrictEqual(toolCalls.map(toolCall => ({ +- toolCallId: toolCall.toolCallId, +- toolName: toolCall.toolName, +- content: toolCall.status === ToolCallStatus.Completed ? toolCall.content : undefined, +- })), [{ +- toolCallId: 'tc-bash', +- toolName: 'bash', +- content: [{ type: ToolResultContentType.Text, text: 'passed' }], +- }]); - }); - -- test('does not auto-approve unrelated files inside tmpdir', async () => { -- const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables); -- const resultPromise = runtime.handlePermissionRequest({ -- kind: 'read', -- path: join('/mock-tmp', 'something-else.txt'), -- toolCallId: 'tc-tmp-other', -- }); -- -- await waitForSignal(s => s.kind === 'pending_confirmation'); -- assert.strictEqual(signals.length, 1); +- test('subagent message delta does not suppress final parent assistant message', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-1'); - -- assert.ok(session.respondToPermissionRequest('tc-tmp-other', true)); -- const result = await resultPromise; -- assert.strictEqual(result.kind, 'approve-once'); -- }); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-subagent', +- toolName: 'task', +- arguments: { description: 'Explore tests', agent_type: 'explore' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); - -- test('does not auto-approve write to a tool-output temp path', async () => { -- const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables); -- const resultPromise = runtime.handlePermissionRequest({ -- kind: 'write', -- fileName: join('/mock-tmp', 'copilot-tool-output-1730000000000-abc123.txt'), -- toolCallId: 'tc-tool-output-write', -- }); +- mockSession.fire('subagent.started', { +- toolCallId: 'tc-subagent', +- agentName: 'explore', +- agentDisplayName: 'Explore', +- agentDescription: 'Explore tests', +- } as SessionEventPayload<'subagent.started'>['data'], { agentId: 'agent-1' }); - -- await waitForSignal(s => s.kind === 'pending_confirmation'); -- assert.strictEqual(signals.length, 1); +- mockSession.fire('assistant.message_delta', { +- messageId: 'msg-child', +- deltaContent: 'Subagent found the answer.', +- } as SessionEventPayload<'assistant.message_delta'>['data'], { agentId: 'agent-1' }); - -- assert.ok(session.respondToPermissionRequest('tc-tool-output-write', true)); -- const result = await resultPromise; -- assert.strictEqual(result.kind, 'approve-once'); -- }); +- mockSession.fire('tool.execution_complete', { +- toolCallId: 'tc-subagent', +- success: true, +- result: { content: 'done' }, +- } as SessionEventPayload<'tool.execution_complete'>['data']); - -- test('write permission outside working directory fires tool_ready', async () => { -- const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables); +- mockSession.fire('assistant.message', { +- messageId: 'msg-parent-final', +- content: 'Final parent answer.', +- } as SessionEventPayload<'assistant.message'>['data']); - -- const resultPromise = runtime.handlePermissionRequest({ -- kind: 'write', -- fileName: '/other/file.ts', -- toolCallId: 'tc-write-outside', +- const markdownParts = signals.flatMap(signal => { +- if (signal.kind !== 'action' || signal.action.type !== ActionType.ChatResponsePart) { +- return []; +- } +- const part = (signal.action as ChatResponsePartAction).part; +- if (part.kind !== ResponsePartKind.Markdown) { +- return []; +- } +- return [{ parentToolCallId: signal.parentToolCallId, content: part.content }]; - }); - -- await waitForSignal(s => s.kind === 'pending_confirmation'); -- assert.strictEqual(signals.length, 1); -- -- assert.ok(session.respondToPermissionRequest('tc-write-outside', true)); -- const result = await resultPromise; -- assert.strictEqual(result.kind, 'approve-once'); +- assert.deepStrictEqual(markdownParts, [ +- { parentToolCallId: 'tc-subagent', content: 'Subagent found the answer.' }, +- { parentToolCallId: undefined, content: 'Final parent answer.' }, +- ]); - }); - -- test('read permission outside working directory fires tool_ready', async () => { -- const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables); +- test('reasoning delta after tool_start starts a new reasoning response part', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-1'); - -- // Kick off permission request but don't await — it will block -- const resultPromise = runtime.handlePermissionRequest({ -- kind: 'read', -- path: '/other/file.ts', -- toolCallId: 'tc-2', -- }); +- // First reasoning delta — allocates a fresh reasoning response part. +- mockSession.fire('assistant.reasoning_delta', { +- deltaContent: 'thinking step 1', +- } as SessionEventPayload<'assistant.reasoning_delta'>['data']); - -- // Should have fired a pending_confirmation signal -- await waitForSignal(s => s.kind === 'pending_confirmation'); -- assert.strictEqual(signals.length, 1); +- // A tool call interleaves between reasoning rounds. +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-r-1', +- toolName: 'bash', +- arguments: { command: 'echo hi' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- mockSession.fire('tool.execution_complete', { +- toolCallId: 'tc-r-1', +- success: true, +- result: { content: 'hi' }, +- } as SessionEventPayload<'tool.execution_complete'>['data']); - -- // Respond to it -- assert.ok(session.respondToPermissionRequest('tc-2', true)); -- const result = await resultPromise; -- assert.strictEqual(result.kind, 'approve-once'); -- }); +- // Second round of reasoning, after the tool call. This must +- // land in a NEW reasoning response part — otherwise the +- // 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', { +- deltaContent: 'thinking step 2', +- } as SessionEventPayload<'assistant.reasoning_delta'>['data']); - -- test('denies permission when no toolCallId', async () => { -- const { runtime } = await createAgentSession(disposables); -- const result = await runtime.handlePermissionRequest({ kind: 'write' }); -- assert.strictEqual(result.kind, 'reject'); +- // Pull the protocol-level reasoning response parts. Both +- // `ChatResponsePart{Reasoning}` (allocates a new part) and +- // `ChatReasoning` (appends to an existing part) translate to +- // the legacy `'reasoning'` view, so we have to inspect raw +- // signals to tell them apart. +- const reasoningResponseParts = signals.flatMap(s => { +- if (s.kind !== 'action' || s.action.type !== ActionType.ChatResponsePart) { +- return []; +- } +- return s.action.part.kind === ResponsePartKind.Reasoning ? [s.action.part] : []; +- }); +- assert.strictEqual(reasoningResponseParts.length, 2, +- 'reasoning after a tool call should allocate a new response part, not append to the part from before the tool call'); +- assert.notStrictEqual(reasoningResponseParts[0].id, reasoningResponseParts[1].id, +- 'second reasoning round should have a distinct part id'); +- assert.strictEqual(reasoningResponseParts[0].content, 'thinking step 1'); +- assert.strictEqual(reasoningResponseParts[1].content, 'thinking step 2'); - }); - -- test('denied-interactively when user denies', async () => { -- const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables); -- const resultPromise = runtime.handlePermissionRequest({ -- kind: 'shell', -- toolCallId: 'tc-3', -- }); +- test('subagent reasoning delta routes to the subagent session scope', async () => { +- const { session, mockSession, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-1'); - -- await waitForSignal(s => s.kind === 'pending_confirmation'); -- assert.strictEqual(signals.length, 1); -- session.respondToPermissionRequest('tc-3', false); -- const result = await resultPromise; -- assert.strictEqual(result.kind, 'reject'); -- }); +- mockSession.fire('subagent.started', { +- toolCallId: 'tc-subagent', +- agentName: 'explore', +- agentDisplayName: 'Explore', +- agentDescription: 'Explore tests', +- } as SessionEventPayload<'subagent.started'>['data'], { agentId: 'agent-1' }); - -- test('pending permissions are denied on dispose', async () => { -- const { session, runtime } = await createAgentSession(disposables); -- const resultPromise = runtime.handlePermissionRequest({ -- kind: 'write', -- toolCallId: 'tc-4', -- }); +- mockSession.fire('assistant.reasoning_delta', { +- reasoningId: 'reasoning-child', +- deltaContent: 'Subagent thinking.', +- } as SessionEventPayload<'assistant.reasoning_delta'>['data'], { agentId: 'agent-1' }); - -- session.dispose(); -- const result = await resultPromise; -- assert.strictEqual(result.kind, 'reject'); -- }); +- mockSession.fire('assistant.reasoning_delta', { +- reasoningId: 'reasoning-parent', +- deltaContent: 'Parent thinking.', +- } as SessionEventPayload<'assistant.reasoning_delta'>['data']); - -- test('pending permissions are denied on abort', async () => { -- const { session, runtime } = await createAgentSession(disposables); -- const resultPromise = runtime.handlePermissionRequest({ -- kind: 'write', -- toolCallId: 'tc-5', +- const reasoningParts = signals.flatMap(signal => { +- if (signal.kind !== 'action' || signal.action.type !== ActionType.ChatResponsePart) { +- return []; +- } +- const part = (signal.action as ChatResponsePartAction).part; +- if (part.kind !== ResponsePartKind.Reasoning) { +- return []; +- } +- return [{ parentToolCallId: signal.parentToolCallId, content: part.content }]; - }); - -- await session.abort(); -- const result = await resultPromise; -- assert.strictEqual(result.kind, 'reject'); -- }); -- -- test('respondToPermissionRequest returns false for unknown id', async () => { -- const { session } = await createAgentSession(disposables); -- assert.strictEqual(session.respondToPermissionRequest('unknown-id', true), false); +- assert.deepStrictEqual(reasoningParts, [ +- { parentToolCallId: 'tc-subagent', content: 'Subagent thinking.' }, +- { parentToolCallId: undefined, content: 'Parent thinking.' }, +- ]); - }); -- }); -- -- // ---- sendSteering ---- - -- suite('sendSteering', () => { -- -- test('promotes steering to its own turn when the SDK echoes the user message', async () => { +- test('subagent tool completion routes to the subagent session scope', async () => { - const { session, mockSession, signals } = await createAgentSession(disposables); -- session.resetTurnState('turn-original'); +- session.resetTurnState('turn-1'); - -- await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); +- mockSession.fire('subagent.started', { +- toolCallId: 'tc-subagent', +- agentName: 'explore', +- agentDisplayName: 'Explore', +- agentDescription: 'Explore tests', +- } as SessionEventPayload<'subagent.started'>['data'], { agentId: 'agent-1' }); - -- // Sending the steering must not flip turns until the SDK has -- // echoed the user message back through the event stream. -- assert.strictEqual(signals.find(s => s.kind === 'action' && (s as IAgentActionSignal).action.type === ActionType.ChatTurnStarted), undefined); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-child-tool', +- toolName: 'bash', +- arguments: { command: 'echo hi' }, +- } as SessionEventPayload<'tool.execution_start'>['data'], { agentId: 'agent-1' }); - -- mockSession.fire('user.message', { -- content: 'focus on tests', -- interactionId: 'interaction-steer', -- } as SessionEventPayload<'user.message'>['data']); +- mockSession.fire('tool.execution_complete', { +- toolCallId: 'tc-child-tool', +- success: true, +- result: { content: 'hi' }, +- } as SessionEventPayload<'tool.execution_complete'>['data'], { agentId: 'agent-1' }); - -- const actions = signals.filter(s => s.kind === 'action').map(s => (s as IAgentActionSignal).action); -- const turnComplete = actions.find(a => a.type === ActionType.ChatTurnComplete); -- const turnStarted = actions.find(a => a.type === ActionType.ChatTurnStarted); -- assert.ok(turnComplete, 'should complete the in-flight turn before promoting steering'); -- assert.strictEqual(turnComplete.turnId, 'turn-original'); -- assert.ok(turnStarted, 'should start a new turn for the steering message'); -- assert.notStrictEqual(turnStarted.turnId, 'turn-original'); -- assert.deepStrictEqual(turnStarted.message, { text: 'focus on tests', origin: { kind: MessageKind.User } }); -- assert.strictEqual(turnStarted.queuedMessageId, 'steer-1'); +- const toolCompletions = signals.flatMap(signal => { +- if (!isAction(signal, ActionType.ChatToolCallComplete)) { +- return []; +- } +- const action = signal.action as ChatToolCallCompleteAction; +- return [{ parentToolCallId: signal.parentToolCallId, toolCallId: action.toolCallId }]; +- }); +- +- assert.deepStrictEqual(toolCompletions, [ +- { parentToolCallId: 'tc-subagent', toolCallId: 'tc-child-tool' }, +- ]); - }); - -- test('routes subsequent SDK events into the steering turn', async () => { +- test('subagent skill invocation routes to the subagent session scope', async () => { - const { session, mockSession, signals } = await createAgentSession(disposables); -- session.resetTurnState('turn-original'); +- session.resetTurnState('turn-1'); - -- await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); -- mockSession.fire('user.message', { -- content: 'focus on tests', -- interactionId: 'interaction-steer', -- } as SessionEventPayload<'user.message'>['data']); +- mockSession.fire('subagent.started', { +- toolCallId: 'tc-subagent', +- agentName: 'explore', +- agentDisplayName: 'Explore', +- agentDescription: 'Explore tests', +- } as SessionEventPayload<'subagent.started'>['data'], { agentId: 'agent-1' }); - -- const turnStarted = signals -- .filter(s => s.kind === 'action') -- .map(s => (s as IAgentActionSignal).action) -- .find(a => a.type === ActionType.ChatTurnStarted)!; +- mockSession.fire('skill.invoked', { +- name: 'explore', +- path: '/skills/explore/SKILL.md', +- } as SessionEventPayload<'skill.invoked'>['data'], { id: 'skill-event', agentId: 'agent-1' }); +- +- const skillActions = signals +- .filter((signal): signal is IAgentActionSignal => signal.kind === 'action') +- .filter(signal => +- signal.action.type === ActionType.ChatToolCallStart +- || signal.action.type === ActionType.ChatToolCallReady +- || signal.action.type === ActionType.ChatToolCallComplete +- ) +- .map(signal => ({ parentToolCallId: signal.parentToolCallId, action: signal.action })); +- +- assert.deepStrictEqual(skillActions, [ +- { +- parentToolCallId: 'tc-subagent', +- action: { +- type: ActionType.ChatToolCallStart, +- turnId: 'turn-1', +- toolCallId: 'synth-skill-skill-event', +- toolName: 'skill', +- displayName: 'Read Skill', +- }, +- }, +- { +- parentToolCallId: 'tc-subagent', +- action: { +- type: ActionType.ChatToolCallReady, +- turnId: 'turn-1', +- toolCallId: 'synth-skill-skill-event', +- invocationMessage: { markdown: 'Reading skill [explore](file:///skills/explore/SKILL.md)' }, +- confirmed: ToolCallConfirmationReason.NotNeeded, +- }, +- }, +- { +- parentToolCallId: 'tc-subagent', +- action: { +- type: ActionType.ChatToolCallComplete, +- turnId: 'turn-1', +- toolCallId: 'synth-skill-skill-event', +- result: { +- success: true, +- pastTenseMessage: { markdown: 'Read skill [explore](file:///skills/explore/SKILL.md)' }, +- }, +- }, +- }, +- ]); +- }); - -- mockSession.fire('assistant.message_delta', { -- deltaContent: 'No problem', -- } as SessionEventPayload<'assistant.message_delta'>['data']); +- test('history replay seeds turn id from the SDK envelope id, matching `turns.event_id`', async () => { +- // Regression test: fork / truncate look up the SDK boundary +- // event id via `getNextTurnEventId(turnId)`, which keys on +- // either `turns.id` (live `request_xxx`) or `turns.event_id` +- // (SDK envelope id). For sessions restored from disk we want +- // the restored turn id to be the SDK envelope id so that +- // lookup succeeds without translation. +- const { session, mockSession } = await createAgentSession(disposables); +- mockSession.getEvents = async () => [ +- { +- type: 'user.message', +- id: 'sdk-evt-user-1', +- data: { interactionId: 'capi-interaction-1', content: 'first prompt' }, +- }, +- { +- type: 'assistant.message', +- id: 'sdk-evt-asst-1', +- data: { messageId: 'sdk-msg-1', content: 'first response.' }, +- }, +- { +- type: 'user.message', +- id: 'sdk-evt-user-2', +- data: { interactionId: 'capi-interaction-2', content: 'second prompt' }, +- }, +- { +- type: 'assistant.message', +- id: 'sdk-evt-asst-2', +- data: { messageId: 'sdk-msg-2', content: 'second response.' }, +- }, +- ] as SessionEvent[]; - -- const responseParts = signals -- .filter(s => s.kind === 'action') -- .map(s => (s as IAgentActionSignal).action) -- .filter(a => a.type === ActionType.ChatResponsePart); -- assert.ok(responseParts.length > 0, 'expected delta to allocate a response part'); -- assert.strictEqual(responseParts[0].turnId, turnStarted.turnId, 'response part should land in the steering turn, not the original'); +- const turns = await session.getMessages(); +- assert.deepStrictEqual( +- turns.map(t => ({ id: t.id, text: t.message.text })), +- [ +- { id: 'sdk-evt-user-1', text: 'first prompt' }, +- { id: 'sdk-evt-user-2', text: 'second prompt' }, +- ], +- ); - }); +- }); - -- test('does not flip turns for SDK-injected user messages (non-user source)', async () => { -- const { session, mockSession, signals } = await createAgentSession(disposables); -- session.resetTurnState('turn-original'); +- // ---- user input handling ---- - -- await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); +- suite('user input handling', () => { - -- // SDK injects an unrelated user.message (e.g. skill content) -- // with the steering's exact text but a non-'user' source. -- // Even if the text happened to match, the synthetic-source -- // guard MUST skip the flip. -- mockSession.fire('user.message', { -- content: 'focus on tests', -- source: 'skill-pdf', -- } as SessionEventPayload<'user.message'>['data']); +- test('handleUserInputRequest fires user_input_request progress event', async () => { +- const { session, runtime, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-input'); +- +- // Start the request (don't await — it blocks waiting for response) +- const resultPromise = runtime.handleUserInputRequest( +- { question: 'What is your name?' }, +- { sessionId: 'test-session-1' } +- ); +- +- // Verify signal was fired +- assert.strictEqual(signals.length, 1); +- const request = getInputRequest(signals[0]); +- const requestId = request.id; +- assert.ok(request.questions); +- assert.strictEqual(request.questions[0].message, 'What is your name?'); +- const questionId = request.questions[0].id; - -- const turnStarted = signals.find(s => s.kind === 'action' && (s as IAgentActionSignal).action.type === ActionType.ChatTurnStarted); -- assert.strictEqual(turnStarted, undefined, 'synthetic user messages should not promote steering to a turn'); -- }); +- // Respond to unblock the promise +- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { +- [questionId]: { +- state: ChatInputAnswerState.Submitted, +- value: { kind: ChatInputAnswerValueKind.Text, value: 'Alice' } +- } +- }); - -- test('does not flip turns when the user.message content does not match', async () => { -- const { session, mockSession, signals } = await createAgentSession(disposables); -- session.resetTurnState('turn-original'); +- const result = await resultPromise; +- assert.strictEqual(result.answer, 'Alice'); +- assert.strictEqual(result.wasFreeform, true); +- }); - -- await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); -- mockSession.fire('user.message', { -- content: 'something completely different', -- } as SessionEventPayload<'user.message'>['data']); +- test('handleUserInputRequest with choices generates SingleSelect question', async () => { +- const { session, runtime, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-input'); - -- const turnStarted = signals.find(s => s.kind === 'action' && (s as IAgentActionSignal).action.type === ActionType.ChatTurnStarted); -- assert.strictEqual(turnStarted, undefined, 'unrelated user messages should not consume the pending steering'); -- }); +- const resultPromise = runtime.handleUserInputRequest( +- { question: 'Pick a color', choices: ['red', 'blue', 'green'] }, +- { sessionId: 'test-session-1' } +- ); - -- test('does not send the same steering message again before it is flipped', async () => { -- const { session, mockSession } = await createAgentSession(disposables); +- assert.strictEqual(signals.length, 1); +- const request = getInputRequest(signals[0]); +- assert.ok(request.questions); +- assert.strictEqual(request.questions.length, 1); +- assert.strictEqual(request.questions[0].kind, ChatInputQuestionKind.SingleSelect); +- if (request.questions[0].kind === ChatInputQuestionKind.SingleSelect) { +- assert.strictEqual(request.questions[0].options.length, 3); +- assert.strictEqual(request.questions[0].options[0].label, 'red'); +- } - -- await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); -- await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); +- // Respond with a selected choice +- const questions = request.questions; +- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Accept, { +- [questions[0].id]: { +- state: ChatInputAnswerState.Submitted, +- value: { kind: ChatInputAnswerValueKind.Selected, value: 'blue' } +- } +- }); - -- assert.strictEqual(mockSession.sendRequests.length, 1); +- const result = await resultPromise; +- assert.strictEqual(result.answer, 'blue'); +- assert.strictEqual(result.wasFreeform, false); - }); - -- test('fires steering_consumed on abort when the steering never reached its turn', async () => { -- const { session, signals } = await createAgentSession(disposables); +- test('handleUserInputRequest returns empty answer on cancel', async () => { +- const { session, runtime, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-input'); - -- await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); -- await session.abort(); +- const resultPromise = runtime.handleUserInputRequest( +- { question: 'Cancel me' }, +- { sessionId: 'test-session-1' } +- ); - -- const consumed = signals.find(s => s.kind === 'steering_consumed'); -- assert.ok(consumed, 'abort should clean up pending steering UI state'); -- assert.strictEqual((consumed as { id: string }).id, 'steer-1'); +- const request = getInputRequest(signals[0]); +- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Cancel); +- +- const result = await resultPromise; +- assert.strictEqual(result.answer, ''); +- assert.strictEqual(result.wasFreeform, true); - }); - -- test('an abort during a steering turn tears it down without completing it', async () => { -- // A steering turn is promoted mid-loop while the SDK is actively -- // producing its response, so it must be `running` (not `pending`). -- // Otherwise an abort's terminal idle would treat it as a not-yet- -- // started queued turn and leave it open, and a later idle would -- // orphan-complete it. -- const { session, mockSession, signals } = await createAgentSession(disposables); -- session.resetTurnState('turn-original'); -- mockSession.fire('assistant.turn_start', { turnId: 'sdk-0' } as SessionEventPayload<'assistant.turn_start'>['data']); +- test('respondToUserInputRequest returns false for unknown id', async () => { +- const { session } = await createAgentSession(disposables); +- assert.strictEqual(session.respondToUserInputRequest('unknown-id', ChatInputResponseKind.Accept), false); +- }); - -- await session.sendSteering({ id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); -- mockSession.fire('user.message', { -- content: 'focus on tests', -- interactionId: 'interaction-steer', -- } as SessionEventPayload<'user.message'>['data']); +- test('handleUserInputRequest returns empty answer on skipped question', async () => { +- const { session, runtime, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-input'); - -- const steeringTurnId = getActions(signals).find(a => a.type === ActionType.ChatTurnStarted)?.turnId; -- assert.ok(steeringTurnId && steeringTurnId !== 'turn-original', 'steering should start its own turn'); +- const resultPromise = runtime.handleUserInputRequest( +- { question: 'Skip me' }, +- { sessionId: 'test-session-1' } +- ); - -- // Abort: the running steering turn is finalized by the client's -- // ChatTurnCancelled, so its terminal idle must tear it down rather -- // than complete it — and a subsequent stray idle must find no turn. -- mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); -- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); +- const request = getInputRequest(signals[0]); +- const questionId = request.questions![0].id; +- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Accept, { +- [questionId]: { +- state: ChatInputAnswerState.Skipped, +- } +- }); - -- const steeringCompletions = getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete && a.turnId === steeringTurnId); -- assert.strictEqual(steeringCompletions.length, 0, 'an aborted steering turn must not be completed'); +- const result = await resultPromise; +- assert.strictEqual(result.answer, ''); +- assert.strictEqual(result.wasFreeform, true); - }); - -- test('does not signal cleanup when send fails', async () => { -- const { session, mockSession, signals } = await createAgentSession(disposables); -- -- mockSession.send = async () => { throw new Error('send failed'); }; +- test('pending user inputs are cancelled on dispose', async () => { +- const { session, runtime } = await createAgentSession(disposables); +- session.resetTurnState('turn-input'); - -- await session.sendSteering({ id: 'steer-fail', message: { text: 'will fail', origin: { kind: MessageKind.User } } }); +- const resultPromise = runtime.handleUserInputRequest( +- { question: 'Will be cancelled' }, +- { sessionId: 'test-session-1' } +- ); - -- const consumed = signals.find(s => s.kind === 'steering_consumed'); -- const turnStarted = signals.find(s => s.kind === 'action' && (s as IAgentActionSignal).action.type === ActionType.ChatTurnStarted); -- assert.strictEqual(consumed, undefined, 'should not fire steering_consumed on failure'); -- assert.strictEqual(turnStarted, undefined, 'should not start a new turn on failure'); +- session.dispose(); +- const result = await resultPromise; +- assert.strictEqual(result.answer, ''); +- assert.strictEqual(result.wasFreeform, true); - }); -- }); -- -- // ---- system.notification ---- - -- suite('system.notification', () => { +- test('handleUserInputRequest rejects without an active turn', async () => { +- const { runtime, signals } = await createAgentSession(disposables); - -- test('translator handles supported kinds and ignores unsupported kinds', () => { -- const base = { -- id: 'evt-system', -- parentId: null, -- timestamp: new Date().toISOString(), -- type: 'system.notification' as const, -- }; +- const result = await runtime.handleUserInputRequest( +- { question: 'Cannot be displayed' }, +- { sessionId: 'test-session-1' }, +- ); - -- assert.deepStrictEqual(buildCopilotSystemNotification({ -- ...base, -- data: { -- content: '\nShell done\n', -- kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' }, -- }, -- }), { -- content: 'Shell done', -- messageText: '`sleep 6` completed', +- assert.deepStrictEqual({ result, signals }, { +- result: { answer: 'No active turn', wasFreeform: true }, +- signals: [], - }); +- }); - -- assert.deepStrictEqual(buildCopilotSystemNotification({ -- ...base, -- data: { -- content: 'Detached done', -- kind: { type: 'shell_detached_completed', shellId: 'detached-a' }, -- }, -- }), { -- content: 'Detached done', -- messageText: 'Shell `detached-a` completed', +- test('autopilot auto-answers a free-form question and records it in history', async () => { +- const { runtime, signals } = await createAgentSession(disposables, { +- configValues: { [SessionConfigKey.Mode]: 'autopilot' }, - }); - -- assert.deepStrictEqual(buildCopilotSystemNotification({ -- ...base, -- data: { -- content: 'Agent done', -- kind: { type: 'agent_completed', agentId: 'agent-a', agentType: 'task', status: 'completed' }, +- const result = await runtime.handleUserInputRequest( +- { question: 'Pick a color', choices: ['red', 'blue', 'green'] }, +- { sessionId: 'test-session-1' } +- ); +- +- // `wasFreeform: false` because we picked one of the SDK's +- // offered choices — the SDK uses this hint to record whether +- // the user typed something custom. +- assert.strictEqual(result.answer, 'The user is not available to answer your question. Choose a pragmatic option best aligned with the context of the request.'); +- assert.strictEqual(result.wasFreeform, true); +- assert.deepStrictEqual(getActions(signals).map(action => action.type), [ +- ActionType.ChatInputRequested, +- ActionType.ChatInputCompleted, +- ]); +- const completed = getActions(signals)[1]; +- assert.deepStrictEqual(completed.type === ActionType.ChatInputCompleted ? Object.values(completed.answers ?? {}) : [], [{ +- state: ChatInputAnswerState.Submitted, +- value: { +- kind: ChatInputAnswerValueKind.Text, +- value: result.answer, - }, -- }), { -- content: 'Agent done', -- messageText: 'Background agent completed', +- }]); +- }); +- +- test('autopilot does not auto-answer when mode is not "autopilot"', async () => { +- // Sanity check: with mode=interactive the question must +- // still be surfaced as a progress event (the existing behavior). +- const { session, runtime, signals } = await createAgentSession(disposables, { +- configValues: { [SessionConfigKey.Mode]: 'interactive' }, - }); +- session.resetTurnState('turn-input'); - -- assert.strictEqual(buildCopilotSystemNotification({ -- ...base, -- data: { -- content: 'Agent idle', -- kind: { type: 'agent_idle', agentId: 'agent-a', agentType: 'task' }, -- }, -- }), undefined); +- runtime.handleUserInputRequest( +- { question: 'Need user input' }, +- { sessionId: 'test-session-1' } +- ); +- +- // Microtask flush so the handler can run far enough to either +- // short-circuit or emit a progress event. +- await Promise.resolve(); +- assert.strictEqual(signals.length, 1); +- assert.ok(isAction(signals[0], ActionType.ChatInputRequested)); - }); - -- test('idle notification starts a system-initiated turn without sending another SDK message', async () => { -- const { mockSession, signals } = await createAgentSession(disposables); +- test('auto-reply auto-answers a question and records it in history', async () => { +- // `chat.autoReply` is forwarded as the autoReplyEnabled root config. +- // Even in interactive mode it must short-circuit like autopilot. +- const { runtime, signals } = await createAgentSession(disposables, { +- configValues: { [SessionConfigKey.Mode]: 'interactive' }, +- rootValues: { [AgentHostAutoReplyEnabledConfigKey]: true }, +- }); - -- mockSession.fire('system.notification', { -- content: '\nShell command completed\n', -- kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' }, -- } as SessionEventPayload<'system.notification'>['data']); +- const result = await runtime.handleUserInputRequest( +- { question: 'Pick a color', choices: ['red', 'blue', 'green'] }, +- { sessionId: 'test-session-1' } +- ); - -- assert.strictEqual(mockSession.sendRequests.length, 0, 'system notification should not call session.send'); -- const actions = getActions(signals); -- const turnStarted = actions.find(a => a.type === ActionType.ChatTurnStarted); -- assert.ok(turnStarted, 'should synthesize a fresh turn'); -- assert.deepStrictEqual(turnStarted.message, { text: '`sleep 6` completed', origin: { kind: MessageKind.SystemNotification } }); +- assert.strictEqual(result.answer, 'The user is not available to answer your question. Choose a pragmatic option best aligned with the context of the request.'); +- assert.strictEqual(result.wasFreeform, true); +- assert.deepStrictEqual(getActions(signals).map(action => action.type), [ +- ActionType.ChatInputRequested, +- ActionType.ChatInputCompleted, +- ]); +- const completed = getActions(signals)[1]; +- assert.deepStrictEqual(completed.type === ActionType.ChatInputCompleted ? Object.values(completed.answers ?? {}) : [], [{ +- state: ChatInputAnswerState.Submitted, +- value: { +- kind: ChatInputAnswerValueKind.Text, +- value: result.answer, +- }, +- }]); - }); +- }); - -- test('routes subsequent SDK events into the generated system turn', async () => { -- const { mockSession, signals } = await createAgentSession(disposables); +- // ---- elicitation handling ---- - -- mockSession.fire('system.notification', { -- content: 'Shell command completed', -- kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' }, -- } as SessionEventPayload<'system.notification'>['data']); -- const turnStarted = getActions(signals).find(a => a.type === ActionType.ChatTurnStarted)!; +- suite('elicitation handling', () => { - -- mockSession.fire('assistant.message_delta', { -- deltaContent: 'Reading the shell output now.', -- } as SessionEventPayload<'assistant.message_delta'>['data']); +- test('form-mode request projects schema fields to questions and accept round-trips content', async () => { +- const { session, runtime, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-elicitation'); - -- const responsePart = getActions(signals).find(a => a.type === ActionType.ChatResponsePart && a.part.kind === ResponsePartKind.Markdown); -- assert.ok(responsePart, 'expected response part for follow-up assistant delta'); -- assert.strictEqual((responsePart as ChatResponsePartAction).turnId, (turnStarted as { turnId: string }).turnId); -- }); +- const resultPromise = runtime.handleElicitationRequest({ +- sessionId: 'test-session-1', +- message: 'Configure deployment', +- mode: 'form', +- requestedSchema: { +- type: 'object', +- properties: { +- environment: { type: 'string', enum: ['dev', 'prod'], enumNames: ['Development', 'Production'] }, +- replicas: { type: 'integer', minimum: 1, maximum: 10, default: 3 }, +- confirm: { type: 'boolean', default: false }, +- region: { type: 'string', minLength: 2, default: 'us-west-2' }, +- tags: { type: 'array', items: { type: 'string', enum: ['a', 'b', 'c'] } }, +- }, +- required: ['environment', 'confirm'], +- }, +- }); - -- test('notification during an active turn appends a SystemNotification response part', async () => { -- const { session, mockSession, signals } = await createAgentSession(disposables); -- session.resetTurnState('turn-active'); +- assert.strictEqual(signals.length, 1); +- const request = getInputRequest(signals[0]); +- assert.strictEqual(request.message, 'Configure deployment'); +- assert.ok(request.questions); +- assert.deepStrictEqual(request.questions.map(q => ({ id: q.id, kind: q.kind, required: q.required })), [ +- { id: 'environment', kind: ChatInputQuestionKind.SingleSelect, required: true }, +- { id: 'replicas', kind: ChatInputQuestionKind.Integer, required: false }, +- { id: 'confirm', kind: ChatInputQuestionKind.Boolean, required: true }, +- { id: 'region', kind: ChatInputQuestionKind.Text, required: false }, +- { id: 'tags', kind: ChatInputQuestionKind.MultiSelect, required: false }, +- ]); +- const envQuestion = request.questions[0]; +- assert.strictEqual(envQuestion.kind, ChatInputQuestionKind.SingleSelect); +- if (envQuestion.kind === ChatInputQuestionKind.SingleSelect) { +- assert.deepStrictEqual(envQuestion.options, [ +- { id: 'dev', label: 'Development' }, +- { id: 'prod', label: 'Production' }, +- ]); +- } - -- mockSession.fire('system.notification', { -- content: 'Shell command completed', -- kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' }, -- } as SessionEventPayload<'system.notification'>['data']); +- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Accept, { +- environment: { state: ChatInputAnswerState.Submitted, value: { kind: ChatInputAnswerValueKind.Selected, value: 'prod' } }, +- replicas: { state: ChatInputAnswerState.Submitted, value: { kind: ChatInputAnswerValueKind.Number, value: 5 } }, +- confirm: { state: ChatInputAnswerState.Submitted, value: { kind: ChatInputAnswerValueKind.Boolean, value: true } }, +- region: { state: ChatInputAnswerState.Submitted, value: { kind: ChatInputAnswerValueKind.Text, value: 'eu-west-1' } }, +- tags: { state: ChatInputAnswerState.Submitted, value: { kind: ChatInputAnswerValueKind.SelectedMany, value: ['a', 'c'] } }, +- }); - -- const actions = getActions(signals); -- assert.strictEqual(actions.find(a => a.type === ActionType.ChatTurnStarted), undefined, 'should not create a duplicate turn'); -- const systemPart = actions.find(a => a.type === ActionType.ChatResponsePart && a.part.kind === ResponsePartKind.SystemNotification) as ChatResponsePartAction | undefined; -- assert.ok(systemPart, 'expected system notification response part'); -- assert.strictEqual(systemPart.turnId, 'turn-active'); -- assert.strictEqual(systemPart.part.kind, ResponsePartKind.SystemNotification); -- assert.strictEqual(systemPart.part.content, 'Shell command completed'); +- assert.deepStrictEqual(await resultPromise, { +- action: 'accept', +- content: { +- environment: 'prod', +- replicas: 5, +- confirm: true, +- region: 'eu-west-1', +- tags: ['a', 'c'], +- }, +- }); - }); - -- test('generated system turn completes on session.idle', async () => { -- const { mockSession, signals } = await createAgentSession(disposables); +- test('skipped and missing answers are omitted from accept content', async () => { +- const { session, runtime, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-elicitation'); - -- mockSession.fire('system.notification', { -- content: 'Shell command completed', -- kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' }, -- } as SessionEventPayload<'system.notification'>['data']); -- const turnStarted = getActions(signals).find(a => a.type === ActionType.ChatTurnStarted)!; +- const resultPromise = runtime.handleElicitationRequest({ +- sessionId: 'test-session-1', +- message: 'Partial form', +- mode: 'form', +- requestedSchema: { +- type: 'object', +- properties: { +- name: { type: 'string' }, +- count: { type: 'integer' }, +- }, +- }, +- }); - -- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); +- const request = getInputRequest(signals[0]); +- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Accept, { +- name: { state: ChatInputAnswerState.Skipped }, +- // `count` is missing entirely +- }); - -- const turnComplete = getActions(signals).find(a => a.type === ActionType.ChatTurnComplete); -- assert.ok(turnComplete, 'expected idle to complete the generated turn'); -- assert.strictEqual((turnComplete as { turnId: string }).turnId, turnStarted.turnId); +- assert.deepStrictEqual(await resultPromise, { action: 'accept', content: {} }); - }); - -- test('a late event after a completed turn is dropped and logged (never targets the stale turn id)', async () => { -- const logService = new CapturingLogService(); -- const { session, mockSession, signals } = await createAgentSession(disposables, { logService }); -- session.resetTurnState('turn-old'); +- test('url-mode request surfaces url and accept returns no content', async () => { +- const { session, runtime, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-elicitation'); - -- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); -- mockSession.fire('assistant.message_delta', { -- deltaContent: 'late text', -- } as SessionEventPayload<'assistant.message_delta'>['data']); +- const resultPromise = runtime.handleElicitationRequest({ +- sessionId: 'test-session-1', +- message: 'Open this link', +- mode: 'url', +- url: 'https://example.com/auth', +- }); - -- // With no active turn, the late delta is dropped (not emitted even as a -- // no-op), so it can never be attributed to the stale 'turn-old', and the -- // unexpected state is surfaced via an error log. -- const markdownActions = getActions(signals) -- .filter(a => a.type === ActionType.ChatResponsePart && a.part.kind === ResponsePartKind.Markdown); -- assert.strictEqual(markdownActions.length, 0, 'the late delta must be dropped, not emitted'); -- assert.ok(logService.errors.some(e => /no active turn/i.test(String(e.first))), 'the dropped delta should be logged'); -- }); -- }); +- const request = getInputRequest(signals[0]); +- assert.strictEqual(request.url, 'https://example.com/auth'); +- assert.strictEqual(request.questions, undefined); - -- // ---- event mapping ---- +- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Accept); +- assert.deepStrictEqual(await resultPromise, { action: 'accept' }); +- }); - -- suite('event mapping', () => { +- test('free-form request (no schema) returns submitted text as content.answer', async () => { +- const { session, runtime, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-elicitation'); - -- test('tool_start event is mapped for non-hidden tools', async () => { -- const { mockSession, signals } = await createAgentSession(disposables); -- mockSession.fire('tool.execution_start', { -- toolCallId: 'tc-10', -- toolName: 'bash', -- arguments: { command: 'echo hello' }, -- } as SessionEventPayload<'tool.execution_start'>['data']); +- const resultPromise = runtime.handleElicitationRequest({ +- sessionId: 'test-session-1', +- message: 'What is your favorite color?', +- mode: 'form', +- // No requestedSchema — the workbench fallback renders a single text question. +- }); - -- assert.strictEqual(signals.length, 2); -- const toolStart = signals[0]; -- assert.ok(isAction(toolStart, ActionType.ChatToolCallStart)); -- if (isAction(toolStart, ActionType.ChatToolCallStart)) { -- const action = toolStart.action as ChatToolCallStartAction; -- assert.strictEqual(action.toolCallId, 'tc-10'); -- assert.strictEqual(action.toolName, 'bash'); -- } -- }); +- const request = getInputRequest(signals[0]); +- assert.strictEqual(request.questions, undefined); - -- test('live tool_start strips redundant cd prefix matching workingDirectory', async () => { -- const wd = URI.file('/repo/project'); -- const { mockSession, signals } = await createAgentSession(disposables, { workingDirectory: wd }); -- mockSession.fire('tool.execution_start', { -- toolCallId: 'tc-cd', -- toolName: 'bash', -- arguments: { command: 'cd /repo/project && npm test' }, -- } as SessionEventPayload<'tool.execution_start'>['data']); +- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Accept, { +- answer: { state: ChatInputAnswerState.Submitted, value: { kind: ChatInputAnswerValueKind.Text, value: 'teal' } }, +- }); - -- assert.strictEqual(signals.length, 2); -- // toolInput on the auto-ready signal (signals[1]) -- const readySignal = signals[1]; -- assert.ok(isAction(readySignal, ActionType.ChatToolCallReady)); -- if (isAction(readySignal, ActionType.ChatToolCallReady)) { -- const action = readySignal.action as ChatToolCallReadyAction; -- assert.strictEqual(action.toolInput, 'npm test'); -- } -- // toolArguments in _meta on the tool_start signal (signals[0]) -- const startSignal = signals[0]; -- assert.ok(isAction(startSignal, ActionType.ChatToolCallStart)); -- if (isAction(startSignal, ActionType.ChatToolCallStart)) { -- const meta = (startSignal.action as ChatToolCallStartAction)._meta; -- const toolArgs = meta?.['toolArguments'] as string | undefined; -- assert.ok(toolArgs && toolArgs.includes('"npm test"'), `toolArguments should contain rewritten command, was: ${toolArgs}`); -- assert.ok(!toolArgs?.includes('cd /repo/project'), 'toolArguments should not contain stripped prefix'); -- } +- assert.deepStrictEqual(await resultPromise, { action: 'accept', content: { answer: 'teal' } }); - }); - -- test('live tool_complete past-tense message reflects the rewritten command', async () => { -- const wd = URI.file('/repo/project'); -- const { mockSession, signals } = await createAgentSession(disposables, { workingDirectory: wd }); -- -- mockSession.fire('tool.execution_start', { -- toolCallId: 'tc-cd-complete', -- toolName: 'bash', -- arguments: { command: 'cd /repo/project && npm test' }, -- } as SessionEventPayload<'tool.execution_start'>['data']); +- test('decline response maps to action=decline', async () => { +- const { session, runtime, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-elicitation'); - -- mockSession.fire('tool.execution_complete', { -- toolCallId: 'tc-cd-complete', -- success: true, -- result: { content: 'all tests passed' }, -- } as SessionEventPayload<'tool.execution_complete'>['data']); +- const resultPromise = runtime.handleElicitationRequest({ +- sessionId: 'test-session-1', +- message: 'Please confirm', +- mode: 'form', +- requestedSchema: { type: 'object', properties: { ok: { type: 'boolean' } } }, +- }); - -- assert.strictEqual(signals.length, 3); -- const completeSignal = signals[2]; -- assert.ok(isAction(completeSignal, ActionType.ChatToolCallComplete)); -- if (isAction(completeSignal, ActionType.ChatToolCallComplete)) { -- const action = completeSignal.action as ChatToolCallCompleteAction; -- const past = action.result.pastTenseMessage; -- const pastStr = typeof past === 'string' ? past : (past?.markdown ?? ''); -- assert.ok(!pastStr.includes('cd /repo/project'), `past-tense message should not contain stripped prefix, got: ${pastStr}`); -- assert.ok(pastStr.includes('npm test'), `past-tense message should contain the rewritten command, got: ${pastStr}`); -- } +- const request = getInputRequest(signals[0]); +- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Decline); +- assert.deepStrictEqual(await resultPromise, { action: 'decline' }); - }); - -- test('live tool_complete emits languageModelToolInvoked telemetry', async () => { -- const telemetryService = new RecordingTelemetryService(); -- const { mockSession } = await createAgentSession(disposables, { telemetryService }); -- -- mockSession.fire('tool.execution_start', { -- toolCallId: 'tc-bash-telemetry', -- toolName: 'bash', -- arguments: { command: 'npm test' }, -- } as SessionEventPayload<'tool.execution_start'>['data']); -- mockSession.fire('tool.execution_complete', { -- toolCallId: 'tc-bash-telemetry', -- success: true, -- result: { content: 'passed' }, -- } as SessionEventPayload<'tool.execution_complete'>['data']); -- -- mockSession.fire('tool.execution_start', { -- toolCallId: 'tc-mcp-telemetry', -- toolName: 'mcp_tool', -- arguments: {}, -- mcpServerName: 'test-server', -- mcpToolName: 'lookup', -- } as SessionEventPayload<'tool.execution_start'>['data']); -- mockSession.fire('tool.execution_complete', { -- toolCallId: 'tc-mcp-telemetry', -- success: false, -- error: { code: 'denied', message: 'denied' }, -- } as SessionEventPayload<'tool.execution_complete'>['data']); +- test('cancel response maps to action=cancel', async () => { +- const { session, runtime, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-elicitation'); - -- const normalizedEvents = telemetryService.events.map(event => { -- const data = event.data as { -- result: string; -- chatSessionId: string | undefined; -- toolId: string; -- toolExtensionId: string | undefined; -- toolSourceKind: string; -- invocationTimeMs?: number; -- }; -- return { -- eventName: event.eventName, -- data: { -- ...data, -- invocationTimeMs: typeof data.invocationTimeMs === 'number' && data.invocationTimeMs >= 0, -- }, -- }; +- const resultPromise = runtime.handleElicitationRequest({ +- sessionId: 'test-session-1', +- message: 'Please confirm', +- mode: 'form', +- requestedSchema: { type: 'object', properties: { ok: { type: 'boolean' } } }, - }); - -- assert.deepStrictEqual(normalizedEvents, [ -- { -- eventName: 'languageModelToolInvoked', -- data: { -- result: 'success', -- chatSessionId: AgentSession.uri('copilot', 'test-session-1').toString(), -- toolId: 'bash', -- toolExtensionId: undefined, -- toolSourceKind: 'agentHost', -- invocationTimeMs: true, -- }, -- }, -- { -- eventName: 'languageModelToolInvoked', -- data: { -- result: 'userCancelled', -- chatSessionId: AgentSession.uri('copilot', 'test-session-1').toString(), -- toolId: 'mcp_tool', -- toolExtensionId: undefined, -- toolSourceKind: 'mcp', -- invocationTimeMs: true, -- }, -- }, -- ]); +- const request = getInputRequest(signals[0]); +- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Cancel); +- assert.deepStrictEqual(await resultPromise, { action: 'cancel' }); - }); - -- test('client tool telemetry does not use clientId as toolExtensionId', async () => { -- const telemetryService = new RecordingTelemetryService(); -- const tools = [{ name: 'my_tool', description: 'A test tool', inputSchema: { type: 'object', properties: {} } }] as const; -- const activeClientState = new ActiveClientState(); -- activeClientState.update('test-client', tools); -- const { mockSession } = await createAgentSession(disposables, { -- telemetryService, -- clientSnapshot: { -- tools, -- plugins: [], -- mcpServers: {}, -- }, -- activeClientState, +- test('autopilot auto-cancels without firing a progress event', async () => { +- const { runtime, signals } = await createAgentSession(disposables, { +- configValues: { [SessionConfigKey.Mode]: 'autopilot' }, - }); - -- mockSession.fire('tool.execution_start', { -- toolCallId: 'tc-client-telemetry', -- toolName: 'my_tool', -- arguments: {}, -- } as SessionEventPayload<'tool.execution_start'>['data']); -- mockSession.fire('tool.execution_complete', { -- toolCallId: 'tc-client-telemetry', -- success: true, -- result: { content: 'done' }, -- } as SessionEventPayload<'tool.execution_complete'>['data']); +- const result = await runtime.handleElicitationRequest({ +- sessionId: 'test-session-1', +- message: 'Need input', +- mode: 'form', +- requestedSchema: { type: 'object', properties: { ok: { type: 'boolean' } } }, +- }); +- +- assert.deepStrictEqual(result, { action: 'cancel' }); +- assert.strictEqual(signals.length, 0); +- }); - -- const [event] = telemetryService.events; -- assert.deepStrictEqual({ -- eventName: event.eventName, -- data: { -- ...(event.data as object), -- invocationTimeMs: typeof (event.data as { invocationTimeMs?: number }).invocationTimeMs === 'number', -- }, -- }, { -- eventName: 'languageModelToolInvoked', -- data: { -- result: 'success', -- chatSessionId: AgentSession.uri('copilot', 'test-session-1').toString(), -- toolId: 'my_tool', -- toolExtensionId: undefined, -- toolSourceKind: 'client', -- invocationTimeMs: true, -- }, +- test('pending elicitations are cancelled on dispose', async () => { +- const { session, runtime } = await createAgentSession(disposables); +- session.resetTurnState('turn-elicitation'); +- +- const resultPromise = runtime.handleElicitationRequest({ +- sessionId: 'test-session-1', +- message: 'Will be cancelled', +- mode: 'form', +- requestedSchema: { type: 'object', properties: { ok: { type: 'boolean' } } }, - }); - +- session.dispose(); +- assert.deepStrictEqual(await resultPromise, { action: 'cancel' }); - }); - -- test('live task_complete emits root markdown instead of a tool call', async () => { -- const { mockSession, signals } = await createAgentSession(disposables); +- test('elicitation rejects without an active turn', async () => { +- const { runtime, signals } = await createAgentSession(disposables); - -- mockSession.fire('tool.execution_start', { -- toolCallId: 'tc-task-complete', -- toolName: 'task_complete', -- arguments: { summary: 'Completed the requested work.' }, -- } as SessionEventPayload<'tool.execution_start'>['data']); -- mockSession.fire('tool.execution_complete', { -- toolCallId: 'tc-task-complete', -- success: true, -- result: { content: 'Completed the requested work.' }, -- } as SessionEventPayload<'tool.execution_complete'>['data']); +- const result = await runtime.handleElicitationRequest({ +- sessionId: 'test-session-1', +- message: 'Cannot be displayed', +- mode: 'form', +- requestedSchema: { type: 'object', properties: { ok: { type: 'boolean' } } }, +- }); - -- const actions = getActions(signals); -- assert.deepStrictEqual(actions.map(a => a.type), [ActionType.ChatResponsePart]); -- const responsePart = actions[0] as ChatResponsePartAction; -- assert.strictEqual(responsePart.part.kind, ResponsePartKind.Markdown); -- if (responsePart.part.kind !== ResponsePartKind.Markdown) { -- return; -- } -- assert.deepStrictEqual(responsePart.part, { -- kind: ResponsePartKind.Markdown, -- id: responsePart.part.id, -- content: '\n\n**Task completed:** Completed the requested work.', +- assert.deepStrictEqual({ result, signals }, { +- result: { action: 'decline' }, +- signals: [], - }); - }); +- }); - -- test('live tool_start does not rewrite when cd target differs from workingDirectory', async () => { -- const wd = URI.file('/repo/project'); -- const { mockSession, signals } = await createAgentSession(disposables, { workingDirectory: wd }); -- mockSession.fire('tool.execution_start', { -- toolCallId: 'tc-cd-other', -- toolName: 'bash', -- arguments: { command: 'cd /tmp && ls' }, -- } as SessionEventPayload<'tool.execution_start'>['data']); +- suite('SDK callback logging', () => { - -- assert.strictEqual(signals.length, 2); -- const readySignal = signals[1]; -- assert.ok(isAction(readySignal, ActionType.ChatToolCallReady)); -- if (isAction(readySignal, ActionType.ChatToolCallReady)) { -- assert.strictEqual((readySignal.action as ChatToolCallReadyAction).toolInput, 'cd /tmp && ls'); -- } -- }); +- test('logs and rethrows user input callback failures', async () => { +- const logService = new CapturingLogService(); +- const { session, runtime } = await createAgentSession(disposables, { logService }); +- session.resetTurnState('turn-input'); +- const sessionInternals = session as unknown as ISessionInternalsForTest; +- sessionInternals._onDidSessionProgress.fire = () => { +- throw new Error('user input boom'); +- }; - -- test('live tool_start without workingDirectory passes command through', async () => { -- const { mockSession, signals } = await createAgentSession(disposables); -- mockSession.fire('tool.execution_start', { -- toolCallId: 'tc-cd-nowd', -- toolName: 'bash', -- arguments: { command: 'cd /repo/project && npm test' }, -- } as SessionEventPayload<'tool.execution_start'>['data']); +- await assert.rejects( +- runtime.handleUserInputRequest( +- { question: 'Need input' }, +- { sessionId: 'test-session-1' }, +- ), +- /user input boom/, +- ); - -- assert.strictEqual(signals.length, 2); -- const readySignal = signals[1]; -- assert.ok(isAction(readySignal, ActionType.ChatToolCallReady)); -- if (isAction(readySignal, ActionType.ChatToolCallReady)) { -- assert.strictEqual((readySignal.action as ChatToolCallReadyAction).toolInput, 'cd /repo/project && npm test'); -- } +- assert.strictEqual(logService.errors.length, 1); +- const [entry] = logService.errors; +- assert.ok(entry.first instanceof Error); +- assert.strictEqual((entry.first as Error).message, 'user input boom'); +- assert.strictEqual(entry.args[0], '[Copilot:test-session-1] Failed to handle user input request: question="Need input"'); - }); - -- test('edit hooks resolve relative apply_patch file paths against workingDirectory', async () => { +- test('logs and rethrows onPreToolUse failures', async () => { +- const logService = new CapturingLogService(); - const capturedRuntime: { current?: ICopilotSessionRuntime } = {}; -- const workingDirectory = URI.file('/repo/project'); -- const absolutePath = URI.file('/tmp/absolute.ts').fsPath; -- const { session } = await createAgentSession(disposables, { workingDirectory, captureRuntime: capturedRuntime }); +- const { session } = await createAgentSession(disposables, { logService, captureRuntime: capturedRuntime }); - const sessionInternals = session as unknown as ISessionInternalsForTest; -- const started: string[] = []; -- const completed: string[] = []; -- sessionInternals._editTracker.trackEditStart = async path => { started.push(path); }; -- sessionInternals._editTracker.completeEdit = async path => { completed.push(path); }; -- const patch = [ -- '*** Begin Patch', -- '*** Update File: foo.ts', -- '@@', -- '+new', -- '*** Update File: src/bar.ts', -- '@@', -- '+new', -- `*** Update File: ${absolutePath}`, -- '@@', -- '+new', -- '*** End Patch', -- ].join('\n'); +- sessionInternals._editTracker.trackEditStart = async () => { +- throw new Error('pre tool boom'); +- }; - -- await capturedRuntime.current!.handlePreToolUse({ -- sessionId: 'test-session-1', -- timestamp: new Date(0), -- workingDirectory: '/repo/project', -- toolName: 'apply_patch', -- toolArgs: patch, -- }); -- await capturedRuntime.current!.handlePostToolUse({ -- sessionId: 'test-session-1', -- timestamp: new Date(0), -- workingDirectory: '/repo/project', -- toolName: 'apply_patch', -- toolArgs: patch, -- toolResult: { textResultForLlm: '', resultType: 'success' }, -- }); +- await assert.rejects( +- capturedRuntime.current!.handlePreToolUse({ +- sessionId: 'test-session-1', +- timestamp: new Date(0), +- workingDirectory: '/tmp', +- toolName: 'edit', +- toolArgs: { path: '/tmp/file.ts' }, +- }), +- /pre tool boom/, +- ); - -- assert.deepStrictEqual({ started, completed }, { -- started: [join(workingDirectory.fsPath, 'foo.ts'), join(workingDirectory.fsPath, 'src/bar.ts'), absolutePath], -- completed: [join(workingDirectory.fsPath, 'foo.ts'), join(workingDirectory.fsPath, 'src/bar.ts'), absolutePath], -- }); +- assert.strictEqual(logService.errors.length, 1); +- const [entry] = logService.errors; +- assert.ok(entry.first instanceof Error); +- assert.strictEqual((entry.first as Error).message, 'pre tool boom'); +- assert.strictEqual(entry.args[0], '[Copilot:test-session-1] Failed in onPreToolUse: tool=edit'); - }); - -- test('tool_complete resolves relative apply_patch file paths before taking completed edits', async () => { -- const workingDirectory = URI.file('/repo/project'); -- const { session, mockSession, waitForSignal } = await createAgentSession(disposables, { workingDirectory }); +- test('logs and rethrows onPostToolUse failures', async () => { +- const logService = new CapturingLogService(); +- const capturedRuntime: { current?: ICopilotSessionRuntime } = {}; +- const { session } = await createAgentSession(disposables, { logService, captureRuntime: capturedRuntime }); - const sessionInternals = session as unknown as ISessionInternalsForTest; -- const taken: string[] = []; -- sessionInternals._editTracker.takeCompletedEdit = async (_turnId, _toolCallId, path, _toolName, _toolInput, _modelId) => { -- taken.push(path); -- return undefined; +- sessionInternals._editTracker.completeEdit = async () => { +- throw new Error('post tool boom'); - }; -- session.resetTurnState('turn-apply-patch'); -- const patch = [ -- '*** Begin Patch', -- '*** Update File: foo.ts', -- '@@', -- '+new', -- '*** Update File: src/bar.ts', -- '@@', -- '+new', -- '*** End Patch', -- ].join('\n'); - -- mockSession.fire('tool.execution_start', { -- toolCallId: 'tc-apply-patch', -- toolName: 'apply_patch', -- arguments: patch, -- } as unknown as SessionEventPayload<'tool.execution_start'>['data']); +- await assert.rejects( +- capturedRuntime.current!.handlePostToolUse({ +- sessionId: 'test-session-1', +- timestamp: new Date(0), +- workingDirectory: '/tmp', +- toolName: 'edit', +- toolArgs: { path: '/tmp/file.ts' }, +- toolResult: { textResultForLlm: '', resultType: 'success' }, +- }), +- /post tool boom/, +- ); +- +- assert.strictEqual(logService.errors.length, 1); +- const [entry] = logService.errors; +- assert.ok(entry.first instanceof Error); +- assert.strictEqual((entry.first as Error).message, 'post tool boom'); +- assert.strictEqual(entry.args[0], '[Copilot:test-session-1] Failed in onPostToolUse: tool=edit'); +- }); +- }); +- +- // ---- client tool calls ---- +- +- suite('client tool calls', () => { +- +- const snapshot: IActiveClientSnapshot = { +- tools: [{ +- name: 'my_tool', +- description: 'A test tool', +- inputSchema: { type: 'object', properties: {} }, +- }], +- plugins: [], +- mcpServers: {}, +- }; +- +- /** Builds a live ActiveClientToolSet seeded with the given owning clientId and the snapshot's tools. */ +- const activeClientToolSetWith = (clientId: string): ActiveClientToolSet => { +- const toolSet = new ActiveClientToolSet(); +- toolSet.set(clientId, snapshot.tools); +- return toolSet; +- }; +- +- test('client tool started with no connected client fails immediately', async () => { +- // No activeClientState is provided, so the session seeds one with +- // an undefined clientId — i.e. no client is connected to run the tool. +- const { runtime, mockSession, signals } = await createAgentSession(disposables, { clientSnapshot: snapshot }); +- +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-no-client', +- toolName: 'my_tool', +- arguments: {}, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- +- // tool_start is stamped as a client contributor with no owner... +- const startSignal = signals.find(s => isAction(s, ActionType.ChatToolCallStart)); +- assert.ok(startSignal && isAction(startSignal, ActionType.ChatToolCallStart)); +- assert.deepStrictEqual((startSignal.action as ChatToolCallStartAction).contributor, undefined); +- +- // ...and is failed immediately (ready + complete) rather than left +- // pending for the server-side disconnect timeout. +- assert.strictEqual(signals.filter(s => isAction(s, ActionType.ChatToolCallReady)).length, 1); +- const completeSignal = signals.find(s => isAction(s, ActionType.ChatToolCallComplete)); +- assert.ok(completeSignal && isAction(completeSignal, ActionType.ChatToolCallComplete)); +- assert.strictEqual((completeSignal.action as ChatToolCallCompleteAction).result.success, false); +- +- // When the SDK invokes the handler it resolves immediately with the +- // buffered failure result. +- const tools = runtime.createClientSdkTools(); +- const result = await invokeClientToolHandler(tools[0], 'tc-no-client'); +- assert.strictEqual(result.resultType, 'failure'); +- }); +- +- test('client tool handler waits for completion without emitting tool_ready', async () => { +- +- const { session, runtime, mockSession, signals } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientToolSet: activeClientToolSetWith('test-client') }); +- +- // SDK emits tool.execution_start — tool_start fires immediately +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-client-1', +- toolName: 'my_tool', +- arguments: {}, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- +- // tool_start fires immediately (client tools don't auto-ready) +- assert.strictEqual(signals.filter(s => isAction(s, ActionType.ChatToolCallStart)).length, 1); +- const startSignal = signals.find(s => isAction(s, ActionType.ChatToolCallStart)); +- assert.ok(startSignal && isAction(startSignal, ActionType.ChatToolCallStart)); +- if (isAction(startSignal!, ActionType.ChatToolCallStart)) { +- assert.deepStrictEqual((startSignal.action as ChatToolCallStartAction).contributor, { kind: ToolCallContributorKind.Client, clientId: 'test-client' }); +- } - -- mockSession.fire('tool.execution_complete', { -- toolCallId: 'tc-apply-patch', -- success: true, -- } as SessionEventPayload<'tool.execution_complete'>['data']); +- // SDK invokes the handler — it creates a deferred and waits, +- // but does NOT fire tool_ready (that comes from the permission flow). +- const tools = runtime.createClientSdkTools(); +- const handlerPromise = invokeClientToolHandler(tools[0], 'tc-client-1', { file: 'test.ts' }); - -- await waitForSignal(s => isAction(s, ActionType.ChatToolCallComplete)); +- // No pending_confirmation or tool_ready should have been emitted by the handler +- assert.strictEqual(signals.filter(s => s.kind === 'pending_confirmation' || isAction(s, ActionType.ChatToolCallReady)).length, 0); - -- assert.deepStrictEqual(taken, [join(workingDirectory.fsPath, 'foo.ts'), join(workingDirectory.fsPath, 'src/bar.ts')]); +- // Complete the tool call +- session.handleClientToolCallComplete('tc-client-1', { +- success: true, +- pastTenseMessage: 'did it', +- content: [{ type: ToolResultContentType.Text, text: 'result text' }], +- }); +- +- const result = await handlerPromise; +- assert.strictEqual(result.resultType, 'success'); +- assert.strictEqual(result.textResultForLlm, 'result text'); - }); - -- test('hidden tools are not emitted as tool_start', async () => { -- const { mockSession, signals } = await createAgentSession(disposables); +- test('client tool auto-readies when SDK allow-all mode is on', async () => { +- const { session, runtime, mockSession, signals } = await createAgentSession(disposables, { +- clientSnapshot: snapshot, +- activeClientToolSet: activeClientToolSetWith('test-client'), +- configValues: { [SessionConfigKey.AutoApprove]: 'autoApprove' }, +- }); +- await session.syncPermissionMode('turn-start'); +- - mockSession.fire('tool.execution_start', { -- toolCallId: 'tc-11', -- toolName: 'report_intent', +- toolCallId: 'tc-allow-all', +- toolName: 'my_tool', +- arguments: { file: 'test.ts' }, - } as SessionEventPayload<'tool.execution_start'>['data']); - -- assert.strictEqual(signals.length, 0); +- const readySignal = signals.find(s => isAction(s, ActionType.ChatToolCallReady)); +- assert.ok(readySignal && isAction(readySignal, ActionType.ChatToolCallReady)); +- const readyAction = readySignal.action as ChatToolCallReadyAction; +- assert.deepStrictEqual({ +- permissionModeSetCalls: mockSession.permissionModeSetCalls, +- toolCallId: readyAction.toolCallId, +- toolInput: readyAction.toolInput === undefined ? undefined : JSON.parse(readyAction.toolInput), +- confirmed: readyAction.confirmed, +- autoApproveBySetting: readToolCallMeta(readyAction).autoApproveBySetting, +- }, { +- permissionModeSetCalls: ['on'], +- toolCallId: 'tc-allow-all', +- toolInput: { file: 'test.ts' }, +- confirmed: ToolCallConfirmationReason.NotNeeded, +- autoApproveBySetting: true, +- }); +- +- const handlerPromise = invokeClientToolHandler(runtime.createClientSdkTools()[0], 'tc-allow-all', { file: 'test.ts' }); +- session.handleClientToolCallComplete('tc-allow-all', { +- success: true, +- pastTenseMessage: 'did it', +- content: [{ type: ToolResultContentType.Text, text: 'result text' }], +- }); +- assert.strictEqual((await handlerPromise).textResultForLlm, 'result text'); - }); - -- test('report_intent surfaces as session activity', async () => { -- const { mockSession, signals } = await createAgentSession(disposables); +- test('SDK-approved client tool auto-readies in assisted mode', async () => { +- const { session, runtime, mockSession, signals } = await createAgentSession(disposables, { +- clientSnapshot: snapshot, +- activeClientToolSet: activeClientToolSetWith('test-client'), +- configValues: { [SessionConfigKey.AutoApprove]: 'assisted' }, +- }); +- await session.syncPermissionMode('turn-start'); +- - mockSession.fire('tool.execution_start', { -- toolCallId: 'tc-intent-1', -- toolName: 'report_intent', -- arguments: { intent: 'Reading repo docs' }, +- toolCallId: 'tc-assisted', +- toolName: 'my_tool', +- arguments: { file: 'test.ts' }, - } as SessionEventPayload<'tool.execution_start'>['data']); - -- assert.strictEqual(signals.length, 1); -- const signal = signals[0]; -- assert.ok(isAction(signal, ActionType.SessionActivityChanged)); -- if (isAction(signal, ActionType.SessionActivityChanged)) { -- assert.strictEqual((signal.action as { activity: string | undefined }).activity, 'Reading repo docs'); -- } +- mockSession.fire('permission.requested', { +- requestId: 'permission-assisted', +- permissionRequest: { +- kind: 'custom-tool', +- toolCallId: 'tc-assisted', +- toolName: 'my_tool', +- }, +- promptRequest: { +- kind: 'custom-tool', +- toolCallId: 'tc-assisted', +- toolName: 'my_tool', +- autoApproval: { +- recommendation: 'approve', +- reason: 'The requested browser navigation is safe.', +- }, +- }, +- } as SessionEventPayload<'permission.requested'>['data']); +- const permissionResult = await runtime.handlePermissionRequest({ +- kind: 'custom-tool', +- toolCallId: 'tc-assisted', +- toolName: 'my_tool', +- }); +- const readySignal = signals.find((s): s is IAgentToolPendingConfirmationSignal => s.kind === 'pending_confirmation'); +- const readyState = readySignal?.state; - -- // Going idle clears the activity. -- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); -- const clearSignal = signals.find((s, i) => i > 0 && isAction(s, ActionType.SessionActivityChanged)); -- assert.ok(clearSignal, 'expected activity to be cleared on idle'); -- if (clearSignal && isAction(clearSignal, ActionType.SessionActivityChanged)) { -- assert.strictEqual((clearSignal.action as { activity: string | undefined }).activity, undefined); -- } +- assert.deepStrictEqual({ +- permissionModeSetCalls: mockSession.permissionModeSetCalls, +- permissionResult, +- ready: readyState ? { +- ...readyState, +- toolInput: readyState.toolInput === undefined ? undefined : JSON.parse(readyState.toolInput), +- } : undefined, +- }, { +- permissionModeSetCalls: ['auto'], +- permissionResult: { kind: 'approve-once' }, +- ready: { +- status: ToolCallStatus.PendingConfirmation, +- toolCallId: 'tc-assisted', +- toolName: 'my_tool', +- displayName: 'my_tool', +- invocationMessage: 'my_tool', +- toolInput: { file: 'test.ts' }, +- riskAssessment: { +- kind: ToolCallRiskAssessmentKind.Judge, +- status: ToolCallRiskAssessmentStatus.Complete, +- reason: 'The requested browser navigation is safe.', +- safety: 1, +- }, +- }, +- }); - }); - -- test('tool_complete event produces past-tense message', async () => { -- const { mockSession, signals } = await createAgentSession(disposables); +- test('tool-search override routes to the client and injects deferred candidates', async () => { +- const toolSearchSnapshot: IActiveClientSnapshot = { +- tools: [{ name: 'toolSearch', description: 'Search tools', inputSchema: { type: 'object', properties: { query: { type: 'string' } } } }], +- plugins: [], +- mcpServers: {}, +- }; +- const activeClientToolSet = new ActiveClientToolSet(); +- activeClientToolSet.set('tool-search-client', toolSearchSnapshot.tools); +- const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { +- clientSnapshot: toolSearchSnapshot, +- activeClientToolSet, +- modelId: 'claude-opus-4.8', +- rootValues: { [CopilotCliConfigKey.ToolSearchEnabled]: true }, +- }); +- +- const [override] = runtime.createClientSdkTools(); +- assert.strictEqual(override.name, 'tool_search_tool'); +- assert.strictEqual(override.overridesBuiltInTool, true); +- assert.strictEqual(override.defer, 'never'); +- assert.strictEqual(override.skipPermission, true); - -- // First fire tool_start so it's tracked - mockSession.fire('tool.execution_start', { -- toolCallId: 'tc-12', -- toolName: 'bash', -- arguments: { command: 'ls' }, +- toolCallId: 'tc-tool-search', +- toolName: 'tool_search_tool', +- arguments: { query: 'add numbers' }, - } as SessionEventPayload<'tool.execution_start'>['data']); - -- // Then fire complete -- mockSession.fire('tool.execution_complete', { -- toolCallId: 'tc-12', -- success: true, -- result: { content: 'file1.ts\nfile2.ts' }, -- } as SessionEventPayload<'tool.execution_complete'>['data']); +- const start = signals.find(s => isAction(s, ActionType.ChatToolCallStart)); +- assert.ok(start && isAction(start, ActionType.ChatToolCallStart)); +- assert.deepStrictEqual((start.action as ChatToolCallStartAction).contributor, { kind: ToolCallContributorKind.Client, clientId: 'tool-search-client' }); - -- assert.strictEqual(signals.length, 3); -- const completeSignal = signals[2]; -- assert.ok(isAction(completeSignal, ActionType.ChatToolCallComplete)); -- if (isAction(completeSignal, ActionType.ChatToolCallComplete)) { -- const action = completeSignal.action as ChatToolCallCompleteAction; -- assert.strictEqual(action.toolCallId, 'tc-12'); -- assert.ok(action.result.success); -- assert.ok(action.result.pastTenseMessage); -- } -- }); +- const handlerPromise = invokeClientToolHandler(override, 'tc-tool-search', { query: 'add numbers' }, [ +- { name: 'everything-get-sum', description: 'Adds numbers', deferLoading: true }, +- { name: 'read_file', description: 'Reads a file', deferLoading: false }, +- ]); - -- test('tool_complete for untracked tool is ignored', async () => { -- const { mockSession, signals } = await createAgentSession(disposables); -- mockSession.fire('tool.execution_complete', { -- toolCallId: 'tc-untracked', +- const readySignal = await waitForSignal(s => isAction(s, ActionType.ChatToolCallReady)); +- assert.ok(isAction(readySignal, ActionType.ChatToolCallReady)); +- const ready = readySignal.action as ChatToolCallReadyAction; +- assert.strictEqual(ready.confirmed, ToolCallConfirmationReason.NotNeeded); +- assert.deepStrictEqual(ready._meta?.['toolSearchCandidates'], [{ name: 'everything-get-sum', description: 'Adds numbers' }]); +- +- session.handleClientToolCallComplete('tc-tool-search', { - success: true, -- } as SessionEventPayload<'tool.execution_complete'>['data']); +- pastTenseMessage: 'Searched tools', +- content: [{ type: ToolResultContentType.Text, text: '["everything-get-sum"]' }], +- }); - -- assert.strictEqual(signals.length, 0); +- const result = await handlerPromise; +- assert.strictEqual(result.resultType, 'success'); +- assert.deepStrictEqual(result.toolReferences, ['everything-get-sum']); - }); - -- test('idle event completes the active turn', async () => { -- const { session, mockSession, signals } = await createAgentSession(disposables); -- session.resetTurnState('turn-idle'); -- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); +- test('toolSearch is omitted when the flag is off or the model is unsupported', async () => { +- const toolSearchSnapshot: IActiveClientSnapshot = { +- tools: [ +- { name: 'toolSearch', description: 'Search tools', inputSchema: { type: 'object', properties: {} } }, +- { name: 'my_tool', description: 'Regular tool', inputSchema: { type: 'object', properties: {} } }, +- ], +- plugins: [], +- mcpServers: {}, +- }; - -- assert.strictEqual(signals.length, 1); -- assert.ok(isAction(signals[0], ActionType.ChatTurnComplete)); +- const flagOff = await createAgentSession(disposables, { +- clientSnapshot: toolSearchSnapshot, +- modelId: 'claude-opus-4.8', +- rootValues: { [CopilotCliConfigKey.ToolSearchEnabled]: false }, +- }); +- assert.deepStrictEqual(flagOff.runtime.createClientSdkTools().map(tool => tool.name), ['my_tool']); +- +- const unsupported = await createAgentSession(disposables, { +- clientSnapshot: toolSearchSnapshot, +- modelId: 'claude-3-opus', +- rootValues: { [CopilotCliConfigKey.ToolSearchEnabled]: true }, +- }); +- assert.deepStrictEqual(unsupported.runtime.createClientSdkTools().map(tool => tool.name), ['my_tool']); - }); - -- test('idle event without an active turn is ignored', async () => { -- const { mockSession, signals } = await createAgentSession(disposables); -- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); +- test('toolSearch honors a model-family alias so an aliased preview model is treated as supported', async () => { +- const toolSearchSnapshot: IActiveClientSnapshot = { +- tools: [ +- { name: 'toolSearch', description: 'Search tools', inputSchema: { type: 'object', properties: {} } }, +- { name: 'my_tool', description: 'Regular tool', inputSchema: { type: 'object', properties: {} } }, +- ], +- plugins: [], +- mcpServers: {}, +- }; - -- assert.strictEqual(signals.length, 0); +- // The raw preview id is unsupported on its own, so tool search stays off. +- const withoutAlias = await createAgentSession(disposables, { +- clientSnapshot: toolSearchSnapshot, +- modelId: 'preview-model-x', +- rootValues: { [CopilotCliConfigKey.ToolSearchEnabled]: true }, +- }); +- assert.deepStrictEqual(withoutAlias.runtime.createClientSdkTools().map(tool => tool.name), ['my_tool']); +- +- // Aliasing it to a tool-search-capable family enables tool search, matching +- // the prompt/capability routing the launcher applies from the same override. +- const withAlias = await createAgentSession(disposables, { +- clientSnapshot: toolSearchSnapshot, +- modelId: 'preview-model-x', +- rootValues: { +- [CopilotCliConfigKey.ToolSearchEnabled]: true, +- [CopilotCliConfigKey.ModelCapabilityOverrides]: { 'preview-model-x': { family: 'claude-opus-4.8' } }, +- }, +- }); +- assert.deepStrictEqual(withAlias.runtime.createClientSdkTools().map(tool => tool.name), ['tool_search_tool', 'my_tool']); - }); - -- test('drops and logs a markdown delta emitted with no active turn', async () => { -- // A delta should only arrive while a turn is active. With none, we -- // can't persist the part id (so every delta would allocate a fresh -- // part) and the action would carry an empty turnId — drop it and log. -- const logService = new CapturingLogService(); -- const { mockSession, signals } = await createAgentSession(disposables, { logService }); +- test('agent-coordination client tools auto-ready with a tailored invocation message', async () => { +- const agentSnapshot: IActiveClientSnapshot = { +- tools: [{ name: 'list_agents', description: 'List agents', inputSchema: { type: 'object', properties: {} } }], +- plugins: [], +- mcpServers: {}, +- }; +- const activeClientToolSet = new ActiveClientToolSet(); +- activeClientToolSet.set('agent-client', agentSnapshot.tools); +- const { mockSession, signals } = await createAgentSession(disposables, { clientSnapshot: agentSnapshot, activeClientToolSet }); - -- // No resetTurnState → no active turn. -- mockSession.fire('assistant.message_delta', { -- deltaContent: 'orphan text', -- } as SessionEventPayload<'assistant.message_delta'>['data']); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-list-agents', +- toolName: 'list_agents', +- arguments: {}, +- } as SessionEventPayload<'tool.execution_start'>['data']); - -- const parts = getActions(signals).filter(a => a.type === ActionType.ChatResponsePart || a.type === ActionType.ChatDelta); -- assert.strictEqual(parts.length, 0, 'no response part/delta should be emitted without an active turn'); -- assert.strictEqual(logService.errors.length, 1, 'should log an error'); -- assert.match(String(logService.errors[0].first), /no active turn/i); +- // Unlike other client tools (which defer to the permission flow), +- // the auto-approved agent-coordination tools auto-ready so their +- // invocation renders our tailored message instead of the generic +- // "Running {displayName}…" fallback. +- const readySignal = signals.find(s => isAction(s, ActionType.ChatToolCallReady)); +- assert.ok(readySignal && isAction(readySignal, ActionType.ChatToolCallReady)); +- assert.strictEqual((readySignal.action as ChatToolCallReadyAction).invocationMessage, 'Listed agents'); - }); - -- test('abort-induced idle does not complete a pending queued turn', async () => { -- // Repro for the blank-response-after-abort race: a running turn is -- // aborted while a queued message exists. The queued message's -- // `send()` creates a fresh (pending) turn before the abort's -- // terminal `session.idle` is delivered. That idle must not complete -- // the queued turn — structurally, because it has not started running -- // yet, and because the idle carries `aborted: true`. -- const { session, mockSession, signals } = await createAgentSession(disposables); +- test('client tool handler does not emit tool_ready (permission flow owns it)', async () => { +- const activeClientToolSet = new ActiveClientToolSet(); +- activeClientToolSet.set('client-perm', snapshot.tools); +- const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientToolSet }); - -- // The queued message has started its (pending) turn by the time the -- // abort's terminal idle arrives — no SDK event has run it yet. -- session.resetTurnState('turn-queued'); -- mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); +- // SDK emits tool.execution_start — tool_start fires immediately +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-client-perm', +- toolName: 'my_tool', +- arguments: {}, +- } as SessionEventPayload<'tool.execution_start'>['data']); - -- assert.strictEqual( -- getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete).length, -- 0, -- 'abort-induced idle must not complete the pending queued turn', -- ); +- // tool_start fired, no pending_confirmation yet +- assert.strictEqual(signals.filter(s => isAction(s, ActionType.ChatToolCallStart)).length, 1); +- assert.strictEqual(signals.filter(s => s.kind === 'pending_confirmation').length, 0); - -- // The queued turn now actually runs (first SDK event) and then -- // completes on its own (non-abort) idle. -- mockSession.fire('assistant.turn_start', { turnId: 'sdk-0' } as SessionEventPayload<'assistant.turn_start'>['data']); -- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); +- // Permission request fires — pending_confirmation from permission flow. +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'custom-tool', +- toolCallId: 'tc-client-perm', +- toolName: 'my_tool', +- }); - -- const completions = getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete); -- assert.strictEqual(completions.length, 1, 'the queued turn should complete on its real idle'); -- assert.strictEqual((completions[0] as ChatTurnCompleteAction).turnId, 'turn-queued'); +- // pending_confirmation from permission flow should have fired (with confirmationTitle) +- await waitForSignal(s => s.kind === 'pending_confirmation'); +- const permSignals = signals.filter((s): s is IAgentToolPendingConfirmationSignal => s.kind === 'pending_confirmation'); +- assert.strictEqual(permSignals.length, 1); +- assert.strictEqual(permSignals[0].state.toolCallId, 'tc-client-perm'); +- assert.ok(permSignals[0].state.confirmationTitle); +- +- const tools = runtime.createClientSdkTools(); +- const handlerPromise = invokeClientToolHandler(tools[0], 'tc-client-perm'); +- +- // The handler should NOT emit its own pending_confirmation — only the +- // permission flow fires pending_confirmation for client tools. +- assert.strictEqual(signals.filter(s => s.kind === 'pending_confirmation').length, 1, 'handler should not emit a second pending_confirmation'); +- +- // Approve and clean up +- session.respondToPermissionRequest('tc-client-perm', true); +- const permResult = await resultPromise; +- assert.strictEqual(permResult.kind, 'approve-once'); +- session.handleClientToolCallComplete('tc-client-perm', { +- success: true, +- pastTenseMessage: 'did it', +- }); +- assert.deepStrictEqual(await handlerPromise, { +- textResultForLlm: '', +- resultType: 'success', +- binaryResultsForLlm: undefined, +- }); - }); - -- test('abort-induced idle tears down a running turn without completing it', async () => { -- // Plain abort (no queued message): the running turn is finalized by -- // the client-dispatched ChatTurnCancelled, so the abort's idle must -- // not also emit a ChatTurnComplete. The turn handle is dropped so a -- // later stray idle cannot complete it. -- const { session, mockSession, signals } = await createAgentSession(disposables); +- test('pending_confirmation routes follow-up turns after subagent completion', async () => { +- const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientToolSet: activeClientToolSetWith('test-client') }); - -- session.resetTurnState('turn-aborted'); -- mockSession.fire('assistant.turn_start', { turnId: 'sdk-0' } as SessionEventPayload<'assistant.turn_start'>['data']); -- mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); +- mockSession.fire('subagent.started', { +- toolCallId: 'tc-parent-subagent', +- agentName: 'helper', +- agentDisplayName: 'Helper', +- agentDescription: 'Helps', +- } as SessionEventPayload<'subagent.started'>['data'], { agentId: 'agent-client-tool' }); - -- assert.strictEqual( -- getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete).length, -- 0, -- 'abort-induced idle must not complete the running aborted turn', -- ); +- mockSession.fire('subagent.completed', { +- toolCallId: 'tc-parent-subagent', +- agentName: 'helper', +- agentDisplayName: 'Helper', +- durationMs: 1, +- totalTokens: 0, +- totalToolCalls: 0, +- } as SessionEventPayload<'subagent.completed'>['data'], { agentId: 'agent-client-tool' }); - -- // A subsequent stray idle has no turn to act on. -- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); -- assert.strictEqual(getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete).length, 0); -- }); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-sub-client', +- toolName: 'my_tool', +- arguments: {}, +- } as SessionEventPayload<'tool.execution_start'>['data'], { agentId: 'agent-client-tool' }); +- +- assert.deepStrictEqual(signals.filter(signal => signal.kind === 'subagent_resumed').map(signal => signal.toolCallId), ['tc-parent-subagent']); +- +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'custom-tool', +- toolCallId: 'tc-sub-client', +- toolName: 'my_tool', +- }); - -- test('a running turn after a prior abort still completes on its idle', async () => { -- // No lingering state across turns: the next turn completes normally. -- const { session, mockSession, signals } = await createAgentSession(disposables); +- await waitForSignal(s => s.kind === 'pending_confirmation'); +- const permSignals = signals.filter((s): s is IAgentToolPendingConfirmationSignal => s.kind === 'pending_confirmation'); +- assert.strictEqual(permSignals.length, 1); +- assert.strictEqual(permSignals[0].parentToolCallId, 'tc-parent-subagent'); - -- session.resetTurnState('turn-aborted'); -- mockSession.fire('assistant.turn_start', { turnId: 'sdk-0' } as SessionEventPayload<'assistant.turn_start'>['data']); -- mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); -- assert.strictEqual(getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete).length, 0); +- session.respondToPermissionRequest('tc-sub-client', false); +- await resultPromise; - -- session.resetTurnState('turn-next'); -- mockSession.fire('assistant.turn_start', { turnId: 'sdk-1' } as SessionEventPayload<'assistant.turn_start'>['data']); -- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); +- mockSession.fire('hook.end', { +- hookInvocationId: 'hook-follow-up-stop', +- hookType: 'agentStop', +- success: true, +- } as SessionEventPayload<'hook.end'>['data'], { agentId: 'agent-client-tool' }); - -- const completions = getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete); -- assert.strictEqual(completions.length, 1); -- assert.strictEqual((completions[0] as ChatTurnCompleteAction).turnId, 'turn-next'); +- assert.deepStrictEqual(signals.filter(signal => signal.kind === 'subagent_completed').map(signal => signal.toolCallId), [ +- 'tc-parent-subagent', +- 'tc-parent-subagent', +- ]); - }); - -- test('error event is forwarded', async () => { -- const { mockSession, signals } = await createAgentSession(disposables); -- mockSession.fire('session.error', { -- errorType: 'TestError', -- message: 'something went wrong', -- stack: 'Error: something went wrong', -- } as SessionEventPayload<'session.error'>['data']); +- test('handleClientToolCallComplete pre-completes when no handler is waiting yet', async () => { +- const { session, runtime } = await createAgentSession(disposables, { clientSnapshot: snapshot }); - -- assert.strictEqual(signals.length, 1); -- assert.ok(isAction(signals[0], ActionType.ChatError)); -- if (isAction(signals[0], ActionType.ChatError)) { -- const action = signals[0].action as ChatErrorAction; -- assert.strictEqual(action.error.errorType, 'TestError'); -- assert.strictEqual(action.error.message, 'something went wrong'); -- } +- // Completion arrives before handler — pre-creates deferred +- session.handleClientToolCallComplete('tc-unknown', { +- success: true, +- pastTenseMessage: 'done', +- }); +- +- // Handler picks up the pre-completed result +- const tools = runtime.createClientSdkTools(); +- const result = await invokeClientToolHandler(tools[0], 'tc-unknown'); +- assert.strictEqual(result.resultType, 'success'); - }); - -- test('message delta is forwarded', async () => { -- const { session, mockSession, signals } = await createAgentSession(disposables); -- session.resetTurnState('turn-1'); -- mockSession.fire('assistant.message_delta', { -- messageId: 'msg-1', -- deltaContent: 'Hello ', -- } as SessionEventPayload<'assistant.message_delta'>['data']); +- test('handleClientToolCallComplete with failure result', async () => { +- const { session, runtime } = await createAgentSession(disposables, { clientSnapshot: snapshot }); - -- assert.ok(signals.length >= 1); -- const hasDelta = signals.some(s => { -- if (s.kind !== 'action') { return false; } -- if (s.action.type === ActionType.ChatResponsePart) { -- const part = (s.action as ChatResponsePartAction).part; -- return part.kind === ResponsePartKind.Markdown && part.content === 'Hello '; -- } -- if (s.action.type === ActionType.ChatDelta) { -- return (s.action as ChatDeltaAction).content === 'Hello '; -- } -- return false; +- const tools = runtime.createClientSdkTools(); +- const handlerPromise = invokeClientToolHandler(tools[0], 'tc-client-3'); +- +- session.handleClientToolCallComplete('tc-client-3', { +- success: false, +- pastTenseMessage: 'failed', +- error: { message: 'something broke' }, - }); -- assert.ok(hasDelta, 'should have forwarded the delta content'); +- +- const result = await handlerPromise; +- assert.strictEqual(result.resultType, 'failure'); +- assert.strictEqual(result.error, 'something broke'); - }); - -- test('complete assistant message without preceding deltas surfaces a markdown response part', async () => { -- const { mockSession, signals } = await createAgentSession(disposables); -- mockSession.fire('assistant.message', { -- messageId: 'msg-2', -- content: 'Let me help you.', -- toolRequests: [{ -- toolCallId: 'tc-20', -- name: 'bash', -- arguments: { command: 'ls' }, -- type: 'function', -- }], -- } as SessionEventPayload<'assistant.message'>['data']); +- test('pending client tool calls are cancelled on dispose', async () => { +- const { session, runtime } = await createAgentSession(disposables, { clientSnapshot: snapshot }); - -- // The session emits a fresh markdown response part for the -- // content. Tool calls fire their own events, so -- // `toolRequests` on the assistant message are not forwarded -- // during live streaming. -- assert.ok(signals.length >= 1); -- const hasPart = signals.some(s => { -- if (s.kind !== 'action') { return false; } -- if (s.action.type === ActionType.ChatResponsePart) { -- const part = (s.action as ChatResponsePartAction).part; -- return part.kind === ResponsePartKind.Markdown && part.content === 'Let me help you.'; -- } -- if (s.action.type === ActionType.ChatDelta) { -- return (s.action as ChatDeltaAction).content === 'Let me help you.'; -- } -- return false; -- }); -- assert.ok(hasPart, 'should have surfaced the message content'); +- const tools = runtime.createClientSdkTools(); +- const handlerPromise = invokeClientToolHandler(tools[0], 'tc-client-4'); +- +- session.dispose(); +- const result = await handlerPromise; +- assert.strictEqual(result.resultType, 'failure'); +- assert.ok(result.error); - }); - -- test('history replay renders assistant tool requests when lifecycle events are missing', async () => { -- const { session, mockSession } = await createAgentSession(disposables); -- mockSession.getEvents = async () => [ -- { -- type: 'user.message', -- data: { messageId: 'turn-1', content: 'inspect the workspace' }, -- }, -- { -- type: 'assistant.message', -- data: { -- messageId: 'msg-1', -- content: 'I will inspect the workspace.', -- toolRequests: [ -- { -- toolCallId: 'tc-view', -- name: 'view', -- arguments: { path: '/workspace/file.ts' }, -- type: 'function', -- }, -- { -- toolCallId: 'tc-bash', -- name: 'bash', -- arguments: { command: 'npm test' }, -- type: 'function', -- }, -- { -- toolCallId: 'tc-intent', -- name: 'report_intent', -- arguments: { intent: 'Inspecting files' }, -- type: 'function', -- }, -- ], -- }, -- }, -- { -- type: 'tool.execution_complete', -- data: { -- toolCallId: 'tc-bash', -- success: false, -- error: { message: 'tests failed' }, -- }, -- }, -- { -- type: 'assistant.message', -- data: { messageId: 'msg-2', content: 'Done.' }, -- }, -- ] as SessionEvent[]; +- test('multiple concurrent client tool calls resolve independently', async () => { +- const { session, runtime } = await createAgentSession(disposables, { clientSnapshot: snapshot }); - -- const turns = await session.getMessages(); +- const tools = runtime.createClientSdkTools(); +- const promise1 = invokeClientToolHandler(tools[0], 'tc-multi-1'); +- const promise2 = invokeClientToolHandler(tools[0], 'tc-multi-2'); - -- const actual = turns.map(turn => { -- const parts: Array> = []; -- for (const part of turn.responseParts) { -- switch (part.kind) { -- case ResponsePartKind.ToolCall: -- parts.push({ -- kind: part.kind, -- toolCallId: part.toolCall.toolCallId, -- toolName: part.toolCall.toolName, -- status: part.toolCall.status, -- success: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.success : undefined, -- content: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.content : undefined, -- }); -- break; -- case ResponsePartKind.Markdown: -- parts.push({ kind: part.kind, content: part.content }); -- break; -- default: -- parts.push({ kind: part.kind }); -- } -- } -- return { message: turn.message.text, parts }; +- // Complete in reverse order +- session.handleClientToolCallComplete('tc-multi-2', { +- success: true, +- pastTenseMessage: 'second done', +- content: [{ type: ToolResultContentType.Text, text: 'result-2' }], +- }); +- session.handleClientToolCallComplete('tc-multi-1', { +- success: true, +- pastTenseMessage: 'first done', +- content: [{ type: ToolResultContentType.Text, text: 'result-1' }], - }); - -- assert.deepStrictEqual(actual, [{ -- message: 'inspect the workspace', -- parts: [ -- { kind: ResponsePartKind.Markdown, content: 'I will inspect the workspace.' }, -- { kind: ResponsePartKind.ToolCall, toolCallId: 'tc-view', toolName: 'view', status: ToolCallStatus.Completed, success: true, content: undefined }, -- { kind: ResponsePartKind.ToolCall, toolCallId: 'tc-bash', toolName: 'bash', status: ToolCallStatus.Completed, success: false, content: [{ type: ToolResultContentType.Text, text: 'tests failed' }] }, -- { kind: ResponsePartKind.Markdown, content: 'Done.' }, -- ], -- }]); +- const [result1, result2] = await Promise.all([promise1, promise2]); +- assert.strictEqual(result1.textResultForLlm, 'result-1'); +- assert.strictEqual(result2.textResultForLlm, 'result-2'); - }); - -- test('history replay does not duplicate assistant tool requests with lifecycle events', async () => { -- const { session, mockSession } = await createAgentSession(disposables); -- mockSession.getEvents = async () => [ -- { -- type: 'user.message', -- data: { messageId: 'turn-1', content: 'run tests' }, -- }, -- { -- type: 'assistant.message', -- data: { -- messageId: 'msg-1', -- content: '', -- toolRequests: [{ -- toolCallId: 'tc-bash', -- name: 'bash', -- arguments: { command: 'npm test' }, -- type: 'function', -- }], -- }, -- }, -- { -- type: 'tool.execution_start', -- data: { -- toolCallId: 'tc-bash', -- toolName: 'bash', -- arguments: { command: 'npm test' }, -- }, -- }, -- { -- type: 'tool.execution_complete', -- data: { -- toolCallId: 'tc-bash', -- success: true, -- result: { content: 'passed' }, -- }, -- }, -- { -- type: 'assistant.message', -- data: { messageId: 'msg-2', content: 'Done.' }, -- }, -- ] as SessionEvent[]; +- test('handler cleans up deferred after consuming result', async () => { +- const { session, runtime } = await createAgentSession(disposables, { clientSnapshot: snapshot }); - -- const turns = await session.getMessages(); -- const toolCalls = turns[0].responseParts.flatMap(part => part.kind === ResponsePartKind.ToolCall ? [part.toolCall] : []); +- const tools = runtime.createClientSdkTools(); +- const handlerPromise = invokeClientToolHandler(tools[0], 'tc-cleanup'); - -- assert.deepStrictEqual(toolCalls.map(toolCall => ({ -- toolCallId: toolCall.toolCallId, -- toolName: toolCall.toolName, -- content: toolCall.status === ToolCallStatus.Completed ? toolCall.content : undefined, -- })), [{ -- toolCallId: 'tc-bash', -- toolName: 'bash', -- content: [{ type: ToolResultContentType.Text, text: 'passed' }], -- }]); +- session.handleClientToolCallComplete('tc-cleanup', { +- success: true, +- pastTenseMessage: 'done', +- content: [{ type: ToolResultContentType.Text, text: 'ok' }], +- }); +- +- await handlerPromise; +- +- // A second complete for the same toolCallId should create a new +- // deferred (not fail). This tests the cleanup path. +- session.handleClientToolCallComplete('tc-cleanup', { +- success: true, +- pastTenseMessage: 'done again', +- }); - }); - -- test('subagent message delta does not suppress final parent assistant message', async () => { -- const { session, mockSession, signals } = await createAgentSession(disposables); -- session.resetTurnState('turn-1'); +- test('client tool handler logs and rethrows failures', async () => { +- const logService = new CapturingLogService(); +- const { session, runtime } = await createAgentSession(disposables, { clientSnapshot: snapshot, logService }); +- const tools = runtime.createClientSdkTools(); +- const sessionInternals = session as unknown as ISessionInternalsForTest; +- sessionInternals._pendingClientToolCalls.register = () => { +- throw new Error('client tool boom'); +- }; +- +- await assert.rejects( +- invokeClientToolHandler(tools[0], 'tc-client-error'), +- /client tool boom/, +- ); +- +- assert.strictEqual(logService.errors.length, 1); +- const [entry] = logService.errors; +- assert.ok(entry.first instanceof Error); +- assert.strictEqual((entry.first as Error).message, 'client tool boom'); +- assert.strictEqual(entry.args[0], '[Copilot:test-session-1] Failed in client tool handler: tool=my_tool, toolCallId=tc-client-error'); +- }); +- +- test('permission request before client tool handler emits only confirmation ready', async () => { +- const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { +- clientSnapshot: snapshot, +- activeClientToolSet: activeClientToolSetWith('test-client'), +- }); - - mockSession.fire('tool.execution_start', { -- toolCallId: 'tc-subagent', -- toolName: 'task', -- arguments: { description: 'Explore tests', agent_type: 'explore' }, +- toolCallId: 'tc-ready-data', +- toolName: 'my_tool', +- arguments: { file: 'test.ts' }, - } as SessionEventPayload<'tool.execution_start'>['data']); - -- mockSession.fire('subagent.started', { -- toolCallId: 'tc-subagent', -- agentName: 'explore', -- agentDisplayName: 'Explore', -- agentDescription: 'Explore tests', -- } as SessionEventPayload<'subagent.started'>['data'], { agentId: 'agent-1' }); +- // tool_start should have fired +- assert.strictEqual(signals.filter(s => isAction(s, ActionType.ChatToolCallStart)).length, 1); - -- mockSession.fire('assistant.message_delta', { -- messageId: 'msg-child', -- deltaContent: 'Subagent found the answer.', -- } as SessionEventPayload<'assistant.message_delta'>['data'], { agentId: 'agent-1' }); +- // Permission before the handler should produce only the confirmation +- // pending_confirmation, not a synthetic auto-ready. +- const resultPromise = runtime.handlePermissionRequest({ +- kind: 'custom-tool', +- toolCallId: 'tc-ready-data', +- toolName: 'my_tool', +- }); - -- mockSession.fire('tool.execution_complete', { -- toolCallId: 'tc-subagent', -- success: true, -- result: { content: 'done' }, -- } as SessionEventPayload<'tool.execution_complete'>['data']); +- await waitForSignal(s => s.kind === 'pending_confirmation'); +- const permSignals = signals.filter((s): s is IAgentToolPendingConfirmationSignal => s.kind === 'pending_confirmation'); +- assert.strictEqual(permSignals.length, 1); +- assert.ok(permSignals[0].state.confirmationTitle); - -- mockSession.fire('assistant.message', { -- messageId: 'msg-parent-final', -- content: 'Final parent answer.', -- } as SessionEventPayload<'assistant.message'>['data']); +- session.respondToPermissionRequest('tc-ready-data', true); +- await resultPromise; +- }); - -- const markdownParts = signals.flatMap(signal => { -- if (signal.kind !== 'action' || signal.action.type !== ActionType.ChatResponsePart) { -- return []; -- } -- const part = (signal.action as ChatResponsePartAction).part; -- if (part.kind !== ResponsePartKind.Markdown) { -- return []; -- } -- return [{ parentToolCallId: signal.parentToolCallId, content: part.content }]; +- test('client tool completion does not approve a pending managed permission', async () => { +- const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { +- clientSnapshot: snapshot, +- activeClientToolSet: activeClientToolSetWith('test-client'), +- }); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-managed-client', +- toolName: 'my_tool', +- arguments: { file: 'test.ts' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- const permissionPromise = runtime.handlePermissionRequest({ +- kind: 'custom-tool', +- toolCallId: 'tc-managed-client', +- toolName: 'my_tool', +- managedApprovalRequired: true, - }); +- await waitForSignal(signal => signal.kind === 'pending_confirmation'); - -- assert.deepStrictEqual(markdownParts, [ -- { parentToolCallId: 'tc-subagent', content: 'Subagent found the answer.' }, -- { parentToolCallId: undefined, content: 'Final parent answer.' }, -- ]); +- session.handleClientToolCallComplete('tc-managed-client', { +- success: true, +- pastTenseMessage: 'did it', +- content: [{ type: ToolResultContentType.Text, text: 'result text' }], +- }); +- let permissionResult: Awaited | undefined; +- void permissionPromise.then(result => permissionResult = result); +- await timeout(0); +- +- assert.deepStrictEqual({ +- permissionResult, +- pendingConfirmations: signals.filter(signal => signal.kind === 'pending_confirmation').length, +- }, { +- permissionResult: undefined, +- pendingConfirmations: 1, +- }); +- assert.ok(session.respondToPermissionRequest('tc-managed-client', false)); +- assert.deepStrictEqual(await permissionPromise, { kind: 'denied-interactively-by-user' }); - }); - -- test('reasoning delta after tool_start starts a new reasoning response part', async () => { -- const { session, mockSession, signals } = await createAgentSession(disposables); -- session.resetTurnState('turn-1'); +- test('handleClientToolCallComplete with content containing embedded resources', async () => { +- const { session, runtime } = await createAgentSession(disposables, { clientSnapshot: snapshot }); - -- // First reasoning delta — allocates a fresh reasoning response part. -- mockSession.fire('assistant.reasoning_delta', { -- deltaContent: 'thinking step 1', -- } as SessionEventPayload<'assistant.reasoning_delta'>['data']); +- const tools = runtime.createClientSdkTools(); +- const handlerPromise = invokeClientToolHandler(tools[0], 'tc-embedded'); - -- // A tool call interleaves between reasoning rounds. -- mockSession.fire('tool.execution_start', { -- toolCallId: 'tc-r-1', -- toolName: 'bash', -- arguments: { command: 'echo hi' }, -- } as SessionEventPayload<'tool.execution_start'>['data']); -- mockSession.fire('tool.execution_complete', { -- toolCallId: 'tc-r-1', +- session.handleClientToolCallComplete('tc-embedded', { - success: true, -- result: { content: 'hi' }, -- } as SessionEventPayload<'tool.execution_complete'>['data']); +- pastTenseMessage: 'done', +- content: [ +- { type: ToolResultContentType.Text, text: 'text part' }, +- { type: ToolResultContentType.EmbeddedResource, data: 'base64data', contentType: 'image/png' }, +- ], +- }); - -- // Second round of reasoning, after the tool call. This must -- // land in a NEW reasoning response part — otherwise the -- // 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', { -- deltaContent: 'thinking step 2', -- } as SessionEventPayload<'assistant.reasoning_delta'>['data']); +- const result = await handlerPromise; +- assert.strictEqual(result.resultType, 'success'); +- // Text content should be extracted +- assert.strictEqual(result.textResultForLlm, 'text part'); +- }); - -- // Pull the protocol-level reasoning response parts. Both -- // `ChatResponsePart{Reasoning}` (allocates a new part) and -- // `ChatReasoning` (appends to an existing part) translate to -- // the legacy `'reasoning'` view, so we have to inspect raw -- // signals to tell them apart. -- const reasoningResponseParts = signals.flatMap(s => { -- if (s.kind !== 'action' || s.action.type !== ActionType.ChatResponsePart) { -- return []; -- } -- return s.action.part.kind === ResponsePartKind.Reasoning ? [s.action.part] : []; -- }); -- assert.strictEqual(reasoningResponseParts.length, 2, -- 'reasoning after a tool call should allocate a new response part, not append to the part from before the tool call'); -- assert.notStrictEqual(reasoningResponseParts[0].id, reasoningResponseParts[1].id, -- 'second reasoning round should have a distinct part id'); -- assert.strictEqual(reasoningResponseParts[0].content, 'thinking step 1'); -- assert.strictEqual(reasoningResponseParts[1].content, 'thinking step 2'); +- test('handleClientToolCallComplete describes embedded-resource-only content', async () => { +- const testCases = [ +- { +- toolCallId: 'tc-image-only', +- contentType: 'image/png', +- expectedText: 'Tool produced the attached image', +- expectedType: 'image', +- }, +- { +- toolCallId: 'tc-file-only', +- contentType: 'application/pdf', +- expectedText: 'Tool produced the attached file', +- expectedType: 'resource', +- }, +- { +- toolCallId: 'tc-image-and-file', +- contentType: 'image/png', +- additionalContentType: 'application/pdf', +- expectedText: 'Tool produced the attached image and file', +- expectedType: 'image', +- }, +- ] satisfies ReadonlyArray<{ +- readonly toolCallId: string; +- readonly contentType: string; +- readonly additionalContentType?: string; +- readonly expectedText: string; +- readonly expectedType: 'image' | 'resource'; +- }>; +- const embeddedResource = (data: string, contentType: string): ToolResultContent => ({ type: ToolResultContentType.EmbeddedResource, data, contentType }); +- +- for (const testCase of testCases) { +- const { session, runtime } = await createAgentSession(disposables, { clientSnapshot: snapshot }); +- const tools = runtime.createClientSdkTools(); +- const handlerPromise = invokeClientToolHandler(tools[0], testCase.toolCallId); +- const content: ToolResultContent[] = [ +- embeddedResource('base64data', testCase.contentType), +- ...(testCase.additionalContentType ? [embeddedResource('base64data2', testCase.additionalContentType)] : []), +- ]; +- +- session.handleClientToolCallComplete(testCase.toolCallId, { +- success: true, +- pastTenseMessage: 'done', +- content, +- }); +- +- assert.deepStrictEqual(await handlerPromise, { +- textResultForLlm: testCase.expectedText, +- resultType: 'success', +- binaryResultsForLlm: [ +- { data: 'base64data', mimeType: testCase.contentType, type: testCase.expectedType }, +- ...(testCase.additionalContentType ? [{ data: 'base64data2', mimeType: testCase.additionalContentType, type: 'resource' }] : []), +- ], +- }); +- disposables.clear(); +- } - }); - -- test('subagent reasoning delta routes to the subagent session scope', async () => { -- const { session, mockSession, signals } = await createAgentSession(disposables); -- session.resetTurnState('turn-1'); +- test('client tool start stamps the owning clientId from the shared ActiveClientToolSet', async () => { +- const activeClientToolSet = new ActiveClientToolSet(); +- activeClientToolSet.set('client-A', snapshot.tools); +- const { mockSession, signals } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientToolSet }); - -- mockSession.fire('subagent.started', { -- toolCallId: 'tc-subagent', -- agentName: 'explore', -- agentDisplayName: 'Explore', -- agentDescription: 'Explore tests', -- } as SessionEventPayload<'subagent.started'>['data'], { agentId: 'agent-1' }); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-live-1', +- toolName: 'my_tool', +- arguments: {}, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- +- // A window reload removes the old client and re-pushes the same +- // tools under a new clientId. +- activeClientToolSet.delete('client-A'); +- activeClientToolSet.set('client-B', snapshot.tools); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-live-2', +- toolName: 'my_tool', +- arguments: {}, +- } as SessionEventPayload<'tool.execution_start'>['data']); - -- mockSession.fire('assistant.reasoning_delta', { -- reasoningId: 'reasoning-child', -- deltaContent: 'Subagent thinking.', -- } as SessionEventPayload<'assistant.reasoning_delta'>['data'], { agentId: 'agent-1' }); +- const starts = signals.filter((s): s is IAgentActionSignal => isAction(s, ActionType.ChatToolCallStart)); +- assert.deepStrictEqual(starts.map(s => (s.action as ChatToolCallStartAction).contributor), [ +- { kind: ToolCallContributorKind.Client, clientId: 'client-A' }, +- { kind: ToolCallContributorKind.Client, clientId: 'client-B' }, +- ]); +- }); - -- mockSession.fire('assistant.reasoning_delta', { -- reasoningId: 'reasoning-parent', -- deltaContent: 'Parent thinking.', -- } as SessionEventPayload<'assistant.reasoning_delta'>['data']); +- test('completion arriving before the SDK handler registers still resolves', async () => { +- const { session, runtime } = await createAgentSession(disposables, { clientSnapshot: snapshot }); +- const tools = runtime.createClientSdkTools(); - -- const reasoningParts = signals.flatMap(signal => { -- if (signal.kind !== 'action' || signal.action.type !== ActionType.ChatResponsePart) { -- return []; -- } -- const part = (signal.action as ChatResponsePartAction).part; -- if (part.kind !== ResponsePartKind.Reasoning) { -- return []; -- } -- return [{ parentToolCallId: signal.parentToolCallId, content: part.content }]; +- // Completion races ahead of the handler. +- session.handleClientToolCallComplete('tc-early', { +- success: true, +- pastTenseMessage: 'done', +- content: [{ type: ToolResultContentType.Text, text: 'buffered result' }], - }); - -- assert.deepStrictEqual(reasoningParts, [ -- { parentToolCallId: 'tc-subagent', content: 'Subagent thinking.' }, -- { parentToolCallId: undefined, content: 'Parent thinking.' }, -- ]); +- const result = await invokeClientToolHandler(tools[0], 'tc-early'); +- assert.strictEqual(result.resultType, 'success'); +- assert.strictEqual(result.textResultForLlm, 'buffered result'); - }); - -- test('subagent tool completion routes to the subagent session scope', async () => { -- const { session, mockSession, signals } = await createAgentSession(disposables); -- session.resetTurnState('turn-1'); -- -- mockSession.fire('subagent.started', { -- toolCallId: 'tc-subagent', -- agentName: 'explore', -- agentDisplayName: 'Explore', -- agentDescription: 'Explore tests', -- } as SessionEventPayload<'subagent.started'>['data'], { agentId: 'agent-1' }); +- test('completion arriving before the permission request unblocks the SDK', async () => { +- const activeClientToolSet = new ActiveClientToolSet(); +- activeClientToolSet.set('client-disconnected', snapshot.tools); +- const { session, runtime, mockSession, signals } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientToolSet }); - - mockSession.fire('tool.execution_start', { -- toolCallId: 'tc-child-tool', -- toolName: 'bash', -- arguments: { command: 'echo hi' }, -- } as SessionEventPayload<'tool.execution_start'>['data'], { agentId: 'agent-1' }); +- toolCallId: 'tc-complete-before-permission', +- toolName: 'my_tool', +- arguments: {}, +- } as SessionEventPayload<'tool.execution_start'>['data']); - -- mockSession.fire('tool.execution_complete', { -- toolCallId: 'tc-child-tool', -- success: true, -- result: { content: 'hi' }, -- } as SessionEventPayload<'tool.execution_complete'>['data'], { agentId: 'agent-1' }); +- session.handleClientToolCallComplete('tc-complete-before-permission', { +- success: false, +- pastTenseMessage: 'my_tool failed', +- error: { message: 'Client disconnected' }, +- }); - -- const toolCompletions = signals.flatMap(signal => { -- if (!isAction(signal, ActionType.ChatToolCallComplete)) { -- return []; -- } -- const action = signal.action as ChatToolCallCompleteAction; -- return [{ parentToolCallId: signal.parentToolCallId, toolCallId: action.toolCallId }]; +- const permissionPromise = runtime.handlePermissionRequest({ +- kind: 'custom-tool', +- toolCallId: 'tc-complete-before-permission', +- toolName: 'my_tool', - }); +- let permissionResult: Awaited | undefined; +- void permissionPromise.then(result => permissionResult = result); +- await Promise.resolve(); +- if (!permissionResult) { +- session.respondToPermissionRequest('tc-complete-before-permission', false); +- await permissionPromise; +- } - -- assert.deepStrictEqual(toolCompletions, [ -- { parentToolCallId: 'tc-subagent', toolCallId: 'tc-child-tool' }, -- ]); +- const toolResult = await invokeClientToolHandler(runtime.createClientSdkTools()[0], 'tc-complete-before-permission'); +- assert.deepStrictEqual({ +- permissionResult, +- pendingConfirmations: signals.filter(signal => signal.kind === 'pending_confirmation').length, +- toolResult, +- }, { +- permissionResult: { kind: 'approve-once' }, +- pendingConfirmations: 0, +- toolResult: { +- textResultForLlm: 'Client disconnected', +- resultType: 'failure', +- error: 'Client disconnected', +- binaryResultsForLlm: undefined, +- }, +- }); - }); - -- test('history replay seeds turn id from the SDK envelope id, matching `turns.event_id`', async () => { -- // Regression test: fork / truncate look up the SDK boundary -- // event id via `getNextTurnEventId(turnId)`, which keys on -- // either `turns.id` (live `request_xxx`) or `turns.event_id` -- // (SDK envelope id). For sessions restored from disk we want -- // the restored turn id to be the SDK envelope id so that -- // lookup succeeds without translation. -- const { session, mockSession } = await createAgentSession(disposables); -- mockSession.getEvents = async () => [ -- { -- type: 'user.message', -- id: 'sdk-evt-user-1', -- data: { interactionId: 'capi-interaction-1', content: 'first prompt' }, -- }, -- { -- type: 'assistant.message', -- id: 'sdk-evt-asst-1', -- data: { messageId: 'sdk-msg-1', content: 'first response.' }, -- }, -- { -- type: 'user.message', -- id: 'sdk-evt-user-2', -- data: { interactionId: 'capi-interaction-2', content: 'second prompt' }, -- }, -- { -- type: 'assistant.message', -- id: 'sdk-evt-asst-2', -- data: { messageId: 'sdk-msg-2', content: 'second response.' }, -- }, -- ] as SessionEvent[]; +- test('buffered client tool completion does not approve a managed permission', async () => { +- const activeClientToolSet = new ActiveClientToolSet(); +- activeClientToolSet.set('test-client', snapshot.tools); +- const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientToolSet }); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tc-managed-buffered', +- toolName: 'my_tool', +- arguments: {}, +- } as SessionEventPayload<'tool.execution_start'>['data']); +- session.handleClientToolCallComplete('tc-managed-buffered', { +- success: true, +- pastTenseMessage: 'did it', +- content: [{ type: ToolResultContentType.Text, text: 'buffered result' }], +- }); - -- const turns = await session.getMessages(); -- assert.deepStrictEqual( -- turns.map(t => ({ id: t.id, text: t.message.text })), -- [ -- { id: 'sdk-evt-user-1', text: 'first prompt' }, -- { id: 'sdk-evt-user-2', text: 'second prompt' }, -- ], -- ); +- const permissionPromise = runtime.handlePermissionRequest({ +- kind: 'custom-tool', +- toolCallId: 'tc-managed-buffered', +- toolName: 'my_tool', +- managedApprovalRequired: true, +- }); +- await waitForSignal(signal => signal.kind === 'pending_confirmation'); +- let permissionResult: Awaited | undefined; +- void permissionPromise.then(result => permissionResult = result); +- await timeout(0); +- +- assert.deepStrictEqual({ +- permissionResult, +- pendingConfirmations: signals.filter(signal => signal.kind === 'pending_confirmation').length, +- }, { +- permissionResult: undefined, +- pendingConfirmations: 1, +- }); +- assert.ok(session.respondToPermissionRequest('tc-managed-buffered', false)); +- assert.deepStrictEqual(await permissionPromise, { kind: 'denied-interactively-by-user' }); - }); - }); - -- // ---- user input handling ---- +- // ---- Server tools ------------------------------------------------------- - -- suite('user input handling', () => { +- suite('server tools', () => { - -- test('handleUserInputRequest fires user_input_request progress event', async () => { -- const { session, runtime, signals } = await createAgentSession(disposables); +- const fakeToolDefinitions: readonly ToolDefinition[] = [ +- { name: 'serverToolA', description: 'A', inputSchema: { type: 'object', properties: {} } }, +- { name: 'serverToolB', description: 'B', inputSchema: { type: 'object', properties: {} } }, +- ]; - -- // Start the request (don't await — it blocks waiting for response) -- const resultPromise = runtime.handleUserInputRequest( -- { question: 'What is your name?' }, -- { sessionId: 'test-session-1' } -- ); +- class FakeServerToolHost implements IAgentServerToolHost { +- readonly definitions: readonly ToolDefinition[] = fakeToolDefinitions; +- readonly toolNames: readonly string[] = fakeToolDefinitions.map(def => def.name); +- readonly advertised: string[] = []; +- readonly executions: Array<{ sessionUri: string; toolName: string; rawArgs: unknown }> = []; +- result = 'ok'; +- error: Error | undefined; - -- // Verify signal was fired -- assert.strictEqual(signals.length, 1); -- const request = getInputRequest(signals[0]); -- const requestId = request.id; -- assert.ok(request.questions); -- assert.strictEqual(request.questions[0].message, 'What is your name?'); -- const questionId = request.questions[0].id; +- advertise(sessionUri: string): void { +- this.advertised.push(sessionUri); +- } - -- // Respond to unblock the promise -- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { -- [questionId]: { -- state: ChatInputAnswerState.Submitted, -- value: { kind: ChatInputAnswerValueKind.Text, value: 'Alice' } +- requiresConfirmation(_toolName: string): boolean { return false; } +- +- executeTool(sessionUri: string, toolName: string, rawArgs: unknown): string { +- this.executions.push({ sessionUri, toolName, rawArgs }); +- if (this.error) { +- throw this.error; - } -- }); +- return this.result; +- } +- } - -- const result = await resultPromise; -- assert.strictEqual(result.answer, 'Alice'); -- assert.strictEqual(result.wasFreeform, true); +- test('advertises the server tools on initialize and exposes them as server SDK tools', async () => { +- const serverToolHost = new FakeServerToolHost(); +- const { runtime } = await createAgentSession(disposables, { serverToolHost }); +- +- const sessionUri = AgentSession.uri('copilot', 'test-session-1').toString(); +- assert.deepStrictEqual(serverToolHost.advertised, [sessionUri]); +- +- const tools = runtime.createServerSdkTools(); +- assert.deepStrictEqual(tools.map(t => t.name).sort(), [...serverToolHost.toolNames].sort()); +- // Server tools are always-available internal tools; they must be +- // eager (`defer: 'never'`) so tool search never hides them behind +- // `tool_search_tool`. +- assert.deepStrictEqual(tools.map(t => t.defer), tools.map(() => 'never')); - }); - -- test('handleUserInputRequest with choices generates SingleSelect question', async () => { -- const { session, runtime, signals } = await createAgentSession(disposables); +- test('server tool handler routes to the host and returns a success result', async () => { +- const serverToolHost = new FakeServerToolHost(); +- serverToolHost.result = 'listed 2 comments'; +- const { runtime } = await createAgentSession(disposables, { serverToolHost }); - -- const resultPromise = runtime.handleUserInputRequest( -- { question: 'Pick a color', choices: ['red', 'blue', 'green'] }, -- { sessionId: 'test-session-1' } -- ); +- const tools = runtime.createServerSdkTools(); +- const result = await invokeClientToolHandler(tools[0], 'tc-server-tool', { foo: 'bar' }); - -- assert.strictEqual(signals.length, 1); -- const request = getInputRequest(signals[0]); -- assert.ok(request.questions); -- assert.strictEqual(request.questions.length, 1); -- assert.strictEqual(request.questions[0].kind, ChatInputQuestionKind.SingleSelect); -- if (request.questions[0].kind === ChatInputQuestionKind.SingleSelect) { -- assert.strictEqual(request.questions[0].options.length, 3); -- assert.strictEqual(request.questions[0].options[0].label, 'red'); +- const sessionUri = buildDefaultChatUri(AgentSession.uri('copilot', 'test-session-1')); +- assert.deepStrictEqual(serverToolHost.executions, [{ sessionUri, toolName: tools[0].name, rawArgs: { foo: 'bar' } }]); +- assert.strictEqual(result.resultType, 'success'); +- assert.strictEqual(result.textResultForLlm, 'listed 2 comments'); +- }); +- +- test('server tool handler surfaces host failures as a failure result', async () => { +- const serverToolHost = new FakeServerToolHost(); +- serverToolHost.error = new Error('boom'); +- const { runtime } = await createAgentSession(disposables, { serverToolHost }); +- +- const tools = runtime.createServerSdkTools(); +- const result = await invokeClientToolHandler(tools[0], 'tc-server-tool'); +- +- assert.strictEqual(result.resultType, 'failure'); +- assert.strictEqual(result.textResultForLlm, 'boom'); +- assert.strictEqual(result.error, 'boom'); +- }); +- +- test('exposes no server SDK tools and advertises nothing when no host is wired', async () => { +- const { runtime } = await createAgentSession(disposables); +- assert.deepStrictEqual(runtime.createServerSdkTools(), []); +- }); +- +- test('auto-approves every server tool without prompting for confirmation', async () => { +- const serverToolHost = new FakeServerToolHost(); +- const { runtime, signals } = await createAgentSession(disposables, { serverToolHost }); +- +- const results = []; +- for (const toolName of serverToolHost.toolNames) { +- results.push(await runtime.handlePermissionRequest({ kind: 'custom-tool', toolCallId: `tc-${toolName}`, toolName })); - } - -- // Respond with a selected choice -- const questions = request.questions; -- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Accept, { -- [questions[0].id]: { -- state: ChatInputAnswerState.Submitted, -- value: { kind: ChatInputAnswerValueKind.Selected, value: 'blue' } -- } +- assert.deepStrictEqual({ +- results, +- pendingConfirmations: signals.filter(s => s.kind === 'pending_confirmation').length, +- }, { +- results: serverToolHost.toolNames.map(() => ({ kind: 'approve-once' })), +- pendingConfirmations: 0, - }); -- -- const result = await resultPromise; -- assert.strictEqual(result.answer, 'blue'); -- assert.strictEqual(result.wasFreeform, false); - }); +- }); - -- test('handleUserInputRequest returns empty answer on cancel', async () => { -- const { session, runtime, signals } = await createAgentSession(disposables); +- // ---- Plan mode ---------------------------------------------------------- - -- const resultPromise = runtime.handleUserInputRequest( -- { question: 'Cancel me' }, -- { sessionId: 'test-session-1' } -- ); +- suite('plan mode', () => { - -- const request = getInputRequest(signals[0]); -- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Cancel); +- const planRequestParams = (overrides?: Partial<{ actions: string[]; recommendedAction: string; summary: string }>) => ({ +- sessionId: 'test-session-1', +- summary: overrides?.summary ?? '## Plan summary', +- planContent: '## Plan', +- actions: overrides?.actions ?? ['autopilot', 'interactive', 'exit_only'], +- recommendedAction: overrides?.recommendedAction ?? 'autopilot', +- }); - -- const result = await resultPromise; -- assert.strictEqual(result.answer, ''); -- assert.strictEqual(result.wasFreeform, true); +- test('applyMode pushes the mode to the SDK only when it changes', async () => { +- const { session, mockSession } = await createAgentSession(disposables); +- +- await session.applyMode('plan'); +- await session.applyMode('plan'); +- await session.applyMode('autopilot'); +- await session.applyMode(undefined); +- await session.applyMode('autopilot'); +- +- assert.deepStrictEqual(mockSession.modeSetCalls, [ +- { mode: 'plan' }, +- { mode: 'autopilot' }, +- ]); - }); - -- test('respondToUserInputRequest returns false for unknown id', async () => { -- const { session } = await createAgentSession(disposables); -- assert.strictEqual(session.respondToUserInputRequest('unknown-id', ChatInputResponseKind.Accept), false); +- test('send applies mode before forwarding to the SDK', async () => { +- const { session, mockSession } = await createAgentSession(disposables); +- +- await session.send('hi', undefined, 'turn-1', 'plan'); +- +- assert.deepStrictEqual(mockSession.modeSetCalls, [{ mode: 'plan' }]); +- assert.strictEqual(mockSession.sendRequests.length, 1); - }); - -- test('handleUserInputRequest returns empty answer on skipped question', async () => { -- const { session, runtime, signals } = await createAgentSession(disposables); +- test('handleExitPlanModeRequest produces a plan-review input request with fallback question', async () => { +- const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables); +- session.resetTurnState('turn-plan'); - -- const resultPromise = runtime.handleUserInputRequest( -- { question: 'Skip me' }, -- { sessionId: 'test-session-1' } -- ); +- mockSession.planReadResult = { exists: true, content: '## Plan', path: '/sessions/abc/plan.md' }; - -- const request = getInputRequest(signals[0]); -- const questionId = request.questions![0].id; -- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Accept, { -- [questionId]: { -- state: ChatInputAnswerState.Skipped, -- } +- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams(), { sessionId: 'test-session-1' }); +- +- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); +- const request = getInputRequest(signal); +- +- const planReview = (request as ChatInputRequestWithPlanReview).planReview; +- assert.deepStrictEqual(planReview, { +- title: 'Review Plan', +- content: '## Plan summary', +- canProvideFeedback: true, +- answerQuestionId: request.questions?.[0].id, +- planUri: URI.file('/sessions/abc/plan.md').toString(), +- actions: [ +- { +- id: 'autopilot', +- label: 'Implement with Autopilot', +- description: 'Continue autonomously until done, using the selected approval level.', +- default: true, +- }, +- { +- id: 'interactive', +- label: 'Implement Plan', +- description: 'Implement the plan, asking for input and approval for each action.', +- }, +- { +- id: 'exit_only', +- label: 'Approve Plan Only', +- description: 'Approve the plan without executing it. I will implement it myself.', +- }, +- ], - }); - -- const result = await resultPromise; -- assert.strictEqual(result.answer, ''); -- assert.strictEqual(result.wasFreeform, true); +- // The summary is now carried by the plan-review payload so the +- // renderer can dock the richer plan review widget without duplicating +- // the content as a separate markdown response part. +- const deltaContent = signals.flatMap(s => { +- if (s.kind !== 'action') { return []; } +- if (s.action.type === ActionType.ChatResponsePart) { +- const part = (s.action as ChatResponsePartAction).part; +- return part.kind === ResponsePartKind.Markdown ? [part.content] : []; +- } +- if (s.action.type === ActionType.ChatDelta) { +- return [(s.action as ChatDeltaAction).content]; +- } +- return []; +- }).join(''); +- assert.strictEqual(deltaContent, ''); +- +- const question = request.questions?.[0]; +- assert.strictEqual(question?.kind, ChatInputQuestionKind.SingleSelect); +- if (question?.kind === ChatInputQuestionKind.SingleSelect) { +- assert.deepStrictEqual(question.options.map(o => o.id), ['autopilot', 'interactive', 'exit_only']); +- const recommended = question.options.find(o => o.recommended); +- assert.strictEqual(recommended?.id, 'autopilot'); +- assert.strictEqual(question.allowFreeformInput, true); +- } +- +- // Resolve the request so the deferred completes and the test can clean up. +- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Decline); +- await responsePromise; - }); - -- test('pending user inputs are cancelled on dispose', async () => { -- const { session, runtime } = await createAgentSession(disposables); +- test('handleExitPlanModeRequest rejects when its turn changes while reading the plan', async () => { +- const { session, runtime, mockSession, signals } = await createAgentSession(disposables); +- const planRead = new DeferredPromise<{ exists: boolean; content: string | null; path: string | null }>(); +- mockSession.planReadPromise = planRead.p; +- session.resetTurnState('turn-plan'); - -- const resultPromise = runtime.handleUserInputRequest( -- { question: 'Will be cancelled' }, -- { sessionId: 'test-session-1' } -- ); +- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams(), { sessionId: 'test-session-1' }); +- session.resetTurnState('turn-next'); +- planRead.complete({ exists: true, content: '## Plan', path: '/sessions/abc/plan.md' }); - -- session.dispose(); -- const result = await resultPromise; -- assert.strictEqual(result.answer, ''); -- assert.strictEqual(result.wasFreeform, true); +- assert.deepStrictEqual({ +- response: await responsePromise, +- inputRequests: signals.filter(signal => isAction(signal, ActionType.ChatInputRequested)).length, +- }, { +- response: { approved: false }, +- inputRequests: 0, +- }); - }); - -- test('autopilot auto-answers a free-form question without firing a progress event', async () => { -- const { runtime, signals } = await createAgentSession(disposables, { -- configValues: { [SessionConfigKey.Mode]: 'autopilot' }, -- }); +- test('keeps an in-flight callback cancelled after send resets the abort token', async () => { +- const { session, runtime, mockSession, signals } = await createAgentSession(disposables); +- const planRead = new DeferredPromise<{ exists: boolean; content: string | null; path: string | null }>(); +- mockSession.planReadPromise = planRead.p; +- session.resetTurnState('turn-plan'); - -- const result = await runtime.handleUserInputRequest( -- { question: 'Pick a color', choices: ['red', 'blue', 'green'] }, -- { sessionId: 'test-session-1' } -- ); +- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams(), { sessionId: 'test-session-1' }); +- await session.abort(); +- await session.send('continue'); +- planRead.complete({ exists: true, content: '## Plan', path: '/sessions/abc/plan.md' }); +- await timeout(0); - -- // `wasFreeform: false` because we picked one of the SDK's -- // offered choices — the SDK uses this hint to record whether -- // the user typed something custom. -- assert.strictEqual(result.answer, 'The user is not available to answer your question. Choose a pragmatic option best aligned with the context of the request.'); -- assert.strictEqual(result.wasFreeform, true); -- assert.strictEqual(signals.length, 0); +- const inputRequests = signals.filter(signal => isAction(signal, ActionType.ChatInputRequested)); +- assert.strictEqual(inputRequests.length, 1); +- const request = getInputRequest(inputRequests[0]); +- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Accept, { +- [request.questions![0].id]: { +- state: ChatInputAnswerState.Submitted, +- value: { kind: ChatInputAnswerValueKind.Selected, value: 'interactive' }, +- }, +- }); +- +- assert.deepStrictEqual(await responsePromise, { approved: false }); - }); - -- test('autopilot does not auto-answer when mode is not "autopilot"', async () => { -- // Sanity check: with mode=interactive the question must -- // still be surfaced as a progress event (the existing behavior). -- const { runtime, signals } = await createAgentSession(disposables, { -- configValues: { [SessionConfigKey.Mode]: 'interactive' }, -- }); +- test('resolves an in-flight callback after abort without a client response', async () => { +- const { session, runtime, mockSession } = await createAgentSession(disposables); +- const planRead = new DeferredPromise<{ exists: boolean; content: string | null; path: string | null }>(); +- mockSession.planReadPromise = planRead.p; +- session.resetTurnState('turn-plan'); - -- runtime.handleUserInputRequest( -- { question: 'Need user input' }, -- { sessionId: 'test-session-1' } -- ); +- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams(), { sessionId: 'test-session-1' }); +- await session.abort(); +- planRead.complete({ exists: true, content: '## Plan', path: '/sessions/abc/plan.md' }); - -- // Microtask flush so the handler can run far enough to either -- // short-circuit or emit a progress event. -- await Promise.resolve(); -- assert.strictEqual(signals.length, 1); -- assert.ok(isAction(signals[0], ActionType.ChatInputRequested)); +- const timeoutPromise = timeout(100); +- try { +- assert.deepStrictEqual(await Promise.race([ +- responsePromise, +- timeoutPromise.then(() => { +- throw new Error('Timed out waiting for the aborted callback to resolve'); +- }), +- ]), { approved: false }); +- } finally { +- timeoutPromise.cancel(); +- } - }); -- }); - -- // ---- elicitation handling ---- -- -- suite('elicitation handling', () => { +- test('completing the input request with autopilot preserves Ask When Needed and syncs mode=autopilot', async () => { +- const { session, runtime, waitForSignal, sessionConfigUpdates } = await createAgentSession(disposables); +- session.resetTurnState('turn-plan'); - -- test('form-mode request projects schema fields to questions and accept round-trips content', async () => { -- const { session, runtime, signals } = await createAgentSession(disposables); +- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'autopilot' }), { sessionId: 'test-session-1' }); +- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); +- const request = getInputRequest(signal); +- const requestId = request.id; +- const questionId = request.questions![0].id; - -- const resultPromise = runtime.handleElicitationRequest({ -- sessionId: 'test-session-1', -- message: 'Configure deployment', -- mode: 'form', -- requestedSchema: { -- type: 'object', -- properties: { -- environment: { type: 'string', enum: ['dev', 'prod'], enumNames: ['Development', 'Production'] }, -- replicas: { type: 'integer', minimum: 1, maximum: 10, default: 3 }, -- confirm: { type: 'boolean', default: false }, -- region: { type: 'string', minLength: 2, default: 'us-west-2' }, -- tags: { type: 'array', items: { type: 'string', enum: ['a', 'b', 'c'] } }, -- }, -- required: ['environment', 'confirm'], +- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { +- [questionId]: { +- state: ChatInputAnswerState.Submitted, +- value: { kind: ChatInputAnswerValueKind.Selected, value: 'autopilot' }, - }, - }); - -- assert.strictEqual(signals.length, 1); -- const request = getInputRequest(signals[0]); -- assert.strictEqual(request.message, 'Configure deployment'); -- assert.ok(request.questions); -- assert.deepStrictEqual(request.questions.map(q => ({ id: q.id, kind: q.kind, required: q.required })), [ -- { id: 'environment', kind: ChatInputQuestionKind.SingleSelect, required: true }, -- { id: 'replicas', kind: ChatInputQuestionKind.Integer, required: false }, -- { id: 'confirm', kind: ChatInputQuestionKind.Boolean, required: true }, -- { id: 'region', kind: ChatInputQuestionKind.Text, required: false }, -- { id: 'tags', kind: ChatInputQuestionKind.MultiSelect, required: false }, +- assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'autopilot' }); +- // Picking "Implement with Autopilot" flips the AHP mode immediately. +- assert.deepStrictEqual(sessionConfigUpdates, [ +- { session: 'copilot:/test-session-1', patch: { mode: 'autopilot' } }, - ]); -- const envQuestion = request.questions[0]; -- assert.strictEqual(envQuestion.kind, ChatInputQuestionKind.SingleSelect); -- if (envQuestion.kind === ChatInputQuestionKind.SingleSelect) { -- assert.deepStrictEqual(envQuestion.options, [ -- { id: 'dev', label: 'Development' }, -- { id: 'prod', label: 'Production' }, -- ]); -- } +- }); - -- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Accept, { -- environment: { state: ChatInputAnswerState.Submitted, value: { kind: ChatInputAnswerValueKind.Selected, value: 'prod' } }, -- replicas: { state: ChatInputAnswerState.Submitted, value: { kind: ChatInputAnswerValueKind.Number, value: 5 } }, -- confirm: { state: ChatInputAnswerState.Submitted, value: { kind: ChatInputAnswerValueKind.Boolean, value: true } }, -- region: { state: ChatInputAnswerState.Submitted, value: { kind: ChatInputAnswerValueKind.Text, value: 'eu-west-1' } }, -- tags: { state: ChatInputAnswerState.Submitted, value: { kind: ChatInputAnswerValueKind.SelectedMany, value: ['a', 'c'] } }, +- test('completing the input request with autopilot enables edit auto-approval under Allow All', async () => { +- const { session, runtime, waitForSignal } = await createAgentSession(disposables, { +- configValues: { [SessionConfigKey.AutoApprove]: 'autoApprove' }, - }); +- session.resetTurnState('turn-plan'); - -- assert.deepStrictEqual(await resultPromise, { -- action: 'accept', -- content: { -- environment: 'prod', -- replicas: 5, -- confirm: true, -- region: 'eu-west-1', -- tags: ['a', 'c'], +- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'autopilot' }), { sessionId: 'test-session-1' }); +- const request = getInputRequest(await waitForSignal(s => isAction(s, ActionType.ChatInputRequested))); +- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Accept, { +- [request.questions![0].id]: { +- state: ChatInputAnswerState.Submitted, +- value: { kind: ChatInputAnswerValueKind.Selected, value: 'autopilot' }, - }, - }); +- +- assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'autopilot', autoApproveEdits: true }); - }); - -- test('skipped and missing answers are omitted from accept content', async () => { -- const { session, runtime, signals } = await createAgentSession(disposables); +- test('completing the input request with interactive resolves with approved + interactive (no autoApprove) and syncs mode=interactive', async () => { +- const { session, runtime, waitForSignal, sessionConfigUpdates } = await createAgentSession(disposables); +- session.resetTurnState('turn-plan'); - -- const resultPromise = runtime.handleElicitationRequest({ -- sessionId: 'test-session-1', -- message: 'Partial form', -- mode: 'form', -- requestedSchema: { -- type: 'object', -- properties: { -- name: { type: 'string' }, -- count: { type: 'integer' }, -- }, -- }, -- }); +- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'interactive' }), { sessionId: 'test-session-1' }); +- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); +- const request = getInputRequest(signal); +- const requestId = request.id; +- const questionId = request.questions![0].id; - -- const request = getInputRequest(signals[0]); -- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Accept, { -- name: { state: ChatInputAnswerState.Skipped }, -- // `count` is missing entirely +- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { +- [questionId]: { +- state: ChatInputAnswerState.Submitted, +- value: { kind: ChatInputAnswerValueKind.Selected, value: 'interactive' }, +- }, - }); - -- assert.deepStrictEqual(await resultPromise, { action: 'accept', content: {} }); +- assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'interactive' }); +- assert.deepStrictEqual(sessionConfigUpdates, [ +- { session: 'copilot:/test-session-1', patch: { mode: 'interactive' } }, +- ]); - }); - -- test('url-mode request surfaces url and accept returns no content', async () => { -- const { session, runtime, signals } = await createAgentSession(disposables); +- test('declining the input request resolves with approved=false', async () => { +- const { session, runtime, waitForSignal } = await createAgentSession(disposables); +- session.resetTurnState('turn-plan'); - -- const resultPromise = runtime.handleElicitationRequest({ -- sessionId: 'test-session-1', -- message: 'Open this link', -- mode: 'url', -- url: 'https://example.com/auth', -- }); +- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams(), { sessionId: 'test-session-1' }); +- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); - -- const request = getInputRequest(signals[0]); -- assert.strictEqual(request.url, 'https://example.com/auth'); -- assert.strictEqual(request.questions, undefined); +- session.respondToUserInputRequest(getInputRequest(signal).id, ChatInputResponseKind.Decline); - -- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Accept); -- assert.deepStrictEqual(await resultPromise, { action: 'accept' }); +- assert.deepStrictEqual(await responsePromise, { approved: false }); - }); - -- test('free-form request (no schema) returns submitted text as content.answer', async () => { -- const { session, runtime, signals } = await createAgentSession(disposables); +- test('abort resolves and clears a pending plan review', async () => { +- const { session, runtime, mockSession, waitForSignal } = await createAgentSession(disposables); +- session.resetTurnState('turn-plan'); - -- const resultPromise = runtime.handleElicitationRequest({ -- sessionId: 'test-session-1', -- message: 'What is your favorite color?', -- mode: 'form', -- // No requestedSchema — the workbench fallback renders a single text question. -- }); +- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams(), { sessionId: 'test-session-1' }); +- const request = getInputRequest(await waitForSignal(s => isAction(s, ActionType.ChatInputRequested))); - -- const request = getInputRequest(signals[0]); -- assert.strictEqual(request.questions, undefined); +- await session.abort(); +- const lateResponseHandled = session.respondToUserInputRequest(request.id, ChatInputResponseKind.Accept); - -- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Accept, { -- answer: { state: ChatInputAnswerState.Submitted, value: { kind: ChatInputAnswerValueKind.Text, value: 'teal' } }, +- assert.deepStrictEqual({ +- response: await responsePromise, +- abortCalls: mockSession.abortCalls, +- lateResponseHandled, +- }, { +- response: { approved: false }, +- abortCalls: 1, +- lateResponseHandled: false, - }); -- -- assert.deepStrictEqual(await resultPromise, { action: 'accept', content: { answer: 'teal' } }); - }); - -- test('decline response maps to action=decline', async () => { -- const { session, runtime, signals } = await createAgentSession(disposables); +- test('exit_only resolves as approved + interactive without autoApproveEdits', async () => { +- const { session, runtime, waitForSignal } = await createAgentSession(disposables); +- session.resetTurnState('turn-plan'); - -- const resultPromise = runtime.handleElicitationRequest({ -- sessionId: 'test-session-1', -- message: 'Please confirm', -- mode: 'form', -- requestedSchema: { type: 'object', properties: { ok: { type: 'boolean' } } }, +- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive', 'exit_only'], recommendedAction: 'exit_only' }), { sessionId: 'test-session-1' }); +- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); +- const request = getInputRequest(signal); +- const requestId = request.id; +- const questionId = request.questions![0].id; +- +- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { +- [questionId]: { +- state: ChatInputAnswerState.Submitted, +- value: { kind: ChatInputAnswerValueKind.Selected, value: 'exit_only' }, +- }, - }); - -- const request = getInputRequest(signals[0]); -- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Decline); -- assert.deepStrictEqual(await resultPromise, { action: 'decline' }); +- assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'exit_only' }); - }); - -- test('cancel response maps to action=cancel', async () => { -- const { session, runtime, signals } = await createAgentSession(disposables); +- test('freeform feedback alongside a selected action becomes a revision request', async () => { +- const { session, runtime, waitForSignal } = await createAgentSession(disposables); +- session.resetTurnState('turn-plan'); - -- const resultPromise = runtime.handleElicitationRequest({ -- sessionId: 'test-session-1', -- message: 'Please confirm', -- mode: 'form', -- requestedSchema: { type: 'object', properties: { ok: { type: 'boolean' } } }, +- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'interactive' }), { sessionId: 'test-session-1' }); +- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); +- const request = getInputRequest(signal); +- const requestId = request.id; +- const questionId = request.questions![0].id; +- +- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { +- [questionId]: { +- state: ChatInputAnswerState.Submitted, +- value: { +- kind: ChatInputAnswerValueKind.Selected, +- value: 'interactive', +- freeformValues: ['Please use Python instead of Node.js'], +- }, +- }, - }); - -- const request = getInputRequest(signals[0]); -- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Cancel); -- assert.deepStrictEqual(await resultPromise, { action: 'cancel' }); +- assert.deepStrictEqual(await responsePromise, { +- approved: false, +- feedback: 'Please use Python instead of Node.js', +- selectedAction: 'interactive', +- }); - }); - -- test('autopilot auto-cancels without firing a progress event', async () => { -- const { runtime, signals } = await createAgentSession(disposables, { -- configValues: { [SessionConfigKey.Mode]: 'autopilot' }, -- }); +- test('selectedAction not in offered actions falls back to recommendedAction', async () => { +- const { session, runtime, waitForSignal } = await createAgentSession(disposables); +- session.resetTurnState('turn-plan'); - -- const result = await runtime.handleElicitationRequest({ -- sessionId: 'test-session-1', -- message: 'Need input', -- mode: 'form', -- requestedSchema: { type: 'object', properties: { ok: { type: 'boolean' } } }, +- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['interactive', 'exit_only'], recommendedAction: 'interactive' }), { sessionId: 'test-session-1' }); +- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); +- const request = getInputRequest(signal); +- const requestId = request.id; +- const questionId = request.questions![0].id; +- +- // SDK only offered `interactive` and `exit_only`; the client +- // somehow sent `autopilot` (e.g. stale UI state). The agent +- // host clamps to `recommendedAction` so the SDK never sees a +- // value it didn't offer. +- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { +- [questionId]: { +- state: ChatInputAnswerState.Submitted, +- value: { kind: ChatInputAnswerValueKind.Selected, value: 'autopilot' }, +- }, - }); - -- assert.deepStrictEqual(result, { action: 'cancel' }); -- assert.strictEqual(signals.length, 0); +- assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'interactive' }); - }); - -- test('pending elicitations are cancelled on dispose', async () => { -- const { session, runtime } = await createAgentSession(disposables); +- test('selectedAction not in offered actions and no fallback resolves to approved=false', async () => { +- const { session, runtime, waitForSignal } = await createAgentSession(disposables); +- session.resetTurnState('turn-plan'); - -- const resultPromise = runtime.handleElicitationRequest({ -- sessionId: 'test-session-1', -- message: 'Will be cancelled', -- mode: 'form', -- requestedSchema: { type: 'object', properties: { ok: { type: 'boolean' } } }, +- // SDK offered `exit_only` only and recommended a value not in +- // the offered set. The client picked something invalid. With +- // no usable selectedAction and no feedback, decline. +- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['exit_only'], recommendedAction: 'autopilot' }), { sessionId: 'test-session-1' }); +- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); +- const request = getInputRequest(signal); +- const requestId = request.id; +- const questionId = request.questions![0].id; +- +- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { +- [questionId]: { +- state: ChatInputAnswerState.Submitted, +- value: { kind: ChatInputAnswerValueKind.Selected, value: 'interactive' }, +- }, - }); - -- session.dispose(); -- assert.deepStrictEqual(await resultPromise, { action: 'cancel' }); +- assert.deepStrictEqual(await responsePromise, { approved: false }); - }); -- }); - -- suite('SDK callback logging', () => { +- test('text answer with feedback becomes a revision request without selectedAction', async () => { +- const { session, runtime, waitForSignal } = await createAgentSession(disposables); +- session.resetTurnState('turn-plan'); - -- test('logs and rethrows user input callback failures', async () => { -- const logService = new CapturingLogService(); -- const { session, runtime } = await createAgentSession(disposables, { logService }); -- const sessionInternals = session as unknown as ISessionInternalsForTest; -- sessionInternals._onDidSessionProgress.fire = () => { -- throw new Error('user input boom'); -- }; +- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'interactive' }), { sessionId: 'test-session-1' }); +- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); +- const request = getInputRequest(signal); +- const requestId = request.id; +- const questionId = request.questions![0].id; - -- await assert.rejects( -- runtime.handleUserInputRequest( -- { question: 'Need input' }, -- { sessionId: 'test-session-1' }, -- ), -- /user input boom/, -- ); +- // The single-select question normally produces a Selected +- // value, but a defensive Text response should still be +- // translated to a revision request when the answer is +- // non-empty (selectedAction falls back to recommendedAction). +- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { +- [questionId]: { +- state: ChatInputAnswerState.Submitted, +- value: { kind: ChatInputAnswerValueKind.Text, value: 'Add tests for edge cases' }, +- }, +- }); - -- assert.strictEqual(logService.errors.length, 1); -- const [entry] = logService.errors; -- assert.ok(entry.first instanceof Error); -- assert.strictEqual((entry.first as Error).message, 'user input boom'); -- assert.strictEqual(entry.args[0], '[Copilot:test-session-1] Failed to handle user input request: question="Need input"'); +- assert.deepStrictEqual(await responsePromise, { +- approved: false, +- feedback: 'Add tests for edge cases', +- selectedAction: 'interactive', +- }); - }); - -- test('logs and rethrows onPreToolUse failures', async () => { -- const logService = new CapturingLogService(); -- const capturedRuntime: { current?: ICopilotSessionRuntime } = {}; -- const { session } = await createAgentSession(disposables, { logService, captureRuntime: capturedRuntime }); -- const sessionInternals = session as unknown as ISessionInternalsForTest; -- sessionInternals._editTracker.trackEditStart = async () => { -- throw new Error('pre tool boom'); -- }; +- test('whitespace-only freeform feedback is ignored', async () => { +- const { session, runtime, waitForSignal } = await createAgentSession(disposables); +- session.resetTurnState('turn-plan'); - -- await assert.rejects( -- capturedRuntime.current!.handlePreToolUse({ -- sessionId: 'test-session-1', -- timestamp: new Date(0), -- workingDirectory: '/tmp', -- toolName: 'edit', -- toolArgs: { path: '/tmp/file.ts' }, -- }), -- /pre tool boom/, -- ); +- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'interactive' }), { sessionId: 'test-session-1' }); +- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); +- const request = getInputRequest(signal); +- const requestId = request.id; +- const questionId = request.questions![0].id; - -- assert.strictEqual(logService.errors.length, 1); -- const [entry] = logService.errors; -- assert.ok(entry.first instanceof Error); -- assert.strictEqual((entry.first as Error).message, 'pre tool boom'); -- assert.strictEqual(entry.args[0], '[Copilot:test-session-1] Failed in onPreToolUse: tool=edit'); +- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { +- [questionId]: { +- state: ChatInputAnswerState.Submitted, +- value: { +- kind: ChatInputAnswerValueKind.Selected, +- value: 'interactive', +- freeformValues: [' ', ''], +- }, +- }, +- }); +- +- assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'interactive' }); - }); - -- test('logs and rethrows onPostToolUse failures', async () => { -- const logService = new CapturingLogService(); -- const capturedRuntime: { current?: ICopilotSessionRuntime } = {}; -- const { session } = await createAgentSession(disposables, { logService, captureRuntime: capturedRuntime }); -- const sessionInternals = session as unknown as ISessionInternalsForTest; -- sessionInternals._editTracker.completeEdit = async () => { -- throw new Error('post tool boom'); -- }; +- test('session.mode_changed → plan updates the AHP session config', async () => { +- const { mockSession, sessionConfigUpdates } = await createAgentSession(disposables); - -- await assert.rejects( -- capturedRuntime.current!.handlePostToolUse({ -- sessionId: 'test-session-1', -- timestamp: new Date(0), -- workingDirectory: '/tmp', -- toolName: 'edit', -- toolArgs: { path: '/tmp/file.ts' }, -- toolResult: { textResultForLlm: '', resultType: 'success' }, -- }), -- /post tool boom/, -- ); +- mockSession.fire('session.mode_changed', { previousMode: 'interactive', newMode: 'plan' } as SessionEventPayload<'session.mode_changed'>['data']); - -- assert.strictEqual(logService.errors.length, 1); -- const [entry] = logService.errors; -- assert.ok(entry.first instanceof Error); -- assert.strictEqual((entry.first as Error).message, 'post tool boom'); -- assert.strictEqual(entry.args[0], '[Copilot:test-session-1] Failed in onPostToolUse: tool=edit'); +- assert.deepStrictEqual(sessionConfigUpdates, [ +- { session: 'copilot:/test-session-1', patch: { mode: 'plan' } }, +- ]); - }); -- }); - -- // ---- client tool calls ---- +- test('session.mode_changed → interactive updates the AHP session config', async () => { +- const { mockSession, sessionConfigUpdates } = await createAgentSession(disposables); - -- suite('client tool calls', () => { +- mockSession.fire('session.mode_changed', { previousMode: 'plan', newMode: 'interactive' } as SessionEventPayload<'session.mode_changed'>['data']); - -- const snapshot: IActiveClientSnapshot = { -- tools: [{ -- name: 'my_tool', -- description: 'A test tool', -- inputSchema: { type: 'object', properties: {} }, -- }], -- plugins: [], -- mcpServers: {}, -- }; +- assert.deepStrictEqual(sessionConfigUpdates, [ +- { session: 'copilot:/test-session-1', patch: { mode: 'interactive' } }, +- ]); +- }); - -- /** Builds a live ActiveClientState seeded with the given owning clientId and the snapshot's tools. */ -- const activeClientStateWith = (clientId: string): ActiveClientState => { -- const state = new ActiveClientState(); -- state.update(clientId, snapshot.tools); -- return state; -- }; +- test('session.mode_changed → autopilot maps directly to mode=autopilot', async () => { +- // The SDK and AHP share the same three-mode space; autopilot now +- // lives on the `mode` axis and the `autoApprove` axis is left +- // untouched. The translation is contained in the Copilot agent. +- const { mockSession, sessionConfigUpdates } = await createAgentSession(disposables); - -- test('client tool started with no connected client fails immediately', async () => { -- // No activeClientState is provided, so the session seeds one with -- // an undefined clientId — i.e. no client is connected to run the tool. -- const { runtime, mockSession, signals } = await createAgentSession(disposables, { clientSnapshot: snapshot }); +- mockSession.fire('session.mode_changed', { previousMode: 'plan', newMode: 'autopilot' } as SessionEventPayload<'session.mode_changed'>['data']); - -- mockSession.fire('tool.execution_start', { -- toolCallId: 'tc-no-client', -- toolName: 'my_tool', -- arguments: {}, -- } as SessionEventPayload<'tool.execution_start'>['data']); +- assert.deepStrictEqual(sessionConfigUpdates, [ +- { session: 'copilot:/test-session-1', patch: { mode: 'autopilot' } }, +- ]); +- }); - -- // tool_start is stamped as a client contributor with no owner... -- const startSignal = signals.find(s => isAction(s, ActionType.ChatToolCallStart)); -- assert.ok(startSignal && isAction(startSignal, ActionType.ChatToolCallStart)); -- assert.deepStrictEqual((startSignal.action as ChatToolCallStartAction).contributor, undefined); +- test('session.mode_changed for unsupported mode is ignored', async () => { +- const { mockSession, sessionConfigUpdates } = await createAgentSession(disposables); - -- // ...and is failed immediately (ready + complete) rather than left -- // pending for the server-side disconnect timeout. -- assert.strictEqual(signals.filter(s => isAction(s, ActionType.ChatToolCallReady)).length, 1); -- const completeSignal = signals.find(s => isAction(s, ActionType.ChatToolCallComplete)); -- assert.ok(completeSignal && isAction(completeSignal, ActionType.ChatToolCallComplete)); -- assert.strictEqual((completeSignal.action as ChatToolCallCompleteAction).result.success, false); +- mockSession.fire('session.mode_changed', { previousMode: 'interactive', newMode: 'shell' } as unknown as SessionEventPayload<'session.mode_changed'>['data']); - -- // When the SDK invokes the handler it resolves immediately with the -- // buffered failure result. -- const tools = runtime.createClientSdkTools(); -- const result = await invokeClientToolHandler(tools[0], 'tc-no-client'); -- assert.strictEqual(result.resultType, 'failure'); +- assert.strictEqual(sessionConfigUpdates.length, 0); - }); - -- test('client tool handler waits for completion without emitting tool_ready', async () => { +- test('session.mode_changed from a subagent does not update the session config', async () => { +- // Sub-agents (e.g. a `task` tool sub-agent running in plan mode) +- // emit `session.mode_changed` carrying an `agentId`. These reflect +- // the sub-agent's internal mode, not the root session's, and must +- // not flip the shared session mode picker (e.g. to Plan) mid-turn. +- const { mockSession, sessionConfigUpdates } = await createAgentSession(disposables); - -- const { session, runtime, mockSession, signals } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientState: activeClientStateWith('test-client') }); +- mockSession.fire('session.mode_changed', { previousMode: 'interactive', newMode: 'plan' } as SessionEventPayload<'session.mode_changed'>['data'], { agentId: 'subagent-1' }); - -- // SDK emits tool.execution_start — tool_start fires immediately -- mockSession.fire('tool.execution_start', { -- toolCallId: 'tc-client-1', -- toolName: 'my_tool', -- arguments: {}, -- } as SessionEventPayload<'tool.execution_start'>['data']); +- assert.strictEqual(sessionConfigUpdates.length, 0); +- }); - -- // tool_start fires immediately (client tools don't auto-ready) -- assert.strictEqual(signals.filter(s => isAction(s, ActionType.ChatToolCallStart)).length, 1); -- const startSignal = signals.find(s => isAction(s, ActionType.ChatToolCallStart)); -- assert.ok(startSignal && isAction(startSignal, ActionType.ChatToolCallStart)); -- if (isAction(startSignal!, ActionType.ChatToolCallStart)) { -- assert.deepStrictEqual((startSignal.action as ChatToolCallStartAction).contributor, { kind: ToolCallContributorKind.Client, clientId: 'test-client' }); -- } +- // ---- no automatic plan → implementation handoff ------------------- - -- // SDK invokes the handler — it creates a deferred and waits, -- // but does NOT fire tool_ready (that comes from the permission flow). -- const tools = runtime.createClientSdkTools(); -- const handlerPromise = invokeClientToolHandler(tools[0], 'tc-client-1', { file: 'test.ts' }); +- test('handleExitPlanModeRequest always surfaces the plan-review UI, even in autopilot mode', async () => { +- const { session, runtime, waitForSignal } = await createAgentSession(disposables, { +- configValues: { [SessionConfigKey.Mode]: 'autopilot' }, +- }); +- session.resetTurnState('turn-plan'); - -- // No pending_confirmation or tool_ready should have been emitted by the handler -- assert.strictEqual(signals.filter(s => s.kind === 'pending_confirmation' || isAction(s, ActionType.ChatToolCallReady)).length, 0); +- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ +- actions: ['autopilot', 'interactive', 'exit_only'], +- recommendedAction: 'autopilot', +- }), { sessionId: 'test-session-1' }); - -- // Complete the tool call -- session.handleClientToolCallComplete('tc-client-1', { -- success: true, -- pastTenseMessage: 'did it', -- content: [{ type: ToolResultContentType.Text, text: 'result text' }], +- // There is no automatic handoff from plan into implementation: the +- // user must explicitly choose an action regardless of mode. +- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); +- session.respondToUserInputRequest(getInputRequest(signal).id, ChatInputResponseKind.Decline); +- await responsePromise; +- }); +- +- test('handleExitPlanModeRequest does NOT auto-accept when autoApprove=default', async () => { +- const { session, runtime, waitForSignal } = await createAgentSession(disposables, { +- configValues: { [SessionConfigKey.AutoApprove]: 'default' }, - }); +- session.resetTurnState('turn-plan'); - -- const result = await handlerPromise; -- assert.strictEqual(result.resultType, 'success'); -- assert.strictEqual(result.textResultForLlm, 'result text'); +- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams(), { sessionId: 'test-session-1' }); +- +- // The user-input request fires — the user must respond. +- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); +- session.respondToUserInputRequest(getInputRequest(signal).id, ChatInputResponseKind.Decline); +- await responsePromise; - }); +- }); - -- test('client tool handler does not emit tool_ready (permission flow owns it)', async () => { -- const activeClientState = new ActiveClientState(); -- activeClientState.update('client-perm', snapshot.tools); -- const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientState }); +- suite('MCP server inventory', () => { - -- // SDK emits tool.execution_start — tool_start fires immediately -- mockSession.fire('tool.execution_start', { -- toolCallId: 'tc-client-perm', -- toolName: 'my_tool', -- arguments: {}, -- } as SessionEventPayload<'tool.execution_start'>['data']); +- test('session MCP desired enablement reconciles runtime drift', async () => { +- const serverName = 'slack'; +- const id = 'mcp-top-level:copilot:test-session-1:slack'; +- let desiredEnabled = true; +- const { session, mockSession } = await createAgentSession(disposables, { +- sessionCustomizations: () => [{ +- type: CustomizationType.McpServer, +- id, +- uri: id, +- name: serverName, +- enabled: desiredEnabled, +- state: { kind: McpServerStatus.Starting }, +- }], +- configureMockSession: mock => { +- mock.mcpListResult = { servers: [{ name: serverName, status: 'pending' }] }; +- }, +- }); - -- // tool_start fired, no pending_confirmation yet -- assert.strictEqual(signals.filter(s => isAction(s, ActionType.ChatToolCallStart)).length, 1); -- assert.strictEqual(signals.filter(s => s.kind === 'pending_confirmation').length, 0); +- desiredEnabled = false; +- await session.send('disable Slack'); +- const afterDisable = session.topLevelMcpCustomizations(); +- mockSession.fire('session.mcp_servers_loaded', { servers: [{ name: serverName, status: 'pending' }] }); +- mockSession.mcpListResult = { servers: [{ name: serverName, status: 'pending' }] }; +- const afterRuntimeUpdate = session.topLevelMcpCustomizations(); +- await session.send('keep Slack disabled'); +- const afterReconcile = session.topLevelMcpCustomizations(); +- desiredEnabled = true; +- await session.send('enable Slack'); - -- // Permission request fires — pending_confirmation from permission flow. -- const resultPromise = runtime.handlePermissionRequest({ -- kind: 'custom-tool', -- toolCallId: 'tc-client-perm', -- toolName: 'my_tool', +- assert.deepStrictEqual({ +- disableCalls: mockSession.mcpDisableCalls, +- afterDisable, +- afterRuntimeUpdate, +- afterReconcile, +- enableCalls: mockSession.mcpEnableCalls, +- afterEnable: session.topLevelMcpCustomizations(), +- }, { +- disableCalls: [{ serverName }, { serverName }], +- afterDisable: [{ +- type: CustomizationType.McpServer, +- id, +- uri: id, +- name: serverName, +- enabled: false, +- state: { kind: McpServerStatus.Stopped }, +- channel: undefined, +- mcpApp: { capabilities: { serverTools: { listChanged: true }, serverResources: {}, sampling: {} } }, +- }], +- afterRuntimeUpdate: [{ +- type: CustomizationType.McpServer, +- id, +- uri: id, +- name: serverName, +- enabled: false, +- state: { kind: McpServerStatus.Starting }, +- channel: undefined, +- mcpApp: { capabilities: { serverTools: { listChanged: true }, serverResources: {}, sampling: {} } }, +- }], +- afterReconcile: [{ +- type: CustomizationType.McpServer, +- id, +- uri: id, +- name: serverName, +- enabled: false, +- state: { kind: McpServerStatus.Stopped }, +- channel: undefined, +- mcpApp: { capabilities: { serverTools: { listChanged: true }, serverResources: {}, sampling: {} } }, +- }], +- enableCalls: [{ serverName }], +- afterEnable: [{ +- type: CustomizationType.McpServer, +- id, +- uri: id, +- name: serverName, +- enabled: true, +- state: { kind: McpServerStatus.Starting }, +- channel: undefined, +- mcpApp: { capabilities: { serverTools: { listChanged: true }, serverResources: {}, sampling: {} } }, +- }], - }); +- }); - -- // pending_confirmation from permission flow should have fired (with confirmationTitle) -- await waitForSignal(s => s.kind === 'pending_confirmation'); -- const permSignals = signals.filter((s): s is IAgentToolPendingConfirmationSignal => s.kind === 'pending_confirmation'); -- assert.strictEqual(permSignals.length, 1); -- assert.strictEqual(permSignals[0].state.toolCallId, 'tc-client-perm'); -- assert.ok(permSignals[0].state.confirmationTitle); +- test('sending a message does not mark an enabled server as Starting', async () => { +- const serverName = 'db'; +- const id = 'mcp-top-level:copilot:test-session-1:db'; +- const { session } = await createAgentSession(disposables, { +- sessionCustomizations: () => [{ +- type: CustomizationType.McpServer, +- id, +- uri: id, +- name: serverName, +- enabled: true, +- state: { kind: McpServerStatus.Stopped }, +- }], +- // The server is settled (failed) before the turn. Sending does not +- // reconnect it, so its state must stay put until the SDK reports a +- // live `pending` of its own. +- configureMockSession: mock => { mock.mcpListResult = { servers: [{ name: serverName, status: 'failed', error: 'boom' }] }; }, +- }); - -- const tools = runtime.createClientSdkTools(); -- const handlerPromise = invokeClientToolHandler(tools[0], 'tc-client-perm'); +- const beforeSend = session.topLevelMcpCustomizations()[0]?.state; +- await session.send('hello'); +- const afterSend = session.topLevelMcpCustomizations()[0]?.state; - -- // The handler should NOT emit its own pending_confirmation — only the -- // permission flow fires pending_confirmation for client tools. -- assert.strictEqual(signals.filter(s => s.kind === 'pending_confirmation').length, 1, 'handler should not emit a second pending_confirmation'); +- assert.deepStrictEqual({ beforeSend, afterSend }, { +- beforeSend: { kind: McpServerStatus.Error, error: { errorType: 'mcp-server-failed', message: 'boom' } }, +- afterSend: { kind: McpServerStatus.Error, error: { errorType: 'mcp-server-failed', message: 'boom' } }, +- }); +- }); - -- // Approve and clean up -- session.respondToPermissionRequest('tc-client-perm', true); -- const permResult = await resultPromise; -- assert.strictEqual(permResult.kind, 'approve-once'); -- session.handleClientToolCallComplete('tc-client-perm', { -- success: true, -- pastTenseMessage: 'did it', +- test('startMcpServer reports Starting only once the SDK reports the reconnect', async () => { +- const serverName = 'db'; +- const id = 'mcp-top-level:copilot:test-session-1:db'; +- const { session, mockSession } = await createAgentSession(disposables, { +- sessionCustomizations: () => [{ +- type: CustomizationType.McpServer, +- id, +- uri: id, +- name: serverName, +- enabled: true, +- state: { kind: McpServerStatus.Stopped }, +- }], +- // Settled (failed) before the explicit start, and startServer +- // rejects, so the SDK never reports a connect attempt. +- configureMockSession: mock => { +- mock.mcpListResult = { servers: [{ name: serverName, status: 'failed', error: 'boom' }] }; +- mock.mcpStartServerError = new Error('start failed'); +- }, - }); -- assert.deepStrictEqual(await handlerPromise, { -- textResultForLlm: '', -- resultType: 'success', -- binaryResultsForLlm: undefined, +- +- const beforeStart = session.topLevelMcpCustomizations()[0]?.state; +- await session.startMcpServer(id).catch(() => { }); +- // Let the fire-and-forget inventory refresh settle. +- await timeout(0); +- const afterFailedStart = session.topLevelMcpCustomizations()[0]?.state; +- mockSession.fire('session.mcp_server_status_changed', { +- serverName, +- status: 'pending', +- } as SessionEventPayload<'session.mcp_server_status_changed'>['data']); +- const afterSdkPending = session.topLevelMcpCustomizations()[0]?.state; +- +- assert.deepStrictEqual({ startServerCalls: mockSession.mcpStartServerCalls, beforeStart, afterFailedStart, afterSdkPending }, { +- startServerCalls: [{ serverName }], +- beforeStart: { kind: McpServerStatus.Error, error: { errorType: 'mcp-server-failed', message: 'boom' } }, +- afterFailedStart: { kind: McpServerStatus.Error, error: { errorType: 'mcp-server-failed', message: 'boom' } }, +- afterSdkPending: { kind: McpServerStatus.Starting }, - }); - }); - -- test('pending_confirmation forwards parentToolCallId for tools inside subagents', async () => { -- // Regression: when a client tool runs inside a subagent the -- // permission-flow `pending_confirmation` must carry the -- // parentToolCallId from the originating tool_start. Without it -- // the host has no way to route the resulting -- // ChatToolCallReady to the subagent session and emits a -- // stray ready against the parent session (no preceding -- // ChatToolCallStart). -- const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientState: activeClientStateWith('test-client') }); +- test('stopMcpServer uses the SDK lifecycle method', async () => { +- const serverName = 'db'; +- const id = 'mcp-top-level:copilot:test-session-1:db'; +- const { session, mockSession } = await createAgentSession(disposables, { +- sessionCustomizations: () => [{ +- type: CustomizationType.McpServer, +- id, +- uri: id, +- name: serverName, +- enabled: true, +- state: { kind: McpServerStatus.Ready }, +- }], +- configureMockSession: mock => { +- mock.mcpListResult = { servers: [{ name: serverName, status: 'connected' }] }; +- }, +- }); - -- mockSession.fire('subagent.started', { -- toolCallId: 'tc-parent-subagent', -- agentName: 'helper', -- agentDisplayName: 'Helper', -- agentDescription: 'Helps', -- } as SessionEventPayload<'subagent.started'>['data'], { agentId: 'agent-client-tool' }); +- await session.stopMcpServer(id); - -- mockSession.fire('tool.execution_start', { -- toolCallId: 'tc-sub-client', -- toolName: 'my_tool', -- arguments: {}, -- } as SessionEventPayload<'tool.execution_start'>['data'], { agentId: 'agent-client-tool' }); +- assert.deepStrictEqual({ +- stopServerCalls: mockSession.mcpStopServerCalls, +- state: session.topLevelMcpCustomizations()[0]?.state, +- }, { +- stopServerCalls: [{ serverName }], +- state: { kind: McpServerStatus.Stopped }, +- }); +- }); - -- const resultPromise = runtime.handlePermissionRequest({ -- kind: 'custom-tool', -- toolCallId: 'tc-sub-client', -- toolName: 'my_tool', +- test('peer chat MCP desired enablement uses parent session customizations', async () => { +- const parentSessionUri = AgentSession.uri('copilot', 'parent-session'); +- const peerChatUri = URI.parse(buildChatUri(parentSessionUri, 'peer-1')); +- const serverName = 'slack'; +- const id = 'mcp-top-level:copilot:test-session-1:slack'; +- const { session, mockSession } = await createAgentSession(disposables, { +- sessionUri: parentSessionUri, +- chatChannelUri: peerChatUri, +- sessionCustomizations: () => [{ +- type: CustomizationType.McpServer, +- id, +- uri: id, +- name: serverName, +- enabled: false, +- state: { kind: McpServerStatus.Starting }, +- }], +- configureMockSession: mock => { +- mock.mcpListResult = { servers: [{ name: serverName, status: 'pending' }] }; +- }, - }); - -- await waitForSignal(s => s.kind === 'pending_confirmation'); -- const permSignals = signals.filter((s): s is IAgentToolPendingConfirmationSignal => s.kind === 'pending_confirmation'); -- assert.strictEqual(permSignals.length, 1); -- assert.strictEqual(permSignals[0].parentToolCallId, 'tc-parent-subagent'); +- await session.send('keep Slack disabled'); - -- session.respondToPermissionRequest('tc-sub-client', false); -- await resultPromise; +- assert.deepStrictEqual({ +- disableCalls: mockSession.mcpDisableCalls, +- effectiveEnabled: session.topLevelMcpCustomizations()[0]?.enabled, +- }, { +- disableCalls: [{ serverName }], +- effectiveEnabled: false, +- }); - }); - -- test('handleClientToolCallComplete pre-completes when no handler is waiting yet', async () => { -- const { session, runtime } = await createAgentSession(disposables, { clientSnapshot: snapshot }); -- -- // Completion arrives before handler — pre-creates deferred -- session.handleClientToolCallComplete('tc-unknown', { -- success: true, -- pastTenseMessage: 'done', +- test('nested MCP desired enablement includes parent container enablement', async () => { +- const serverName = 'slack'; +- const serverId = 'plugin-1/mcp/slack'; +- const { session, mockSession } = await createAgentSession(disposables, { +- sessionCustomizations: () => [{ +- type: CustomizationType.Plugin, +- id: 'plugin-1', +- uri: 'file:///plugin', +- name: 'Slack Plugin', +- enabled: false, +- children: [{ +- type: CustomizationType.McpServer, +- id: serverId, +- uri: 'file:///plugin/package.json', +- name: serverName, +- enabled: true, +- state: { kind: McpServerStatus.Starting }, +- }], +- }], +- configureMockSession: mock => { +- mock.mcpListResult = { servers: [{ name: serverName, status: 'pending' }] }; +- }, +- resolveMcpChildId: name => name === serverName ? serverId : undefined, - }); - -- // Handler picks up the pre-completed result -- const tools = runtime.createClientSdkTools(); -- const result = await invokeClientToolHandler(tools[0], 'tc-unknown'); -- assert.strictEqual(result.resultType, 'success'); +- await session.send('keep plugin disabled'); +- +- assert.deepStrictEqual(mockSession.mcpDisableCalls, [{ serverName }]); - }); - -- test('handleClientToolCallComplete with failure result', async () => { -- const { session, runtime } = await createAgentSession(disposables, { clientSnapshot: snapshot }); +- test('disabling an MCP server cancels its pending authentication before awaiting the SDK', async () => { +- const serverName = 'slack'; +- const id = 'mcp-top-level:copilot:test-session-1:slack'; +- let desiredEnabled = true; +- const { session, mockSession, runtime } = await createAgentSession(disposables, { +- sessionCustomizations: () => [{ +- type: CustomizationType.McpServer, +- id, +- uri: id, +- name: serverName, +- enabled: desiredEnabled, +- state: { kind: McpServerStatus.Starting }, +- }], +- configureMockSession: mock => { +- mock.mcpListResult = { servers: [{ name: serverName, status: 'pending' }] }; +- }, +- }); +- const authPromise = runtime.handleMcpAuthRequest({ +- requestId: 'auth-disable', +- serverName, +- serverUrl: 'https://mcp.slack.com/mcp', +- reason: 'initial', +- resourceMetadata: JSON.stringify({ +- resource: 'https://mcp.slack.com', +- authorization_servers: ['https://slack.com/oauth'], +- }), +- }, { sessionId: session.sessionId }); +- mockSession.mcpDisableGate = authPromise; +- desiredEnabled = false; - -- const tools = runtime.createClientSdkTools(); -- const handlerPromise = invokeClientToolHandler(tools[0], 'tc-client-3'); +- let timeoutHandle: ReturnType | undefined; +- try { +- await Promise.race([ +- session.send('disable Slack'), +- new Promise((_, reject) => { +- timeoutHandle = setTimeout(() => reject(new Error('Timed out disabling Slack')), 1000); +- }), +- ]); +- } finally { +- clearTimeout(timeoutHandle); +- } - -- session.handleClientToolCallComplete('tc-client-3', { -- success: false, -- pastTenseMessage: 'failed', -- error: { message: 'something broke' }, +- assert.deepStrictEqual({ +- authResult: await authPromise, +- disableCalls: mockSession.mcpDisableCalls, +- }, { +- authResult: { kind: 'cancelled' }, +- disableCalls: [{ serverName }], - }); -- -- const result = await handlerPromise; -- assert.strictEqual(result.resultType, 'failure'); -- assert.strictEqual(result.error, 'something broke'); - }); - -- test('pending client tool calls are cancelled on dispose', async () => { -- const { session, runtime } = await createAgentSession(disposables, { clientSnapshot: snapshot }); +- test('tool-triggered MCP auth transitions the active MCP tool call until authentication resolves', async () => { +- const { session, mockSession, runtime, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-auth'); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tool-auth', +- toolName: 'mcp_tool', +- mcpServerName: 'github', +- arguments: { owner: 'microsoft', repo: 'vscode' }, +- } as SessionEventPayload<'tool.execution_start'>['data']); - -- const tools = runtime.createClientSdkTools(); -- const handlerPromise = invokeClientToolHandler(tools[0], 'tc-client-4'); +- 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); - -- session.dispose(); -- const result = await handlerPromise; -- assert.strictEqual(result.resultType, 'failure'); -- assert.ok(result.error); -- }); +- const beforeResolve = getActions(signals) +- .filter(action => action.type === ActionType.ChatToolCallAuthRequired) +- .map(action => action.type === ActionType.ChatToolCallAuthRequired ? action : undefined); +- const resolved = await session.resolveMcpAuthentication({ +- resource: 'https://api.githubcopilot.com/mcp/', +- scopes: ['repo', 'notifications'], +- token: 'token', +- }); +- await authPromise; +- const afterResolve = getActions(signals) +- .filter(action => action.type === ActionType.ChatToolCallAuthResolved) +- .map(action => action.type === ActionType.ChatToolCallAuthResolved ? action : undefined); - -- test('multiple concurrent client tool calls resolve independently', async () => { -- const { session, runtime } = await createAgentSession(disposables, { clientSnapshot: snapshot }); +- assert.deepStrictEqual({ +- beforeResolve, +- resolved, +- afterResolve, +- }, { +- beforeResolve: [{ +- type: ActionType.ChatToolCallAuthRequired, +- turnId: 'turn-auth', +- toolCallId: 'tool-auth', +- 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', +- }, +- }], +- resolved: true, +- afterResolve: [{ +- type: ActionType.ChatToolCallAuthResolved, +- turnId: 'turn-auth', +- toolCallId: 'tool-auth', +- }], +- }); +- }); - -- const tools = runtime.createClientSdkTools(); -- const promise1 = invokeClientToolHandler(tools[0], 'tc-multi-1'); -- const promise2 = invokeClientToolHandler(tools[0], 'tc-multi-2'); +- 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'); +- mockSession.fire('tool.execution_start', { +- toolCallId: 'tool-auth-cancel', +- toolName: 'mcp_tool', +- mcpServerName: 'github', +- } as SessionEventPayload<'tool.execution_start'>['data']); +- const authPromise = runtime.handleMcpAuthRequest({ +- requestId: 'auth-tool-cancel', +- serverName: 'github', +- serverUrl: 'https://api.githubcopilot.com/mcp/', +- reason: 'upscope', +- wwwAuthenticateParams: { scope: 'notifications', error: 'insufficient_scope' }, +- }, { sessionId: 'test-session-1' }); - -- // Complete in reverse order -- session.handleClientToolCallComplete('tc-multi-2', { -- success: true, -- pastTenseMessage: 'second done', -- content: [{ type: ToolResultContentType.Text, text: 'result-2' }], -- }); -- session.handleClientToolCallComplete('tc-multi-1', { -- success: true, -- pastTenseMessage: 'first done', -- content: [{ type: ToolResultContentType.Text, text: 'result-1' }], +- session.handleClientToolCallComplete('tool-auth-cancel', { +- success: false, +- pastTenseMessage: 'Cancelled tool call', +- error: { message: 'MCP authentication was cancelled', code: 'cancelled' }, - }); - -- const [result1, result2] = await Promise.all([promise1, promise2]); -- assert.strictEqual(result1.textResultForLlm, 'result-1'); -- assert.strictEqual(result2.textResultForLlm, 'result-2'); +- assert.deepStrictEqual(await authPromise, { kind: 'cancelled' }); - }); - -- test('handler cleans up deferred after consuming result', async () => { -- const { session, runtime } = await createAgentSession(disposables, { clientSnapshot: snapshot }); +- test('client tool completion preserves shared MCP authentication for other tool calls', async () => { +- const { session, mockSession, runtime, signals } = await createAgentSession(disposables); +- session.resetTurnState('turn-shared-auth'); +- for (const toolCallId of ['tool-auth-cancel', 'tool-auth-continue']) { +- mockSession.fire('tool.execution_start', { +- toolCallId, +- toolName: 'mcp_tool', +- mcpServerName: 'github', +- } as SessionEventPayload<'tool.execution_start'>['data']); +- } +- const authPromise = runtime.handleMcpAuthRequest({ +- requestId: 'shared-auth', +- serverName: 'github', +- serverUrl: 'https://api.githubcopilot.com/mcp/', +- reason: 'upscope', +- wwwAuthenticateParams: { scope: 'notifications', error: 'insufficient_scope' }, +- }, { sessionId: 'test-session-1' }); - -- const tools = runtime.createClientSdkTools(); -- const handlerPromise = invokeClientToolHandler(tools[0], 'tc-cleanup'); +- session.handleClientToolCallComplete('tool-auth-cancel', { +- success: false, +- pastTenseMessage: 'Cancelled tool call', +- error: { message: 'MCP authentication was cancelled', code: 'cancelled' }, +- }); +- const resolved = await session.resolveMcpAuthentication({ +- resource: 'https://api.githubcopilot.com/mcp/', +- scopes: ['notifications'], +- token: 'token', +- }); - -- session.handleClientToolCallComplete('tc-cleanup', { -- success: true, -- pastTenseMessage: 'done', -- content: [{ type: ToolResultContentType.Text, text: 'ok' }], +- assert.deepStrictEqual({ +- resolved, +- authResult: await authPromise, +- resolvedToolCalls: getActions(signals) +- .filter(action => action.type === ActionType.ChatToolCallAuthResolved) +- .map(action => action.type === ActionType.ChatToolCallAuthResolved ? action.toolCallId : undefined), +- }, { +- resolved: true, +- authResult: { kind: 'token', accessToken: 'token' }, +- resolvedToolCalls: ['tool-auth-continue'], - }); +- }); - -- await handlerPromise; +- test('initial GitHub MCP auth reuses the existing token without requesting the advertised scope catalog', async () => { +- const { runtime, signals } = await createAgentSession(disposables, { githubToken: 'existing-token' }); - -- // A second complete for the same toolCallId should create a new -- // deferred (not fail). This tests the cleanup path. -- session.handleClientToolCallComplete('tc-cleanup', { -- success: true, -- pastTenseMessage: 'done again', +- const result = await runtime.handleMcpAuthRequest({ +- requestId: 'faa18cc1-fe2d-492a-8350-9faa4bbb5389', +- serverName: 'github', +- serverUrl: 'https://api.githubcopilot.com/mcp/', +- reason: 'initial', +- resourceMetadata: JSON.stringify({ +- resource: 'https://api.githubcopilot.com/mcp/', +- resource_name: 'GitHub MCP Server', +- authorization_servers: ['https://github.com/login/oauth'], +- scopes_supported: ['repo', 'read:org', 'read:user', 'user:email', 'read:packages', 'write:packages', 'read:project', 'project', 'gist', 'notifications', 'workflow', 'codespace'], +- }), +- wwwAuthenticateParams: { +- resourceMetadataUrl: 'https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp/', +- error: 'invalid_request', +- }, +- }, { sessionId: 'test-session-1' }); +- +- assert.deepStrictEqual({ +- result, +- customizationUpdates: getActions(signals).filter(action => action.type === ActionType.SessionCustomizationUpdated), +- }, { +- result: { kind: 'token', accessToken: 'existing-token' }, +- customizationUpdates: [], - }); - }); - -- test('client tool handler logs and rethrows failures', async () => { -- const logService = new CapturingLogService(); -- const { session, runtime } = await createAgentSession(disposables, { clientSnapshot: snapshot, logService }); -- const tools = runtime.createClientSdkTools(); -- const sessionInternals = session as unknown as ISessionInternalsForTest; -- sessionInternals._pendingClientToolCalls.register = () => { -- throw new Error('client tool boom'); -- }; +- test('initial GitHub MCP auth reuses the existing token for the per-user enterprise endpoint', async () => { +- const { runtime, signals } = await createAgentSession(disposables, { +- githubToken: 'existing-enterprise-token', +- copilotApiEndpoint: 'https://api.enterprise.githubcopilot.com', +- }); - -- await assert.rejects( -- invokeClientToolHandler(tools[0], 'tc-client-error'), -- /client tool boom/, -- ); +- const result = await runtime.handleMcpAuthRequest({ +- requestId: 'enterprise-initial-auth', +- serverName: 'github', +- serverUrl: 'https://api.enterprise.githubcopilot.com/mcp/', +- reason: 'initial', +- }, { sessionId: 'test-session-1' }); - -- assert.strictEqual(logService.errors.length, 1); -- const [entry] = logService.errors; -- assert.ok(entry.first instanceof Error); -- assert.strictEqual((entry.first as Error).message, 'client tool boom'); -- assert.strictEqual(entry.args[0], '[Copilot:test-session-1] Failed in client tool handler: tool=my_tool, toolCallId=tc-client-error'); +- assert.deepStrictEqual({ +- result, +- customizationUpdates: getActions(signals).filter(action => action.type === ActionType.SessionCustomizationUpdated), +- }, { +- result: { kind: 'token', accessToken: 'existing-enterprise-token' }, +- customizationUpdates: [], +- }); - }); - -- test('permission request before client tool handler emits only confirmation ready', async () => { -- const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { clientSnapshot: snapshot }); +- test('MCP auth request publishes authRequired state and resolves with authenticate token', async () => { +- const { session, runtime, waitForSignal } = await createAgentSession(disposables, { githubToken: 'existing-token' }); - -- mockSession.fire('tool.execution_start', { -- toolCallId: 'tc-ready-data', -- toolName: 'my_tool', -- arguments: { file: 'test.ts' }, -- } as SessionEventPayload<'tool.execution_start'>['data']); +- const authPromise = runtime.handleMcpAuthRequest({ +- requestId: 'auth-1', +- serverName: 'github', +- serverUrl: 'https://api.githubcopilot.com/mcp', +- reason: 'upscope', +- staticClientConfig: { +- clientId: 'configured-client-id', +- clientSecret: 'configured-client-secret', +- publicClient: false, +- }, +- 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' }); - -- // tool_start should have fired -- assert.strictEqual(signals.filter(s => isAction(s, ActionType.ChatToolCallStart)).length, 1); +- const signal = await waitForSignal(s => isAction(s, ActionType.SessionCustomizationUpdated)) as IAgentActionSignal; +- const action = signal.action as Extract; - -- // Permission before the handler should produce only the confirmation -- // pending_confirmation, not a synthetic auto-ready. -- const resultPromise = runtime.handlePermissionRequest({ -- kind: 'custom-tool', -- toolCallId: 'tc-ready-data', -- toolName: 'my_tool', +- assert.deepStrictEqual(action.customization, { +- type: 'mcpServer', +- id: 'mcp-top-level:copilot:test-session-1:github', +- uri: 'mcp-top-level:copilot:test-session-1:github', +- name: 'github', +- enabled: true, +- state: { +- kind: McpServerStatus.AuthRequired, +- reason: McpAuthRequiredReason.InsufficientScope, +- oauthClient: { +- clientId: 'configured-client-id', +- clientSecret: 'configured-client-secret', +- }, +- 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', +- }, +- channel: undefined, +- mcpApp: { capabilities: { serverTools: { listChanged: true }, serverResources: {}, sampling: {} } }, - }); - -- await waitForSignal(s => s.kind === 'pending_confirmation'); -- const permSignals = signals.filter((s): s is IAgentToolPendingConfirmationSignal => s.kind === 'pending_confirmation'); -- assert.strictEqual(permSignals.length, 1); -- assert.ok(permSignals[0].state.confirmationTitle); -- -- session.respondToPermissionRequest('tc-ready-data', true); -- await resultPromise; +- assert.strictEqual(await session.resolveMcpAuthentication({ resource: 'https://api.githubcopilot.com/mcp', scopes: ['repo', 'notifications'], token: 'token-1' }), true); +- assert.deepStrictEqual(await authPromise, { kind: 'token', accessToken: 'token-1' }); - }); - -- test('handleClientToolCallComplete with content containing embedded resources', async () => { -- const { session, runtime } = await createAgentSession(disposables, { clientSnapshot: snapshot }); +- test('initial auth for a server impersonating the GitHub MCP name uses the normal auth flow', async () => { +- const { session, runtime, waitForSignal } = await createAgentSession(disposables, { githubToken: 'existing-token' }); - -- const tools = runtime.createClientSdkTools(); -- const handlerPromise = invokeClientToolHandler(tools[0], 'tc-embedded'); +- const authPromise = runtime.handleMcpAuthRequest({ +- requestId: 'auth-lookalike', +- serverName: 'github', +- serverUrl: 'https://mcp.example.com', +- reason: 'initial', +- staticClientConfig: { +- clientId: 'public-client-id', +- publicClient: true, +- }, +- resourceMetadata: JSON.stringify({ +- resource: 'https://mcp.example.com', +- resource_name: 'Lookalike MCP', +- authorization_servers: ['https://github.com/login/oauth'], +- scopes_supported: ['repo'], +- }), +- }, { sessionId: 'test-session-1' }); - -- session.handleClientToolCallComplete('tc-embedded', { -- success: true, -- pastTenseMessage: 'done', -- content: [ -- { type: ToolResultContentType.Text, text: 'text part' }, -- { type: ToolResultContentType.EmbeddedResource, data: 'base64data', contentType: 'image/png' }, -- ], +- const signal = await waitForSignal(signal => isAction(signal, ActionType.SessionCustomizationUpdated)) as IAgentActionSignal; +- const customization = (signal.action as Extract).customization; +- if (customization.type !== CustomizationType.McpServer) { +- assert.fail(`Expected MCP server customization, got ${customization.type}`); +- } +- const resolved = await session.resolveMcpAuthentication({ resource: 'https://mcp.example.com', scopes: ['repo'], token: 'interactive-token' }); +- +- assert.deepStrictEqual({ +- resolved, +- result: await authPromise, +- oauthClient: customization.state.kind === McpServerStatus.AuthRequired ? customization.state.oauthClient : undefined, +- requiredScopes: customization.state.kind === McpServerStatus.AuthRequired ? customization.state.requiredScopes : undefined, +- supportedScopes: customization.state.kind === McpServerStatus.AuthRequired ? customization.state.resource.scopes_supported : undefined, +- }, { +- resolved: true, +- result: { kind: 'token', accessToken: 'interactive-token' }, +- oauthClient: { clientId: 'public-client-id' }, +- requiredScopes: undefined, +- supportedScopes: ['repo'], - }); +- }); - -- const result = await handlerPromise; -- assert.strictEqual(result.resultType, 'success'); -- // Text content should be extracted -- assert.strictEqual(result.textResultForLlm, 'text part'); +- test('needs-auth status remains starting when no auth request details are available', async () => { +- const { mockSession, waitForSignal } = await createAgentSession(disposables); +- +- mockSession.fire('session.mcp_server_status_changed', { +- serverName: 'github', +- status: 'needs-auth', +- } as SessionEventPayload<'session.mcp_server_status_changed'>['data']); +- +- const signal = await waitForSignal(s => isAction(s, ActionType.SessionCustomizationUpdated)) as IAgentActionSignal; +- const action = signal.action as Extract; +- assert.strictEqual(action.customization.type, 'mcpServer'); +- assert.deepStrictEqual(action.customization.state, { kind: McpServerStatus.Starting }); - }); - -- test('handleClientToolCallComplete describes embedded-resource-only content', async () => { -- const testCases = [ -- { -- toolCallId: 'tc-image-only', -- contentType: 'image/png', -- expectedText: 'Tool produced the attached image', -- expectedType: 'image', -- }, -- { -- toolCallId: 'tc-file-only', -- contentType: 'application/pdf', -- expectedText: 'Tool produced the attached file', -- expectedType: 'resource', -- }, -- { -- toolCallId: 'tc-image-and-file', -- contentType: 'image/png', -- additionalContentType: 'application/pdf', -- expectedText: 'Tool produced the attached image and file', -- expectedType: 'image', +- test('seeds inventory from rpc.mcp.list at subscription time', async () => { +- const { signals, waitForSignal } = await createAgentSession(disposables, { +- configureMockSession: m => { +- m.mcpListResult = { +- servers: [ +- { name: 'alpha', status: 'connected' }, +- { name: 'beta', status: 'pending' }, +- ], +- }; - }, -- ] satisfies ReadonlyArray<{ -- readonly toolCallId: string; -- readonly contentType: string; -- readonly additionalContentType?: string; -- readonly expectedText: string; -- readonly expectedType: 'image' | 'resource'; -- }>; -- const embeddedResource = (data: string, contentType: string): ToolResultContent => ({ type: ToolResultContentType.EmbeddedResource, data, contentType }); +- }); - -- for (const testCase of testCases) { -- const { session, runtime } = await createAgentSession(disposables, { clientSnapshot: snapshot }); -- const tools = runtime.createClientSdkTools(); -- const handlerPromise = invokeClientToolHandler(tools[0], testCase.toolCallId); -- const content: ToolResultContent[] = [ -- embeddedResource('base64data', testCase.contentType), -- ...(testCase.additionalContentType ? [embeddedResource('base64data2', testCase.additionalContentType)] : []), -- ]; +- await waitForSignal(s => isAction(s, ActionType.SessionCustomizationUpdated)); +- // Give the seed's microtask chain time to apply both servers. +- await timeout(0); - -- session.handleClientToolCallComplete(testCase.toolCallId, { -- success: true, -- pastTenseMessage: 'done', -- content, -- }); +- const updates = getActions(signals).filter(a => a.type === ActionType.SessionCustomizationUpdated); +- const names = updates.map(a => (a as { customization: { name: string } }).customization.name).sort(); +- assert.deepStrictEqual(names, ['alpha', 'beta']); +- }); +- +- test('logs a warning and continues when rpc.mcp.list rejects', async () => { +- const logService = new CapturingLogService(); +- const { mockSession, waitForSignal } = await createAgentSession(disposables, { +- logService, +- configureMockSession: m => { m.mcpListError = new Error('boom'); }, +- }); +- // Allow the rejected promise to surface. +- await timeout(0); +- await timeout(0); +- +- assert.ok( +- logService.warnings.some(w => w.message.includes('Failed to seed MCP server inventory')), +- `expected seed-failure warning, got: ${JSON.stringify(logService.warnings)}`, +- ); - -- assert.deepStrictEqual(await handlerPromise, { -- textResultForLlm: testCase.expectedText, -- resultType: 'success', -- binaryResultsForLlm: [ -- { data: 'base64data', mimeType: testCase.contentType, type: testCase.expectedType }, -- ...(testCase.additionalContentType ? [{ data: 'base64data2', mimeType: testCase.additionalContentType, type: 'resource' }] : []), -- ], -- }); -- disposables.clear(); -- } +- // Subsequent live events still flow through the normal pipeline. +- mockSession.fire('session.mcp_servers_loaded', { +- servers: [{ name: 'late', status: 'connected' }], +- } as SessionEventPayload<'session.mcp_servers_loaded'>['data']); +- await waitForSignal(s => isAction(s, ActionType.SessionCustomizationUpdated)); - }); - -- test('client tool start stamps the LIVE clientId from the shared ActiveClientState', async () => { -- const activeClientState = new ActiveClientState(); -- activeClientState.update('client-A', snapshot.tools); -- const { mockSession, signals } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientState }); +- test('a failed MCP server logs at error with structured attributes and preserves the failure detail', async () => { +- const logService = new CapturingLogService(); +- const { mockSession, waitForSignal } = await createAgentSession(disposables, { logService }); - -- mockSession.fire('tool.execution_start', { -- toolCallId: 'tc-live-1', -- toolName: 'my_tool', -- arguments: {}, -- } as SessionEventPayload<'tool.execution_start'>['data']); +- mockSession.fire('session.mcp_server_status_changed', { +- serverName: 'db', +- status: 'failed', +- error: 'connection refused', +- } as SessionEventPayload<'session.mcp_server_status_changed'>['data']); - -- // A window reload re-pushes the same tools under a new clientId. -- activeClientState.update('client-B', snapshot.tools); -- mockSession.fire('tool.execution_start', { -- toolCallId: 'tc-live-2', -- toolName: 'my_tool', -- arguments: {}, -- } as SessionEventPayload<'tool.execution_start'>['data']); +- const signal = await waitForSignal(s => isAction(s, ActionType.SessionCustomizationUpdated)) as IAgentActionSignal; +- const action = signal.action as Extract; +- const record = logService.errors.find(e => String(e.first).includes('MCP server \'db\'')); - -- const starts = signals.filter((s): s is IAgentActionSignal => isAction(s, ActionType.ChatToolCallStart)); -- assert.deepStrictEqual(starts.map(s => (s.action as ChatToolCallStartAction).contributor), [ -- { kind: ToolCallContributorKind.Client, clientId: 'client-A' }, -- { kind: ToolCallContributorKind.Client, clientId: 'client-B' }, -- ]); +- assert.deepStrictEqual({ +- state: action.customization.type === 'mcpServer' ? action.customization.state : undefined, +- body: record ? String(record.first).replace(/^\[Copilot:[^\]]*\]\s*/, '') : undefined, +- attributes: record?.args[0] instanceof OtelData ? record.args[0].attributes : undefined, +- }, { +- state: { kind: McpServerStatus.Error, error: { errorType: 'mcp-server-failed', message: 'connection refused' } }, +- body: 'MCP server \'db\' failed (error): connection refused', +- attributes: { mcpEvent: 'statusChanged', mcpServer: 'db', mcpStatus: 'failed', mcpState: 'error', errorType: 'mcp-server-failed' }, +- }); - }); - -- test('completion arriving before the SDK handler registers still resolves', async () => { -- const { session, runtime } = await createAgentSession(disposables, { clientSnapshot: snapshot }); -- const tools = runtime.createClientSdkTools(); +- test('an MCP lifecycle change logs at info with the SDK-reported metadata', async () => { +- const logService = new CapturingLogService(); +- const { mockSession, waitForSignal } = await createAgentSession(disposables, { logService }); - -- // Completion races ahead of the handler. -- session.handleClientToolCallComplete('tc-early', { -- success: true, -- pastTenseMessage: 'done', -- content: [{ type: ToolResultContentType.Text, text: 'buffered result' }], -- }); +- mockSession.fire('session.mcp_servers_loaded', { +- servers: [{ name: 'docs', status: 'connected', source: 'plugin', transport: 'stdio', pluginName: 'acme', pluginVersion: '1.2.3' }], +- } as SessionEventPayload<'session.mcp_servers_loaded'>['data']); +- await waitForSignal(s => isAction(s, ActionType.SessionCustomizationUpdated)); - -- const result = await invokeClientToolHandler(tools[0], 'tc-early'); -- assert.strictEqual(result.resultType, 'success'); -- assert.strictEqual(result.textResultForLlm, 'buffered result'); +- const record = logService.infos.find(i => i.message.includes('MCP server \'docs\'')); +- assert.deepStrictEqual(record?.args[0] instanceof OtelData ? record.args[0].attributes : undefined, { +- mcpEvent: 'loaded', +- mcpServer: 'docs', +- mcpStatus: 'connected', +- mcpState: 'ready', +- mcpSource: 'plugin', +- mcpTransport: 'stdio', +- mcpPlugin: 'acme', +- mcpPluginVersion: '1.2.3', +- }); - }); -- }); - -- // ---- Server tools ------------------------------------------------------- +- test('an unchanged MCP status is not logged twice', async () => { +- const logService = new CapturingLogService(); +- // Seeding the same server keeps the first log deterministic regardless +- // of when the rpc seed and the live events interleave. +- const { mockSession, waitForSignal } = await createAgentSession(disposables, { +- logService, +- configureMockSession: m => { m.mcpListResult = { servers: [{ name: 'docs', status: 'connected' }] }; }, +- }); - -- suite('server tools', () => { +- mockSession.fire('session.mcp_servers_loaded', { servers: [{ name: 'docs', status: 'connected' }] } as SessionEventPayload<'session.mcp_servers_loaded'>['data']); +- await waitForSignal(s => isAction(s, ActionType.SessionCustomizationUpdated)); +- mockSession.fire('session.mcp_servers_loaded', { servers: [{ name: 'docs', status: 'connected' }] } as SessionEventPayload<'session.mcp_servers_loaded'>['data']); +- await timeout(0); - -- const fakeToolDefinitions: readonly ToolDefinition[] = [ -- { name: 'serverToolA', description: 'A', inputSchema: { type: 'object', properties: {} } }, -- { name: 'serverToolB', description: 'B', inputSchema: { type: 'object', properties: {} } }, -- ]; +- const docsLogs = logService.infos.filter(i => i.message.includes('MCP server \'docs\'')); +- assert.strictEqual(docsLogs.length, 1); +- }); +- }); - -- class FakeServerToolHost implements IAgentServerToolHost { -- readonly definitions: readonly ToolDefinition[] = fakeToolDefinitions; -- readonly toolNames: readonly string[] = fakeToolDefinitions.map(def => def.name); -- readonly advertised: string[] = []; -- readonly executions: Array<{ sessionUri: string; toolName: string; rawArgs: unknown }> = []; -- result = 'ok'; -- error: Error | undefined; +- suite('restricted telemetry', () => { +- test('uses the client request id for model conversation.messageText', async () => { +- const telemetryService = new CapturingRestrictedTelemetryService(); +- const { mockSession } = await createAgentSession(disposables, { +- telemetryService, +- restrictedTelemetryContext: { +- restrictedTelemetryEnabled: true, +- trackingId: 'tracking-id', +- telemetryEndpoint: 'https://telemetry.example', +- }, +- }); - -- advertise(sessionUri: string): void { -- this.advertised.push(sessionUri); -- } +- mockSession.fire('assistant.message', { +- messageId: 'message-1', +- content: 'model response', +- clientRequestId: 'client-request-id', +- serviceRequestId: 'service-request-id', +- } as SessionEventPayload<'assistant.message'>['data']); +- await timeout(0); - -- requiresConfirmation(_toolName: string): boolean { return false; } +- assert.deepStrictEqual(telemetryService.events +- .filter(event => event.eventName === 'conversation.messageText') +- .map(event => ({ destination: event.destination, headerRequestId: event.properties?.headerRequestId })), [ +- { destination: 'enhanced', headerRequestId: 'client-request-id' }, +- { destination: 'internal', headerRequestId: 'client-request-id' }, +- ]); +- }); - -- executeTool(sessionUri: string, toolName: string, rawArgs: unknown): string { -- this.executions.push({ sessionUri, toolName, rawArgs }); -- if (this.error) { -- throw this.error; -- } -- return this.result; -- } -- } +- test('emits automode.routerDecisionRestricted from root Auto mode resolution', async () => { +- const telemetryService = new CapturingRestrictedTelemetryService(); +- const { session, mockSession } = await createAgentSession(disposables, { +- telemetryService, +- restrictedTelemetryContext: { +- restrictedTelemetryEnabled: true, +- trackingId: 'tracking-id', +- telemetryEndpoint: 'https://telemetry.example', +- }, +- }); +- session.resetTurnState('turn-auto', undefined, AgentHostClientType.AgentsWindow); +- +- mockSession.fire('session.auto_mode_resolved', { +- chosenModel: 'subagent-model', +- } as SessionEventPayload<'session.auto_mode_resolved'>['data'], { agentId: 'subagent-1' }); +- mockSession.fire('session.auto_mode_resolved', { +- chosenModel: 'gpt-5', +- predictedLabel: 'needs_reasoning', +- confidence: 0.91, +- candidateModels: ['gpt-5', 'gpt-4.1'], +- categoryScores: { needs_reasoning: 0.9, no_reasoning: 0.1 }, +- } as SessionEventPayload<'session.auto_mode_resolved'>['data']); +- +- assert.deepStrictEqual(telemetryService.events +- .filter(event => event.eventName === 'automode.routerDecisionRestricted') +- .map(event => ({ destination: event.destination, properties: event.properties })), [{ +- destination: 'enhanced', +- properties: { +- conversationId: 'test-session-1', +- vscodeRequestId: 'turn-auto', +- initiatorClientType: 'agents_window', +- predictedLabel: 'needs_reasoning', +- candidateModel: 'gpt-5', +- chosenModel: 'gpt-5', +- candidateModels: JSON.stringify(['gpt-5', 'gpt-4.1']), +- binaryScores: JSON.stringify({ needs_reasoning: 0.9, no_reasoning: 0.1 }), +- }, +- }]); +- }); +- }); - -- test('advertises the server tools on initialize and exposes them as server SDK tools', async () => { -- const serverToolHost = new FakeServerToolHost(); -- const { runtime } = await createAgentSession(disposables, { serverToolHost }); +- suite('repoInfo telemetry', () => { +- test('captures one begin and end across root SDK rounds', async () => { +- const workingDirectory = URI.file('/repo'); +- const gitService: IAgentHostGitService = { +- ...createNoopGitService(), +- getRepositoryRoot: async () => workingDirectory, +- getSessionGitState: async () => ({ branchName: 'feature', baseBranchName: 'main' }), +- getFetchRemoteUrls: async () => ['https://github.com/microsoft/vscode'], +- resolveBranchBaselineCommit: async () => 'base', +- getBranchDiffSafetyInfo: async () => ({ hasVirtualFileSystem: false, baselineCommitTimestamp: Date.now(), commitCount: 1, workspaceFileCount: 10 }), +- captureWorkingTreeAsTree: async () => 'tree', +- computeFileDiffsBetweenRefs: async () => [], +- }; +- const telemetryService = new CapturingRestrictedTelemetryService(); +- const { session, mockSession } = await createAgentSession(disposables, { +- workingDirectory, +- gitService, +- telemetryService, +- githubToken: 'github-token', +- restrictedTelemetryContext: { +- restrictedTelemetryEnabled: true, +- trackingId: 'tracking-id', +- telemetryEndpoint: 'https://telemetry.example', +- isInternal: true, +- userName: 'octocat', +- isVscodeTeamMember: true, +- copilotIgnoreEnabled: false, +- }, +- }); - -- const sessionUri = AgentSession.uri('copilot', 'test-session-1').toString(); -- assert.deepStrictEqual(serverToolHost.advertised, [sessionUri]); +- mockSession.fire('assistant.turn_start', { turnId: 'subagent-turn' }, { agentId: 'subagent-1' }); +- mockSession.fire('assistant.turn_end', { turnId: 'subagent-turn' }, { agentId: 'subagent-1' }); +- session.resetTurnState('request-1', undefined, AgentHostClientType.EditorWindow); +- mockSession.fire('assistant.turn_start', { turnId: 'root-round-1' }); +- await timeout(0); +- mockSession.fire('assistant.turn_end', { turnId: 'root-round-1' }); +- mockSession.fire('assistant.turn_start', { turnId: 'root-round-2' }); +- mockSession.fire('assistant.turn_end', { turnId: 'root-round-2' }); +- mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); +- await timeout(0); - -- const tools = runtime.createServerSdkTools(); -- assert.deepStrictEqual(tools.map(t => t.name).sort(), [...serverToolHost.toolNames].sort()); +- assert.deepStrictEqual(telemetryService.events +- .filter(event => event.eventName === 'request.repoInfo') +- .map(event => ({ destination: event.destination, initiatorClientType: event.properties?.initiatorClientType, location: event.properties?.location, telemetryMessageId: event.properties?.telemetryMessageId, result: event.properties?.result })), [ +- { destination: 'enhanced', initiatorClientType: 'editor_window', location: 'begin', telemetryMessageId: 'request-1', result: 'noChanges' }, +- { destination: 'internal', initiatorClientType: 'editor_window', location: 'begin', telemetryMessageId: 'request-1', result: 'noChanges' }, +- { destination: 'enhanced', initiatorClientType: 'editor_window', location: 'end', telemetryMessageId: 'request-1', result: 'noChanges' }, +- { destination: 'internal', initiatorClientType: 'editor_window', location: 'end', telemetryMessageId: 'request-1', result: 'noChanges' }, +- ]); - }); - -- test('server tool handler routes to the host and returns a success result', async () => { -- const serverToolHost = new FakeServerToolHost(); -- serverToolHost.result = 'listed 2 comments'; -- const { runtime } = await createAgentSession(disposables, { serverToolHost }); -- -- const tools = runtime.createServerSdkTools(); -- const result = await invokeClientToolHandler(tools[0], 'tc-server-tool', { foo: 'bar' }); +- test('drops an in-flight capture when the launch token is no longer current', async () => { +- let tokenCurrent = true; +- const workingDirectory = URI.file('/repo'); +- const telemetryService = new CapturingRestrictedTelemetryService(); +- const gitService: IAgentHostGitService = { +- ...createNoopGitService(), +- getRepositoryRoot: async () => workingDirectory, +- getSessionGitState: async () => ({ branchName: 'feature', baseBranchName: 'main' }), +- getFetchRemoteUrls: async () => ['https://github.com/microsoft/vscode'], +- resolveBranchBaselineCommit: async () => 'base', +- getBranchDiffSafetyInfo: async () => ({ hasVirtualFileSystem: false, baselineCommitTimestamp: Date.now(), commitCount: 1, workspaceFileCount: 10 }), +- captureWorkingTreeAsTree: async () => 'tree', +- computeFileDiffsBetweenRefs: async () => [], +- }; +- const { mockSession } = await createAgentSession(disposables, { +- workingDirectory, +- gitService, +- telemetryService, +- githubToken: 'github-token', +- isLaunchTokenCurrent: () => tokenCurrent, +- restrictedTelemetryContext: { +- restrictedTelemetryEnabled: true, +- trackingId: 'tracking-id', +- telemetryEndpoint: 'https://telemetry.example', +- isInternal: true, +- userName: 'octocat', +- isVscodeTeamMember: true, +- copilotIgnoreEnabled: false, +- }, +- }); +- mockSession.fire('assistant.turn_start', { turnId: 'root-turn' }); +- tokenCurrent = false; +- await timeout(0); - -- const sessionUri = AgentSession.uri('copilot', 'test-session-1').toString(); -- assert.deepStrictEqual(serverToolHost.executions, [{ sessionUri, toolName: tools[0].name, rawArgs: { foo: 'bar' } }]); -- assert.strictEqual(result.resultType, 'success'); -- assert.strictEqual(result.textResultForLlm, 'listed 2 comments'); +- assert.deepStrictEqual(telemetryService.events.filter(event => event.eventName === 'request.repoInfo'), []); - }); - -- test('server tool handler surfaces host failures as a failure result', async () => { -- const serverToolHost = new FakeServerToolHost(); -- serverToolHost.error = new Error('boom'); -- const { runtime } = await createAgentSession(disposables, { serverToolHost }); -- -- const tools = runtime.createServerSdkTools(); -- const result = await invokeClientToolHandler(tools[0], 'tc-server-tool'); +- test('does not touch Git when repo-info telemetry is disabled', async () => { +- let gitCalls = 0; +- const { mockSession } = await createAgentSession(disposables, { +- workingDirectory: URI.file('/repo'), +- githubToken: 'github-token', +- rootValues: { [AgentHostDisableRepoInfoTelemetryConfigKey]: true }, +- gitService: { +- ...createNoopGitService(), +- getSessionGitState: async () => { gitCalls++; return undefined; }, +- }, +- }); - -- assert.strictEqual(result.resultType, 'failure'); -- assert.strictEqual(result.textResultForLlm, 'boom'); -- assert.strictEqual(result.error, 'boom'); -- }); +- mockSession.fire('assistant.turn_start', { turnId: 'root-turn' }); +- await timeout(0); - -- test('exposes no server SDK tools and advertises nothing when no host is wired', async () => { -- const { runtime } = await createAgentSession(disposables); -- assert.deepStrictEqual(runtime.createServerSdkTools(), []); +- assert.strictEqual(gitCalls, 0); - }); - -- test('auto-approves every server tool without prompting for confirmation', async () => { -- const serverToolHost = new FakeServerToolHost(); -- const { runtime, signals } = await createAgentSession(disposables, { serverToolHost }); +- test('skips capture when repository telemetry context resolution fails', async () => { +- const logService = new CapturingLogService(); +- const telemetryService = new CapturingRestrictedTelemetryService(); +- const { mockSession } = await createAgentSession(disposables, { +- workingDirectory: URI.file('/repo'), +- githubToken: 'github-token', +- logService, +- telemetryService, +- restrictedTelemetryContextError: new Error('context failed'), +- }); - -- const results = []; -- for (const toolName of serverToolHost.toolNames) { -- results.push(await runtime.handlePermissionRequest({ kind: 'custom-tool', toolCallId: `tc-${toolName}`, toolName })); -- } +- mockSession.fire('assistant.turn_start', { turnId: 'root-turn' }); +- await timeout(0); +- mockSession.fire('assistant.turn_end', { turnId: 'root-turn' }); +- await timeout(0); - - assert.deepStrictEqual({ -- results, -- pendingConfirmations: signals.filter(s => s.kind === 'pending_confirmation').length, +- events: telemetryService.events.filter(event => event.eventName === 'request.repoInfo'), +- warnings: logService.warnings.map(warning => warning.message).filter(message => message.includes('repository info telemetry context')), - }, { -- results: serverToolHost.toolNames.map(() => ({ kind: 'approve-once' })), -- pendingConfirmations: 0, +- events: [], +- warnings: ['[Copilot:test-session-1] Failed to resolve repository info telemetry context: context failed'], - }); - }); - }); - -- // ---- Plan mode ---------------------------------------------------------- +- suite('instructionsCollected telemetry', () => { - -- suite('plan mode', () => { +- class CapturingTelemetryService implements ITelemetryService { +- declare readonly _serviceBrand: undefined; +- readonly telemetryLevel = TelemetryLevel.USAGE; +- readonly sendErrorTelemetry = true; +- readonly sessionId = 'sessionId'; +- readonly machineId = 'machineId'; +- readonly sqmId = 'sqmId'; +- readonly devDeviceId = 'devDeviceId'; +- readonly firstSessionDate = 'firstSessionDate'; - -- const planRequestParams = (overrides?: Partial<{ actions: string[]; recommendedAction: string; summary: string }>) => ({ -- sessionId: 'test-session-1', -- summary: overrides?.summary ?? '## Plan summary', -- planContent: '## Plan', -- actions: overrides?.actions ?? ['autopilot', 'interactive', 'exit_only'], -- recommendedAction: overrides?.recommendedAction ?? 'autopilot', +- readonly events: Array<{ eventName: string; data: unknown }> = []; +- +- publicLog(): void { } +- publicLog2> = never, T extends IGDPRProperty = never>(eventName: string, data?: StrictPropertyCheck): void { +- this.events.push({ eventName, data }); +- } +- publicLogError(): void { } +- publicLogError2(): void { } +- setExperimentProperty(): void { } +- setCommonProperty(): void { } +- } +- +- function fireUserMessage(mockSession: MockCopilotSession, overrides?: Partial<{ content: string; source: string }>) { +- mockSession.fire('user.message', { +- content: overrides?.content ?? 'hi', +- ...(overrides?.source !== undefined ? { source: overrides.source } : {}), +- } as SessionEventPayload<'user.message'>['data']); +- } +- +- test('emits with counts derived from source types + AH identifiers', async () => { +- const telemetryService = new CapturingTelemetryService(); +- const { mockSession } = await createAgentSession(disposables, { telemetryService }); +- +- mockSession.getInstructionSourcesResult = { +- sources: [ +- { id: 'a', label: '', sourcePath: '.github/copilot-instructions.md', content: '', type: 'repo', location: 'repository' }, +- { id: 'b', label: '', sourcePath: 'AGENTS.md', content: '', type: 'model', location: 'repository' }, +- { id: 'c', label: '', sourcePath: 'CLAUDE.md', content: '', type: 'model', location: 'repository' }, +- { id: 'd', label: '', sourcePath: '.github/instructions/ts.instructions.md', content: '', type: 'vscode', location: 'repository', applyTo: ['**/*.ts'] }, +- { id: 'e', label: '', sourcePath: '.github/instructions/general.instructions.md', content: '', type: 'vscode', location: 'repository' }, +- { id: 'f', label: '', sourcePath: 'nested AGENTS.md', content: '', type: 'nested-agents', location: 'working-directory' }, +- { id: 'g', label: '', sourcePath: 'child instruction files', content: '', type: 'child-instructions', location: 'working-directory' }, +- ], +- }; +- +- fireUserMessage(mockSession); +- await timeout(0); +- +- const emitted = telemetryService.events.filter(e => e.eventName === 'agentHost.instructionsCollected'); +- assert.deepStrictEqual(emitted, [{ +- eventName: 'agentHost.instructionsCollected', +- data: { +- provider: 'copilot', +- agentSessionId: 'test-session-1', +- isSubagentSession: false, +- totalInstructionsCount: 7, +- agentInstructionsCount: 3, // repo + model + model +- applyingInstructionsCount: 1, // only the ts.instructions.md has applyTo +- referencedInstructionsCount: 2, // nested-agents + child-instructions +- claudeMdCount: 1, +- }, +- }]); +- assert.strictEqual(mockSession.getInstructionSourcesCallCount, 1); - }); - -- test('applyMode pushes the mode to the SDK only when it changes', async () => { -- const { session, mockSession } = await createAgentSession(disposables); +- test('skips SDK-injected messages (source !== "user")', async () => { +- const telemetryService = new CapturingTelemetryService(); +- const { mockSession } = await createAgentSession(disposables, { telemetryService }); - -- await session.applyMode('plan'); -- await session.applyMode('plan'); -- await session.applyMode('autopilot'); -- await session.applyMode(undefined); -- await session.applyMode('autopilot'); +- fireUserMessage(mockSession, { source: 'skill-pdf' }); +- await timeout(0); - -- assert.deepStrictEqual(mockSession.modeSetCalls, [ -- { mode: 'plan' }, -- { mode: 'autopilot' }, -- ]); +- assert.strictEqual(telemetryService.events.filter(e => e.eventName === 'agentHost.instructionsCollected').length, 0); +- assert.strictEqual(mockSession.getInstructionSourcesCallCount, 0, 'should short-circuit before the RPC'); - }); - -- test('send applies mode before forwarding to the SDK', async () => { -- const { session, mockSession } = await createAgentSession(disposables); +- test('skips subagent user messages', async () => { +- const telemetryService = new CapturingTelemetryService(); +- const { mockSession } = await createAgentSession(disposables, { telemetryService }); +- +- mockSession.fire('user.message', { +- content: 'delegated prompt', +- } as SessionEventPayload<'user.message'>['data'], { agentId: 'agent-1' }); +- await timeout(0); +- +- assert.deepStrictEqual({ +- events: telemetryService.events.filter(e => e.eventName === 'agentHost.instructionsCollected'), +- getSourcesCalls: mockSession.getInstructionSourcesCallCount, +- }, { +- events: [], +- getSourcesCalls: 0, +- }); +- }); +- +- test('does not emit or leak an unhandled rejection when getSources throws', async () => { +- const telemetryService = new CapturingTelemetryService(); +- const { mockSession } = await createAgentSession(disposables, { telemetryService }); +- mockSession.getInstructionSourcesError = new Error('rpc unavailable'); +- +- // Any unhandled rejection during this turn would fail the test process. +- fireUserMessage(mockSession); +- await timeout(0); +- +- assert.strictEqual(telemetryService.events.filter(e => e.eventName === 'agentHost.instructionsCollected').length, 0); +- assert.strictEqual(mockSession.getInstructionSourcesCallCount, 1); +- }); +- }); +-}); +diff --git a/src/vs/platform/agentHost/test/node/copilotGitHubTelemetryForwarder.test.ts b/src/vs/platform/agentHost/test/node/copilotGitHubTelemetryForwarder.test.ts +index f9e8a963..5ad71bea 100644 +--- a/src/vs/platform/agentHost/test/node/copilotGitHubTelemetryForwarder.test.ts ++++ b/src/vs/platform/agentHost/test/node/copilotGitHubTelemetryForwarder.test.ts +@@ -5,3 +5,6 @@ + +-import type { GitHubTelemetryNotification } from '@github/copilot-sdk'; ++// `GitHubTelemetryNotification` 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 GitHubTelemetryNotification = any; + import assert from 'assert'; +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 16221839..00000000 +--- a/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts ++++ /dev/null +@@ -1,600 +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 { 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 { FileService } from '../../../files/common/fileService.js'; +-import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +-import { NullLogService } from '../../../log/common/log.js'; +-import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; +-import { toSdkInstructionDirectories, toSdkMcpServers, toSdkCustomAgents, toSdkSessionCustomAgents, toSdkSkillDirectories, 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 { CustomizationType, McpServerStatus, type HookCustomization, type McpServerCustomization, type SkillCustomization } from '../../common/state/protocol/state.js'; - -- await session.send('hi', undefined, 'turn-1', 'plan'); +-function stubMcpCustomization(name = 'test'): McpServerCustomization { +- return { type: CustomizationType.McpServer, id: `mcp:${name}`, uri: 'file:///plugin', name, enabled: true, state: { kind: McpServerStatus.Starting } }; +-} +-function stubHookCustomization(type: string): HookCustomization { +- return { type: CustomizationType.Hook, id: `hook:${type}`, uri: 'file:///plugin/hooks.json', name: 'hooks.json' }; +-} +-function stubSkillCustomization(name: string): SkillCustomization { +- return { type: CustomizationType.Skill, id: `skill:${name}`, uri: `file:///${name}/SKILL.md`, name }; +-} - -- assert.deepStrictEqual(mockSession.modeSetCalls, [{ mode: 'plan' }]); -- assert.strictEqual(mockSession.sendRequests.length, 1); -- }); +-suite('copilotPluginConverters', () => { - -- test('handleExitPlanModeRequest produces a single-select input request with options and recommended', async () => { -- const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables); -- session.resetTurnState('turn-plan'); +- const disposables = new DisposableStore(); +- let fileService: FileService; - -- mockSession.planReadResult = { exists: true, content: '## Plan', path: '/sessions/abc/plan.md' }; +- setup(() => { +- fileService = disposables.add(new FileService(new NullLogService())); +- disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); +- }); - -- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams(), { sessionId: 'test-session-1' }); +- teardown(() => disposables.clear()); +- ensureNoDisposablesAreLeakedInTestSuite(); - -- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); -- const request = getInputRequest(signal); +- // ---- toSdkMcpServers ------------------------------------------------ - -- // The plan summary and "View full plan" link are emitted as a -- // markdown response part before the input request, so the -- // client renders them inline above the question. -- const deltaContent = signals.flatMap(s => { -- if (s.kind !== 'action') { return []; } -- if (s.action.type === ActionType.ChatResponsePart) { -- const part = (s.action as ChatResponsePartAction).part; -- return part.kind === ResponsePartKind.Markdown ? [part.content] : []; -- } -- if (s.action.type === ActionType.ChatDelta) { -- return [(s.action as ChatDeltaAction).content]; -- } -- return []; -- }).join(''); -- assert.ok(deltaContent.includes('Plan summary'), `expected delta to include plan summary; got: ${deltaContent}`); -- assert.ok(deltaContent.includes('plan.md'), 'delta should include a link to the plan file'); +- suite('toSdkMcpServers', () => { - -- const question = request.questions?.[0]; -- assert.strictEqual(question?.kind, ChatInputQuestionKind.SingleSelect); -- if (question?.kind === ChatInputQuestionKind.SingleSelect) { -- assert.deepStrictEqual(question.options.map(o => o.id), ['autopilot', 'interactive', 'exit_only']); -- const recommended = question.options.find(o => o.recommended); -- assert.strictEqual(recommended?.id, 'autopilot'); -- assert.strictEqual(question.allowFreeformInput, true); -- } +- test('converts local server definitions', () => { +- const defs: IMcpServerDefinition[] = [{ +- name: 'test-server', +- uri: URI.file('/plugin'), +- configuration: { +- type: McpServerType.LOCAL, +- command: 'node', +- args: ['server.js', '--port', '3000'], +- env: { NODE_ENV: 'production', PORT: 3000 as unknown as string }, +- cwd: '/workspace', +- }, +- customization: stubMcpCustomization('test-server'), +- }]; - -- // Resolve the request so the deferred completes and the test can clean up. -- session.respondToUserInputRequest(request.id, ChatInputResponseKind.Decline); -- await responsePromise; +- const result = toSdkMcpServers(defs); +- assert.deepStrictEqual(result, { +- 'test-server': { +- type: 'local', +- command: 'node', +- args: ['server.js', '--port', '3000'], +- tools: ['*'], +- env: { NODE_ENV: 'production', PORT: '3000' }, +- cwd: '/workspace', +- }, +- }); - }); - -- test('completing the input request with autopilot resolves with approved + autopilot + autoApproveEdits and syncs mode=autopilot', async () => { -- const { session, runtime, waitForSignal, sessionConfigUpdates } = await createAgentSession(disposables); -- -- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'autopilot' }), { sessionId: 'test-session-1' }); -- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); -- const request = getInputRequest(signal); -- const requestId = request.id; -- const questionId = request.questions![0].id; +- test('converts remote/http server definitions', () => { +- const defs: IMcpServerDefinition[] = [{ +- name: 'remote-server', +- uri: URI.file('/plugin'), +- configuration: { +- type: McpServerType.REMOTE, +- url: 'https://example.com/mcp', +- headers: { 'Authorization': 'Bearer token' }, +- }, +- customization: stubMcpCustomization('remote-server'), +- }]; - -- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { -- [questionId]: { -- state: ChatInputAnswerState.Submitted, -- value: { kind: ChatInputAnswerValueKind.Selected, value: 'autopilot' }, +- const result = toSdkMcpServers(defs); +- assert.deepStrictEqual(result, { +- 'remote-server': { +- type: 'http', +- url: 'https://example.com/mcp', +- tools: ['*'], +- headers: { 'Authorization': 'Bearer token' }, - }, - }); - -- assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'autopilot', autoApproveEdits: true }); -- // Picking "Implement with Autopilot" flips the AHP mode immediately. -- assert.deepStrictEqual(sessionConfigUpdates, [ -- { session: 'copilot:/test-session-1', patch: { mode: 'autopilot' } }, -- ]); - }); - -- test('completing the input request with interactive resolves with approved + interactive (no autoApprove) and syncs mode=interactive', async () => { -- const { session, runtime, waitForSignal, sessionConfigUpdates } = await createAgentSession(disposables); +- test('handles empty definitions', () => { +- const result = toSdkMcpServers([]); +- assert.deepStrictEqual(result, {}); +- }); - -- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'interactive' }), { sessionId: 'test-session-1' }); -- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); -- const request = getInputRequest(signal); -- const requestId = request.id; -- const questionId = request.questions![0].id; +- test('omits optional fields when undefined', () => { +- const defs: IMcpServerDefinition[] = [{ +- name: 'minimal', +- uri: URI.file('/plugin'), +- configuration: { +- type: McpServerType.LOCAL, +- command: 'echo', +- }, +- customization: stubMcpCustomization('minimal'), +- }]; - -- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { -- [questionId]: { -- state: ChatInputAnswerState.Submitted, -- value: { kind: ChatInputAnswerValueKind.Selected, value: 'interactive' }, +- const result = toSdkMcpServers(defs); +- assert.strictEqual(result['minimal'].type, 'local'); +- assert.deepStrictEqual((result['minimal'] as { args?: string[] }).args, []); +- assert.strictEqual(Object.hasOwn(result['minimal'], 'env'), false); +- assert.strictEqual(Object.hasOwn(result['minimal'], 'cwd'), false); +- }); +- +- test('filters null values from env', () => { +- const defs: IMcpServerDefinition[] = [{ +- name: 'with-null-env', +- uri: URI.file('/plugin'), +- configuration: { +- type: McpServerType.LOCAL, +- command: 'test', +- env: { KEEP: 'value', DROP: null as unknown as string }, - }, -- }); +- customization: stubMcpCustomization('with-null-env'), +- }]; - -- assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'interactive' }); -- assert.deepStrictEqual(sessionConfigUpdates, [ -- { session: 'copilot:/test-session-1', patch: { mode: 'interactive' } }, -- ]); +- const result = toSdkMcpServers(defs); +- const env = (result['with-null-env'] as { env?: Record }).env; +- assert.deepStrictEqual(env, { KEEP: 'value' }); - }); +- }); - -- test('declining the input request resolves with approved=false', async () => { -- const { session, runtime, waitForSignal } = await createAgentSession(disposables); +- // ---- toSdkCustomAgents ---------------------------------------------- - -- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams(), { sessionId: 'test-session-1' }); -- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); +- suite('toSdkCustomAgents', () => { - -- session.respondToUserInputRequest(getInputRequest(signal).id, ChatInputResponseKind.Decline); +- 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')); - -- assert.deepStrictEqual(await responsePromise, { approved: false }); -- }); +- const agents: INamedPluginResource[] = [{ uri: agentUri, name: 'helper' }]; +- const result = await toSdkCustomAgents(agents, fileService); - -- test('exit_only resolves as approved + interactive without autoApproveEdits', async () => { -- const { session, runtime, waitForSignal } = await createAgentSession(disposables); +- assert.deepStrictEqual(result, [{ +- name: 'helper', +- tools: null, +- prompt: 'You are a helpful assistant', +- }]); +- }); - -- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive', 'exit_only'], recommendedAction: 'exit_only' }), { sessionId: 'test-session-1' }); -- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); -- const request = getInputRequest(signal); -- const requestId = request.id; -- const questionId = request.questions![0].id; +- test('skips agents whose files cannot be read', async () => { +- const agents: INamedPluginResource[] = [ +- { uri: URI.from({ scheme: Schemas.inMemory, path: '/nonexistent/agent.md' }), name: 'missing' }, +- ]; +- const result = await toSdkCustomAgents(agents, fileService); +- assert.deepStrictEqual(result, []); +- }); - -- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { -- [questionId]: { -- state: ChatInputAnswerState.Submitted, -- value: { kind: ChatInputAnswerValueKind.Selected, value: 'exit_only' }, -- }, -- }); +- test('processes multiple agents, skipping failures', async () => { +- const goodUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/good.md' }); +- await fileService.writeFile(goodUri, VSBuffer.fromString('Good agent')); - -- assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'exit_only' }); +- const agents: INamedPluginResource[] = [ +- { uri: goodUri, name: 'good' }, +- { 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'); - }); - -- test('freeform feedback alongside a selected action becomes a revision request', async () => { -- const { session, runtime, waitForSignal } = await createAgentSession(disposables); +- test('parses YAML frontmatter for name, description, tools, and body', async () => { +- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/review.md' }); +- await fileService.writeFile(agentUri, VSBuffer.fromString([ +- '---', +- 'name: code-reviewer', +- 'description: Reviews code for quality issues', +- 'tools:', +- ' - read_file', +- ' - grep_search', +- '---', +- 'You are a meticulous code reviewer.', +- '', +- ].join('\n'))); - -- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'interactive' }), { sessionId: 'test-session-1' }); -- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); -- const request = getInputRequest(signal); -- const requestId = request.id; -- const questionId = request.questions![0].id; +- const agents: INamedPluginResource[] = [{ uri: agentUri, name: 'review' }]; +- const result = await toSdkCustomAgents(agents, fileService); - -- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { -- [questionId]: { -- state: ChatInputAnswerState.Submitted, -- value: { -- kind: ChatInputAnswerValueKind.Selected, -- value: 'interactive', -- freeformValues: ['Please use Python instead of Node.js'], -- }, -- }, -- }); +- assert.deepStrictEqual(result, [{ +- name: 'code-reviewer', +- description: 'Reviews code for quality issues', +- tools: ['read_file', 'grep_search'], +- prompt: 'You are a meticulous code reviewer.\n', +- }]); +- }); - -- assert.deepStrictEqual(await responsePromise, { -- approved: false, -- feedback: 'Please use Python instead of Node.js', -- selectedAction: 'interactive', -- }); +- test('parses skills and infer from frontmatter', async () => { +- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/skilled.md' }); +- await fileService.writeFile(agentUri, VSBuffer.fromString([ +- '---', +- 'name: skilled', +- 'skills:', +- ' - baking-cake', +- ' - cooking-pasta', +- 'infer: true', +- '---', +- 'Body.', +- ].join('\n'))); +- +- const agents: INamedPluginResource[] = [{ uri: agentUri, name: 'skilled' }]; +- const result = await toSdkCustomAgents(agents, fileService); +- +- assert.deepStrictEqual(result, [{ +- name: 'skilled', +- tools: null, +- skills: ['baking-cake', 'cooking-pasta'], +- infer: true, +- prompt: 'Body.', +- }]); - }); - -- test('selectedAction not in offered actions falls back to recommendedAction', async () => { -- const { session, runtime, waitForSignal } = await createAgentSession(disposables); -- -- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['interactive', 'exit_only'], recommendedAction: 'interactive' }), { sessionId: 'test-session-1' }); -- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); -- const request = getInputRequest(signal); -- const requestId = request.id; -- const questionId = request.questions![0].id; +- test('infer defaults to false when disable-model-invocation is set', async () => { +- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/no-invoke.md' }); +- await fileService.writeFile(agentUri, VSBuffer.fromString([ +- '---', +- 'name: no-invoke', +- 'disable-model-invocation: true', +- '---', +- 'Body.', +- ].join('\n'))); - -- // SDK only offered `interactive` and `exit_only`; the client -- // somehow sent `autopilot` (e.g. stale UI state). The agent -- // host clamps to `recommendedAction` so the SDK never sees a -- // value it didn't offer. -- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { -- [questionId]: { -- state: ChatInputAnswerState.Submitted, -- value: { kind: ChatInputAnswerValueKind.Selected, value: 'autopilot' }, -- }, -- }); +- const agents: INamedPluginResource[] = [{ uri: agentUri, name: 'no-invoke' }]; +- const result = await toSdkCustomAgents(agents, fileService); - -- assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'interactive' }); +- assert.deepStrictEqual(result, [{ +- name: 'no-invoke', +- tools: null, +- infer: false, +- prompt: 'Body.', +- }]); - }); - -- test('selectedAction not in offered actions and no fallback resolves to approved=false', async () => { -- const { session, runtime, waitForSignal } = await createAgentSession(disposables); -- -- // SDK offered `exit_only` only and recommended a value not in -- // the offered set. The client picked something invalid. With -- // no usable selectedAction and no feedback, decline. -- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['exit_only'], recommendedAction: 'autopilot' }), { sessionId: 'test-session-1' }); -- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); -- const request = getInputRequest(signal); -- const requestId = request.id; -- const questionId = request.questions![0].id; +- test('omits skills and infer when frontmatter does not specify them', async () => { +- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/plain.md' }); +- await fileService.writeFile(agentUri, VSBuffer.fromString([ +- '---', +- 'name: plain', +- '---', +- 'Body.', +- ].join('\n'))); - -- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { -- [questionId]: { -- state: ChatInputAnswerState.Submitted, -- value: { kind: ChatInputAnswerValueKind.Selected, value: 'interactive' }, -- }, -- }); +- const agents: INamedPluginResource[] = [{ uri: agentUri, name: 'plain' }]; +- const result = await toSdkCustomAgents(agents, fileService); - -- assert.deepStrictEqual(await responsePromise, { approved: false }); +- assert.strictEqual(Object.hasOwn(result[0], 'skills'), false); +- assert.strictEqual(Object.hasOwn(result[0], 'infer'), false); - }); - -- test('text answer with feedback becomes a revision request without selectedAction', async () => { -- const { session, runtime, waitForSignal } = await createAgentSession(disposables); -- -- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'interactive' }), { sessionId: 'test-session-1' }); -- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); -- const request = getInputRequest(signal); -- const requestId = request.id; -- const questionId = request.questions![0].id; +- test('empty tools array becomes null (all tools)', async () => { +- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/empty-tools.md' }); +- await fileService.writeFile(agentUri, VSBuffer.fromString([ +- '---', +- 'name: free-for-all', +- 'tools: []', +- '---', +- 'Body.', +- ].join('\n'))); - -- // The single-select question normally produces a Selected -- // value, but a defensive Text response should still be -- // translated to a revision request when the answer is -- // non-empty (selectedAction falls back to recommendedAction). -- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { -- [questionId]: { -- state: ChatInputAnswerState.Submitted, -- value: { kind: ChatInputAnswerValueKind.Text, value: 'Add tests for edge cases' }, -- }, -- }); +- const agents: INamedPluginResource[] = [{ uri: agentUri, name: 'fallback' }]; +- const result = await toSdkCustomAgents(agents, fileService); - -- assert.deepStrictEqual(await responsePromise, { -- approved: false, -- feedback: 'Add tests for edge cases', -- selectedAction: 'interactive', -- }); +- assert.deepStrictEqual(result, [{ +- name: 'free-for-all', +- tools: null, +- prompt: 'Body.', +- }]); - }); - -- test('whitespace-only freeform feedback is ignored', async () => { -- const { session, runtime, waitForSignal } = await createAgentSession(disposables); -- -- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'interactive' }), { sessionId: 'test-session-1' }); -- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); -- const request = getInputRequest(signal); -- const requestId = request.id; -- const questionId = request.questions![0].id; +- test('falls back to resource name when frontmatter omits name', async () => { +- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/no-name.md' }); +- await fileService.writeFile(agentUri, VSBuffer.fromString([ +- '---', +- 'description: Helper without an explicit name', +- '---', +- 'Body only.', +- ].join('\n'))); - -- session.respondToUserInputRequest(requestId, ChatInputResponseKind.Accept, { -- [questionId]: { -- state: ChatInputAnswerState.Submitted, -- value: { -- kind: ChatInputAnswerValueKind.Selected, -- value: 'interactive', -- freeformValues: [' ', ''], -- }, -- }, -- }); +- const agents: INamedPluginResource[] = [{ uri: agentUri, name: 'resource-name' }]; +- const result = await toSdkCustomAgents(agents, fileService); - -- assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'interactive' }); +- assert.deepStrictEqual(result, [{ +- name: 'resource-name', +- description: 'Helper without an explicit name', +- tools: null, +- prompt: 'Body only.', +- }]); - }); - -- test('session.mode_changed → plan updates the AHP session config', async () => { -- const { mockSession, sessionConfigUpdates } = await createAgentSession(disposables); +- test('trims whitespace from frontmatter name to match parsed agent name', async () => { +- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/padded.md' }); +- await fileService.writeFile(agentUri, VSBuffer.fromString([ +- '---', +- 'name: " Inbox "', +- '---', +- 'Body.', +- ].join('\n'))); - -- mockSession.fire('session.mode_changed', { previousMode: 'interactive', newMode: 'plan' } as SessionEventPayload<'session.mode_changed'>['data']); +- const agents: INamedPluginResource[] = [{ uri: agentUri, name: 'padded' }]; +- const result = await toSdkCustomAgents(agents, fileService); - -- assert.deepStrictEqual(sessionConfigUpdates, [ -- { session: 'copilot:/test-session-1', patch: { mode: 'plan' } }, -- ]); +- assert.strictEqual(result[0].name, 'Inbox'); - }); +- }); - -- test('session.mode_changed → interactive updates the AHP session config', async () => { -- const { mockSession, sessionConfigUpdates } = await createAgentSession(disposables); +- // ---- toSdkSessionCustomAgents --------------------------------------- - -- mockSession.fire('session.mode_changed', { previousMode: 'plan', newMode: 'interactive' } as SessionEventPayload<'session.mode_changed'>['data']); +- suite('toSdkSessionCustomAgents', () => { - -- assert.deepStrictEqual(sessionConfigUpdates, [ -- { session: 'copilot:/test-session-1', patch: { mode: 'interactive' } }, -- ]); +- test('includes agents from plugins without a file directory', async () => { +- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/loose/helper.md' }); +- await fileService.writeFile(agentUri, VSBuffer.fromString('Loose agent')); +- +- const plugins: IPluginAgentsForSdk[] = [{ agents: [{ uri: agentUri, name: 'helper' }] }]; +- const result = await toSdkSessionCustomAgents(plugins, undefined, fileService); +- +- assert.deepStrictEqual(result, [{ name: 'helper', tools: null, prompt: 'Loose agent' }]); - }); - -- test('session.mode_changed → autopilot maps directly to mode=autopilot', async () => { -- // The SDK and AHP share the same three-mode space; autopilot now -- // lives on the `mode` axis and the `autoApprove` axis is left -- // untouched. The translation is contained in the Copilot agent. -- const { mockSession, sessionConfigUpdates } = await createAgentSession(disposables); +- test('excludes file-dir plugin agents when none is selected', async () => { +- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/plugin/inbox.md' }); +- await fileService.writeFile(agentUri, VSBuffer.fromString('Inbox agent')); - -- mockSession.fire('session.mode_changed', { previousMode: 'plan', newMode: 'autopilot' } as SessionEventPayload<'session.mode_changed'>['data']); +- const plugins: IPluginAgentsForSdk[] = [{ +- pluginDir: URI.file('/plugins/inbox'), +- agents: [{ uri: agentUri, name: 'Inbox' }], +- }]; +- const result = await toSdkSessionCustomAgents(plugins, undefined, fileService); - -- assert.deepStrictEqual(sessionConfigUpdates, [ -- { session: 'copilot:/test-session-1', patch: { mode: 'autopilot' } }, -- ]); +- assert.deepStrictEqual(result, []); - }); - -- test('session.mode_changed for unsupported mode is ignored', async () => { -- const { mockSession, sessionConfigUpdates } = await createAgentSession(disposables); +- test('forces the selected file-dir plugin agent into customAgents', async () => { +- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/plugin/inbox.md' }); +- await fileService.writeFile(agentUri, VSBuffer.fromString('Inbox agent')); - -- mockSession.fire('session.mode_changed', { previousMode: 'interactive', newMode: 'shell' } as unknown as SessionEventPayload<'session.mode_changed'>['data']); +- const plugins: IPluginAgentsForSdk[] = [{ +- pluginDir: URI.file('/plugins/inbox'), +- agents: [{ uri: agentUri, name: 'Inbox' }], +- }]; +- const result = await toSdkSessionCustomAgents(plugins, 'Inbox', fileService); - -- assert.strictEqual(sessionConfigUpdates.length, 0); +- assert.deepStrictEqual(result, [{ name: 'Inbox', tools: null, prompt: 'Inbox agent' }]); - }); - -- // ---- no automatic plan → implementation handoff ------------------- -- -- test('handleExitPlanModeRequest always surfaces the plan-review UI, even in autopilot mode', async () => { -- const { session, runtime, waitForSignal } = await createAgentSession(disposables, { -- configValues: { [SessionConfigKey.Mode]: 'autopilot' }, -- }); +- test('does not duplicate the selected agent when already present', async () => { +- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/loose/helper.md' }); +- await fileService.writeFile(agentUri, VSBuffer.fromString('Loose agent')); - -- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams({ -- actions: ['autopilot', 'interactive', 'exit_only'], -- recommendedAction: 'autopilot', -- }), { sessionId: 'test-session-1' }); +- const plugins: IPluginAgentsForSdk[] = [{ agents: [{ uri: agentUri, name: 'helper' }] }]; +- const result = await toSdkSessionCustomAgents(plugins, 'helper', fileService); - -- // There is no automatic handoff from plan into implementation: the -- // user must explicitly choose an action regardless of mode. -- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); -- session.respondToUserInputRequest(getInputRequest(signal).id, ChatInputResponseKind.Decline); -- await responsePromise; +- assert.deepStrictEqual(result, [{ name: 'helper', tools: null, prompt: 'Loose agent' }]); - }); +- }); - -- test('handleExitPlanModeRequest does NOT auto-accept when autoApprove=default', async () => { -- const { session, runtime, waitForSignal } = await createAgentSession(disposables, { -- configValues: { [SessionConfigKey.AutoApprove]: 'default' }, -- }); +- // ---- toSdkSkillDirectories ------------------------------------------ - -- const responsePromise = runtime.handleExitPlanModeRequest(planRequestParams(), { sessionId: 'test-session-1' }); +- suite('toSdkSkillDirectories', () => { - -- // The user-input request fires — the user must respond. -- const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); -- session.respondToUserInputRequest(getInputRequest(signal).id, ChatInputResponseKind.Decline); -- await responsePromise; +- test('extracts parent directories of skill URIs', () => { +- const skills: INamedPluginResource[] = [ +- { uri: URI.file('/plugins/skill-a/SKILL.md'), name: 'skill-a' }, +- { uri: URI.file('/plugins/skill-b/SKILL.md'), name: 'skill-b' }, +- ]; +- const result = toSdkSkillDirectories(skills); +- assert.strictEqual(result.length, 2); - }); -- }); - -- suite('MCP server inventory', () => { +- test('deduplicates directories', () => { +- const skills: INamedPluginResource[] = [ +- { uri: URI.file('/plugins/shared/SKILL.md'), name: 'skill-a' }, +- { uri: URI.file('/plugins/shared/SKILL.md'), name: 'skill-b' }, +- ]; +- const result = toSdkSkillDirectories(skills); +- assert.strictEqual(result.length, 1); +- }); - -- test('seeds inventory from rpc.mcp.list at subscription time', async () => { -- const { signals, waitForSignal } = await createAgentSession(disposables, { -- configureMockSession: m => { -- m.mcpListResult = { -- servers: [ -- { name: 'alpha', status: 'connected' }, -- { name: 'beta', status: 'pending' }, -- ], -- }; -- }, -- }); +- test('handles empty input', () => { +- const result = toSdkSkillDirectories([]); +- assert.deepStrictEqual(result, []); +- }); +- }); - -- await waitForSignal(s => isAction(s, ActionType.SessionCustomizationUpdated)); -- // Give the seed's microtask chain time to apply both servers. -- await timeout(0); +- // ---- toSdkHooks ------------------------------------------------------- - -- const updates = getActions(signals).filter(a => a.type === ActionType.SessionCustomizationUpdated); -- const names = updates.map(a => (a as { customization: { name: string } }).customization.name).sort(); -- assert.deepStrictEqual(names, ['alpha', 'beta']); +- suite('toSdkInstructionDirectories', () => { +- +- test('extracts parent directories of instruction files', () => { +- const instructions: INamedPluginResource[] = [ +- { uri: URI.file('/plugins/rules/project.mdc'), name: 'project' }, +- { uri: URI.file('/plugins/rules/review.instructions.md'), name: 'review' }, +- ]; +- const result = toSdkInstructionDirectories(instructions); +- assert.strictEqual(result.length, 1); +- assert.strictEqual(result[0].replaceAll('\\', '/'), '/plugins/rules'); - }); - -- test('logs a warning and continues when rpc.mcp.list rejects', async () => { -- const logService = new CapturingLogService(); -- const { mockSession, waitForSignal } = await createAgentSession(disposables, { -- logService, -- configureMockSession: m => { m.mcpListError = new Error('boom'); }, -- }); -- // Allow the rejected promise to surface. -- await timeout(0); -- await timeout(0); -- -- assert.ok( -- logService.warnings.some(w => w.message.includes('Failed to seed MCP server inventory')), -- `expected seed-failure warning, got: ${JSON.stringify(logService.warnings)}`, -- ); +- test('deduplicates directories', () => { +- const instructions: INamedPluginResource[] = [ +- { uri: URI.file('/plugins/rules/a.mdc'), name: 'a' }, +- { uri: URI.file('/plugins/rules/b.mdc'), name: 'b' }, +- ]; +- const result = toSdkInstructionDirectories(instructions); +- assert.strictEqual(result.length, 1); +- }); - -- // Subsequent live events still flow through the normal pipeline. -- mockSession.fire('session.mcp_servers_loaded', { -- servers: [{ name: 'late', status: 'connected' }], -- } as SessionEventPayload<'session.mcp_servers_loaded'>['data']); -- await waitForSignal(s => isAction(s, ActionType.SessionCustomizationUpdated)); +- test('handles empty input', () => { +- const result = toSdkInstructionDirectories([]); +- assert.deepStrictEqual(result, []); - }); - }); --}); -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 21baa7f1..00000000 ---- a/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts -+++ /dev/null -@@ -1,511 +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 { 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 { FileService } from '../../../files/common/fileService.js'; --import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; --import { NullLogService } from '../../../log/common/log.js'; --import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; --import { toSdkInstructionDirectories, toSdkMcpServers, toSdkCustomAgents, toSdkSessionCustomAgents, toSdkSkillDirectories, parsedPluginsEqual, toSdkHooks, type IPluginAgentsForSdk } from '../../node/copilot/copilotPluginConverters.js'; --import type { IMcpServerDefinition, INamedPluginResource, IParsedHookGroup, IParsedPlugin, IParsedSkill } from '../../../agentPlugins/common/pluginParsers.js'; --import { CustomizationType, McpServerStatus, type HookCustomization, type McpServerCustomization, type SkillCustomization } from '../../common/state/protocol/state.js'; +- // ---- toSdkHooks ------------------------------------------------------- - --function stubMcpCustomization(name = 'test'): McpServerCustomization { -- return { type: CustomizationType.McpServer, id: `mcp:${name}`, uri: 'file:///plugin', name, enabled: true, state: { kind: McpServerStatus.Starting } }; --} --function stubHookCustomization(type: string): HookCustomization { -- return { type: CustomizationType.Hook, id: `hook:${type}`, uri: 'file:///plugin/hooks.json', name: 'hooks.json' }; --} --function stubSkillCustomization(name: string): SkillCustomization { -- return { type: CustomizationType.Skill, id: `skill:${name}`, uri: `file:///${name}/SKILL.md`, name }; --} +- suite('toSdkHooks', () => { - --suite('copilotPluginConverters', () => { +- function makeHookGroup(type: string, command: string): IParsedHookGroup { +- return { +- type, +- commands: [{ command }], +- uri: URI.file('/plugin/hooks.json'), +- originalId: type, +- customization: stubHookCustomization(type), +- }; +- } - -- const disposables = new DisposableStore(); -- let fileService: FileService; +- /** +- * Writes a temp JS script that outputs JSON to stdout and returns +- * a `node ` command. Works on both bash (/bin/sh -c) and +- * cmd.exe without any shell-quoting issues. +- * The script is written alongside the compiled test file which is +- * guaranteed to exist, be writable, and have no spaces in CI. +- */ +- function echoJsonCmd(value: object): { command: string; cleanup: () => void } { +- const json = JSON.stringify(value); +- // fileURLToPath(new URL('.', import.meta.url)) is the Node ESM equivalent +- // of __dirname and works on Node 12+, unlike import.meta.dirname (Node 21.2+). +- const dir = fileURLToPath(new URL('.', import.meta.url)).replace(/[\\/]$/, ''); +- const filePath = `${dir}/vscode-test-hook-${Date.now()}.js`; +- writeFileSync(filePath, `process.stdout.write(${JSON.stringify(json)});\n`); +- // Do NOT quote the path: cmd.exe /c "node path" strips the outer quotes, +- // leaving "node path" without inner quoting which cmd.exe handles cleanly. +- const command = `node ${filePath}`; +- return { command, cleanup: () => { try { unlinkSync(filePath); } catch { /* ignore */ } } }; +- } - -- setup(() => { -- fileService = disposables.add(new FileService(new NullLogService())); -- disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); +- 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(); +- } +- }); +- +- test('onPostToolUse returns undefined when output is non-JSON', async () => { +- // Use a script file so there are no cmd.exe quoting issues on Windows. +- 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 */ } +- } +- }); +- +- 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 */ } +- } +- }); +- +- test('onPostToolUse returns undefined when no commands', async () => { +- const hooks = toSdkHooks([]); +- assert.strictEqual(hooks.onPostToolUse, undefined); +- }); +- +- 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; }, +- }; +- 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(); +- } +- }); - }); - -- teardown(() => disposables.clear()); -- ensureNoDisposablesAreLeakedInTestSuite(); +- // ---- parsedPluginsEqual --------------------------------------------- - -- // ---- toSdkMcpServers ------------------------------------------------ +- suite('parsedPluginsEqual', () => { - -- suite('toSdkMcpServers', () => { +- function makePlugin(overrides?: Partial): IParsedPlugin { +- return { +- format: PluginFormat.Copilot, +- hooks: [], +- mcpServers: [], +- skills: [], +- agents: [], +- instructions: [], +- ...overrides, +- }; +- } - -- test('converts local server definitions', () => { -- const defs: IMcpServerDefinition[] = [{ -- name: 'test-server', -- uri: URI.file('/plugin'), -- configuration: { -- type: McpServerType.LOCAL, -- command: 'node', -- args: ['server.js', '--port', '3000'], -- env: { NODE_ENV: 'production', PORT: 3000 as unknown as string }, -- cwd: '/workspace', -- }, -- customization: stubMcpCustomization('test-server'), -- }]; +- test('returns true for identical empty plugins', () => { +- assert.strictEqual(parsedPluginsEqual([makePlugin()], [makePlugin()]), true); +- }); - -- const result = toSdkMcpServers(defs); -- assert.deepStrictEqual(result, { -- 'test-server': { -- type: 'local', -- command: 'node', -- args: ['server.js', '--port', '3000'], -- tools: ['*'], -- env: { NODE_ENV: 'production', PORT: '3000' }, -- cwd: '/workspace', -- }, +- test('returns true for same content', () => { +- const a = makePlugin({ +- skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a', customization: stubSkillCustomization('a') } satisfies IParsedSkill], +- mcpServers: [{ +- name: 'server', +- uri: URI.file('/mcp'), +- configuration: { type: McpServerType.LOCAL, command: 'node' }, +- customization: stubMcpCustomization('server'), +- }], +- }); +- const b = makePlugin({ +- skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a', customization: stubSkillCustomization('a') } satisfies IParsedSkill], +- mcpServers: [{ +- name: 'server', +- uri: URI.file('/mcp'), +- configuration: { type: McpServerType.LOCAL, command: 'node' }, +- customization: stubMcpCustomization('server'), +- }], - }); +- assert.strictEqual(parsedPluginsEqual([a], [b]), true); - }); - -- test('converts remote/http server definitions', () => { -- const defs: IMcpServerDefinition[] = [{ -- name: 'remote-server', -- uri: URI.file('/plugin'), -- configuration: { -- type: McpServerType.REMOTE, -- url: 'https://example.com/mcp', -- headers: { 'Authorization': 'Bearer token' }, -- }, -- customization: stubMcpCustomization('remote-server'), -- }]; -- -- const result = toSdkMcpServers(defs); -- assert.deepStrictEqual(result, { -- 'remote-server': { -- type: 'http', -- url: 'https://example.com/mcp', -- tools: ['*'], -- headers: { 'Authorization': 'Bearer token' }, -- }, -- }); +- test('returns false for different content', () => { +- const a = makePlugin({ skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a', customization: stubSkillCustomization('a') } satisfies IParsedSkill] }); +- const b = makePlugin({ skills: [{ uri: URI.file('/b/SKILL.md'), name: 'b', customization: stubSkillCustomization('b') } satisfies IParsedSkill] }); +- assert.strictEqual(parsedPluginsEqual([a], [b]), false); - }); - -- test('handles empty definitions', () => { -- const result = toSdkMcpServers([]); -- assert.deepStrictEqual(result, {}); +- test('returns false for different plugin formats', () => { +- assert.strictEqual(parsedPluginsEqual( +- [makePlugin({ format: PluginFormat.AgentPlugin })], +- [makePlugin({ format: PluginFormat.OpenPlugin })], +- ), false); - }); - -- test('omits optional fields when undefined', () => { -- const defs: IMcpServerDefinition[] = [{ -- name: 'minimal', -- uri: URI.file('/plugin'), -- configuration: { -- type: McpServerType.LOCAL, -- command: 'echo', -- }, -- customization: stubMcpCustomization('minimal'), -- }]; -- -- const result = toSdkMcpServers(defs); -- assert.strictEqual(result['minimal'].type, 'local'); -- assert.deepStrictEqual((result['minimal'] as { args?: string[] }).args, []); -- assert.strictEqual(Object.hasOwn(result['minimal'], 'env'), false); -- assert.strictEqual(Object.hasOwn(result['minimal'], 'cwd'), false); +- test('returns false for different lengths', () => { +- assert.strictEqual(parsedPluginsEqual([makePlugin()], [makePlugin(), makePlugin()]), false); - }); - -- test('filters null values from env', () => { -- const defs: IMcpServerDefinition[] = [{ -- name: 'with-null-env', -- uri: URI.file('/plugin'), -- configuration: { -- type: McpServerType.LOCAL, -- command: 'test', -- env: { KEEP: 'value', DROP: null as unknown as string }, -- }, -- customization: stubMcpCustomization('with-null-env'), -- }]; -- -- const result = toSdkMcpServers(defs); -- const env = (result['with-null-env'] as { env?: Record }).env; -- assert.deepStrictEqual(env, { KEEP: 'value' }); +- test('returns true for empty arrays', () => { +- assert.strictEqual(parsedPluginsEqual([], []), 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 cec81ac7..00000000 +--- a/src/vs/platform/agentHost/test/node/copilotSdkImportSession.integrationTest.ts ++++ /dev/null +@@ -1,345 +0,0 @@ +-/*--------------------------------------------------------------------------------------------- +- * Copyright (c) Microsoft Corporation. All rights reserved. +- * Licensed under the MIT License. See License.txt in the project root for license information. +- *--------------------------------------------------------------------------------------------*/ - -- // ---- toSdkCustomAgents ---------------------------------------------- +-/** +- * Copilot SDK integration test for importing a translated conversation. +- * +- * Validates the core premise of local→Copilot-CLI migration: a synthesized +- * `events.jsonl` (built by {@link buildSessionEventLogFromTurns}) seeded through +- * a {@link DiskSessionFsProvider}, then `resumeSession`d, reconstitutes as real +- * SDK turns — and is therefore editable (proven by `history.truncate`). +- * +- * Disabled by default. To run it, set `AGENT_HOST_REAL_SDK=1` (a Copilot CLI +- * package must be installed under `node_modules/@github/copilot*`): +- * +- * AGENT_HOST_REAL_SDK=1 ./scripts/test-integration.sh --run src/vs/platform/agentHost/test/node/copilotSdkImportSession.integrationTest.ts +- * +- * Authentication: token from `gh auth token`, overridable via `GITHUB_TOKEN`. +- */ - -- suite('toSdkCustomAgents', () => { +-import assert from 'assert'; +-import { promises as fs } from 'fs'; +-import { tmpdir } from 'os'; +-import { CopilotClient, RuntimeConnection, approveAll, type CopilotSession, type SessionEvent, type SessionFsFileInfo, type SessionFsProvider } from '@github/copilot-sdk'; +-import { FileAccess } from '../../../../base/common/network.js'; +-import { delimiter, dirname, join } from '../../../../base/common/path.js'; +-import { URI } from '../../../../base/common/uri.js'; +-import { generateUuid } from '../../../../base/common/uuid.js'; +-import { rgDiskPath } from '../../../../base/node/ripgrep.js'; +-import { MessageKind, ResponsePartKind, TurnState, type ResponsePart, type Turn } from '../../common/state/sessionState.js'; +-import { buildSessionEventLogFromTurns } from '../../node/copilot/buildSessionEvents.js'; +-import { DiskSessionFsProvider } from '../../node/copilot/diskSessionFsProvider.js'; +-import { resolveGitHubToken } from './e2e/harness/agentHostE2ETestHarness.js'; - -- 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')); +-/** +- * Directory entry shape returned by {@link SessionFsProvider.readdirWithTypes}. +- * Mirrors the SDK's `SessionFsReaddirWithTypesEntry`, which is not re-exported +- * from the package root. +- */ +-type SessionFsReaddirWithTypesEntry = { name: string; type: 'file' | 'directory' }; - -- const agents: INamedPluginResource[] = [{ uri: agentUri, name: 'helper' }]; -- const result = await toSdkCustomAgents(agents, fileService); +-const REAL_SDK_ENABLED = process.env['AGENT_HOST_REAL_SDK'] === '1'; - -- assert.deepStrictEqual(result, [{ -- name: 'helper', -- tools: null, -- prompt: 'You are a helpful assistant', -- }]); -- }); +-/** Session-state directory the client advertises to the runtime for session-scoped files. */ +-const SESSION_STATE_PATH = 'session-state'; - -- test('skips agents whose files cannot be read', async () => { -- const agents: INamedPluginResource[] = [ -- { uri: URI.from({ scheme: Schemas.inMemory, path: '/nonexistent/agent.md' }), name: 'missing' }, -- ]; -- const result = await toSdkCustomAgents(agents, fileService); -- assert.deepStrictEqual(result, []); -- }); +-/** `node_modules` directory that ships alongside the compiled `out/`. */ +-function nodeModulesUri(): URI { +- return URI.joinPath(FileAccess.asFileUri(''), '..', 'node_modules'); +-} - -- test('processes multiple agents, skipping failures', async () => { -- const goodUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/good.md' }); -- await fileService.writeFile(goodUri, VSBuffer.fromString('Good agent')); +-/** Resolve a Copilot CLI entry point from `node_modules/@github/copilot*`. */ +-async function resolveCopilotCliPath(): Promise { +- const nodeModules = URI.joinPath(nodeModulesUri(), '@github').fsPath; +- const entries = await fs.readdir(nodeModules).catch(() => [] as string[]); +- const candidates = entries +- .filter(name => name === 'copilot' || name.startsWith('copilot-')) +- .filter(name => name !== 'copilot-sdk') +- .map(name => join(nodeModules, name, 'index.js')); +- for (const candidate of candidates) { +- if (await fs.stat(candidate).then(() => true, () => false)) { +- return candidate; +- } +- } +- throw new Error(`No Copilot CLI found under ${nodeModules} (looked for copilot*/index.js). Install a @github/copilot- package.`); +-} - -- const agents: INamedPluginResource[] = [ -- { uri: goodUri, name: 'good' }, -- { 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'); -- }); +-/** +- * Build the subprocess environment the Copilot CLI needs to start in +- * stdio-server mode. Mirrors the production wiring in `copilotAgent.ts`: +- * without `COPILOT_CLI_RUN_AS_NODE=1` the CLI entry point runs interactively +- * and exits ("CLI server exited unexpectedly with code 0"), and without +- * `MXC_BIN_DIR` the sandbox auto-detection cannot locate its binaries. +- */ +-async function buildCliEnv(): Promise> { +- const env: Record = Object.assign({}, process.env, { ELECTRON_RUN_AS_NODE: '1' }); +- delete env['NODE_OPTIONS']; +- delete env['VSCODE_INSPECTOR_OPTIONS']; +- delete env['VSCODE_ESM_ENTRYPOINT']; +- delete env['VSCODE_HANDLES_UNCAUGHT_ERRORS']; +- for (const key of Object.keys(env)) { +- if (key === 'ELECTRON_RUN_AS_NODE') { +- continue; +- } +- if (key.startsWith('VSCODE_') || key.startsWith('ELECTRON_')) { +- delete env[key]; +- } +- } +- env['COPILOT_CLI_RUN_AS_NODE'] = '1'; +- env['USE_BUILTIN_RIPGREP'] = 'false'; +- env['COPILOT_MCP_APPS'] = 'true'; - -- test('parses YAML frontmatter for name, description, tools, and body', async () => { -- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/review.md' }); -- await fileService.writeFile(agentUri, VSBuffer.fromString( -- `---\nname: code-reviewer\ndescription: Reviews code for quality issues\ntools:\n - read_file\n - grep_search\n---\nYou are a meticulous code reviewer.\n` -- )); +- // Point the MXC sandbox auto-detection at VS Code's bundled binaries. +- env['MXC_BIN_DIR'] = URI.joinPath(nodeModulesUri(), '@microsoft', 'mxc-sdk', 'bin').fsPath; - -- const agents: INamedPluginResource[] = [{ uri: agentUri, name: 'review' }]; -- const result = await toSdkCustomAgents(agents, fileService); +- // Make VS Code's built-in ripgrep discoverable to the CLI subprocess. +- const rgDir = dirname(await rgDiskPath()); +- const pathKey = Object.keys(env).find(k => k.toUpperCase() === 'PATH') ?? 'PATH'; +- const currentPath = env[pathKey]; +- env[pathKey] = currentPath ? `${currentPath}${delimiter}${rgDir}` : rgDir; - -- assert.deepStrictEqual(result, [{ -- name: 'code-reviewer', -- description: 'Reviews code for quality issues', -- tools: ['read_file', 'grep_search'], -- prompt: 'You are a meticulous code reviewer.\n', -- }]); -- }); +- return env; +-} - -- test('empty tools array becomes null (all tools)', async () => { -- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/empty-tools.md' }); -- await fileService.writeFile(agentUri, VSBuffer.fromString( -- `---\nname: free-for-all\ntools: []\n---\nBody.` -- )); +-/** Recursively list every file under `root`, returned as paths relative to `root`. */ +-async function walkFiles(root: string): Promise { +- const out: string[] = []; +- const rec = async (dir: string, rel: string): Promise => { +- let names: string[]; +- try { +- names = await fs.readdir(dir); +- } catch { +- return; +- } +- for (const name of names) { +- const childRel = rel ? join(rel, name) : name; +- const stat = await fs.stat(join(dir, name)).catch(() => undefined); +- if (stat?.isDirectory()) { +- await rec(join(dir, name), childRel); +- } else if (stat) { +- out.push(childRel); +- } +- } +- }; +- await rec(root, ''); +- return out; +-} - -- const agents: INamedPluginResource[] = [{ uri: agentUri, name: 'fallback' }]; -- const result = await toSdkCustomAgents(agents, fileService); +-/** Wraps a provider to record the SessionFs paths the runtime touches (diagnostics on failure). */ +-class RecordingSessionFsProvider implements SessionFsProvider { +- readonly reads: string[] = []; +- constructor(private readonly _inner: SessionFsProvider) { } +- readFile(path: string): Promise { this.reads.push(`readFile ${path}`); return this._inner.readFile(path); } +- writeFile(path: string, content: string, mode?: number): Promise { return this._inner.writeFile(path, content, mode); } +- appendFile(path: string, content: string, mode?: number): Promise { return this._inner.appendFile(path, content, mode); } +- exists(path: string): Promise { this.reads.push(`exists ${path}`); return this._inner.exists(path); } +- stat(path: string): Promise { this.reads.push(`stat ${path}`); return this._inner.stat(path); } +- mkdir(path: string, recursive: boolean, mode?: number): Promise { return this._inner.mkdir(path, recursive, mode); } +- readdir(path: string): Promise { this.reads.push(`readdir ${path}`); return this._inner.readdir(path); } +- readdirWithTypes(path: string): Promise { this.reads.push(`readdirWithTypes ${path}`); return this._inner.readdirWithTypes(path); } +- rm(path: string, recursive: boolean, force: boolean): Promise { return this._inner.rm(path, recursive, force); } +- rename(src: string, dest: string): Promise { return this._inner.rename(src, dest); } +-} - -- assert.deepStrictEqual(result, [{ -- name: 'free-for-all', -- tools: null, -- prompt: 'Body.', -- }]); +-function markdown(content: string): ResponsePart { +- return { kind: ResponsePartKind.Markdown, id: generateUuid(), content }; +-} +- +-function userTurn(id: string, text: string, response: string): Turn { +- return { +- id, +- message: { text, origin: { kind: MessageKind.User } }, +- responseParts: response ? [markdown(response)] : [], +- usage: undefined, +- state: TurnState.Complete, +- }; +-} +- +-(REAL_SDK_ENABLED ? suite : suite.skip)('Copilot SDK — import via seeded events.jsonl', function () { +- +- this.timeout(120_000); +- +- let client: CopilotClient; +- let baseDir: string; +- +- suiteSetup(async function () { +- baseDir = await fs.mkdtemp(join(tmpdir(), 'ahp-import-')); +- const cliPath = await resolveCopilotCliPath(); +- client = new CopilotClient({ +- useLoggedInUser: false, +- gitHubToken: resolveGitHubToken(), +- connection: RuntimeConnection.forStdio({ path: cliPath }), +- env: await buildCliEnv(), +- logLevel: 'error', +- sessionFs: { +- initialCwd: baseDir, +- sessionStatePath: SESSION_STATE_PATH, +- conventions: process.platform === 'win32' ? 'windows' : 'posix', +- }, - }); +- await client.start(); +- }); - -- test('falls back to resource name when frontmatter omits name', async () => { -- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/no-name.md' }); -- await fileService.writeFile(agentUri, VSBuffer.fromString( -- `---\ndescription: Helper without an explicit name\n---\nBody only.` -- )); +- suiteTeardown(async function () { +- await client?.stop().catch(() => { }); +- if (baseDir) { +- await fs.rm(baseDir, { recursive: true, force: true }).catch(() => { }); +- } +- }); - -- const agents: INamedPluginResource[] = [{ uri: agentUri, name: 'resource-name' }]; -- const result = await toSdkCustomAgents(agents, fileService); +- test('seeded conversation resumes as real, editable turns', async function () { +- const sessionId = generateUuid(); +- const turns: Turn[] = [ +- userTurn('turn-a', 'What is 2+2? Reply with just the number.', 'It is 4.'), +- userTurn('turn-b', 'And 3+3? Reply with just the number.', 'It is 6.'), +- ]; - -- assert.deepStrictEqual(result, [{ -- name: 'resource-name', -- description: 'Helper without an explicit name', -- tools: null, -- prompt: 'Body only.', -- }]); -- }); +- // Seed the synthesized event log at the runtime's session-state path. +- const sessionDir = join(baseDir, SESSION_STATE_PATH); +- await fs.mkdir(sessionDir, { recursive: true }); +- const jsonl = buildSessionEventLogFromTurns(turns, { sessionId, workingDirectory: baseDir }); +- await fs.writeFile(join(sessionDir, 'events.jsonl'), jsonl, 'utf8'); - -- test('trims whitespace from frontmatter name to match parsed agent name', async () => { -- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/padded.md' }); -- await fileService.writeFile(agentUri, VSBuffer.fromString( -- `---\nname: " Inbox "\n---\nBody.` -- )); +- const provider = new RecordingSessionFsProvider(new DiskSessionFsProvider(baseDir)); - -- const agents: INamedPluginResource[] = [{ uri: agentUri, name: 'padded' }]; -- const result = await toSdkCustomAgents(agents, fileService); +- let session: CopilotSession; +- try { +- session = await client.resumeSession(sessionId, { +- onPermissionRequest: approveAll, +- createSessionFsProvider: () => provider, +- workingDirectory: baseDir, +- }); +- } catch (err) { +- assert.fail(`resumeSession failed. SessionFs accesses:\n ${provider.reads.join('\n ')}\nError: ${err instanceof Error ? err.message : String(err)}`); +- } - -- assert.strictEqual(result[0].name, 'Inbox'); -- }); +- try { +- const events: SessionEvent[] = await session.getEvents(); +- const userMessages = events.filter(e => e.type === 'user.message').map(e => e.data.content); +- assert.ok( +- userMessages.some(c => c.includes('What is 2+2?')) && userMessages.some(c => c.includes('And 3+3?')), +- `expected both imported prompts in reconstructed history, got: ${JSON.stringify(userMessages)}\nSessionFs accesses:\n ${provider.reads.join('\n ')}`, +- ); +- +- // Editability: truncating at the first imported user turn removes it +- // and everything after — only possible because these are real events. +- const firstUser = events.find(e => e.type === 'user.message'); +- assert.ok(firstUser, 'expected a reconstructed user.message event'); +- const truncate = await session.rpc.history.truncate({ eventId: firstUser.id }); +- assert.ok(truncate.eventsRemoved >= 1, `expected truncate to remove events, removed ${truncate.eventsRemoved}`); +- } finally { +- await session.disconnect().catch(() => { }); +- } - }); +-}); - -- // ---- toSdkSessionCustomAgents --------------------------------------- +-/** +- * Validates the PRODUCTION import seam: `SessionConfigBase.configDirectory`. +- * +- * Unlike the `SessionFsProvider` route above (which requires flipping the +- * client-level `sessionFs` master switch — routing *all* sessions through a +- * provider and dropping native SQLite/todo support), `configDirectory` is a +- * per-session override. We seed a synthesized `events.jsonl` at the CLI's +- * native on-disk layout under a per-session `configDirectory`, then resume +- * with an ordinary client (no `sessionFs`), leaving every other session's +- * storage untouched. The test first creates a throwaway session to *discover* +- * the exact native layout, then seeds a fresh session at that layout. +- */ +-(REAL_SDK_ENABLED ? suite : suite.skip)('Copilot SDK — import via configDirectory (native storage)', function () { +- +- this.timeout(120_000); +- +- let client: CopilotClient; +- let root: string; +- let configDir: string; +- let workDir: string; +- +- suiteSetup(async function () { +- root = await fs.mkdtemp(join(tmpdir(), 'ahp-import-cfg-')); +- configDir = join(root, 'config'); +- workDir = join(root, 'work'); +- await fs.mkdir(configDir, { recursive: true }); +- await fs.mkdir(workDir, { recursive: true }); +- const cliPath = await resolveCopilotCliPath(); +- client = new CopilotClient({ +- // Deliberately NO `sessionFs`: native on-disk storage, redirected +- // per session via `configDirectory` — the low-risk production seam. +- useLoggedInUser: false, +- gitHubToken: resolveGitHubToken(), +- connection: RuntimeConnection.forStdio({ path: cliPath }), +- env: await buildCliEnv(), +- logLevel: 'error', +- }); +- await client.start(); +- }); - -- suite('toSdkSessionCustomAgents', () => { +- suiteTeardown(async function () { +- await client?.stop().catch(() => { }); +- if (root) { +- await fs.rm(root, { recursive: true, force: true }).catch(() => { }); +- } +- }); - -- test('includes agents from plugins without a file directory', async () => { -- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/loose/helper.md' }); -- await fileService.writeFile(agentUri, VSBuffer.fromString('Loose agent')); +- test('seeding events.jsonl under configDirectory resumes as real, editable turns', async function () { +- // Phase 1 — discover the native events.jsonl layout by creating a +- // throwaway session and inspecting what the CLI writes on disk. +- const discoverId = generateUuid(); +- try { +- const throwaway = await client.createSession({ +- sessionId: discoverId, +- configDirectory: configDir, +- workingDirectory: workDir, +- onPermissionRequest: approveAll, +- }); +- await throwaway.disconnect().catch(() => { }); +- } catch { +- // Best-effort discovery; fall back to the assumed layout below. +- } +- const discoveredRel = (await walkFiles(configDir)).find(f => f.endsWith('events.jsonl') && f.includes(discoverId)); - -- const plugins: IPluginAgentsForSdk[] = [{ agents: [{ uri: agentUri, name: 'helper' }] }]; -- const result = await toSdkSessionCustomAgents(plugins, undefined, fileService); +- // Phase 2 — seed a fresh session's events.jsonl at the discovered +- // layout (or the assumed one) and resume it with the normal client. +- const importId = generateUuid(); +- const turns: Turn[] = [ +- userTurn('turn-a', 'What is 2+2? Reply with just the number.', 'It is 4.'), +- userTurn('turn-b', 'And 3+3? Reply with just the number.', 'It is 6.'), +- ]; +- const jsonl = buildSessionEventLogFromTurns(turns, { sessionId: importId, workingDirectory: workDir }); +- const seedRel = discoveredRel +- ? discoveredRel.replace(discoverId, importId) +- : join('session-state', importId, 'events.jsonl'); +- const seedPath = join(configDir, seedRel); +- await fs.mkdir(dirname(seedPath), { recursive: true }); +- await fs.writeFile(seedPath, jsonl, 'utf8'); +- +- let session: CopilotSession; +- try { +- session = await client.resumeSession(importId, { +- configDirectory: configDir, +- workingDirectory: workDir, +- onPermissionRequest: approveAll, +- }); +- } catch (err) { +- const tree = (await walkFiles(configDir)).join('\n '); +- assert.fail(`resumeSession(configDirectory) failed.\nDiscovered layout: ${discoveredRel ?? '(none — used assumed layout)'}\nSeeded at: ${seedRel}\nconfigDir tree:\n ${tree}\nError: ${err instanceof Error ? err.message : String(err)}`); +- } - -- assert.deepStrictEqual(result, [{ name: 'helper', tools: null, prompt: 'Loose agent' }]); -- }); +- try { +- const events: SessionEvent[] = await session.getEvents(); +- const userMessages = events.filter(e => e.type === 'user.message').map(e => e.data.content); +- assert.ok( +- userMessages.some(c => c.includes('What is 2+2?')) && userMessages.some(c => c.includes('And 3+3?')), +- `expected both imported prompts in reconstructed history, got: ${JSON.stringify(userMessages)}\nSeeded at: ${seedRel}`, +- ); - -- test('excludes file-dir plugin agents when none is selected', async () => { -- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/plugin/inbox.md' }); -- await fileService.writeFile(agentUri, VSBuffer.fromString('Inbox agent')); +- const firstUser = events.find(e => e.type === 'user.message'); +- assert.ok(firstUser, 'expected a reconstructed user.message event'); +- const truncate = await session.rpc.history.truncate({ eventId: firstUser.id }); +- assert.ok(truncate.eventsRemoved >= 1, `expected truncate to remove events, removed ${truncate.eventsRemoved}`); +- } finally { +- await session.disconnect().catch(() => { }); +- } +- }); +-}); +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 c6d1cdbe..00000000 +--- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts ++++ /dev/null +@@ -1,521 +0,0 @@ +-/*--------------------------------------------------------------------------------------------- +- * Copyright (c) Microsoft Corporation. All rights reserved. +- * Licensed under the MIT License. See License.txt in the project root for license information. +- *--------------------------------------------------------------------------------------------*/ - -- const plugins: IPluginAgentsForSdk[] = [{ -- pluginDir: URI.file('/plugins/inbox'), -- agents: [{ uri: agentUri, name: 'Inbox' }], -- }]; -- const result = await toSdkSessionCustomAgents(plugins, undefined, fileService); +-import assert from 'assert'; +-import type { CopilotClient, CopilotSession } from '@github/copilot-sdk'; +-import { Emitter, Event } from '../../../../base/common/event.js'; +-import { DisposableStore } from '../../../../base/common/lifecycle.js'; +-import { URI } from '../../../../base/common/uri.js'; +-import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +-import { PluginFormat } from '../../../agentPlugins/common/pluginParsers.js'; +-import type { IFileService } from '../../../files/common/files.js'; +-import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; +-import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; +-import { ILogService, NullLogService } from '../../../log/common/log.js'; +-import type { IByokLmBridgeConnection, IByokLmChatRequest, IByokLmChatResult, IByokLmModelInfo } from '../../common/agentHostByokLm.js'; +-import { CustomizationType, type ModelSelection } from '../../common/state/protocol/state.js'; +-import type { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; +-import { ActiveClientToolSet } from '../../node/activeClientState.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 { CopilotSessionLauncher, getCopilotReasoningEffort, resolveByokSessionConfig, type CopilotSessionLaunchPlan, type ICopilotSessionRuntime } from '../../node/copilot/copilotSessionLauncher.js'; +-import type { ICopilotPluginInfo } from '../../node/copilot/copilotAgent.js'; +- +-const testRuntime: ICopilotSessionRuntime = { +- 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'); }, +- handleMcpAuthRequest: async () => { throw new Error('Unexpected MCP auth request'); }, +- requestUnsandboxedCommandConfirmation: async () => false, +- handlePreToolUse: async () => { }, +- handlePostToolUse: async () => { }, +- createClientSdkTools: () => [], +- createServerSdkTools: () => [], +-}; - -- assert.deepStrictEqual(result, []); -- }); +-const testWorkingDirectory = URI.file(process.cwd()); - -- test('forces the selected file-dir plugin agent into customAgents', async () => { -- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/plugin/inbox.md' }); -- await fileService.writeFile(agentUri, VSBuffer.fromString('Inbox agent')); +-function createTestLauncher(): CopilotSessionLauncher { +- const configurationService = { +- getRootValue: () => undefined, +- } as Partial as IAgentConfigurationService; +- return new CopilotSessionLauncher( +- configurationService, +- {} as IAgentHostTerminalManager, +- new NullLogService(), +- {} as IFileService, +- { _serviceBrand: undefined, start: async () => { throw new Error('Unexpected proxy start'); }, dispose: () => { } }, +- new ByokLmBridgeRegistry(), +- ); +-} +- +-/** +- * Covers the BYOK provider/model synthesis the launcher feeds into +- * `createSession` / `resumeSession`. The first four tests pin the gating and +- * graceful-degradation branches plus the exact SDK config shape using a real +- * {@link ByokLmBridgeRegistry} and a counting proxy thunk (no real proxy). The +- * last test wires the synthesized config straight into a live +- * {@link ByokLmProxyService} and POSTs at it, proving the launcher's output is +- * consumable end-to-end: provider `baseUrl` + `Bearer .` + +- * `model = id` route through the proxy to the renderer bridge. +- */ +-suite('resolveByokSessionConfig', () => { +- +- const store = ensureNoDisposablesAreLeakedInTestSuite(); +- +- const sessionId = 'sess-1'; +- const log = new NullLogService(); +- +- /** +- * A bridge connection that pushes `models` as its snapshot synchronously when +- * the registry subscribes; `chat` is scripted (unused by most tests). +- */ +- function connectionOf(models: IByokLmModelInfo[], chat: IByokLmBridgeConnection['chat'] = async () => ({ output: [] })): IByokLmBridgeConnection { +- const emitter = store.add(new Emitter({ +- onDidAddFirstListener: () => emitter.fire(models), +- })); +- return { chat, onDidChangeModels: emitter.event }; +- } +- +- /** A fake proxy handle plus a `startProxy` thunk that records its call count. */ +- function countingProxy() { +- let starts = 0; +- const handle: IByokLmProxyHandle = { +- baseUrl: 'http://127.0.0.1:1', +- nonce: 'NONCE', +- providerBaseUrl: vendor => `http://127.0.0.1:1/v/${vendor}`, +- dispose: () => { }, +- }; +- return { +- get starts() { return starts; }, +- startProxy: async () => { starts++; return handle; }, +- }; +- } +- +- test('returns empty and never starts the proxy when no bridge is active', async () => { +- const registry = new ByokLmBridgeRegistry(); +- const proxy = countingProxy(); +- +- const config = await resolveByokSessionConfig(sessionId, registry, proxy.startProxy, log); +- +- assert.deepStrictEqual(config, {}); +- assert.strictEqual(proxy.starts, 0); +- }); +- +- test('returns empty and never starts the proxy when the bridge reports no models', async () => { +- const registry = new ByokLmBridgeRegistry(); +- const registration = registry.register('client-1', connectionOf([])); +- const proxy = countingProxy(); +- +- const config = await resolveByokSessionConfig(sessionId, registry, proxy.startProxy, log); +- registration.dispose(); - -- const plugins: IPluginAgentsForSdk[] = [{ -- pluginDir: URI.file('/plugins/inbox'), -- agents: [{ uri: agentUri, name: 'Inbox' }], -- }]; -- const result = await toSdkSessionCustomAgents(plugins, 'Inbox', fileService); +- assert.deepStrictEqual(config, {}); +- assert.strictEqual(proxy.starts, 0); +- }); - -- assert.deepStrictEqual(result, [{ name: 'Inbox', tools: null, prompt: 'Inbox agent' }]); -- }); +- test('returns empty and never starts the proxy for a window that never pushes a snapshot', async () => { +- const registry = new ByokLmBridgeRegistry(); +- // A window connected without a BYOK handler never pushes, so it stays +- // non-serving and contributes no models. +- const registration = registry.register('client-1', { chat: async (): Promise => ({ output: [] }), onDidChangeModels: Event.None }); +- const proxy = countingProxy(); - -- test('does not duplicate the selected agent when already present', async () => { -- const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/loose/helper.md' }); -- await fileService.writeFile(agentUri, VSBuffer.fromString('Loose agent')); +- const config = await resolveByokSessionConfig(sessionId, registry, proxy.startProxy, log); +- registration.dispose(); - -- const plugins: IPluginAgentsForSdk[] = [{ agents: [{ uri: agentUri, name: 'helper' }] }]; -- const result = await toSdkSessionCustomAgents(plugins, 'helper', fileService); +- assert.deepStrictEqual(config, {}); +- assert.strictEqual(proxy.starts, 0); +- }); - -- assert.deepStrictEqual(result, [{ name: 'helper', tools: null, prompt: 'Loose agent' }]); +- test('synthesizes deduped providers and per-model config from the active bridge', async () => { +- const registry = new ByokLmBridgeRegistry(); +- const registration = registry.register('client-1', connectionOf([ +- { vendor: 'acme', id: 'claude', name: 'Acme Claude', maxContextWindowTokens: 200000 }, +- { vendor: 'acme', id: 'gpt', name: undefined, maxContextWindowTokens: undefined }, +- { vendor: 'globex', id: 'llama', name: 'Globex Llama' }, +- ])); +- const proxy = countingProxy(); +- +- const config = await resolveByokSessionConfig(sessionId, registry, proxy.startProxy, log); +- registration.dispose(); +- +- assert.strictEqual(proxy.starts, 1); +- assert.deepStrictEqual(config, { +- providers: [ +- { name: 'acme', type: 'openai', wireApi: 'responses', baseUrl: 'http://127.0.0.1:1/v/acme', bearerToken: 'NONCE.sess-1' }, +- { name: 'globex', type: 'openai', wireApi: 'responses', baseUrl: 'http://127.0.0.1:1/v/globex', bearerToken: 'NONCE.sess-1' }, +- ], +- models: [ +- { id: 'claude', provider: 'acme', name: 'Acme Claude', maxContextWindowTokens: 200000 }, +- { id: 'gpt', provider: 'acme' }, +- { id: 'llama', provider: 'globex', name: 'Globex Llama' }, +- ], - }); - }); - -- // ---- toSdkSkillDirectories ------------------------------------------ +- test('synthesized provider config routes through a live proxy to the bridge', async () => { +- const registry = new ByokLmBridgeRegistry(); +- let captured: IByokLmChatRequest | undefined; +- const registration = registry.register('client-1', connectionOf( +- [{ vendor: 'acme', id: 'claude' }], +- async (request) => { +- captured = request; +- return { output: [{ type: 'message', content: [{ type: 'text', text: 'hello from byok' }] }] }; +- }, +- )); +- const service = new ByokLmProxyService(log, registry); +- let handle: IByokLmProxyHandle | undefined; - -- suite('toSdkSkillDirectories', () => { +- const config = await resolveByokSessionConfig(sessionId, registry, async () => (handle = await service.start()), log); +- const provider = config.providers![0]; +- const model = config.models![0]; +- try { +- const response = await fetch(`${provider.baseUrl}/responses`, { +- method: 'POST', +- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${provider.bearerToken}` }, +- body: JSON.stringify({ model: model.id, input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hi' }] }] }), +- }); +- assert.strictEqual(response.status, 200); +- const text = await response.text(); +- assert.ok(text.includes('hello from byok'), `expected content in SSE: ${text}`); +- } finally { +- handle?.dispose(); +- registration.dispose(); +- service.dispose(); +- } +- assert.strictEqual(captured?.vendor, 'acme'); +- assert.strictEqual(captured?.modelId, 'claude'); +- }); - -- test('extracts parent directories of skill URIs', () => { -- const skills: INamedPluginResource[] = [ -- { uri: URI.file('/plugins/skill-a/SKILL.md'), name: 'skill-a' }, -- { uri: URI.file('/plugins/skill-b/SKILL.md'), name: 'skill-b' }, -- ]; -- const result = toSdkSkillDirectories(skills); -- assert.strictEqual(result.length, 2); +- test('reads the latest pushed snapshot from the registry cache', async () => { +- const registry = new ByokLmBridgeRegistry(); +- const emitter = store.add(new Emitter()); +- const registration = registry.register('client-1', { +- chat: async (): Promise => ({ output: [] }), +- onDidChangeModels: emitter.event, - }); +- const proxy = countingProxy(); - -- test('deduplicates directories', () => { -- const skills: INamedPluginResource[] = [ -- { uri: URI.file('/plugins/shared/SKILL.md'), name: 'skill-a' }, -- { uri: URI.file('/plugins/shared/SKILL.md'), name: 'skill-b' }, -- ]; -- const result = toSdkSkillDirectories(skills); -- assert.strictEqual(result.length, 1); -- }); +- // The window starts serving-but-empty, then pushes a model; the resolved +- // config reflects the latest cached push with no renderer round-trip. +- emitter.fire([]); +- emitter.fire([{ vendor: 'acme', id: 'claude', name: 'Acme Claude' }]); - -- test('handles empty input', () => { -- const result = toSdkSkillDirectories([]); -- assert.deepStrictEqual(result, []); -- }); +- const config = await resolveByokSessionConfig(sessionId, registry, proxy.startProxy, log); +- registration.dispose(); +- +- assert.deepStrictEqual(config.models, [{ id: 'claude', provider: 'acme', name: 'Acme Claude' }]); - }); +-}); - -- // ---- toSdkHooks ------------------------------------------------------- +-/** +- * Covers the launcher's lazy memoization and disposal of the shared BYOK proxy +- * handle: concurrent launches share one bind, and +- * {@link CopilotSessionLauncher.disposeByokProxyHandle} (called by the agent +- * after the runtime subprocess stops) releases it so the next launch mints a +- * fresh nonce. +- */ +-suite('CopilotSessionLauncher BYOK proxy lifecycle', () => { - -- suite('toSdkInstructionDirectories', () => { +- ensureNoDisposablesAreLeakedInTestSuite(); - -- test('extracts parent directories of instruction files', () => { -- const instructions: INamedPluginResource[] = [ -- { uri: URI.file('/plugins/rules/project.mdc'), name: 'project' }, -- { uri: URI.file('/plugins/rules/review.instructions.md'), name: 'review' }, -- ]; -- const result = toSdkInstructionDirectories(instructions); -- assert.strictEqual(result.length, 1); -- assert.strictEqual(result[0].replaceAll('\\', '/'), '/plugins/rules'); -- }); +- const sessionId = 'sess-1'; - -- test('deduplicates directories', () => { -- const instructions: INamedPluginResource[] = [ -- { uri: URI.file('/plugins/rules/a.mdc'), name: 'a' }, -- { uri: URI.file('/plugins/rules/b.mdc'), name: 'b' }, -- ]; -- const result = toSdkInstructionDirectories(instructions); -- assert.strictEqual(result.length, 1); -- }); +- /** +- * A bridge connection that pushes `models` as its snapshot synchronously when +- * the registry subscribes; the backing emitter is owned by `store`. +- */ +- function connectionOf(store: DisposableStore, models: IByokLmModelInfo[]): IByokLmBridgeConnection { +- const emitter = store.add(new Emitter({ +- onDidAddFirstListener: () => emitter.fire(models), +- })); +- return { chat: async (): Promise => ({ output: [] }), onDidChangeModels: emitter.event }; +- } - -- test('handles empty input', () => { -- const result = toSdkInstructionDirectories([]); -- assert.deepStrictEqual(result, []); -- }); +- /** A fake proxy service whose handles carry a unique nonce per `start()`. */ +- function fakeProxyService() { +- let starts = 0; +- let disposes = 0; +- const service: IByokLmProxyService = { +- _serviceBrand: undefined, +- start: async (): Promise => { +- const nonce = `NONCE-${++starts}`; +- return { +- baseUrl: 'http://127.0.0.1:1', +- nonce, +- providerBaseUrl: vendor => `http://127.0.0.1:1/v/${vendor}`, +- dispose: () => { disposes++; }, +- }; +- }, +- dispose: () => { }, +- }; +- return { service, get starts() { return starts; }, get disposes() { return disposes; } }; +- } +- +- function createLauncher(store: DisposableStore, proxy: IByokLmProxyService, registry: IByokLmBridgeRegistry): CopilotSessionLauncher { +- const services = new ServiceCollection(); +- services.set(ILogService, new NullLogService()); +- services.set(IByokLmProxyService, proxy); +- services.set(IByokLmBridgeRegistry, registry); +- // The launcher's other dependencies are unused by the BYOK path and +- // resolve to `undefined` under the non-strict InstantiationService. +- const instantiationService = store.add(new InstantiationService(services)); +- return instantiationService.createInstance(CopilotSessionLauncher); +- } +- +- test('memoizes the handle, and disposeByokProxyHandle releases it so the next launch mints a fresh nonce', async () => { +- const store = 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); +- const resolve = () => (launcher as unknown as { _resolveByokSessionConfig(id: string): Promise<{ providers?: { bearerToken: string }[] }> })._resolveByokSessionConfig(sessionId); +- +- const first = await resolve(); +- const second = await resolve(); +- assert.strictEqual(proxy.starts, 1, 'subsequent launches share the memoized bind'); +- assert.strictEqual(first.providers![0].bearerToken, second.providers![0].bearerToken, 'the shared bind reuses one nonce'); +- +- await launcher.disposeByokProxyHandle(); +- await launcher.disposeByokProxyHandle(); +- assert.strictEqual(proxy.disposes, 1, 'the handle is released exactly once and disposal is idempotent'); +- +- const third = await resolve(); +- assert.strictEqual(proxy.starts, 2, 'a fresh bind is minted after disposal'); +- assert.notStrictEqual(third.providers![0].bearerToken, first.providers![0].bearerToken, 'the fresh bind carries a new nonce'); +- +- store.dispose(); - }); +-}); - -- // ---- toSdkHooks ------------------------------------------------------- +-suite('CopilotSessionLauncher client identity', () => { - -- suite('toSdkHooks', () => { +- ensureNoDisposablesAreLeakedInTestSuite(); - -- function makeHookGroup(type: string, command: string): IParsedHookGroup { -- return { -- type, -- commands: [{ command }], -- uri: URI.file('/plugin/hooks.json'), -- originalId: type, -- customization: stubHookCustomization(type), -- }; -- } +- test('passes the Agent Host client name to create and resume', async () => { +- const createConfigs: Parameters[0][] = []; +- const resumeConfigs: Parameters[1][] = []; +- const session = { +- sessionId: 'session-1', +- on: () => () => { }, +- disconnect: async () => { }, +- } as unknown as CopilotSession; +- const client = { +- createSession: async (config: Parameters[0]) => { +- createConfigs.push(config); +- return session; +- }, +- resumeSession: async (_sessionId: string, config: Parameters[1]) => { +- resumeConfigs.push(config); +- return session; +- }, +- }; +- const launcher = createTestLauncher(); +- const pluginDir = URI.file('/tmp/synced-customizations'); +- const skillUri = URI.joinPath(pluginDir, 'skills', 'user-skill', 'SKILL.md'); +- const instructionUri = URI.joinPath(pluginDir, 'rules', 'user.instructions.md'); +- const plugin: ICopilotPluginInfo = { +- format: PluginFormat.Copilot, +- hooks: [], +- mcpServers: [], +- agents: [], +- skills: [{ +- uri: skillUri, +- name: 'user-skill', +- customization: { type: CustomizationType.Skill, id: skillUri.toString(), uri: skillUri.toString(), name: 'user-skill' }, +- }], +- instructions: [{ +- uri: instructionUri, +- name: 'user', +- customization: { type: CustomizationType.Rule, id: instructionUri.toString(), uri: instructionUri.toString(), name: 'user', alwaysApply: true }, +- }], +- pluginDir, +- }; +- const basePlan = { +- client, +- sessionId: 'session-1', +- workingDirectory: testWorkingDirectory, +- resolvedAgentName: undefined, +- snapshot: { tools: [], plugins: [plugin], mcpServers: {} }, +- activeClientToolSet: new ActiveClientToolSet(), +- shellManager: undefined, +- githubToken: undefined, +- }; +- const createPlan: CopilotSessionLaunchPlan = { +- ...basePlan, +- kind: 'create', +- model: undefined, +- }; +- const resumePlan: CopilotSessionLaunchPlan = { +- ...basePlan, +- kind: 'resume', +- fallback: { model: undefined }, +- }; - -- /** -- * Writes a temp JS script that outputs JSON to stdout and returns -- * a `node ` command. Works on both bash (/bin/sh -c) and -- * cmd.exe without any shell-quoting issues. -- * The script is written alongside the compiled test file which is -- * guaranteed to exist, be writable, and have no spaces in CI. -- */ -- function echoJsonCmd(value: object): { command: string; cleanup: () => void } { -- const json = JSON.stringify(value); -- // fileURLToPath(new URL('.', import.meta.url)) is the Node ESM equivalent -- // of __dirname and works on Node 12+, unlike import.meta.dirname (Node 21.2+). -- const dir = fileURLToPath(new URL('.', import.meta.url)).replace(/[\\/]$/, ''); -- const filePath = `${dir}/vscode-test-hook-${Date.now()}.js`; -- writeFileSync(filePath, `process.stdout.write(${JSON.stringify(json)});\n`); -- // Do NOT quote the path: cmd.exe /c "node path" strips the outer quotes, -- // leaving "node path" without inner quoting which cmd.exe handles cleanly. -- const command = `node ${filePath}`; -- return { command, cleanup: () => { try { unlinkSync(filePath); } catch { /* ignore */ } } }; +- const sessions = new DisposableStore(); +- try { +- sessions.add(await launcher.launch(createPlan, testRuntime)); +- sessions.add(await launcher.launch(resumePlan, testRuntime)); +- +- assert.deepStrictEqual({ +- createClientName: createConfigs[0].clientName, +- createPluginDirectories: createConfigs[0].pluginDirectories, +- createSkillDirectories: createConfigs[0].skillDirectories, +- createInstructionDirectories: createConfigs[0].instructionDirectories, +- resumeClientName: resumeConfigs[0].clientName, +- resumePluginDirectories: resumeConfigs[0].pluginDirectories, +- resumeSkillDirectories: resumeConfigs[0].skillDirectories, +- resumeInstructionDirectories: resumeConfigs[0].instructionDirectories, +- }, { +- createClientName: 'vscode-agent-host', +- createPluginDirectories: [pluginDir.fsPath], +- createSkillDirectories: [], +- createInstructionDirectories: [URI.joinPath(pluginDir, 'rules').fsPath], +- resumeClientName: 'vscode-agent-host', +- resumePluginDirectories: [pluginDir.fsPath], +- resumeSkillDirectories: [], +- resumeInstructionDirectories: [URI.joinPath(pluginDir, 'rules').fsPath], +- }); +- } finally { +- sessions.dispose(); +- await launcher.disposeByokProxyHandle(); - } +- }); +-}); - -- 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(); -- } -- }); +-suite('CopilotSessionLauncher resume fallback', () => { - -- test('onPostToolUse returns undefined when output is non-JSON', async () => { -- // Use a script file so there are no cmd.exe quoting issues on Windows. -- 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 */ } -- } -- }); +- ensureNoDisposablesAreLeakedInTestSuite(); - -- 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 */ } -- } -- }); +- class TestSdkError extends Error { +- constructor(message: string, readonly code: number) { +- super(message); +- } +- } - -- test('onPostToolUse returns undefined when no commands', async () => { -- const hooks = toSdkHooks([]); -- assert.strictEqual(hooks.onPostToolUse, undefined); -- }); +- function createResumeFailingLaunch(message: string, code = -32603): { readonly launcher: CopilotSessionLauncher; readonly plan: CopilotSessionLaunchPlan; readonly getCreateSessionCalls: () => number } { +- let createSessionCalls = 0; +- const session = { +- sessionId: 'session-1', +- on: () => () => { }, +- disconnect: async () => { }, +- } as unknown as CopilotSession; +- const client = { +- createSession: async () => { +- createSessionCalls++; +- return session; +- }, +- resumeSession: async () => { +- throw new TestSdkError(message, code); +- }, +- }; +- return { +- launcher: createTestLauncher(), +- plan: { +- client, +- sessionId: 'session-1', +- workingDirectory: testWorkingDirectory, +- resolvedAgentName: undefined, +- snapshot: { tools: [], plugins: [], mcpServers: {} }, +- activeClientToolSet: new ActiveClientToolSet(), +- shellManager: undefined, +- githubToken: undefined, +- kind: 'resume', +- fallback: { model: undefined }, +- }, +- getCreateSessionCalls: () => createSessionCalls, +- }; +- } - -- 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; }, -- }; -- 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(); -- } -- }); +- test('falls back to createSession after a Start Over truncate leaves the session empty', async () => { +- const { launcher, plan, getCreateSessionCalls } = createResumeFailingLaunch(`Request session.resume failed with message: LocalRpcSession: 'session.getMessages' returned no events for session session-1`); +- +- const sessions = new DisposableStore(); +- try { +- sessions.add(await launcher.launch(plan, testRuntime)); +- assert.strictEqual(getCreateSessionCalls(), 1); +- } finally { +- sessions.dispose(); +- await launcher.disposeByokProxyHandle(); +- } - }); - -- // ---- parsedPluginsEqual --------------------------------------------- +- test('falls back to createSession when the SDK reports the session was not found', async () => { +- const { launcher, plan, getCreateSessionCalls } = createResumeFailingLaunch('Request session.resume failed with message: Session not found: session-1'); - -- suite('parsedPluginsEqual', () => { +- const sessions = new DisposableStore(); +- try { +- sessions.add(await launcher.launch(plan, testRuntime)); +- assert.strictEqual(getCreateSessionCalls(), 1); +- } finally { +- sessions.dispose(); +- await launcher.disposeByokProxyHandle(); +- } +- }); - -- function makePlugin(overrides?: Partial): IParsedPlugin { -- return { -- hooks: [], -- mcpServers: [], -- skills: [], -- agents: [], -- instructions: [], -- ...overrides, -- }; +- test('does not replace a session with an empty one after a transient network failure', async () => { +- // Regression: this used to fall through to `createSession`, presenting a +- // session with real history as having zero turns — which the empty-session +- // GC then deleted along with its worktree. +- const { launcher, plan, getCreateSessionCalls } = createResumeFailingLaunch('Request session.resume failed with message: network fetch failed: request failed: error sending request for url (https://api.github.com/copilot_internal/user)'); +- +- try { +- await assert.rejects(() => launcher.launch(plan, testRuntime), /network fetch failed/); +- assert.strictEqual(getCreateSessionCalls(), 0); +- } finally { +- await launcher.disposeByokProxyHandle(); - } +- }); - -- test('returns true for identical empty plugins', () => { -- assert.strictEqual(parsedPluginsEqual([makePlugin()], [makePlugin()]), true); -- }); +- test('does not replace a session with an empty one for an unrecognized -32603', async () => { +- const { launcher, plan, getCreateSessionCalls } = createResumeFailingLaunch('Request session.resume failed: something went wrong'); +- +- try { +- await assert.rejects(() => launcher.launch(plan, testRuntime), /something went wrong/); +- assert.strictEqual(getCreateSessionCalls(), 0); +- } finally { +- await launcher.disposeByokProxyHandle(); +- } +- }); - -- test('returns true for same content', () => { -- const a = makePlugin({ -- skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a', customization: stubSkillCustomization('a') } satisfies IParsedSkill], -- mcpServers: [{ -- name: 'server', -- uri: URI.file('/mcp'), -- configuration: { type: McpServerType.LOCAL, command: 'node' }, -- customization: stubMcpCustomization('server'), -- }], -- }); -- const b = makePlugin({ -- skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a', customization: stubSkillCustomization('a') } satisfies IParsedSkill], -- mcpServers: [{ -- name: 'server', -- uri: URI.file('/mcp'), -- configuration: { type: McpServerType.LOCAL, command: 'node' }, -- customization: stubMcpCustomization('server'), -- }], -- }); -- assert.strictEqual(parsedPluginsEqual([a], [b]), true); -- }); +- test('does not replace a corrupted session file with an empty session', async () => { +- const { launcher, plan, getCreateSessionCalls } = createResumeFailingLaunch('Request session.resume failed with message: Session file is corrupted (line 19567: data.compactionTokensUsed.copilotUsage.tokenDetails.0.batchSize: Number must be greater than 0)'); - -- test('returns false for different content', () => { -- const a = makePlugin({ skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a', customization: stubSkillCustomization('a') } satisfies IParsedSkill] }); -- const b = makePlugin({ skills: [{ uri: URI.file('/b/SKILL.md'), name: 'b', customization: stubSkillCustomization('b') } satisfies IParsedSkill] }); -- assert.strictEqual(parsedPluginsEqual([a], [b]), false); -- }); +- try { +- await assert.rejects(() => launcher.launch(plan, testRuntime), /Session file is corrupted/); +- assert.strictEqual(getCreateSessionCalls(), 0); +- } finally { +- await launcher.disposeByokProxyHandle(); +- } +- }); +-}); - -- test('returns false for different lengths', () => { -- assert.strictEqual(parsedPluginsEqual([makePlugin()], [makePlugin(), makePlugin()]), false); -- }); +-/** +- * Covers the reasoning-effort resolution fed into `createSession` and +- * `CopilotAgent._changeModel`: the host-level override (see +- * `CopilotCliConfigKey.ReasoningEffortOverride`) wins over the model picker's +- * thinking level when valid, and degrades to the picker value otherwise. +- */ +-suite('getCopilotReasoningEffort', () => { - -- test('returns true for empty arrays', () => { -- assert.strictEqual(parsedPluginsEqual([], []), true); -- }); +- ensureNoDisposablesAreLeakedInTestSuite(); +- +- test('a valid override wins over the picker value; an invalid or absent override falls back', () => { +- const model: ModelSelection = { id: 'gpt-5', config: { thinkingLevel: 'medium' } }; +- assert.deepStrictEqual( +- [ +- getCopilotReasoningEffort(model), +- getCopilotReasoningEffort(model, 'xhigh'), +- getCopilotReasoningEffort(model, 'turbo'), +- getCopilotReasoningEffort(undefined, 'high'), +- getCopilotReasoningEffort(undefined), +- ], +- ['medium', 'xhigh', 'medium', 'high', undefined] +- ); - }); -}); 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 5f6c455d..00000000 +index 5dcbe7df..00000000 --- a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts +++ /dev/null -@@ -1,944 +0,0 @@ +@@ -1,991 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -17202,7 +29816,7 @@ index 5f6c455d..00000000 -import { URI } from '../../../../base/common/uri.js'; -import * as platform from '../../../../base/common/platform.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; --import { Disposable, DisposableStore, type IDisposable } from '../../../../base/common/lifecycle.js'; +-import { DisposableStore, type IDisposable } from '../../../../base/common/lifecycle.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; -import { IEnvironmentService } from '../../../environment/common/environment.js'; @@ -17233,11 +29847,13 @@ index 5f6c455d..00000000 - commandDetectionSupported = false; - readonly commandFinishedListenerRegistered = new DeferredPromise(); - private readonly _onCommandFinished = new Emitter(); +- private readonly _onData = new Emitter(); - private readonly _onExit = new Emitter(); - private readonly _onClaimChanged = new Emitter(); - private readonly _onDidSendText = new Emitter(); - readonly onDidSendText = this._onDidSendText.event; - private readonly _altBufferPromises: DeferredPromise[] = []; +- private _content: string | undefined; - - async createTerminal(params: CreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean }): Promise { - this.created.push({ params, options: { ...options, shell: options?.shell ?? this.defaultShell } }); @@ -17250,7 +29866,7 @@ index 5f6c455d..00000000 - this.writeInput(uri, formatTerminalText(data, options)); - this._onDidSendText.fire(); - } -- onData(): IDisposable { return Disposable.None; } +- onData(_uri: string, cb: (data: string) => void): IDisposable { return this._onData.event(cb); } - onExit(_uri: string, cb: (exitCode: number) => void): IDisposable { return this._onExit.event(cb); } - onClaimChanged(_uri: string, cb: (claim: TerminalClaim) => void): IDisposable { return this._onClaimChanged.event(cb); } - onCommandFinished(_uri: string, cb: (event: ICommandFinishedEvent) => void): IDisposable { @@ -17270,7 +29886,7 @@ index 5f6c455d..00000000 - }); - return deferred.p; - } -- getContent(): string | undefined { return undefined; } +- getContent(): string | undefined { return this._content; } - getClaim(): TerminalClaim | undefined { return undefined; } - hasTerminal(uri: string): boolean { return this.existingTerminalUris.has(uri); } - getExitCode(): number | undefined { return undefined; } @@ -17279,9 +29895,15 @@ index 5f6c455d..00000000 - getTerminalInfos(): TerminalInfo[] { return []; } - getTerminalState(): undefined { return undefined; } - async getDefaultShell(): Promise { return this.defaultShell; } +- createOutputTerminal(): void { } +- appendOutputTerminalData(): void { } +- resetOutputTerminal(): void { } +- finalizeOutputTerminal(): void { } - fireCommandFinished(event: ICommandFinishedEvent): void { this._onCommandFinished.fire(event); } +- fireData(data: string): void { this._onData.fire(data); } - fireExit(exitCode: number): void { this._onExit.fire(exitCode); } - fireClaimChanged(claim: TerminalClaim): void { this._onClaimChanged.fire(claim); } +- setContent(content: string | undefined): void { this._content = content; } - fireDidEnterAltBuffer(): void { - for (const promise of [...this._altBufferPromises]) { - promise.complete(); @@ -17308,8 +29930,12 @@ index 5f6c455d..00000000 - const service: IAgentConfigurationService = { - _serviceBrand: undefined, - onDidRootConfigChange: emitter.event, +- onDidSessionConfigChange: Event.None, - getEffectiveValue: () => undefined, - getEffectiveWorkingDirectory: () => undefined, +- getEffectiveWorkingDirectories: () => undefined, +- isWorkingDirectoryPending: () => false, +- resolveWorkingDirectoryForResume: async (_session, workingDirectory) => workingDirectory, - getSessionConfigValues: () => undefined, - updateSessionConfig: () => { /* no-op */ }, - getRootValue: ((_schema: unknown, key: string) => configValues[key]) as IAgentConfigurationService['getRootValue'], @@ -17579,6 +30205,41 @@ index 5f6c455d..00000000 - assert.match(terminalManager.writes[1].data, /^echo "<<>>"\r$/); - }); - +- test('primary shell tool ignores echoed sentinel command text', async () => { +- const { instantiationService, terminalManager } = createServices(); +- +- const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); +- const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); +- const bashTool = tools.find(tool => tool.name === 'bash'); +- assert.ok(bashTool); +- +- const invocation: ToolInvocation = { +- sessionId: 'session-1', +- toolCallId: 'tool-1', +- toolName: 'bash', +- arguments: { command: 'echo MOCKED_AGENT_HOST_SANDBOX_RESPONSE', timeout: 1000 }, +- }; +- const resultPromise = bashTool.handler!({ command: 'echo MOCKED_AGENT_HOST_SANDBOX_RESPONSE', timeout: 1000 }, invocation) as Promise; +- await waitForSentTexts(terminalManager, 2); +- +- const sentinelMatch = terminalManager.writes[1].data.match(/<<>/); +- assert.ok(sentinelMatch, 'sentinel marker should be present'); +- const sentinelId = sentinelMatch[1]; +- const content = [ +- ' echo MOCKED_AGENT_HOST_SANDBOX_RESPONSE', +- 'MOCKED_AGENT_HOST_SANDBOX_RESPONSE', +- `echo "<<>>"`, +- `<<>>`, +- ].join('\r\n'); +- terminalManager.setContent(content); +- terminalManager.fireData(content); +- +- const result = await resultPromise; +- assert.strictEqual(result.resultType, 'success'); +- assert.match(result.textResultForLlm, /Exit code: 0/); +- assert.match(result.textResultForLlm, /MOCKED_AGENT_HOST_SANDBOX_RESPONSE/); +- }); +- - test('primary shell tool forces bracketed paste with shell integration', async () => { - const { instantiationService, terminalManager } = createServices(); - terminalManager.commandDetectionSupported = true; @@ -18137,10 +30798,10 @@ index 5f6c455d..00000000 -}); diff --git a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts deleted file mode 100644 -index 70d60420..00000000 +index 0351b053..00000000 --- a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts +++ /dev/null -@@ -1,295 +0,0 @@ +@@ -1,612 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -18149,9 +30810,23 @@ index 70d60420..00000000 -import assert from 'assert'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; --import { CompletionItemKind } from '../../common/state/protocol/commands.js'; --import { MessageAttachmentKind } from '../../common/state/protocol/state.js'; --import { CopilotSlashCommandCompletionProvider, parseLeadingSlashCommand } from '../../node/copilot/copilotSlashCommandCompletionProvider.js'; +-import { SYNCED_CUSTOMIZATION_SCHEME } from '../../common/agentHostFileSystemService.js'; +-import { CompletionItem, CompletionItemKind } from '../../common/state/protocol/commands.js'; +-import { Customization, CustomizationLoadStatus, CustomizationType, McpServerStatus, MessageAttachmentKind, type PluginCustomization, type SkillCustomization } from '../../common/state/protocol/state.js'; +-import { CopilotSlashCommandCompletionProvider, ICopilotRuntimeSlashCommandInfo, parseLeadingSlashCommand } from '../../node/copilot/copilotSlashCommandCompletionProvider.js'; +- +-/** +- * The provider now also injects workbench-defined config-action items +- * (permission/mode toggles like `/yolo`, `/autopilot`) into every leading-slash +- * completion result; these carry an `action` bag on their attachment `_meta`. +- * The runtime-focused assertions below filter them out with this helper so they +- * keep asserting on the runtime SDK command set. Runtime commands whose name +- * collides with a config-action command (e.g. `plan`) are intentionally dropped +- * by the provider, so they no longer appear even after filtering. +- */ +-function runtimeOnly(items: readonly CompletionItem[]): CompletionItem[] { +- return items.filter(i => i.attachment?._meta?.action === undefined); +-} - -suite('CopilotSlashCommandCompletionProvider', () => { - @@ -18159,100 +30834,97 @@ index 70d60420..00000000 - - suite('parseLeadingSlashCommand', () => { - test('matches lone /plan', () => { -- assert.deepStrictEqual(parseLeadingSlashCommand('/plan'), { command: 'plan', rest: '' }); +- assert.deepStrictEqual(parseLeadingSlashCommand('/plan'), { command: 'plan', rest: '', rawRest: '' }); - }); - - test('matches lone /compact', () => { -- assert.deepStrictEqual(parseLeadingSlashCommand('/compact'), { command: 'compact', rest: '' }); +- assert.deepStrictEqual(parseLeadingSlashCommand('/compact'), { command: 'compact', rest: '', rawRest: '' }); - }); - - test('matches lone /research', () => { -- assert.deepStrictEqual(parseLeadingSlashCommand('/research'), { command: 'research', rest: '' }); +- assert.deepStrictEqual(parseLeadingSlashCommand('/research'), { command: 'research', rest: '', rawRest: '' }); - }); - - test('captures trailing text after a space for /research', () => { -- assert.deepStrictEqual(parseLeadingSlashCommand('/research How does React work?'), { command: 'research', rest: 'How does React work?' }); +- assert.deepStrictEqual(parseLeadingSlashCommand('/research How does React work?'), { command: 'research', rest: 'How does React work?', rawRest: 'How does React work?' }); - }); - - test('matches lone /rubber-duck', () => { -- assert.deepStrictEqual(parseLeadingSlashCommand('/rubber-duck'), { command: 'rubber-duck', rest: '' }); +- assert.deepStrictEqual(parseLeadingSlashCommand('/rubber-duck'), { command: 'rubber-duck', rest: '', rawRest: '' }); - }); - - test('matches lone /env', () => { -- assert.deepStrictEqual(parseLeadingSlashCommand('/env'), { command: 'env', rest: '' }); +- assert.deepStrictEqual(parseLeadingSlashCommand('/env'), { command: 'env', rest: '', rawRest: '' }); - }); - - test('matches lone /review', () => { -- assert.deepStrictEqual(parseLeadingSlashCommand('/review'), { command: 'review', rest: '' }); +- assert.deepStrictEqual(parseLeadingSlashCommand('/review'), { command: 'review', rest: '', rawRest: '' }); - }); - - test('matches lone /security-review', () => { -- assert.deepStrictEqual(parseLeadingSlashCommand('/security-review'), { command: 'security-review', rest: '' }); +- assert.deepStrictEqual(parseLeadingSlashCommand('/security-review'), { command: 'security-review', rest: '', rawRest: '' }); - }); - - test('captures trailing text after a space for /rubber-duck', () => { -- assert.deepStrictEqual(parseLeadingSlashCommand('/rubber-duck review my approach'), { command: 'rubber-duck', rest: 'review my approach' }); +- assert.deepStrictEqual(parseLeadingSlashCommand('/rubber-duck review my approach'), { command: 'rubber-duck', rest: 'review my approach', rawRest: 'review my approach' }); - }); - - test('captures trailing text after a space for /env', () => { -- assert.deepStrictEqual(parseLeadingSlashCommand('/env ignored input'), { command: 'env', rest: 'ignored input' }); +- assert.deepStrictEqual(parseLeadingSlashCommand('/env ignored input'), { command: 'env', rest: 'ignored input', rawRest: 'ignored input' }); - }); - - test('captures trailing text after a space for /review', () => { -- assert.deepStrictEqual(parseLeadingSlashCommand('/review focus on tests'), { command: 'review', rest: 'focus on tests' }); +- assert.deepStrictEqual(parseLeadingSlashCommand('/review focus on tests'), { command: 'review', rest: 'focus on tests', rawRest: 'focus on tests' }); - }); - - test('captures trailing text after a space for /security-review', () => { -- assert.deepStrictEqual(parseLeadingSlashCommand('/security-review focus on auth'), { command: 'security-review', rest: 'focus on auth' }); -- }); -- -- test('rejects /rubber-duck-extra (no separator)', () => { -- assert.strictEqual(parseLeadingSlashCommand('/rubber-duck-extra'), undefined); +- assert.deepStrictEqual(parseLeadingSlashCommand('/security-review focus on auth'), { command: 'security-review', rest: 'focus on auth', rawRest: 'focus on auth' }); - }); - -- test('rejects /env-extra (no separator)', () => { -- assert.strictEqual(parseLeadingSlashCommand('/env-extra'), undefined); +- test('parses arbitrary slash command tokens', () => { +- assert.deepStrictEqual(parseLeadingSlashCommand('/rubber-duck-extra'), { command: 'rubber-duck-extra', rest: '', rawRest: '' }); - }); - -- test('rejects /review-extra (no separator)', () => { -- assert.strictEqual(parseLeadingSlashCommand('/review-extra'), undefined); +- test('preserves multiline command input as rawRest', () => { +- assert.deepStrictEqual(parseLeadingSlashCommand('/foo first line\nsecond line'), { command: 'foo', rest: 'first line\nsecond line', rawRest: 'first line\nsecond line' }); - }); - -- test('rejects /security-review-extra (no separator)', () => { -- assert.strictEqual(parseLeadingSlashCommand('/security-review-extra'), undefined); -- }); -- -- test('rejects /rubber alone (incomplete command)', () => { -- assert.strictEqual(parseLeadingSlashCommand('/rubber'), undefined); +- test('trims rest while retaining rawRest', () => { +- assert.deepStrictEqual(parseLeadingSlashCommand('/foo padded '), { command: 'foo', rest: 'padded', rawRest: 'padded ' }); - }); - - test('captures trailing text after a space', () => { -- assert.deepStrictEqual(parseLeadingSlashCommand('/plan build a hello world'), { command: 'plan', rest: 'build a hello world' }); +- assert.deepStrictEqual(parseLeadingSlashCommand('/plan build a hello world'), { command: 'plan', rest: 'build a hello world', rawRest: 'build a hello world' }); - }); - - test('captures trailing text after a space for /compact', () => { -- assert.deepStrictEqual(parseLeadingSlashCommand('/compact some text'), { command: 'compact', rest: 'some text' }); -- }); -- -- test('rejects /compact-hello (no separator)', () => { -- assert.strictEqual(parseLeadingSlashCommand('/compact-hello world'), undefined); -- }); -- -- test('rejects /plans (longer command)', () => { -- assert.strictEqual(parseLeadingSlashCommand('/plans'), undefined); +- assert.deepStrictEqual(parseLeadingSlashCommand('/compact some text'), { command: 'compact', rest: 'some text', rawRest: 'some text' }); - }); - - test('rejects leading whitespace', () => { - assert.strictEqual(parseLeadingSlashCommand(' /compact'), undefined); - }); - -- test('case-sensitive', () => { -- assert.strictEqual(parseLeadingSlashCommand('/PLAN'), undefined); +- test('accepts uppercase command tokens', () => { +- assert.deepStrictEqual(parseLeadingSlashCommand('/PLAN'), { command: 'PLAN', rest: '', rawRest: '' }); - }); - }); - - suite('provideCompletionItems', () => { -- const provider = new CopilotSlashCommandCompletionProvider('copilotcli', { hasHistory: () => true, isRubberDuckEnabled: () => true, hasRuntimeSlashCommand: async () => true }); +- const runtimeCommands = [ +- { name: 'plan', description: 'Runtime plan', kind: 'builtin' as const, allowDuringAgentExecution: true, input: { hint: 'task' } }, +- { name: 'compact', description: 'Runtime compact', kind: 'builtin' as const, allowDuringAgentExecution: true }, +- { name: 'research', description: 'Runtime research', kind: 'builtin' as const, allowDuringAgentExecution: true, input: { hint: 'query' } }, +- { name: 'rubber-duck', description: 'Runtime rubber-duck', kind: 'builtin' as const, allowDuringAgentExecution: true, input: { hint: 'review prompt' } }, +- { name: 'env', description: 'Runtime env', kind: 'builtin' as const, allowDuringAgentExecution: true }, +- { name: 'review', description: 'Runtime review', kind: 'builtin' as const, allowDuringAgentExecution: true, input: { hint: 'scope' } }, +- { name: 'security-review', description: 'Runtime security review', kind: 'builtin' as const, allowDuringAgentExecution: true, input: { hint: 'scope' } }, +- ]; +- const provider = new CopilotSlashCommandCompletionProvider('copilotcli', { +- isRubberDuckEnabled: () => true, +- getRuntimeSlashCommands: async () => runtimeCommands, +- getSessionCustomizations: async () => [], +- }); - const session = 'copilotcli:/abc'; - - async function run(text: string, offset = text.length) { @@ -18269,9 +30941,18 @@ index 70d60420..00000000 - assert.deepStrictEqual(items, []); - }); - -- test('returns all items for lone "/"', async () => { +- test('returns all runtime items for lone "/" (config-action items filtered)', async () => { - const items = await run('/'); -- assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/compact', '/research ', '/rubber-duck ', '/env', '/review ', '/security-review ']); +- // `plan` collides with a config-action command and is dropped from the runtime set. +- assert.deepStrictEqual(runtimeOnly(items).map(i => i.insertText), ['/compact ', '/research ', '/rubber-duck ', '/env ', '/review ', '/security-review '].sort()); +- }); +- +- test('injects config-action items (permission/mode toggles) for a leading slash', async () => { +- const items = await run('/'); +- const byLabel = new Map(items.filter(i => i.attachment?._meta?.action !== undefined).map(i => [i.attachment?.label, i])); +- assert.ok(byLabel.has('/yolo on')); +- assert.ok(byLabel.has('/autopilot on')); +- assert.strictEqual(byLabel.get('/autopilot')?.insertText, '/autopilot '); - }); - - test('filters to /plan when "/p" typed', async () => { @@ -18281,17 +30962,26 @@ index 70d60420..00000000 - - test('filters to /compact when "/c" typed', async () => { - const items = await run('/c'); -- assert.deepStrictEqual(items.map(i => i.insertText), ['/compact']); +- assert.deepStrictEqual(items.map(i => i.insertText), ['/compact ']); +- }); +- +- test('fuzzy matches /compact when "/cc" typed', async () => { +- const items = await run('/cc'); +- assert.deepStrictEqual(items.map(i => i.insertText), ['/compact ']); - }); - - test('filters to /env when "/e" typed and runtime command exists', async () => { - const items = await run('/e'); -- assert.deepStrictEqual(items.map(i => i.insertText), ['/env']); +- assert.deepStrictEqual(items.map(i => i.insertText), ['/env ']); - }); - - test('filters to /research and /rubber-duck when "/r" typed', async () => { - const items = await run('/r'); -- assert.deepStrictEqual(items.map(i => i.insertText), ['/research ', '/rubber-duck ', '/review ']); +- assert.deepStrictEqual(items.map(i => i.insertText), [ +- '/research ', +- '/review ', +- '/rubber-duck ' +- ].sort()); - }); - - test('filters to /security-review when "/s" typed', async () => { @@ -18324,137 +31014,424 @@ index 70d60420..00000000 - - test('attachment is Simple with command + description meta', async () => { - const items = await run('/'); -- assert.deepStrictEqual(items.map(item => ({ type: item.attachment?.type, meta: item.attachment?._meta })), [ -- { -- type: MessageAttachmentKind.Simple, -- meta: { -- command: 'plan', -- description: 'Create an implementation plan before coding', -- }, -- }, +- assert.deepStrictEqual(runtimeOnly(items).map(item => ({ insertText: item.insertText, type: item.attachment?.type, meta: item.attachment?._meta })), [ - { +- insertText: '/compact ', - type: MessageAttachmentKind.Simple, - meta: { - command: 'compact', -- description: 'Free up context by compacting the conversation history', +- description: 'Runtime compact', - }, - }, - { +- insertText: '/env ', - type: MessageAttachmentKind.Simple, - meta: { -- command: 'research', -- description: 'Run deep research on a topic using search and web sources', +- command: 'env', +- description: 'Runtime env', - }, - }, - { +- insertText: '/research ', - type: MessageAttachmentKind.Simple, - meta: { -- command: 'rubber-duck', -- description: 'Get an independent critique of the current approach', +- command: 'research', +- description: 'Runtime research', +- argumentHint: 'query', - }, - }, - { +- insertText: '/review ', - type: MessageAttachmentKind.Simple, - meta: { -- command: 'env', -- description: 'Show loaded environment details', +- command: 'review', +- description: 'Runtime review', +- argumentHint: 'scope', - }, - }, - { +- insertText: '/rubber-duck ', - type: MessageAttachmentKind.Simple, - meta: { -- command: 'review', -- description: 'Run code review agent to analyze changes', +- command: 'rubber-duck', +- description: 'Runtime rubber-duck', +- argumentHint: 'review prompt', - }, - }, - { +- insertText: '/security-review ', - type: MessageAttachmentKind.Simple, - meta: { - command: 'security-review', -- description: 'Analyze staged and unstaged changes for security vulnerabilities', +- description: 'Runtime security review', +- argumentHint: 'scope', - }, - }, - ]); - }); - -- test('omits /compact when session has no history', async () => { -- const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { hasHistory: () => false, isRubberDuckEnabled: () => true, hasRuntimeSlashCommand: async () => true }); +- test('omits /rubber-duck when not enabled', async () => { +- const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { +- isRubberDuckEnabled: () => false, +- getRuntimeSlashCommands: async () => runtimeCommands, +- getSessionCustomizations: async () => [], +- }); - const items = await gated.provideCompletionItems({ - kind: CompletionItemKind.UserMessage, channel: session, text: '/', offset: 1, - }, CancellationToken.None); -- assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/research ', '/rubber-duck ', '/env', '/review ', '/security-review ']); +- assert.deepStrictEqual(runtimeOnly(items).map(i => i.insertText), [ +- '/compact ', +- '/env ', +- '/research ', +- '/review ', +- '/security-review ' +- ].sort()); - }); - -- test('omits /rubber-duck when not enabled', async () => { -- const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { hasHistory: () => true, isRubberDuckEnabled: () => false, hasRuntimeSlashCommand: async () => true }); +- test('returns no completion items when runtime command list is empty', async () => { +- const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { +- isRubberDuckEnabled: () => true, +- getRuntimeSlashCommands: async () => [], +- getSessionCustomizations: async () => [], +- }); - const items = await gated.provideCompletionItems({ - kind: CompletionItemKind.UserMessage, channel: session, text: '/', offset: 1, - }, CancellationToken.None); -- assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/compact', '/research ', '/env', '/review ', '/security-review ']); +- assert.deepStrictEqual(runtimeOnly(items), []); - }); - -- test('omits /env when runtime command is unavailable', async () => { -- const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { hasHistory: () => true, isRubberDuckEnabled: () => true, hasRuntimeSlashCommand: async (_id, command) => command !== 'env' }); +- test('filters out runtime commands omitted from the catalog', async () => { +- const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { +- isRubberDuckEnabled: () => true, +- getRuntimeSlashCommands: async () => runtimeCommands.filter(command => command.name !== 'env'), +- getSessionCustomizations: async () => [], +- }); - const items = await gated.provideCompletionItems({ - kind: CompletionItemKind.UserMessage, channel: session, text: '/', offset: 1, - }, CancellationToken.None); -- assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/compact', '/research ', '/rubber-duck ', '/review ', '/security-review ']); +- // `plan` collides with a config-action command and is dropped from the runtime set. +- assert.deepStrictEqual(runtimeOnly(items).map(i => i.insertText), [ +- '/compact ', +- '/research ', +- '/review ', +- '/rubber-duck ', +- '/security-review ', +- ].sort()); +- }); +- +- test('includes runtime SDK commands in completion results', async () => { +- const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { +- isRubberDuckEnabled: () => true, +- getRuntimeSlashCommands: async () => [{ +- name: 'focus', +- description: 'Focus on specific files', +- kind: 'builtin', +- allowDuringAgentExecution: true, +- input: { hint: 'scope' }, +- }], +- getSessionCustomizations: async () => [], +- }); +- const items = await gated.provideCompletionItems({ +- kind: CompletionItemKind.UserMessage, channel: session, text: '/f', offset: 2, +- }, CancellationToken.None); +- assert.deepStrictEqual(items.map(i => i.insertText), ['/focus ']); - }); - -- test('keeps prompt-invoked commands when runtime commands are unavailable', async () => { -- const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { hasHistory: () => true, isRubberDuckEnabled: () => true, hasRuntimeSlashCommand: async (_id, command) => command === 'env' }); +- test('config-action commands shadow runtime commands of the same name', async () => { +- const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { +- isRubberDuckEnabled: () => true, +- getRuntimeSlashCommands: async () => [ +- { name: 'plan', description: 'runtime plan', kind: 'builtin', allowDuringAgentExecution: true, input: { hint: 'task' } }, +- { name: 'compact', description: 'runtime compact', kind: 'builtin', allowDuringAgentExecution: true }, +- { name: 'runtime-only', description: 'runtime only', kind: 'client', allowDuringAgentExecution: true }, +- ], +- getSessionCustomizations: async () => [], +- }); - const items = await gated.provideCompletionItems({ - kind: CompletionItemKind.UserMessage, channel: session, text: '/', offset: 1, - }, CancellationToken.None); -- assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/compact', '/research ', '/rubber-duck ', '/env', '/review ', '/security-review ']); +- // `plan` collides with a config-action command, so the runtime `plan` is +- // dropped; non-colliding runtime commands are kept. +- assert.deepStrictEqual(runtimeOnly(items).map(i => i.insertText), ['/compact ', '/runtime-only '].sort()); +- // The config-action `/plan ` item is still surfaced (carrying an action bag). +- const planItem = items.find(i => i.insertText === '/plan '); +- assert.ok(planItem?.attachment?._meta?.action !== undefined); - }); - -- test('passes raw session id (no scheme/slash) to hasHistory', async () => { -- let seen: string | undefined; +- test('uses runtime input metadata to determine trailing space insertion', async () => { - const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { -- hasHistory: (id: string) => { seen = id; return true; }, - isRubberDuckEnabled: () => true, -- hasRuntimeSlashCommand: async () => true, +- getRuntimeSlashCommands: async () => [ +- { name: 'no-input', description: 'No input', kind: 'builtin', allowDuringAgentExecution: true }, +- { name: 'needs-input', description: 'Needs input', kind: 'builtin', allowDuringAgentExecution: true, input: { hint: 'value' } }, +- ], +- getSessionCustomizations: async () => [], - }); -- await gated.provideCompletionItems({ -- kind: CompletionItemKind.UserMessage, channel: 'copilotcli:/abc', text: '/', offset: 1, +- const withInput = await gated.provideCompletionItems({ +- kind: CompletionItemKind.UserMessage, channel: session, text: '/n', offset: 2, - }, CancellationToken.None); -- assert.strictEqual(seen, 'abc'); +- assert.deepStrictEqual(withInput.map(i => i.insertText), ['/no-input ', '/needs-input '].sort()); +- }); +- +- test('expands input choices into one item per choice', async () => { +- const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { +- isRubberDuckEnabled: () => true, +- getRuntimeSlashCommands: async () => [ +- { name: 'toggle', description: 'Toggle a feature on or off', kind: 'builtin', allowDuringAgentExecution: true, input: { hint: '', choices: [{ name: 'on', description: 'Turn the feature on' }, { name: 'off', description: 'Turn the feature off' }] } }, +- ], +- getSessionCustomizations: async () => [], +- }); +- const items = await gated.provideCompletionItems({ +- kind: CompletionItemKind.UserMessage, channel: session, text: '/t', offset: 2, +- }, CancellationToken.None); +- // Structured choices expand into one item per choice, each carrying its own description. +- assert.deepStrictEqual(items.map(item => ({ insertText: item.insertText, meta: item.attachment?._meta })), [ +- { insertText: '/toggle off ', meta: { command: 'toggle', description: 'Turn the feature off' } }, +- { insertText: '/toggle on ', meta: { command: 'toggle', description: 'Turn the feature on' } }, +- ]); +- }); +- +- test('includes a bare command item when a choice has an empty name', async () => { +- const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { +- isRubberDuckEnabled: () => true, +- getRuntimeSlashCommands: async () => [ +- { name: 'toggle', description: 'Toggle a feature on or off', kind: 'builtin', allowDuringAgentExecution: true, input: { hint: '', choices: [{ name: '', description: 'Show the current state' }, { name: 'on', description: 'Turn on' }, { name: 'off', description: 'Turn off' }] } }, +- ], +- getSessionCustomizations: async () => [], +- }); +- const items = await gated.provideCompletionItems({ +- kind: CompletionItemKind.UserMessage, channel: session, text: '/t', offset: 2, +- }, CancellationToken.None); +- // A choice with an empty name produces the bare command alongside the other options. +- assert.deepStrictEqual(items.map(i => i.insertText), ['/toggle ', '/toggle off ', '/toggle on ']); +- }); +- +- test('surfaces the free-text hint as an argument hint when there are no choices', async () => { +- const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { +- isRubberDuckEnabled: () => true, +- getRuntimeSlashCommands: async () => [ +- { name: 'toggle', description: 'Toggle a feature on or off', kind: 'builtin', allowDuringAgentExecution: true, input: { hint: '[on|off]' } }, +- ], +- getSessionCustomizations: async () => [], +- }); +- const items = await gated.provideCompletionItems({ +- kind: CompletionItemKind.UserMessage, channel: session, text: '/t', offset: 2, +- }, CancellationToken.None); +- // Without structured choices, the free-text hint is not expanded into options; it is surfaced as an argument hint on a single item. +- assert.deepStrictEqual(items.map(item => ({ insertText: item.insertText, meta: item.attachment?._meta })), [ +- { insertText: '/toggle ', meta: { command: 'toggle', description: 'Toggle a feature on or off', argumentHint: '[on|off]' } }, +- ]); - }); - -- test('passes raw session id to runtime command availability', async () => { +- test('passes raw session id to runtime command listing', async () => { - let seen: string | undefined; - const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { -- hasHistory: () => true, - isRubberDuckEnabled: () => true, -- hasRuntimeSlashCommand: async (id: string) => { seen = id; return true; }, +- getRuntimeSlashCommands: async (id: string) => { +- seen = id; +- return [{ name: 'focus', kind: 'builtin', description: 'Focus', allowDuringAgentExecution: true }]; +- }, +- getSessionCustomizations: async () => [], - }); - await gated.provideCompletionItems({ -- kind: CompletionItemKind.UserMessage, channel: 'copilotcli:/abc', text: '/e', offset: 2, +- kind: CompletionItemKind.UserMessage, channel: 'copilotcli:/abc', text: '/f', offset: 2, - }, CancellationToken.None); - assert.strictEqual(seen, 'abc'); - }); - }); +- +- suite('runtime skill completions', () => { +- const session = 'copilotcli:/abc'; +- +- function skill(name: string, description?: string): SkillCustomization { +- return { +- type: CustomizationType.Skill, +- id: `file:///skills/${name}/SKILL.md`, +- uri: `file:///skills/${name}/SKILL.md`, +- name, +- ...(description !== undefined ? { description } : {}), +- }; +- } +- +- function plugin(name: string, children?: readonly SkillCustomization[], enabled = true): PluginCustomization { +- return { +- type: CustomizationType.Plugin, +- id: `file:///plugins/${name}`, +- uri: `file:///plugins/${name}`, +- name, +- enabled, +- load: { kind: CustomizationLoadStatus.Loaded }, +- ...(children ? { children: [...children] } : {}), +- }; +- } +- +- function syncedPlugin(name: string, children?: readonly SkillCustomization[]): PluginCustomization { +- return { +- ...plugin(name, children), +- id: `${SYNCED_CUSTOMIZATION_SCHEME}:/plugins/${name}`, +- uri: `${SYNCED_CUSTOMIZATION_SCHEME}:/plugins/${name}`, +- }; +- } +- +- function createProvider(runtimeCommands: readonly ICopilotRuntimeSlashCommandInfo[], customizations: readonly Customization[] = []): CopilotSlashCommandCompletionProvider { +- return new CopilotSlashCommandCompletionProvider('copilotcli', { +- isRubberDuckEnabled: () => true, +- getRuntimeSlashCommands: async () => runtimeCommands, +- getSessionCustomizations: async () => customizations, +- }); +- } +- +- async function run(provider: CopilotSlashCommandCompletionProvider, text: string, offset = text.length) { +- return provider.provideCompletionItems({ kind: CompletionItemKind.UserMessage, channel: session, text, offset }, CancellationToken.None); +- } +- +- test('includes runtime skills that are not known local skills', async () => { +- const provider = createProvider([ +- { name: 'my-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }, +- ]); +- const items = await run(provider, '/'); +- assert.deepStrictEqual(runtimeOnly(items).map(i => i.insertText), ['/my-skill ']); +- }); +- +- test('excludes runtime skills that match a known plugin skill (with plugin prefix)', async () => { +- const provider = createProvider( +- [{ name: 'my-plugin:my-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }], +- [plugin('my-plugin', [skill('my-skill')])], +- ); +- const items = await run(provider, '/'); +- assert.deepStrictEqual(runtimeOnly(items), []); +- }); +- +- test('excludes runtime skills that match a known plugin skill with the same name (no prefix)', async () => { +- const provider = createProvider( +- [{ name: 'monitor-pr', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }], +- [plugin('monitor-pr', [skill('monitor-pr')])], +- ); +- const items = await run(provider, '/'); +- assert.deepStrictEqual(runtimeOnly(items), []); +- }); +- +- test('excludes runtime skills that match a known synced plugin skill (no prefix)', async () => { +- const provider = createProvider( +- [{ name: 'monitor-pr', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }], +- [syncedPlugin('skills-bundle', [skill('monitor-pr')])], +- ); +- const items = await run(provider, '/'); +- assert.deepStrictEqual(runtimeOnly(items), []); +- }); +- +- test('includes runtime skills whose name differs from the prefixed known skill candidate', async () => { +- // A non-synced plugin skill is known as `my-plugin:my-skill`, so a bare `my-skill` runtime skill is still surfaced. +- const provider = createProvider( +- [{ name: 'my-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }], +- [plugin('my-plugin', [skill('my-skill')])], +- ); +- const items = await run(provider, '/'); +- assert.deepStrictEqual(runtimeOnly(items).map(i => i.insertText), ['/my-skill ']); +- }); +- +- test('treats skills inside disabled containers as unknown', async () => { +- const provider = createProvider( +- [{ name: 'my-plugin:my-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }], +- [plugin('my-plugin', [skill('my-skill')], false)], +- ); +- const items = await run(provider, '/'); +- assert.deepStrictEqual(runtimeOnly(items).map(i => i.insertText), ['/my-plugin:my-skill ']); +- }); +- +- test('ignores mcp server containers when computing known skills', async () => { +- const mcpServer: Customization = { +- type: CustomizationType.McpServer, +- id: 'file:///mcp/my-skill', +- uri: 'file:///mcp/my-skill', +- name: 'my-skill', +- enabled: true, +- state: { kind: McpServerStatus.Ready }, +- }; +- const provider = createProvider( +- [{ name: 'my-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }], +- [mcpServer], +- ); +- const items = await run(provider, '/'); +- assert.deepStrictEqual(runtimeOnly(items).map(i => i.insertText), ['/my-skill ']); +- }); +- +- test('surfaces the skill prompt hint as an argument hint', async () => { +- const provider = createProvider([ +- { name: 'my-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true, input: { hint: 'do stuff' } }, +- ]); +- const items = await run(provider, '/'); +- assert.deepStrictEqual(runtimeOnly(items).map(item => ({ insertText: item.insertText, type: item.attachment?.type, meta: item.attachment?._meta })), [ +- { +- insertText: '/my-skill ', +- type: MessageAttachmentKind.Simple, +- meta: { +- command: 'my-skill', +- description: 'Runtime skill', +- argumentHint: 'do stuff', +- }, +- }, +- ]); +- }); +- +- test('does not expand a skill hint into option items', async () => { +- const provider = createProvider([ +- { name: 'toggle-skill', description: 'Toggle skill', kind: 'skill', allowDuringAgentExecution: true, input: { hint: '[on|off]' } }, +- ]); +- const items = await run(provider, '/'); +- assert.deepStrictEqual(runtimeOnly(items).map(i => i.insertText), ['/toggle-skill ']); +- }); +- +- test('surfaces runtime skills alongside builtins for a leading slash', async () => { +- const provider = createProvider([ +- { name: 'compact', description: 'Runtime compact', kind: 'builtin', allowDuringAgentExecution: true }, +- { name: 'alpha-skill', description: 'Alpha skill', kind: 'skill', allowDuringAgentExecution: true }, +- ]); +- const items = await run(provider, '/'); +- assert.deepStrictEqual(runtimeOnly(items).map(i => i.insertText), ['/compact ', '/alpha-skill '].sort()); +- }); +- +- test('returns only runtime skills for an in-message slash token', async () => { +- const provider = createProvider([ +- { name: 'plan', description: 'Runtime plan', kind: 'builtin', allowDuringAgentExecution: true, input: { hint: 'task' } }, +- { name: 'runtime-only', description: 'Client command', kind: 'client', allowDuringAgentExecution: true }, +- { name: 'my-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }, +- ]); +- const items = await run(provider, 'use /'); +- assert.deepStrictEqual(items.map(i => i.insertText), ['/my-skill ']); +- }); +- +- test('excludes known skills even for an in-message slash token', async () => { +- const provider = createProvider( +- [ +- { name: 'my-plugin:my-skill', description: 'Known skill', kind: 'skill', allowDuringAgentExecution: true }, +- { name: 'other-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }, +- ], +- [plugin('my-plugin', [skill('my-skill')])], +- ); +- const items = await run(provider, 'use /'); +- assert.deepStrictEqual(items.map(i => i.insertText), ['/other-skill ']); +- }); +- }); -}); diff --git a/src/vs/platform/agentHost/test/node/copilotTestEvents.ts b/src/vs/platform/agentHost/test/node/copilotTestEvents.ts -index 4e64f874..f3065c70 100644 +index ab23cc65..a333e567 100644 --- a/src/vs/platform/agentHost/test/node/copilotTestEvents.ts +++ b/src/vs/platform/agentHost/test/node/copilotTestEvents.ts -@@ -3,7 +3,13 @@ - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ +@@ -5,3 +5,12 @@ --import type { Attachment, SessionEvent } from '@github/copilot-sdk'; -+// `Attachment`/`SessionEvent` originally came from '@github/copilot-sdk' -+// (removed in !!APP_NAME!!). This fixture only uses them as opaque types it -+// casts into, so alias them to `any` locally. +-import type { Attachment, SessionEvent, SessionEventPayload, ToolExecutionCompleteContent } from '@github/copilot-sdk'; ++// `Attachment`/`SessionEvent`/`SessionEventPayload`/`ToolExecutionCompleteContent` ++// originally came from '@github/copilot-sdk' (removed in !!APP_NAME!!). This ++// fixture only uses them as opaque types it casts into, so alias them to `any` ++// locally. +/* eslint-disable @typescript-eslint/no-explicit-any */ +type Attachment = any; +type SessionEvent = any; ++type SessionEventPayload = any; ++type ToolExecutionCompleteContent = any; +/* eslint-enable @typescript-eslint/no-explicit-any */ - // ============================================================================= - // Minimal session-event shapes for tests diff --git a/src/vs/platform/agentHost/test/node/copilotTokenFields.test.ts b/src/vs/platform/agentHost/test/node/copilotTokenFields.test.ts deleted file mode 100644 index a9d124e4..00000000 @@ -18517,33 +31494,171 @@ index a9d124e4..00000000 - assert.strictEqual(isRestrictedTelemetryEnabled('tid=abc;exp=123:HMAC'), false); - }); - -- test('false when rt=0', () => { -- assert.strictEqual(isRestrictedTelemetryEnabled('tid=abc;rt=0;exp=123:HMAC'), false); +- test('false when rt=0', () => { +- assert.strictEqual(isRestrictedTelemetryEnabled('tid=abc;rt=0;exp=123:HMAC'), false); +- }); +- +- test('true when rt=1 with other fields', () => { +- assert.strictEqual(isRestrictedTelemetryEnabled('tid=abc;rt=1;exp=123:HMAC'), true); +- }); +- +- test('true when rt=1 is the first field', () => { +- assert.strictEqual(isRestrictedTelemetryEnabled('rt=1;tid=abc:HMAC'), true); +- }); +- +- test('true when rt=1 is the last field', () => { +- assert.strictEqual(isRestrictedTelemetryEnabled('tid=abc;exp=123;rt=1:HMAC'), true); +- }); +- +- test('true when token has no colon-delimited signature segment', () => { +- assert.strictEqual(isRestrictedTelemetryEnabled('tid=abc;rt=1'), true); +- }); +- }); +-}); +diff --git a/src/vs/platform/agentHost/test/node/providerIntegration/copilotByokResponses.integrationTest.ts b/src/vs/platform/agentHost/test/node/providerIntegration/copilotByokResponses.integrationTest.ts +deleted file mode 100644 +index 93121cc4..00000000 +--- a/src/vs/platform/agentHost/test/node/providerIntegration/copilotByokResponses.integrationTest.ts ++++ /dev/null +@@ -1,132 +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 { mkdtemp, rm } from 'fs/promises'; +-import { tmpdir } from 'os'; +-import { CopilotClient } from '@github/copilot-sdk'; +-import { Emitter } from '../../../../../base/common/event.js'; +-import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +-import { NullLogService } from '../../../../log/common/log.js'; +-import type { IByokLmChatRequest, IByokLmModelInfo } from '../../../common/agentHostByokLm.js'; +-import { ByokLmBridgeRegistry } from '../../../node/byokLmBridgeRegistry.js'; +-import { ByokLmProxyService } from '../../../node/copilot/byokLmProxyService.js'; +- +-const REAL_SDK_ENABLED = process.env['AGENT_HOST_REAL_SDK'] === '1'; +- +-(REAL_SDK_ENABLED ? suite : suite.skip)('Agent Host Provider Integration - Copilot BYOK Responses', function () { +- +- const store = ensureNoDisposablesAreLeakedInTestSuite(); +- +- test('real SDK consumes structured reasoning and text from the proxy', async function () { +- this.timeout(120_000); +- +- const sessionId = 'byok-responses-integration'; +- const baseDirectory = await mkdtemp(`${tmpdir()}/byok-responses-sdk-`); +- const models = store.add(new Emitter()); +- const registry = new ByokLmBridgeRegistry(); +- const captured: IByokLmChatRequest[] = []; +- const registration = registry.register('client', { +- chat: async request => { +- captured.push(request); +- if (captured.length > 1) { +- return { +- responseId: 'resp_provider_2', +- output: [{ type: 'message', content: [{ type: 'text', text: 'second' }] }], +- }; +- } +- return { +- responseId: 'resp_provider', +- output: [ +- { type: 'reasoning', id: 'rs_provider', summary: ['considered options'], encryptedContent: 'opaque' }, +- { type: 'message', content: [{ type: 'text', text: 'hello' }] }, +- ], +- usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 1 }, +- }; +- }, +- onDidChangeModels: models.event, +- }); +- models.fire([{ vendor: 'acme', id: 'test-model' }]); +- +- const proxy = new ByokLmProxyService(new NullLogService(), registry); +- const handle = await proxy.start(); +- const client = new CopilotClient({ +- mode: 'empty', +- baseDirectory, +- useLoggedInUser: false, +- logLevel: 'error', - }); +- let session: Awaited> | undefined; +- let clientStarted = false; - -- test('true when rt=1 with other fields', () => { -- assert.strictEqual(isRestrictedTelemetryEnabled('tid=abc;rt=1;exp=123:HMAC'), true); -- }); +- try { +- await client.start(); +- clientStarted = true; +- session = await client.createSession({ +- sessionId, +- model: 'test-model', +- reasoningEffort: 'medium', +- availableTools: [], +- provider: { +- type: 'openai', +- wireApi: 'responses', +- baseUrl: handle.providerBaseUrl('acme'), +- bearerToken: `${handle.nonce}.${sessionId}`, +- }, +- }); +- const reasoning: string[] = []; +- session.on('assistant.reasoning', event => reasoning.push(event.data.content)); - -- test('true when rt=1 is the first field', () => { -- assert.strictEqual(isRestrictedTelemetryEnabled('rt=1;tid=abc:HMAC'), true); -- }); +- const result = await session.sendAndWait({ prompt: 'Reply exactly hello.' }, 30_000); +- const secondResult = await session.sendAndWait({ prompt: 'Reply exactly second.' }, 30_000); +- const replayedReasoning = captured[1]?.input.find(item => item.type === 'reasoning'); - -- test('true when rt=1 is the last field', () => { -- assert.strictEqual(isRestrictedTelemetryEnabled('tid=abc;exp=123;rt=1:HMAC'), true); -- }); +- assert.deepStrictEqual({ +- result: result?.type === 'assistant.message' ? result.data.content : undefined, +- secondResult: secondResult?.type === 'assistant.message' ? secondResult.data.content : undefined, +- reasoning, +- firstRequest: { +- vendor: captured[0]?.vendor, +- modelId: captured[0]?.modelId, +- inputTypes: captured[0]?.input.map(item => item.type), +- reasoningEffort: captured[0]?.reasoningEffort, +- }, +- replayedReasoning, +- }, { +- result: 'hello', +- secondResult: 'second', +- reasoning: ['considered options'], +- firstRequest: { +- vendor: 'acme', +- modelId: 'test-model', +- inputTypes: ['message'], +- reasoningEffort: 'medium', +- }, +- replayedReasoning: { +- type: 'reasoning', +- id: 'rs_provider', +- summary: ['considered options'], +- encryptedContent: 'opaque', +- }, +- }); - -- test('true when token has no colon-delimited signature segment', () => { -- assert.strictEqual(isRestrictedTelemetryEnabled('tid=abc;rt=1'), true); -- }); +- } finally { +- try { +- await session?.disconnect(); +- } finally { +- try { +- if (clientStarted) { +- await client.stop(); +- } +- } finally { +- handle.dispose(); +- registration.dispose(); +- proxy.dispose(); +- await rm(baseDirectory, { recursive: true, force: true }); +- } +- } +- } - }); -}); 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 e81afe38..00000000 +index b7b014da..00000000 --- a/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts +++ /dev/null -@@ -1,242 +0,0 @@ +@@ -1,208 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -18576,11 +31691,7 @@ index e81afe38..00000000 - } - const cfg: ISandboxConfigValue = {}; - if (enabled !== undefined) { -- if (platform === 'win32') { -- cfg[AgentHostSandboxKey.WindowsEnabled] = enabled; -- } else { -- cfg[AgentHostSandboxKey.Enabled] = enabled; -- } +- cfg[AgentHostSandboxKey.Enabled] = enabled; - } - if (fs) { - const fsKey = platform === 'win32' @@ -18618,37 +31729,30 @@ index e81afe38..00000000 - assert.strictEqual(buildSandboxConfigForSdk('win32', sandbox('win32', AgentSandboxEnabledValue.Off)), undefined); - }); - -- test('enables sandbox for `on` on every platform', () => { -- for (const platform of ['darwin', 'linux', 'win32'] as const) { +- test('enables sandbox for `on` on non-Windows platforms', () => { +- for (const platform of ['darwin', 'linux'] as const) { - assert.deepStrictEqual(buildSandboxConfigForSdk(platform, sandbox(platform, AgentSandboxEnabledValue.On)), { - enabled: true, +- allowBypass: true, - userPolicy: { filesystem: {}, network: { allowOutbound: false } }, - }); - } - }); - -- test('enables sandbox and outbound network for `allowNetwork` on every platform', () => { -- for (const platform of ['darwin', 'linux', 'win32'] as const) { +- test('enables sandbox and outbound network for `allowNetwork` on non-Windows platforms', () => { +- for (const platform of ['darwin', 'linux'] as const) { - assert.deepStrictEqual(buildSandboxConfigForSdk(platform, sandbox(platform, AgentSandboxEnabledValue.AllowNetwork)), { - enabled: true, +- allowBypass: true, - userPolicy: { filesystem: {}, network: { allowOutbound: true } }, - }); - } - }); - -- test('on Windows, WindowsEnabled overrides the cross-platform Enabled key', () => { -- // Cross-platform value says off, but the Windows override says on -- // → Windows session honors the override. -- const cfg = { -- [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.Off, -- [AgentHostSandboxKey.WindowsEnabled]: AgentSandboxEnabledValue.On, -- }; -- assert.deepStrictEqual(buildSandboxConfigForSdk('win32', cfg), { -- enabled: true, -- userPolicy: { filesystem: {}, network: { allowOutbound: false } }, -- }); -- // Non-Windows ignores WindowsEnabled. -- assert.strictEqual(buildSandboxConfigForSdk('darwin', cfg), undefined); +- test('ignores the enable settings on Windows', () => { +- // The sandbox is not supported on Windows, so the enable settings are ignored. +- assert.strictEqual(buildSandboxConfigForSdk('win32', sandbox('win32', AgentSandboxEnabledValue.On)), undefined); +- assert.strictEqual(buildSandboxConfigForSdk('win32', sandbox('win32', AgentSandboxEnabledValue.AllowNetwork)), undefined); - }); - }); - @@ -18656,14 +31760,13 @@ index e81afe38..00000000 - test('selects the OS-specific slice from the per-OS filesystem keys', () => { - const cfg: ISandboxConfigValue = { - [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On, -- [AgentHostSandboxKey.WindowsEnabled]: AgentSandboxEnabledValue.On, - [AgentHostSandboxKey.LinuxFileSystem]: { allowWrite: ['/linux'] }, - [AgentHostSandboxKey.MacFileSystem]: { allowWrite: ['/mac'] }, -- [AgentHostSandboxKey.WindowsFileSystem]: { allowWrite: ['C:\\win'] }, - }; - assert.deepStrictEqual(buildSandboxConfigForSdk('linux', cfg)?.userPolicy.filesystem, { readwritePaths: ['/linux'] }); - assert.deepStrictEqual(buildSandboxConfigForSdk('darwin', cfg)?.userPolicy.filesystem, { readwritePaths: ['/mac'] }); -- assert.deepStrictEqual(buildSandboxConfigForSdk('win32', cfg)?.userPolicy.filesystem, { readwritePaths: ['C:\\win'] }); +- // Windows is ignored entirely. +- assert.strictEqual(buildSandboxConfigForSdk('win32', cfg), undefined); - }); - - test('maps each setting to the corresponding SDK list', () => { @@ -18675,6 +31778,7 @@ index e81afe38..00000000 - }; - assert.deepStrictEqual(buildSandboxConfigForSdk('darwin', sandbox('darwin', AgentSandboxEnabledValue.On, fs)), { - enabled: true, +- allowBypass: true, - userPolicy: { - filesystem: { - readwritePaths: ['/work'], @@ -18689,6 +31793,7 @@ index e81afe38..00000000 - test('omits filesystem lists that are empty', () => { - assert.deepStrictEqual(buildSandboxConfigForSdk('darwin', sandbox('darwin', AgentSandboxEnabledValue.On, {})), { - enabled: true, +- allowBypass: true, - userPolicy: { filesystem: {}, network: { allowOutbound: false } }, - }); - }); @@ -18739,32 +31844,20 @@ index e81afe38..00000000 - }); - - suite('network hosts', () => { -- test('forwards allowedHosts and opens outbound even when sandbox is `on`', () => { -- assert.deepStrictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.On, undefined, { allowedHosts: ['github.com'] }))?.userPolicy.network, { -- allowOutbound: true, -- allowedHosts: ['github.com'], -- }); +- test('drops host lists and keeps outbound closed when sandbox is `on` (host lists disabled on all platforms)', () => { +- for (const platform of ['darwin', 'linux'] as const) { +- assert.deepStrictEqual(buildSandboxConfigForSdk(platform, sandbox(platform, AgentSandboxEnabledValue.On, undefined, { allowedHosts: ['github.com'], blockedHosts: ['evil.example'] }))?.userPolicy.network, { +- allowOutbound: false, +- }, platform); +- } - }); - - test('ignores host lists when sandbox is `allowNetwork` (allow all)', () => { -- assert.deepStrictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.AllowNetwork, undefined, { allowedHosts: ['a.example'], blockedHosts: ['b.example'] }))?.userPolicy.network, { -- allowOutbound: true, -- }); -- }); -- -- test('forwards blockedHosts and opens outbound when only blockedHosts is set (deny-list-only allows non-denied domains)', () => { -- assert.deepStrictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.On, undefined, { blockedHosts: ['evil.example'] }))?.userPolicy.network, { -- allowOutbound: true, -- blockedHosts: ['evil.example'], -- }); -- }); -- -- test('forwards both allowedHosts and blockedHosts together when sandbox is `on`', () => { -- assert.deepStrictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.On, undefined, { allowedHosts: ['a.example'], blockedHosts: ['b.example'] }))?.userPolicy.network, { -- allowOutbound: true, -- allowedHosts: ['a.example'], -- blockedHosts: ['b.example'], -- }); +- for (const platform of ['darwin', 'linux'] as const) { +- assert.deepStrictEqual(buildSandboxConfigForSdk(platform, sandbox(platform, AgentSandboxEnabledValue.AllowNetwork, undefined, { allowedHosts: ['a.example'], blockedHosts: ['b.example'] }))?.userPolicy.network, { +- allowOutbound: true, +- }, platform); +- } - }); - - test('ignores empty host lists', () => { @@ -18772,26 +31865,14 @@ index e81afe38..00000000 - allowOutbound: false, - }); - }); -- -- test('drops host lists and keeps outbound closed on darwin (Seatbelt has no per-host filter)', () => { -- assert.deepStrictEqual(buildSandboxConfigForSdk('darwin', sandbox('darwin', AgentSandboxEnabledValue.On, undefined, { allowedHosts: ['github.com'], blockedHosts: ['evil.example'] }))?.userPolicy.network, { -- allowOutbound: false, -- }); -- }); -- -- test('darwin `allowNetwork` still opens outbound (host lists were already ignored)', () => { -- assert.deepStrictEqual(buildSandboxConfigForSdk('darwin', sandbox('darwin', AgentSandboxEnabledValue.AllowNetwork, undefined, { allowedHosts: ['github.com'] }))?.userPolicy.network, { -- allowOutbound: true, -- }); -- }); - }); -}); 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 8697c546..00000000 +index a3411404..00000000 --- a/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts +++ /dev/null -@@ -1,728 +0,0 @@ +@@ -1,1200 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. @@ -18813,6 +31894,7 @@ index 8697c546..00000000 -import { ILogService, NullLogService } from '../../../log/common/log.js'; -import { IAgentPluginManager } from '../../common/agentPluginManager.js'; -import { DiscoveredType, SessionCustomizationDiscovery } from '../../node/copilot/sessionCustomizationDiscovery.js'; +-import type { AgentsDiscoverRequest } from '../../node/copilot/copilotRCP.js'; -import { SessionPluginBundler } from '../../node/shared/sessionPluginBundler.js'; -import { mapToParsedPlugin, toDiscoveredDirectoryCustomizations } from '../../node/copilot/copilotAgent.js'; - @@ -18851,11 +31933,16 @@ index 8697c546..00000000 - return uri; - } - +- // Mirror `URI.file`'s separator normalization (it rewrites `\` → `/` on Windows) so a +- // round-trip through `.fsPath` — used by `projectPath` attribution in discovery — matches +- // on Windows too, where `URI.fsPath` yields backslashes. +- const inMemoryPathToUri = (path: string) => URI.from({ scheme: Schemas.inMemory, path: path.replace(/\\/g, '/') }); +- - test('discovers supported agent instruction files in workspace roots', async () => { - const wsCopilotInstructions = await seed('/workspace/.github/copilot-instructions.md', 'workspace copilot instructions'); - const wsGeminiInstructions = await seed('/workspace/GEMINI.md', 'workspace gemini instructions'); - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - const files = (await discovery.scan(CancellationToken.None)) - .flatMap(directory => directory.files.map(file => ({ uri: file.uri, type: directory.type }))) - .filter(entry => entry.type === DiscoveredType.AgentInstruction) @@ -18869,7 +31956,7 @@ index 8697c546..00000000 - }); - - test('groups discovered customizations by parent folder', async () => { -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, inMemoryPathToUri)); - const client = { - rpc: { - agents: { @@ -18904,6 +31991,249 @@ index 8697c546..00000000 - ]); - }); - +- test('discover includes hooks from recursive and fixed hook locations', 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: { +- 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 customizations = await discovery.discover(client, CancellationToken.None); +- const hookDirectories = customizations +- .filter(customization => customization.contents === 'hook') +- .map(customization => ({ +- uri: URI.parse(customization.uri).path, +- children: (customization.children ?? []).map(child => URI.parse(child.uri).path).sort(), +- })) +- .sort((a, b) => a.uri.localeCompare(b.uri)); +- +- 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'] }, +- ]); +- }); +- +- 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'); +- +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, inMemoryPathToUri)); +- const client = { +- rpc: { +- agents: { +- getDiscoveryPaths: async () => ({ paths: [] }), +- discover: async () => ({ agents: [] }), +- }, +- instructions: { +- getDiscoveryPaths: async () => ({ +- paths: [ +- { path: '/workspace/.github/instructions', kind: 'directory' }, +- { path: '/workspace/AGENTS.md', kind: 'file' }, +- ], +- }), +- discover: async () => ({ +- sources: [ +- { id: 'agentInstruction', label: 'AGENTS.md', sourcePath: '/workspace/AGENTS.md', applyTo: [], type: 'repo' }, +- { id: 'scopedInstruction', label: 'Rule', sourcePath: '/workspace/.github/instructions/rule.instructions.md', applyTo: ['src/**'], type: 'child-instructions' }, +- ], +- }), +- }, +- skills: { +- getDiscoveryPaths: async () => ({ paths: [] }), +- discover: async () => ({ skills: [] }), +- }, +- }, +- } as unknown as CopilotClient; +- +- const customizations = await discovery.discover(client, CancellationToken.None); +- const rules = customizations +- .filter(customization => customization.contents === 'rule') +- .flatMap(customization => customization.children ?? []) +- .map(child => ({ +- uri: URI.parse(child.uri).path, +- alwaysApply: child.type === 'rule' ? child.alwaysApply : undefined, +- })) +- .sort((a, b) => a.uri.localeCompare(b.uri)); +- +- assert.deepStrictEqual(rules, [ +- { uri: '/workspace/.github/instructions/rule.instructions.md', alwaysApply: false }, +- { uri: '/workspace/AGENTS.md', alwaysApply: true }, +- ]); +- }); +- +- test('drops missing agent instruction files and empty agent instruction directories', async () => { +- await seed('/workspace/.github/instructions/rule.instructions.md', 'scoped instruction'); +- +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, inMemoryPathToUri)); +- const client = { +- rpc: { +- agents: { +- getDiscoveryPaths: async () => ({ paths: [] }), +- discover: async () => ({ agents: [] }), +- }, +- instructions: { +- getDiscoveryPaths: async () => ({ +- paths: [ +- { path: '/workspace/.github/instructions', kind: 'directory' }, +- { path: '/workspace/AGENTS.md', kind: 'file' }, +- ], +- }), +- discover: async () => ({ +- sources: [ +- { id: 'agentInstruction', label: 'AGENTS.md', sourcePath: '/workspace/AGENTS.md', applyTo: [], type: 'repo' }, +- { id: 'scopedInstruction', label: 'Rule', sourcePath: '/workspace/.github/instructions/rule.instructions.md', applyTo: ['src/**'], type: 'child-instructions' }, +- ], +- }), +- }, +- skills: { +- getDiscoveryPaths: async () => ({ paths: [] }), +- discover: async () => ({ skills: [] }), +- }, +- }, +- } as unknown as CopilotClient; +- +- const customizations = await discovery.discover(client, CancellationToken.None); +- const ruleDirectories = customizations +- .filter(customization => customization.contents === 'rule') +- .map(customization => ({ +- uri: URI.parse(customization.uri).path, +- children: (customization.children ?? []).map(child => URI.parse(child.uri).path).sort(), +- })) +- .sort((a, b) => a.uri.localeCompare(b.uri)); +- +- assert.deepStrictEqual(ruleDirectories, [ +- { uri: '/workspace/.github/instructions', children: ['/workspace/.github/instructions/rule.instructions.md'] }, +- ]); +- }); +- +- test('discover returns working-directory agents, skills, instructions, hooks, and agent instructions', async () => { +- await seed('/workspace/.github/agents/foo.agent.md', 'agent body'); +- await seed('/workspace/.github/skills/bar/SKILL.md', 'skill body'); +- await seed('/workspace/.github/instructions/baz.instructions.md', 'instruction body'); +- await seed('/workspace/.github/hooks/pre-tool.json', '{"PreToolUse": []}'); +- await seed('/workspace/.github/copilot/settings.json', '{"hooks": {"PreToolUse": []}}'); +- await seed('/workspace/.github/copilot-instructions.md', 'workspace copilot instructions'); +- await seed('/workspace/AGENTS.md', 'workspace agents instructions'); +- await seed('/home/.copilot/copilot-instructions.md', 'user copilot instructions'); +- +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, inMemoryPathToUri)); +- const client = { +- rpc: { +- agents: { +- getDiscoveryPaths: async () => ({ paths: [{ path: '/workspace/.github/agents' }] }), +- discover: async () => ({ +- agents: [ +- { id: 'agent', name: 'Agent', description: 'agent description', path: '/workspace/.github/agents/foo.agent.md', userInvocable: true }, +- ], +- }), +- }, +- instructions: { +- getDiscoveryPaths: async () => ({ +- paths: [ +- { path: '/workspace/.github/instructions', kind: 'directory' }, +- { path: '/workspace/.github/copilot-instructions.md', kind: 'file' }, +- { path: '/workspace/AGENTS.md', kind: 'file' }, +- { path: '/home/.copilot/copilot-instructions.md', kind: 'file' }, +- ], +- }), +- discover: async () => ({ +- sources: [ +- { id: 'rule', label: 'Rule', description: 'rule description', sourcePath: '/workspace/.github/instructions/baz.instructions.md', applyTo: [] }, +- ], +- }), +- }, +- skills: { +- getDiscoveryPaths: async () => ({ paths: [{ path: '/workspace/.github/skills' }] }), +- discover: async () => ({ +- skills: [ +- { name: 'Skill', description: 'skill description', path: '/workspace/.github/skills/bar/SKILL.md' }, +- ], +- }), +- }, +- }, +- } as unknown as CopilotClient; +- +- const customizations = await discovery.discover(client, CancellationToken.None); +- const directories = customizations +- .map(customization => ({ +- contents: customization.contents, +- uri: URI.parse(customization.uri).path, +- writable: customization.writable, +- children: (customization.children ?? []).map(child => URI.parse(child.uri).path).sort(), +- })) +- .sort((a, b) => a.uri.localeCompare(b.uri)); +- +- assert.deepStrictEqual(directories, [ +- { contents: 'rule', uri: '/home', writable: false, children: ['/home/.copilot/copilot-instructions.md'] }, +- { 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: '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'] }, +- ]); +- }); +- +- test('discover groups case-variant instructions and nested skills under their roots', async () => { +- const caseVariantUserHome = URI.from({ scheme: Schemas.inMemory, path: '/HOME' }); +- await seed('/home/.copilot/copilot-instructions.md', 'user copilot instructions'); +- await seed('/workspace/.github/skills/bar/SKILL.md', 'skill body'); +- +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], caseVariantUserHome, inMemoryPathToUri)); +- const client = { +- rpc: { +- agents: { +- getDiscoveryPaths: async () => ({ paths: [] }), +- discover: async () => ({ agents: [] }), +- }, +- instructions: { +- getDiscoveryPaths: async () => ({ paths: [{ path: '/home/.copilot/copilot-instructions.md', kind: 'file' }] }), +- discover: async () => ({ sources: [{ id: 'userInstruction', label: 'User instruction', sourcePath: '/home/.copilot/copilot-instructions.md', type: 'home' }] }), +- }, +- skills: { +- getDiscoveryPaths: async () => ({ +- paths: [ +- { path: '/workspace/.github/skills' }, +- { path: '/workspace/.github/skills/bar' }, +- ] +- }), +- discover: async () => ({ skills: [{ name: 'Skill', description: 'skill description', path: '/workspace/.github/skills/bar/SKILL.md' }] }), +- }, +- }, +- } as unknown as CopilotClient; +- +- const customizations = await discovery.discover(client, CancellationToken.None); +- const directories = customizations +- .filter(customization => customization.contents === 'rule' || customization.contents === 'skill') +- .map(customization => ({ +- contents: customization.contents, +- uri: URI.parse(customization.uri).path, +- children: (customization.children ?? []).map(child => URI.parse(child.uri).path), +- })); +- +- assert.deepStrictEqual(directories, [ +- { contents: 'rule', uri: '/HOME', children: ['/home/.copilot/copilot-instructions.md'] }, +- { contents: 'skill', uri: '/workspace/.github/skills', children: ['/workspace/.github/skills/bar/SKILL.md'] }, +- ]); +- }); +- - test('returns directories sorted by type and URI', async () => { - await seed('/workspace/.github/agents/aaa.agent.md', 'workspace agent a'); - await seed('/workspace/.github/agents/foo.agent.md', 'workspace agent'); @@ -18914,10 +32244,11 @@ index 8697c546..00000000 - await seed('/workspace/.github/copilot-instructions.md', 'workspace copilot instructions'); - await seed('/home/.copilot/agents/abc.agent.md', 'user agent abc'); - await seed('/home/.copilot/agents/qux.agent.md', 'user agent'); +- await seed('/home/.copilot/skills/alpha/SKILL.md', 'user copilot skill'); - await seed('/home/.agents/skills/aaa/SKILL.md', 'user skill aaa'); - await seed('/home/.agents/skills/zap/SKILL.md', 'user skill'); - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - const directories = await discovery.scan(CancellationToken.None); - const actual = directories.map(directory => `${directory.type}:${directory.uri.toString()}`); - const expected = [...actual].sort((a, b) => a < b ? -1 : a > b ? 1 : 0); @@ -18936,7 +32267,7 @@ index 8697c546..00000000 - await seed('/workspace/.claude/GEMINI.md', 'unsupported filename in .claude'); - await seed('/home/copilot-instructions.md', 'unsupported home root'); - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - const files = (await discovery.scan(CancellationToken.None)) - .flatMap(directory => directory.files.map(file => ({ uri: file.uri, type: directory.type }))) - .filter(entry => entry.type === DiscoveredType.AgentInstruction) @@ -18956,6 +32287,7 @@ index 8697c546..00000000 - await seed('/workspace/.github/copilot-instructions.md', 'workspace copilot instructions'); - await seed('/workspace/.claude/CLAUDE.md', 'workspace claude instruction'); - await seed('/home/.copilot/agents/user.agent.md', 'user agent'); +- await seed('/home/.copilot/skills/copilot-user-skill/SKILL.md', 'user copilot skill'); - await seed('/home/.agents/skills/user-skill/SKILL.md', 'user skill'); - await seed('/home/.copilot/instructions/user.instructions.md', 'user instruction'); - await seed('/home/.copilot/hooks/post-tool.json', '{"PostToolUse": []}'); @@ -18969,7 +32301,7 @@ index 8697c546..00000000 - return originalWatch(resource, options); - }) as typeof fileService.watch; - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - await discovery.scan(CancellationToken.None); - - const watched = new Map(); @@ -18986,6 +32318,7 @@ index 8697c546..00000000 - assert.strictEqual(watched.get(URI.joinPath(workspace, '.github', 'hooks').toString()), true); - assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot').toString()), false); - assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'agents').toString()), false); +- assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'skills').toString()), true); - assert.strictEqual(watched.get(URI.joinPath(userHome, '.agents', 'skills').toString()), true); - assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'instructions').toString()), true); - assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'hooks').toString()), true); @@ -19009,7 +32342,7 @@ index 8697c546..00000000 - }; - }) as typeof fileService.watch; - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - await discovery.scan(CancellationToken.None); - const watchCallsAfterFirstScan = watchCalls.length; - @@ -19023,7 +32356,7 @@ index 8697c546..00000000 - // Seed an existing agent so `.github/agents` is discovered and watched. - await seed('/workspace/.github/agents/foo.agent.md', 'workspace agent'); - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - await discovery.scan(CancellationToken.None); - - // Flush buffered file change events from the initial seed/scan so the @@ -19046,7 +32379,7 @@ index 8697c546..00000000 - test('fires onDidChange when an existing agent file is modified under a non-recursively watched root', async () => { - await seed('/workspace/.github/agents/foo.agent.md', 'workspace agent'); - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - await discovery.scan(CancellationToken.None); - await timeout(50); - @@ -19069,7 +32402,7 @@ index 8697c546..00000000 - // Seed a second agent so the parent directory still exists after the deletion. - await seed('/workspace/.github/agents/bar.agent.md', 'workspace agent bar'); - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - await discovery.scan(CancellationToken.None); - await timeout(50); - @@ -19090,7 +32423,7 @@ index 8697c546..00000000 - // AGENTS.md lives directly under the workspace root, which is watched non-recursively. - await seed('/workspace/AGENTS.md', 'agents instructions'); - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - await discovery.scan(CancellationToken.None); - await timeout(50); - @@ -19111,7 +32444,7 @@ index 8697c546..00000000 - // Seed a customization so the workspace + `.github` dirs get watchers. - await seed('/workspace/.github/agents/foo.agent.md', 'workspace agent'); - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - await discovery.scan(CancellationToken.None); - await timeout(50); - @@ -19136,10 +32469,48 @@ index 8697c546..00000000 - assert.strictEqual(changeCount, 0, 'expected onDidChange not to fire for paths outside any trigger URI'); - }); - +- test('discover mode watches the discovered skill root so new skills fire onDidChange', async () => { +- // The skill root exists but is empty; getDiscoveryPaths still reports it. +- await fileService.createFolder(URI.from({ scheme: Schemas.inMemory, path: '/workspace/.github/skills' })); +- +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, inMemoryPathToUri)); +- const client = { +- rpc: { +- agents: { +- getDiscoveryPaths: async () => ({ paths: [] }), +- discover: async () => ({ agents: [] }), +- }, +- instructions: { +- getDiscoveryPaths: async () => ({ paths: [] }), +- discover: async () => ({ sources: [] }), +- }, +- skills: { +- getDiscoveryPaths: async () => ({ paths: [{ path: '/workspace/.github/skills' }] }), +- discover: async () => ({ skills: [] }), +- }, +- }, +- } as unknown as CopilotClient; +- +- await discovery.discover(client, CancellationToken.None); +- await timeout(50); +- +- let changeCount = 0; +- const fired = new DeferredPromise(); +- disposables.add(discovery.onDidChange(() => { +- changeCount++; +- fired.complete(); +- })); +- +- await seed('/workspace/.github/skills/new-skill/SKILL.md', 'new workspace skill'); +- await raceTimeout(fired.p, 500); +- +- assert.strictEqual(changeCount, 1, 'expected onDidChange to fire when a skill is added under the discovered skill root'); +- }); +- - test('cancellation of one caller does not affect another concurrent caller', async () => { - await seed('/workspace/.github/agents/foo.agent.md', 'workspace agent'); - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - const cancelSource = new CancellationTokenSource(); - disposables.add(cancelSource); - @@ -19158,18 +32529,20 @@ index 8697c546..00000000 - const wsInstr = await seed('/workspace/.github/instructions/baz.instructions.md', 'instr body'); - const wsHook = await seed('/workspace/.github/hooks/pre-tool.json', '{"PreToolUse": []}'); - const userAgent = await seed('/home/.copilot/agents/qux.agent.md', 'user agent'); +- const userCopilotSkill = await seed('/home/.copilot/skills/copilot-zap/SKILL.md', 'user copilot skill'); - const userSkill = await seed('/home/.agents/skills/zap/SKILL.md', 'user skill'); - const userHook = await seed('/home/.copilot/hooks/post-tool.json', '{"PostToolUse": []}'); - // Noise that should not be picked up - await seed('/workspace/.github/agents/not-an-agent.txt', 'ignored'); - await seed('/workspace/.github/hooks/not-a-hook.md', 'ignored'); - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - const directories = await discovery.scan(CancellationToken.None); - const files = directories.flatMap(directory => directory.files.map(file => ({ uri: file.uri, type: directory.type }))); - - assert.deepStrictEqual([...files].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())), [ - { uri: userAgent, type: DiscoveredType.Agent }, +- { uri: userCopilotSkill, type: DiscoveredType.Skill }, - { uri: userHook, type: DiscoveredType.Hook }, - { uri: userSkill, type: DiscoveredType.Skill }, - { uri: wsAgent, type: DiscoveredType.Agent }, @@ -19184,7 +32557,7 @@ index 8697c546..00000000 - const nestedWsHook = await seed('/workspace/.github/hooks/team/security/pre-tool.json', '{"PreToolUse": []}'); - const nestedUserHook = await seed('/home/.copilot/hooks/domain/tools/post-tool.json', '{"PostToolUse": []}'); - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - const files = (await discovery.scan(CancellationToken.None)).flatMap(directory => directory.files.map(file => ({ uri: file.uri, type: directory.type }))); - - assert.deepStrictEqual([...files].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())), [ @@ -19200,7 +32573,7 @@ index 8697c546..00000000 - const claudeLocalSettings = await seed('/workspace/.claude/settings.local.json', '{"hooks": {"SessionEnd": []}}'); - await seed('/workspace/.github/copilot/settings.dev.json', '{"hooks": {"Ignored": []}}'); - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - const files = (await discovery.scan(CancellationToken.None)).flatMap(directory => directory.files.map(file => ({ uri: file.uri, type: directory.type }))); - - assert.deepStrictEqual([...files].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())), [ @@ -19214,7 +32587,7 @@ index 8697c546..00000000 - test('fires onDidChange when fixed hook settings file is modified', async () => { - await seed('/workspace/.github/copilot/settings.json', '{"hooks": {"PreToolUse": []}}'); - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - await discovery.scan(CancellationToken.None); - await timeout(50); - @@ -19237,7 +32610,7 @@ index 8697c546..00000000 - const wsLowerReadmeAgent = await seed('/workspace/.github/agents/readme.md', 'docs lower'); - await seed('/workspace/.github/agents/README.md', 'docs'); - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - const files = (await discovery.scan(CancellationToken.None)).flatMap(directory => directory.files.map(file => ({ uri: file.uri, type: directory.type }))); - - assert.deepStrictEqual([...files].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())), [ @@ -19256,7 +32629,7 @@ index 8697c546..00000000 - const wsSkill = await seed('/workspace/.github/agents/SKILL.md', 'skill body'); - const wsSkillLowercase = await seed('/workspace/.github/agents/skill.md', 'skill body lowercase'); - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - const files = (await discovery.scan(CancellationToken.None)).flatMap(directory => directory.files.map(file => ({ uri: file.uri, type: directory.type }))); - - assert.deepStrictEqual([...files].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())), [ @@ -19274,7 +32647,7 @@ index 8697c546..00000000 - const nestedWsInstr = await seed('/workspace/.github/instructions/team/security/policy.instructions.md', 'workspace nested instruction'); - const nestedUserInstr = await seed('/home/.copilot/instructions/domain/tools/deep.instructions.md', 'user nested instruction'); - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - const files = (await discovery.scan(CancellationToken.None)).flatMap(directory => directory.files.map(file => ({ uri: file.uri, type: directory.type }))); - - assert.deepStrictEqual([...files].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())), [ @@ -19289,7 +32662,7 @@ index 8697c546..00000000 - await seed('/workspace/.github/instructions/team/security/policy.instructions.md', 'workspace nested instruction'); - await seed('/home/.copilot/instructions/domain/tools/deep.instructions.md', 'user nested instruction'); - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - const bundler = disposables.add(instantiationService.createInstance(SessionPluginBundler, workspace)); - const result = await bundler.bundle(await discovery.scan(CancellationToken.None)); - @@ -19304,11 +32677,34 @@ index 8697c546..00000000 - }); - - test('returns undefined when no files were discovered', async () => { -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); -- const bundler = disposables.add(instantiationService.createInstance(SessionPluginBundler, workspace)); +- // Ensure workspace root exists +- await fileService.createFolder(workspace); +- await fileService.createFolder(userHome); +- +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - const directories = await discovery.scan(CancellationToken.None); -- const result = await bundler.bundle(directories); -- assert.strictEqual(result, undefined); +- +- // Even with no files, discovery should return all search root directories +- // directories should never be null/undefined, should be an empty array if no directories found +- assert.ok(Array.isArray(directories), `Expected directories to be an array, got ${JSON.stringify(directories)}`); +- +- // Since we're now discovering all roots even if they don't exist, +- // we expect to find some directories (at minimum the workspace root for AGENTS.md) +- if (directories.length === 0) { +- // If no directories are discovered, that's okay for this test - it means discovery +- // is still looking for actual files/directories. Update test expectations. +- return; +- } +- +- // All directories should be empty since no files were created +- for (const dir of directories) { +- assert.strictEqual(dir.files.length, 0, `Expected ${dir.uri.toString()} to have no files`); +- } +- +- // Bundler returns undefined when directories are empty (no customizations to bundle) +- const bundler = disposables.add(instantiationService.createInstance(SessionPluginBundler, workspace)); +- await bundler.bundle(directories); +- // Just verify bundling doesn't crash - }); - - test('maps discovered files to parsed plugin preserving source URIs', async () => { @@ -19316,7 +32712,7 @@ index 8697c546..00000000 - const skill = await seed('/workspace/.github/skills/bar/SKILL.md', '---\nname: Workspace Skill\ndescription: Skill description\n---\nbody'); - const instruction = await seed('/workspace/.github/instructions/baz.instructions.md', '---\nname: Workspace Rule\ndescription: Rule description\nglobs:\n - src/**\n---\nbody'); - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - const customizations = await toDiscoveredDirectoryCustomizations(await discovery.scan(CancellationToken.None), fileService); - - const plugin = mapToParsedPlugin(customizations); @@ -19349,7 +32745,7 @@ index 8697c546..00000000 - await seed('/workspace/.github/copilot-instructions.md', 'workspace instructions'); - await seed('/workspace/.agents/skills/bar/SKILL.md', '---\nname: bar\ndescription: Skill description\n---\nbody'); - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - const customizations = await toDiscoveredDirectoryCustomizations(await discovery.scan(CancellationToken.None), fileService); - - const plugin = mapToParsedPlugin(customizations); @@ -19364,13 +32760,170 @@ index 8697c546..00000000 - await seed('/workspace/.github/copilot-instructions.md', 'workspace instructions'); - await seed('/home/.copilot/copilot-instructions.md', 'user instructions'); - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - const customizations = await toDiscoveredDirectoryCustomizations(await discovery.scan(CancellationToken.None), fileService); - - const plugin = mapToParsedPlugin(customizations); - - assert.strictEqual(plugin, undefined); - }); +- +- test('scan discovers agent instruction files across every working directory', async () => { +- const secondWorkspace = URI.from({ scheme: Schemas.inMemory, path: '/workspace2' }); +- const first = await seed('/workspace/.github/copilot-instructions.md', 'first'); +- const second = await seed('/workspace2/.github/copilot-instructions.md', 'second'); +- +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace, secondWorkspace], userHome, URI.file)); +- const files = (await discovery.scan(CancellationToken.None)) +- .filter(directory => directory.type === DiscoveredType.AgentInstruction) +- .flatMap(directory => directory.files.map(file => file.uri.toString())) +- .sort((a, b) => a.localeCompare(b)); +- +- assert.deepStrictEqual(files, [first.toString(), second.toString()].sort((a, b) => a.localeCompare(b))); +- }); +- +- test('constructor rejects an empty working-directory set (non-empty, primary-first invariant)', () => { +- assert.throws( +- () => instantiationService.createInstance(SessionCustomizationDiscovery, [], userHome, URI.file), +- /at least one working directory/, +- ); +- }); +- +- test('scan discovers hooks from the primary working directory only', async () => { +- const secondWorkspace = URI.from({ scheme: Schemas.inMemory, path: '/workspace2' }); +- // `workspace` is primary (index 0); `secondWorkspace` is a non-primary root. +- const primaryHook = await seed('/workspace/.github/hooks/pre-tool.json', '{"PreToolUse": []}'); +- await seed('/workspace2/.github/hooks/pre-tool.json', '{"PreToolUse": []}'); +- +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace, secondWorkspace], userHome, URI.file)); +- const hookFiles = (await discovery.scan(CancellationToken.None)) +- .filter(directory => directory.type === DiscoveredType.Hook) +- .flatMap(directory => directory.files.map(file => file.uri.toString())) +- .sort((a, b) => a.localeCompare(b)); +- +- // Only the primary root's hook is discovered; the non-primary root's hook is ignored. +- assert.deepStrictEqual(hookFiles, [primaryHook.toString()]); +- }); +- +- test('discover includes hooks from the primary working directory only', async () => { +- const secondWorkspace = URI.from({ scheme: Schemas.inMemory, path: '/workspace2' }); +- await seed('/workspace/.github/hooks/pre-tool.json', '{"PreToolUse": []}'); +- await seed('/workspace2/.github/hooks/pre-tool.json', '{"PreToolUse": []}'); +- +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace, secondWorkspace], userHome, inMemoryPathToUri)); +- const client = { +- rpc: { +- 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']); +- }); +- +- test('discover resolves relative instructions against their attributed project root and groups per root', async () => { +- const secondWorkspace = URI.from({ scheme: Schemas.inMemory, path: '/workspace2' }); +- const firstFile = await seed('/workspace/.github/copilot-instructions.md', 'first'); +- const secondFile = await seed('/workspace2/.github/copilot-instructions.md', 'second'); +- +- let requestedProjectPaths: string[] | undefined; +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace, secondWorkspace], userHome, inMemoryPathToUri)); +- const client = { +- rpc: { +- agents: { getDiscoveryPaths: async () => ({ paths: [] }), discover: async () => ({ agents: [] }) }, +- instructions: { +- getDiscoveryPaths: async () => ({ +- paths: [ +- { path: '/workspace/.github/copilot-instructions.md', kind: 'file' }, +- { path: '/workspace2/.github/copilot-instructions.md', kind: 'file' }, +- ], +- }), +- discover: async (request: AgentsDiscoverRequest) => { +- requestedProjectPaths = request.projectPaths; +- // Same RELATIVE sourcePath from two roots, disambiguated only by projectPath. +- return { +- sources: [ +- { id: 'a', label: 'A', sourcePath: '.github/copilot-instructions.md', applyTo: undefined, type: 'repo', projectPath: workspace.fsPath }, +- { id: 'b', label: 'B', sourcePath: '.github/copilot-instructions.md', applyTo: undefined, type: 'repo', projectPath: secondWorkspace.fsPath }, +- ], +- }; +- }, +- }, +- skills: { getDiscoveryPaths: async () => ({ paths: [] }), discover: async () => ({ skills: [] }) }, +- }, +- } as unknown as CopilotClient; +- +- const customizations = await discovery.discover(client, CancellationToken.None); +- const ruleDirectories = customizations +- .filter(customization => customization.contents === 'rule') +- .map(customization => ({ +- uri: customization.uri, +- children: (customization.children ?? []).map(child => child.uri).sort(), +- })) +- .sort((a, b) => a.uri.localeCompare(b.uri)); +- +- assert.deepStrictEqual({ requestedProjectPaths, ruleDirectories }, { +- requestedProjectPaths: [workspace.fsPath, secondWorkspace.fsPath], +- ruleDirectories: [ +- { uri: workspace.toString(), children: [firstFile.toString()] }, +- { uri: secondWorkspace.toString(), children: [secondFile.toString()] }, +- ].sort((a, b) => a.uri.localeCompare(b.uri)), +- }); +- }); +- +- test('discover surfaces agents and skills from every working directory in one call', async () => { +- const secondWorkspace = URI.from({ scheme: Schemas.inMemory, path: '/workspace2' }); +- let agentProjectPaths: string[] | undefined; +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace, secondWorkspace], userHome, inMemoryPathToUri)); +- const client = { +- rpc: { +- agents: { +- getDiscoveryPaths: async () => ({ paths: [] }), +- discover: async (request: AgentsDiscoverRequest) => { +- agentProjectPaths = request.projectPaths; +- return { +- agents: [ +- { id: 'one', name: 'One', description: '', path: '/workspace/.github/agents/one.agent.md', userInvocable: false }, +- { id: 'two', name: 'Two', description: '', path: '/workspace2/.github/agents/two.agent.md', userInvocable: false }, +- ], +- }; +- }, +- }, +- instructions: { getDiscoveryPaths: async () => ({ paths: [] }), discover: async () => ({ sources: [] }) }, +- skills: { +- getDiscoveryPaths: async () => ({ paths: [] }), +- discover: async () => ({ +- skills: [ +- { path: '/workspace/.github/skills/a', name: 'A', description: '' }, +- { path: '/workspace2/.github/skills/b', name: 'B', description: '' }, +- ], +- }), +- }, +- }, +- } as unknown as CopilotClient; +- +- const customizations = await discovery.discover(client, CancellationToken.None); +- const childUris = customizations +- .flatMap(customization => (customization.children ?? []).map(child => URI.parse(child.uri).path)) +- .sort(); +- +- assert.deepStrictEqual({ agentProjectPaths, childUris }, { +- agentProjectPaths: [workspace.fsPath, secondWorkspace.fsPath], +- childUris: [ +- '/workspace/.github/agents/one.agent.md', +- '/workspace/.github/skills/a', +- '/workspace2/.github/agents/two.agent.md', +- '/workspace2/.github/skills/b', +- ], +- }); +- }); -}); - - @@ -19414,7 +32967,7 @@ index 8697c546..00000000 - await seed('/workspace/.github/instructions/baz.instructions.md', 'instr body'); - await seed('/workspace/.github/hooks/pre-tool.json', '{"PreToolUse": []}'); - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - const bundler = disposables.add(instantiationService.createInstance(SessionPluginBundler, workspace)); - const directories = await discovery.scan(CancellationToken.None); - const result = await bundler.bundle(directories); @@ -19445,7 +32998,7 @@ index 8697c546..00000000 - await seed('/workspace/.github/agents/foo.agent.md', 'agent body'); - await seed('/workspace/.github/skills/bar/SKILL.md', 'skill body'); - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - const bundler = disposables.add(instantiationService.createInstance(SessionPluginBundler, workspace)); - const first = await bundler.bundle(await discovery.scan(CancellationToken.None)); - @@ -19487,7 +33040,7 @@ index 8697c546..00000000 - test('returns undefined without rewriting when cancelled', async () => { - await seed('/workspace/.github/agents/foo.agent.md', 'agent body'); - -- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); +- const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, [workspace], userHome, URI.file)); - const bundler = disposables.add(instantiationService.createInstance(SessionPluginBundler, workspace)); - - let writeCalls = 0; @@ -19520,3 +33073,15 @@ index 8697c546..00000000 - assert.notStrictEqual(a.rootUri.toString(), b.rootUri.toString()); - }); -}); +diff --git a/src/vs/platform/agentHost/test/node/toolInstructions.test.ts b/src/vs/platform/agentHost/test/node/toolInstructions.test.ts +index a1481e00..586adb54 100644 +--- a/src/vs/platform/agentHost/test/node/toolInstructions.test.ts ++++ b/src/vs/platform/agentHost/test/node/toolInstructions.test.ts +@@ -6,3 +6,6 @@ + import assert from 'assert'; +-import type { SectionOverride } from '@github/copilot-sdk'; ++// `SectionOverride` originally came from '@github/copilot-sdk' (removed in ++// !!APP_NAME!!); alias it locally so this test keeps compiling. ++// eslint-disable-next-line @typescript-eslint/no-explicit-any ++type SectionOverride = any; + import { resolveToolInstructionsOverride, toolSearchInstructionLines, universalToolInstructions } from '../../node/copilot/prompts/toolInstructions.js'; diff --git a/upstream/stable.json b/upstream/stable.json index fe07fae..4ede78f 100644 --- a/upstream/stable.json +++ b/upstream/stable.json @@ -1,4 +1,4 @@ { - "tag": "1.126.0", - "commit": "7e7950df89d055b5a378379db9ee14290772148a" + "tag": "1.132.0", + "commit": "df53daabb18cd157bdb08c7f01c34df936cf12f4" } From 3a20ef7a420cf172eb6bf85ba2f9716be019d66f Mon Sep 17 00:00:00 2001 From: sbs44 <83440025+sbs44@users.noreply.github.com> Date: Sat, 8 Aug 2026 04:31:19 -0400 Subject: [PATCH 2/3] fix(patches): resolve 10 tsgo errors in 51-ext-copilot-remove-it ports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught by a local build's compile-src typecheck (tsgo), which CI would have hit the same way: - Keep copilotTokenFields.ts (import-free) and copilotSystemNotification.ts (type-only SDK imports, now shimmed) instead of deleting them — new 1.132.0 code in copilotApiService.ts and mapSessionEvents.ts imports both. - sessionCustomizationDiscovery: tsgo infers unknown (not any) through raceCancellationError for Promise, so the CopilotClient shim now models concrete result shapes ({ paths }, { agents }, { sources }, { skills }). - SessionEventPayload aliases are generic (used as SessionEventPayload<'...'>). - softAssertNever(kind as never) — any is not assignable to never under tsgo. src/tsconfig.json now typechecks clean against the full patched tree. --- patches/51-ext-copilot-remove-it.patch | 156 ++++++------------------- 1 file changed, 37 insertions(+), 119 deletions(-) diff --git a/patches/51-ext-copilot-remove-it.patch b/patches/51-ext-copilot-remove-it.patch index dbbd3c0..6717f1d 100644 --- a/patches/51-ext-copilot-remove-it.patch +++ b/patches/51-ext-copilot-remove-it.patch @@ -13076,118 +13076,27 @@ index 34a750af..cab1676d 100644 +}; import { ILogService } from '../../../log/common/log.js'; diff --git a/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts b/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts -deleted file mode 100644 -index 56653c57..00000000 +index 56653c57..f5ff0ae4 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts -+++ /dev/null -@@ -1,75 +0,0 @@ --/*--------------------------------------------------------------------------------------------- -- * Copyright (c) Microsoft Corporation. All rights reserved. -- * Licensed under the MIT License. See License.txt in the project root for license information. -- *--------------------------------------------------------------------------------------------*/ -- ++++ b/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts +@@ -5,3 +5,9 @@ + -import type { SessionEventPayload, SystemNotification } from '@github/copilot-sdk'; --import { softAssertNever } from '../../../../base/common/assert.js'; --import { localize } from '../../../../nls.js'; -- --export interface ICopilotSystemNotification { -- /** Text for a new system-origin AHP turn; derived from SDK `data.kind` metadata, e.g. shell completion `description`. */ -- readonly messageText: string; -- /** Whether the runtime notification wakes the agent loop when it arrives while idle. */ -- readonly startsTurn: boolean; --} -- --export function buildCopilotSystemNotification(event: SessionEventPayload<'system.notification'>): ICopilotSystemNotification | undefined { -- const data = event.data; -- const kind: SystemNotification = data.kind; -- const content = cleanSystemNotificationContent(data.content); -- if (!content) { -- return undefined; -- } -- -- switch (kind.type) { -- case 'shell_completed': -- case 'shell_detached_completed': { -- const description = kind.description; -- return { -- messageText: description -- ? localize('agentHost.copilot.systemNotification.shellDescriptionCompleted', "`{0}` completed", description) -- : localize('agentHost.copilot.systemNotification.shellCompleted', "Shell completed"), -- startsTurn: true, -- }; -- } -- case 'agent_completed': -- return { -- messageText: kind.status === 'failed' -- ? localize('agentHost.copilot.systemNotification.agentFailed', "Background agent {0} failed", kind.agentId) -- : localize('agentHost.copilot.systemNotification.agentCompleted', "Background agent {0} completed", kind.agentId), -- startsTurn: true, -- }; -- case 'agent_idle': -- return { -- messageText: localize('agentHost.copilot.systemNotification.agentIdle', "Background agent {0} is complete", kind.agentId), -- startsTurn: true, -- }; -- case 'new_inbox_message': -- return { -- messageText: localize('agentHost.copilot.systemNotification.newInboxMessage', "New inbox message from {0}", kind.senderName), -- startsTurn: false, -- }; -- case 'instruction_discovered': -- return { -- messageText: localize('agentHost.copilot.systemNotification.instructionDiscovered', "Instruction discovered: {0}", kind.description ?? kind.sourcePath), -- startsTurn: false, -- }; -- case 'unclassified': -- // External-host notifications that do not match a runtime-owned kind. -- // Use the cleaned content and wake the agent when idle. -- return { -- messageText: content, -- startsTurn: true, -- }; -- default: ++// `SessionEventPayload`/`SystemNotification` originally came from ++// '@github/copilot-sdk' (removed in !!APP_NAME!!); alias them locally so this ++// module keeps compiling. ++/* eslint-disable @typescript-eslint/no-explicit-any */ ++type SessionEventPayload = any; ++type SystemNotification = any; ++/* eslint-enable @typescript-eslint/no-explicit-any */ + import { softAssertNever } from '../../../../base/common/assert.js'; +@@ -65,3 +71,5 @@ export function buildCopilotSystemNotification(event: SessionEventPayload<'syste + default: - softAssertNever(kind); -- return undefined; -- } --} -- --function cleanSystemNotificationContent(content: string): string { -- const trimmed = content.trim(); -- const match = /^\s*([\s\S]*?)\s*<\/system_notification>$/.exec(trimmed); -- return (match?.[1] ?? trimmed).trim(); --} -diff --git a/src/vs/platform/agentHost/node/copilot/copilotTokenFields.ts b/src/vs/platform/agentHost/node/copilot/copilotTokenFields.ts -deleted file mode 100644 -index 998236f0..00000000 ---- a/src/vs/platform/agentHost/node/copilot/copilotTokenFields.ts -+++ /dev/null -@@ -1,26 +0,0 @@ --/*--------------------------------------------------------------------------------------------- -- * Copyright (c) Microsoft Corporation. All rights reserved. -- * Licensed under the MIT License. See License.txt in the project root for license information. -- *--------------------------------------------------------------------------------------------*/ -- --/** Parses the `key=value;...` field map from the leading colon-delimited segment of a Copilot token (e.g. `tid=abc;exp=123;rt=1:HMAC...`). */ --export function parseCopilotTokenFields(token: string | undefined): ReadonlyMap { -- const result = new Map(); -- if (!token) { -- return result; -- } -- const colonIdx = token.indexOf(':'); -- const header = colonIdx === -1 ? token : token.substring(0, colonIdx); -- for (const field of header.split(';')) { -- const eqIdx = field.indexOf('='); -- if (eqIdx <= 0) { -- continue; -- } -- result.set(field.substring(0, eqIdx), field.substring(eqIdx + 1)); -- } -- return result; --} -- --export function isRestrictedTelemetryEnabled(token: string | undefined): boolean { -- return parseCopilotTokenFields(token).get('rt') === '1'; --} ++ // `kind` is locally aliased to `any` (SDK types removed); cast for the ++ // exhaustiveness helper. ++ 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 ccd83b10..c006701d 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -13881,25 +13790,34 @@ index 78dbfd71..00000000 - }; -} diff --git a/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts b/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts -index 7977aed4..61206e5d 100644 +index 7977aed4..e76618e0 100644 --- a/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts +++ b/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts -@@ -5,3 +5,15 @@ +@@ -5,3 +5,24 @@ -import type { CopilotClient } from '@github/copilot-sdk'; +// `CopilotClient` came from '@github/copilot-sdk' (removed in !!APP_NAME!!). +// `discover()` only touches the RPC discovery endpoints and is no longer reached +// by a live Copilot client; model the minimal surface it uses locally. ++/* eslint-disable @typescript-eslint/no-explicit-any */ ++type DiscoveryPathsResult = { paths?: any[] }; +type CopilotClient = { + rpc: { -+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -+ agents: { discover(params: unknown): Promise }; -+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -+ instructions: { discover(params: unknown): Promise }; -+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -+ skills: { discover(params: unknown): Promise }; ++ agents: { ++ discover(params: unknown): Promise<{ agents: any[] }>; ++ getDiscoveryPaths(params: unknown): Promise; ++ }; ++ instructions: { ++ discover(params: unknown): Promise<{ sources: any[] }>; ++ getDiscoveryPaths(params: unknown): Promise; ++ }; ++ skills: { ++ discover(params: unknown): Promise<{ skills: any[] }>; ++ getDiscoveryPaths(params: unknown): Promise; ++ }; + }; +}; ++/* eslint-enable @typescript-eslint/no-explicit-any */ import { appendFile, mkdir } from 'fs/promises'; diff --git a/src/vs/platform/agentHost/node/otel/agentHostOTelService.ts b/src/vs/platform/agentHost/node/otel/agentHostOTelService.ts index 090437f5..1a933ef8 100644 @@ -31415,7 +31333,7 @@ index 0351b053..00000000 - }); -}); diff --git a/src/vs/platform/agentHost/test/node/copilotTestEvents.ts b/src/vs/platform/agentHost/test/node/copilotTestEvents.ts -index ab23cc65..a333e567 100644 +index ab23cc65..42ce5485 100644 --- a/src/vs/platform/agentHost/test/node/copilotTestEvents.ts +++ b/src/vs/platform/agentHost/test/node/copilotTestEvents.ts @@ -5,3 +5,12 @@ @@ -31428,7 +31346,7 @@ index ab23cc65..a333e567 100644 +/* eslint-disable @typescript-eslint/no-explicit-any */ +type Attachment = any; +type SessionEvent = any; -+type SessionEventPayload = any; ++type SessionEventPayload = any; +type ToolExecutionCompleteContent = any; +/* eslint-enable @typescript-eslint/no-explicit-any */ From d185f537a4af9b926cb0cf566982b03aec733457 Mon Sep 17 00:00:00 2001 From: sbs44 <83440025+sbs44@users.noreply.github.com> Date: Sat, 8 Aug 2026 04:36:30 -0400 Subject: [PATCH 3/3] chore: bump .nvmrc to 24.18.0 for VS Code 1.132.0 Upstream's build/npm/preinstall.ts now requires Node >= 24.18.0; matches upstream's own .nvmrc at the pinned commit. --- .nvmrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.nvmrc b/.nvmrc index 5bf4400..ca5c350 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24.15.0 +24.18.0