From d558b270f0b6386a780b5f73e22d9e2700a9e13d Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 23 Oct 2026 00:10:50 -0500 Subject: [PATCH] =?UTF-8?q?Reconstruct=20batch=20086/124:=202026-09-28=20?= =?UTF-8?q?=E2=86=92=202026-10-05=20(sessions,=20agenthost,=20chat,=20fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed reconstruction of 50 upstream commits (5e86c7c4c2e..32a12c70b96). 274 files changed, 12194 insertions(+), 1655 deletions(-) sessions: - sessions: Reopen dismissed canvases from Add Tab (#339574) - sessions: enable unified workspace picker by default (#339518) - sessions: Track accepted Dev Container sample selections (#339588) - sessions: Fix empty cloud sandbox resume views (#339605) - sessions: Sync Cloud Sandbox archives through Mission Control (#339608) - sessions: show Cloud Sandbox setup progress (#339604) - sessions: Use first-prompt titles for Cloud Sandboxes (#339620) - sessions: configure cloud sandbox models and permissions (#339626) - Agents - fix branch picker defaults for New Worktree and Branch selection (#339701) - sessions: Scope PR pills to their creating chat (#339727) - sessions: Support read state with legacy Agent Hosts (#339704) - sessions: fix: canvas reopen failures (#339591) agenthost: - agentHost: renew sandbox credentials without prompting for sign-in (#339618) - Describe result_token_count as a local text-output estimate (#339601) - agentHost: fix cloud sandbox history replay (#339615) - Fix taskless subagent turn completion (#339619) - agentHost: distinguish cancelled subscriptions from missing resources (#339627) - Report failed and cancelled subagent turn outcomes (#339628) - Improve Copilot Agent Host sandbox controls and policy display (#339632) - agentHost: update Copilot SDK to 1.0.17-preview.4 (#339423) - agentHost: Synchronize session read state from chats (#339732) - agentHost: Eagerly load set_workspace for workspaceless chats (#339730) chat: - chat: add Blobby color customization and True Name achievement (#339561) - migrate with agent (#339334) - Report session customization telemetry (#339291) - chat: Fix Cloud Sandbox slash command suggestions (#339629) - chat: align sandbox Copilot behavior with Copilot CLI (#339631) - Show connector names in MCP authentication (#339431) - chat: preserve unchanged history removals (#339635) - chat: validate plugin marketplace source and cache paths (#339655) fix: - fix: memory leak in document paste edits (#338233) - fix: memory leak in native context menu dismissal (#338214) - fix: memory leak in output channel disposal listeners (#338240) - fix: memory leak in debug adapter descriptor factory (#334103) - fix: release terminal command history when clearing the normal buffer (#326931) - fix: memory leak in MainThreadDebugService (#334102) cli: - Fix duration calculation for agent stop command - Translate comment to English for clarity - Refactor duration calculation to use max function test: - test: Align Node unit test timeouts with Electron (#339290) - test: Match Dev Container smoke CLI quality and commit (#339697) copilot: - copilot: update nested HTTP runtime to avoid navigator diagnostics (#339630) workbench: - Keep background shell output visible while it streams (#339636) chore: - chore: bump electron@43.7.7 (#339634) extensions/git: - Git - fix duplicate co-authors in git blame hover for squash merges (#339678) multidiffeditor: - Fix/issue 337524 multidiff order (#337565) extensions/copilot: - Bump version to 1.142.0 (#339650) feature: - feature: reduce number of strings by 1200 (#327689) misc: - Update endgame notebook milestones to 1.141.0 (#339649) Co-Authored-By: Claude Opus 5.5 --- .npmrc | 4 +- .vscode/notebooks/endgame.github-issues | 2 +- .vscode/notebooks/my-endgame.github-issues | 2 +- build/checksums/electron.txt | 150 ++-- cgmanifest.json | 6 +- cli/src/commands/agent_stop.rs | 7 +- .../skills/migrate-customizations/SKILL.md | 147 ++++ .../references/migration-techniques.md | 82 +++ extensions/copilot/package-lock.json | 30 +- extensions/copilot/package.json | 12 +- .../test/node/builtinSkills.spec.ts | 43 ++ extensions/git/src/git.ts | 7 +- extensions/git/src/test/git.test.ts | 26 + package-lock.json | 76 +- package.json | 10 +- remote/package-lock.json | 72 +- remote/package.json | 4 +- src/vs/base/common/event.ts | 2 +- .../electron-browser/contextmenu.ts | 4 +- .../contextmenu/electron-main/contextmenu.ts | 2 + .../browser/copyPasteController.ts | 54 +- .../browser/pasteEditSession.ts | 22 + .../dropOrPasteInto/browser/postEditWidget.ts | 39 +- .../test/browser/copyPasteController.test.ts | 142 ++++ .../agentHost/common/agentHostSessionType.ts | 12 + .../platform/agentHost/common/agentService.ts | 1 + .../agentHost/common/cloudSandboxAgentHost.ts | 14 + .../meta/agentSlashCommandOutputMeta.ts | 7 + .../meta/copilotd/cloudSandboxProjectMeta.ts | 4 + .../common/meta/mcpCustomizationMeta.ts | 2 +- .../meta/vscode/agentSandboxPolicyMeta.ts | 24 +- .../vscode/agentSlashCommandOutputMeta.ts | 35 + .../meta/vscode/mcpCustomizationMeta.ts | 21 + .../agentHost/common/sandboxConfigSchema.ts | 14 + .../agentHost/common/taskEventReplay.ts | 56 +- .../agentHost/node/agentHostStateManager.ts | 29 +- .../platform/agentHost/node/agentService.ts | 4 +- .../agentHost/node/agentServiceFoundation.ts | 1 + .../node/copilot/copilotAgentSession.ts | 160 ++++- .../copilotGitHubTelemetryForwarder.ts | 2 +- .../node/copilot/copilotSandboxDiagnostics.ts | 3 +- .../node/copilot/copilotSandboxPolicy.ts | 4 + .../copilot/copilotSandboxPolicyDisplay.ts | 8 +- .../node/copilot/copilotSessionLauncher.ts | 31 +- .../node/copilot/copilotSlashCommand.ts | 2 +- .../node/copilot/sandboxConfigForSdk.ts | 5 +- .../agentHost/node/protocolServerHandler.ts | 17 +- .../platform/agentHost/node/sessionSandbox.ts | 6 +- .../node/shared/mcpCustomizationController.ts | 21 +- .../node/shared/sessionServerTools.ts | 4 + .../test/common/agentHostSessionType.test.ts | 27 +- .../test/common/agentMetaReaders.test.ts | 34 +- .../common/cloudSandboxProjectMeta.test.ts | 34 + .../test/common/taskEventReplay.test.ts | 222 +++++- .../test/node/agentHostStateManager.test.ts | 76 ++ .../agentHost/test/node/agentService.test.ts | 95 ++- .../test/node/agentSideEffects.test.ts | 49 ++ .../capiReplayProxyPathNormalization.test.ts | 46 +- .../agentHost/test/node/copilotAgent.test.ts | 2 +- .../test/node/copilotAgentSession.test.ts | 239 +++++- .../node/copilotSandboxDiagnostics.test.ts | 27 +- .../test/node/copilotSessionLauncher.test.ts | 56 +- ...ilotSlashCommandCompletionProvider.test.ts | 4 +- .../agentHost/test/node/e2e/KNOWN_ISSUES.md | 34 - .../agentHost/test/node/e2e/README.md | 6 +- ...ompt-converts-a-workspaceless-session.yaml | 147 ++++ ...ompt-converts-a-workspaceless-session.yaml | 122 ++++ .../test/node/e2e/harness/capiReplayProxy.ts | 1 + .../e2e/harness/modelRequestProjection.ts | 7 +- ...Copilot_prompts_claude-haiku-4_5.prompt.md | 34 +- ..._Copilot_prompts_claude-opus-4_5.prompt.md | 34 +- ..._Copilot_prompts_claude-opus-4_6.prompt.md | 34 +- ..._Copilot_prompts_claude-opus-4_7.prompt.md | 34 +- ...___Copilot_prompts_claude-opus-5.prompt.md | 34 +- ..._Copilot_prompts_claude-opus-5_5.prompt.md | 34 +- ...opilot_prompts_claude-sonnet-4_5.prompt.md | 34 +- ...opilot_prompts_claude-sonnet-4_6.prompt.md | 34 +- ..._Copilot_prompts_claude-sonnet-5.prompt.md | 34 +- ...Copilot_prompts_gemini-2_0-flash.prompt.md | 34 +- ...2E___Copilot_prompts_gpt-5-codex.prompt.md | 34 +- ...E2E___Copilot_prompts_gpt-5-mini.prompt.md | 34 +- ...Host_E2E___Copilot_prompts_gpt-5.prompt.md | 34 +- ...pilot_prompts_gpt-5_1-codex-mini.prompt.md | 34 +- ...___Copilot_prompts_gpt-5_1-codex.prompt.md | 34 +- ...st_E2E___Copilot_prompts_gpt-5_1.prompt.md | 34 +- ...E___Copilot_prompts_gpt-5_6-luna.prompt.md | 34 +- ...2E___Copilot_prompts_gpt-5_6-sol.prompt.md | 34 +- ...___Copilot_prompts_gpt-5_6-terra.prompt.md | 34 +- ...copilotOtelAgentHostE2E.integrationTest.ts | 25 +- .../e2e/suites/workspaceConversionSuite.ts | 110 ++- .../test/node/modelRequestProjection.test.ts | 30 + .../test/node/protocolServerHandler.test.ts | 142 +++- .../test/node/sandboxConfigForSdk.test.ts | 27 + .../test/node/sessionSandbox.test.ts | 51 +- .../test/node/sessionServerTools.test.ts | 34 + .../shared/mcpCustomizationController.test.ts | 16 +- .../policy/common/copilotManagedSettings.ts | 12 +- .../common/copilotManagedSettings.test.ts | 16 +- src/vs/platform/sandbox/common/settings.ts | 2 + .../common/capabilities/capabilities.ts | 2 + .../commandDetectionCapability.ts | 6 + .../partialCommandDetectionCapability.ts | 4 + .../common/agentHostSessionsProvider.ts | 8 +- .../browser/account.contribution.ts | 13 +- .../browser/chatPetAchievementBadges.ts | 54 +- .../test/browser/account.contribution.test.ts | 31 +- .../contrib/canvases/common/sessionCanvas.ts | 13 +- .../electron-browser/sessionCanvasActions.ts | 134 ++++ .../electron-browser/sessionCanvasEditor.ts | 2 +- .../electron-browser/sessionCanvasService.ts | 114 ++- .../sessionCanvases.contribution.ts | 4 +- .../sessionCanvasService.test.ts | 276 ++++++- .../chat/browser/agentHostInputCompletions.ts | 8 +- .../chat/browser/sessionChatInputToolbar.ts | 16 +- .../chat/browser/sessionWorkspacePicker.ts | 16 +- .../browser/sessionsChatAccessibilityHelp.ts | 3 +- .../contrib/chat/browser/slashCommands.ts | 16 + .../browser/agentHostInputCompletions.test.ts | 54 +- .../browser/sessionChatInputToolbar.test.ts | 81 ++- .../browser/sessionWorkspacePicker.test.ts | 136 +++- .../sessionsChatAccessibilityHelp.test.ts | 6 +- .../chat/test/browser/slashCommands.test.ts | 36 +- .../github/common/sessionGitHubReferences.ts | 20 +- .../common/sessionGitHubReferences.test.ts | 45 ++ .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 2 +- .../agentHost/browser/agentHostModePicker.ts | 24 +- .../agentHostPermissionPickerDelegate.ts | 26 +- .../browser/baseAgentHostSessionsProvider.ts | 159 +++- .../test/browser/agentHostModePicker.test.ts | 28 +- .../localAgentHostSessionsProvider.test.ts | 185 ++++- .../COPILOT_CHAT_SESSIONS_PROVIDER.md | 2 +- .../browser/cloudSandboxSessionConfig.ts | 48 ++ .../browser/copilotChatSessionsActions.ts | 64 +- .../browser/copilotChatSessionsProvider.ts | 289 ++++++-- .../browser/permissionPicker.ts | 10 + .../copilotChatSessionsProvider.test.ts | 393 +++++++++- .../test/browser/permissionPicker.test.ts | 29 + .../REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md | 6 +- .../cloudSandboxAgentHostContribution.ts | 88 ++- .../browser/cloudSandboxSessionsProvider.ts | 108 ++- .../cloudSandboxAgentHostContribution.test.ts | 379 +++++++++- .../remoteAgentHost.contribution.test.ts | 187 +++++ .../remoteAgentHostSessionsProvider.test.ts | 307 +++++++- .../sessions/common/sessionsProvider.ts | 14 + .../api/browser/mainThreadDebugService.ts | 24 +- .../api/browser/mainThreadLanguageFeatures.ts | 5 +- .../api/common/extHostLanguageFeatures.ts | 2 +- .../browser/extHostLanguageFeatures.test.ts | 54 ++ .../browser/mainThreadDebugService.test.ts | 107 +++ src/vs/workbench/browser/chatDropdownPill.ts | 25 +- .../browser/actions/chatAccessibilityHelp.ts | 3 +- .../browser/agentPluginRepositoryService.ts | 35 +- .../agentCustomizationItemProvider.ts | 14 +- .../agentHost/agentHostActiveClientService.ts | 4 +- .../agentSessions/agentHost/agentHostAuth.ts | 10 +- .../agentHostCustomizationService.ts | 59 +- .../agentHostLanguageModelProvider.ts | 10 +- .../agentHost/agentHostSessionHandler.ts | 108 ++- .../agentHost/stateToProgressAdapter.ts | 19 +- .../browser/agentSessionsConfiguration.ts | 2 +- .../aiCustomizationManagementEditor.ts | 93 ++- .../aiCustomization/customizationMigration.ts | 39 +- .../customizationMigrationAccessibility.ts | 1 + .../customizationMigrationDashboard.ts | 32 +- .../customizationsTelemetryService.ts | 266 +++++++ .../browser/aiCustomization/mcpListWidget.ts | 10 +- .../browser/aiCustomization/mcpServerCount.ts | 16 +- .../media/customizationMigrationDashboard.css | 11 +- .../chat/browser/chat.shared.contribution.ts | 3 + .../chat/browser/chatPetAchievementPreview.ts | 23 +- .../chatPetAchievements.contribution.ts | 61 +- .../chat/browser/chatPetAchievements.ts | 25 +- .../chat/browser/chatPetAchievementsEditor.ts | 121 +++- .../browser/chatPetAchievementsEditorInput.ts | 10 +- .../chat/browser/chatPetAchievementsWidget.ts | 73 +- .../contrib/chat/browser/chatPetColors.ts | 144 ++++ .../chat/browser/chatPetColorsWidget.ts | 212 ++++++ .../contrib/chat/browser/chatPetService.ts | 65 +- .../chatSessions/chatSessions.contribution.ts | 15 +- .../contrib/chat/browser/chatSlashCommands.ts | 13 + .../browser/media/chatPetAchievements.css | 233 +++++- .../contrib/chat/browser/pluginSources.ts | 28 +- .../remoteAgentHost/cloudSandboxApiService.ts | 95 ++- .../cloudSandboxConnectionCustomization.ts | 26 +- .../remoteAgentHost/cloudSandboxModels.ts | 161 +++++ .../cloudSandboxSessionContribution.ts | 42 +- .../cloudSandboxSessionListController.ts | 48 +- .../remoteAgentHost/cloudSandboxTelemetry.ts | 4 +- .../editorCloudSandboxContribution.ts | 7 + .../remoteAgentHost.contribution.ts | 2 + .../remoteAgentHostChatContribution.ts | 18 + .../remoteAgentHostConnectionCustomization.ts | 4 + .../chatMcpAuthenticationContentPart.ts | 10 +- .../chatMcpServersStartingContentPart.ts | 4 +- .../chatToolAuthenticationSubPart.ts | 8 +- .../chat/browser/widget/chatListRenderer.ts | 13 +- .../widget/chatPetAccessoryRenderer.ts | 13 +- .../chat/browser/widget/chatPetWidget.ts | 79 +- .../input/editor/agentHostInputCompletions.ts | 8 +- .../editor/agentHostInputCompletionsBase.ts | 12 + .../chat/browser/widget/media/chatPet.css | 5 +- .../common/chatService/chatServiceImpl.ts | 3 +- .../chatService/chatServiceTelemetry.ts | 4 +- .../chat/common/chatSessionsService.ts | 5 +- .../contrib/chat/common/editorChatUsage.ts | 9 +- .../common/plugins/marketplaceReference.ts | 87 ++- .../plugins/pluginMarketplaceService.ts | 20 +- .../customizationMigrationTelemetryService.ts | 190 ++++- .../electron-browser/chat.contribution.ts | 2 +- .../chatAccessibilityHelp.test.ts | 6 +- .../agentCustomizationItemProvider.test.ts | 35 + .../agentHostChatContribution.test.ts | 173 ++++- .../agentHostClientTools.test.ts | 12 +- .../agentHostCustomizationService.test.ts | 71 +- .../agentHostLanguageModelProvider.test.ts | 25 +- .../stateToProgressAdapter.test.ts | 4 +- .../aiCustomizationManagementEditor.test.ts | 1 + .../customizationMigration.test.ts | 57 +- .../customizationMigrationDashboard.test.ts | 26 +- .../customizationsTelemetryService.test.ts | 217 ++++++ .../aiCustomization/mcpListWidget.test.ts | 19 + .../test/browser/chatConfiguration.test.ts | 17 +- .../browser/chatPetAchievementsEditor.test.ts | 179 ++++- .../chat/test/browser/chatPetColors.test.ts | 680 ++++++++++++++++++ .../agentPluginRepositoryService.test.ts | 148 +++- .../plugins/pluginInstallService.test.ts | 26 +- .../cloudSandboxApiService.test.ts | 171 ++++- ...loudSandboxConnectionCustomization.test.ts | 23 +- .../cloudSandboxModels.test.ts | 163 +++++ .../editorCloudSandboxContribution.test.ts | 112 ++- ...emoteAgentHostCustomizationHarness.test.ts | 2 +- .../sessionBackgroundShellsControl.test.ts | 60 ++ .../chatMcpAuthenticationContentPart.test.ts | 23 +- .../chatMcpServersStartingContentPart.test.ts | 6 + .../chatToolAuthenticationSubPart.test.ts | 31 +- .../browser/widget/chatListRenderer.test.ts | 17 + .../test/browser/widget/chatPetWidget.test.ts | 117 ++- .../input/editor/chatInputCompletions.test.ts | 73 ++ .../chatService/chatRequestTelemetry.test.ts | 7 +- .../common/chatService/chatService.test.ts | 139 ++++ .../chat/test/common/editorChatUsage.test.ts | 6 +- .../chat/test/common/model/chatModel.test.ts | 5 +- .../plugins/pluginMarketplaceService.test.ts | 264 ++++++- .../plugins/strictKnownMarketplaces.test.ts | 21 + ...omizationMigrationTelemetryService.test.ts | 39 +- .../contrib/mcp/browser/mcpCommands.ts | 11 +- .../browser/scmMultiDiffSourceResolver.ts | 5 +- .../scmMultiDiffSourceResolver.test.ts | 74 +- .../contrib/output/browser/outputServices.ts | 11 +- .../test/browser/outputServices.test.ts | 138 ++++ .../preferences/browser/settingsTreeModels.ts | 6 + .../test/browser/settingsTreeModels.test.ts | 16 + .../terminal/browser/xterm/xtermTerminal.ts | 17 +- .../commandDetectionCapability.test.ts | 17 +- .../partialCommandDetectionCapability.test.ts | 18 +- .../test/browser/xterm/xtermTerminal.test.ts | 121 +++- .../common/sandboxSettingsReader.ts | 2 + .../terminalChatAgentToolsConfiguration.ts | 20 +- .../browser/agentHostSandboxForwarder.test.ts | 2 + .../test/common/sandboxSettingsReader.test.ts | 2 + ...erminalChatAgentToolsConfiguration.test.ts | 27 +- .../workbench/test/browser/chatPills.test.ts | 27 + .../chat/chatFixtureUtils.ts | 4 +- ...hatMcpAuthenticationContentPart.fixture.ts | 73 ++ .../chat/chatPetAccessoryRig.fixture.ts | 44 +- .../chat/chatPetAchievementsEditor.fixture.ts | 53 +- .../chat/chatPetFixtureUtils.ts | 14 +- ...customizationMigrationDashboard.fixture.ts | 1 + .../blocks-ci-screenshots.md | 4 +- test/smoke/README.md | 2 + .../areas/agentsWindow/agentsWindow.test.ts | 20 +- .../remoteDevContainerFixtures.ts | 55 ++ test/unit/README.md | 6 + test/unit/node/index.js | 13 +- 274 files changed, 12194 insertions(+), 1655 deletions(-) create mode 100644 extensions/copilot/assets/prompts/skills/migrate-customizations/SKILL.md create mode 100644 extensions/copilot/assets/prompts/skills/migrate-customizations/references/migration-techniques.md create mode 100644 src/vs/editor/contrib/dropOrPasteInto/browser/pasteEditSession.ts create mode 100644 src/vs/editor/contrib/dropOrPasteInto/test/browser/copyPasteController.test.ts create mode 100644 src/vs/platform/agentHost/common/meta/agentSlashCommandOutputMeta.ts create mode 100644 src/vs/platform/agentHost/common/meta/vscode/agentSlashCommandOutputMeta.ts create mode 100644 src/vs/platform/agentHost/test/common/cloudSandboxProjectMeta.test.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-workspace-conversion-first-prompt-converts-a-workspaceless-session.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-workspace-conversion-first-prompt-converts-a-workspaceless-session.yaml create mode 100644 src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasActions.ts create mode 100644 src/vs/sessions/contrib/providers/copilotChatSessions/browser/cloudSandboxSessionConfig.ts create mode 100644 src/vs/workbench/api/test/browser/mainThreadDebugService.test.ts create mode 100644 src/vs/workbench/contrib/chat/browser/aiCustomization/customizationsTelemetryService.ts create mode 100644 src/vs/workbench/contrib/chat/browser/chatPetColors.ts create mode 100644 src/vs/workbench/contrib/chat/browser/chatPetColorsWidget.ts create mode 100644 src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxModels.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationsTelemetryService.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/chatPetColors.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxModels.test.ts create mode 100644 src/vs/workbench/contrib/output/test/browser/outputServices.test.ts create mode 100644 src/vs/workbench/test/browser/componentFixtures/chat/chatMcpAuthenticationContentPart.fixture.ts diff --git a/.npmrc b/.npmrc index 89d0f0c309ff76..7a097f359bcde3 100644 --- a/.npmrc +++ b/.npmrc @@ -1,6 +1,6 @@ disturl="https://electronjs.org/headers" -target="43.7.5" -ms_build_id="15523606" +target="43.7.7" +ms_build_id="15553055" runtime="electron" ignore-scripts=false build_from_source="true" diff --git a/.vscode/notebooks/endgame.github-issues b/.vscode/notebooks/endgame.github-issues index f415e33fa8b830..ac8e4f725f55d4 100644 --- a/.vscode/notebooks/endgame.github-issues +++ b/.vscode/notebooks/endgame.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$MILESTONE=milestone:\"1.140.0\"\n\n$TPI_CREATION=2026-03-23 // Used to find fixes that need to be verified" + "value": "$MILESTONE=milestone:\"1.141.0\"\n\n$TPI_CREATION=2026-03-23 // Used to find fixes that need to be verified" }, { "kind": 1, diff --git a/.vscode/notebooks/my-endgame.github-issues b/.vscode/notebooks/my-endgame.github-issues index 3226ccf9c563d0..d13f3b3e716c2c 100644 --- a/.vscode/notebooks/my-endgame.github-issues +++ b/.vscode/notebooks/my-endgame.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$MILESTONE=milestone:\"1.140.0\"\n\n$MINE=assignee:@me" + "value": "$MILESTONE=milestone:\"1.141.0\"\n\n$MINE=assignee:@me" }, { "kind": 2, diff --git a/build/checksums/electron.txt b/build/checksums/electron.txt index 2ec89bcc11d1d5..ca7700e86c6953 100644 --- a/build/checksums/electron.txt +++ b/build/checksums/electron.txt @@ -1,75 +1,75 @@ -7ca9d9e9654f66e63d3117b868f5407605af53ca6115bcf9f0490a68568afc14 *chromedriver-v43.7.5-darwin-arm64.zip -0133b3e40b192e9ff3fa1e80773a25e591b8638a95aa50e33b773a6222c12493 *chromedriver-v43.7.5-darwin-x64.zip -f85f88fe6cf1be1e55312c1302d1f3d7d0f319dfe4e7e610f9fbf3f0b682018f *chromedriver-v43.7.5-linux-arm64.zip -97f4939ba6d58954673cbda3c14ff5c0b00e5a4b7e70a1b166d0313da91a8a5f *chromedriver-v43.7.5-linux-armv7l.zip -854d5396d3abfb91284a9e66e6cb962a7e274aad28cd86ff6d5c22492bfc06d7 *chromedriver-v43.7.5-linux-x64.zip -fd1cc7a02fc96fabb01482a87807d9e6dd7fd70df5b97be5e5704422d751d063 *chromedriver-v43.7.5-mas-arm64.zip -713387926080fab188c99674548653a144f4e3a0890924f60b81e99b2fff33b7 *chromedriver-v43.7.5-mas-x64.zip -423f1210316104b5daaf5ffee8b7e33c9f537e52b38b8f75d3bc1b9021ea47bd *chromedriver-v43.7.5-win32-arm64.zip -b868501754d37212d561bc5298caf99d38e9c6d9eef8902263a6f6d3eab5e569 *chromedriver-v43.7.5-win32-ia32.zip -9f5d50e29f3cfb13b8afd5cc711ae3219cdd76d52d2dcc86991221d2b7342a39 *chromedriver-v43.7.5-win32-x64.zip -c94a7f38ff9c34b6ef2d4861ac74b6015ce93b92814721ae1e0e7fc7ef75bc6c *electron-api.json -9236a0cc6e74086761f31edf26e053751d9f69e0ca69fec8a2cba3e7034c74fc *electron-v43.7.5-darwin-arm64-dsym-snapshot.zip -6bf01e64c0aa457c7725ddaa8abaec50a7e06a3d2f325795934e3a6370f83db7 *electron-v43.7.5-darwin-arm64-dsym.tar.xz -9486b06f8aa6b20b082e0fa8741141a463de96b25a2b362a81f192d1a823c996 *electron-v43.7.5-darwin-arm64-symbols.zip -4ddb8717f3c9ade6c5b8a86f0f2b9648067108de93f9a1e7fd6a9ffcf06692ac *electron-v43.7.5-darwin-arm64.zip -9236a0cc6e74086761f31edf26e053751d9f69e0ca69fec8a2cba3e7034c74fc *electron-v43.7.5-darwin-x64-dsym-snapshot.zip -25c9e7e63ce1b904a2bc7a49f4d1845b4ab4fb461f84a4520d1ec78c72c3d37a *electron-v43.7.5-darwin-x64-dsym.tar.xz -9cd6fc5cf09edfed1ba29cf6b72ce929dda71aecc584f9823a93196bcda25ed9 *electron-v43.7.5-darwin-x64-symbols.zip -fec7945208ade18b15c9f386c29bdf973f3cb7f779df9bc5e1a04f9de8a146ce *electron-v43.7.5-darwin-x64.zip -ec18f1ed85958b6bb0e7285a15fba7da3f546c5919e477a108150f86936e3988 *electron-v43.7.5-linux-arm64-debug.zip -5893561c6452225dd6dc8eb8bde572e6d675b5cdcdbba6e0d3c9ad11a95df564 *electron-v43.7.5-linux-arm64-symbols.zip -0e8017c56a243deed2510467e5de4be17a2c0634eb017429851ad026551e28ec *electron-v43.7.5-linux-arm64.zip -5224ccd7679443680c5a51f9ccaf2ede12b940a39191261dd9011eec6d0231d3 *electron-v43.7.5-linux-armv7l-debug.zip -0f8d9b40fde9a6dbc211f99a3caae6f0367afb636e1770f985eaba5993598cf3 *electron-v43.7.5-linux-armv7l-symbols.zip -1079cbaf38af752402c29e1a888a9f044403baf784693de3147c7c29097a4157 *electron-v43.7.5-linux-armv7l.zip -6ed81092b68add4ec6fb375615f14bdb4a6f5c598242f7800431c7b4d11f7645 *electron-v43.7.5-linux-x64-debug.zip -829e3e9c97c08cc61126c43885798325ce1feffcfdb0c882c5489db46a9fb079 *electron-v43.7.5-linux-x64-symbols.zip -dce41d6d9a36a2854c5fa1a97f90bc7a8ac8285cf2a76fe3f997c7a02432b364 *electron-v43.7.5-linux-x64.zip -9236a0cc6e74086761f31edf26e053751d9f69e0ca69fec8a2cba3e7034c74fc *electron-v43.7.5-mas-arm64-dsym-snapshot.zip -abf2db07595e11531aa2cb4c572b6d5fbe81705878b382d962d00b2262f810c7 *electron-v43.7.5-mas-arm64-dsym.tar.xz -8e2cde372c0a5f5245c334343741a94ad840e86a698f1ec943f259e39ba3f552 *electron-v43.7.5-mas-arm64-symbols.zip -bebd05a60252b9a4f6c83202e3a5b435ef8a489ff6de110fa76aa52a7903dd3c *electron-v43.7.5-mas-arm64.zip -9236a0cc6e74086761f31edf26e053751d9f69e0ca69fec8a2cba3e7034c74fc *electron-v43.7.5-mas-x64-dsym-snapshot.zip -1bbd11786d0ef8b3ba36c49e6831f4948f6fddc6a1bba1696aa2dce6724c6d8d *electron-v43.7.5-mas-x64-dsym.tar.xz -7c6c3971bcc89d4caa4a6f16a7244aac7f3ae300e86fe65820347f5e46987f9f *electron-v43.7.5-mas-x64-symbols.zip -5502585a5c698e92a5472b8fbeeaf6aa757f70d5f1a42f5592dff889c6b8ee37 *electron-v43.7.5-mas-x64.zip -012133e592664f7b1fd74a42b2f4b9e0e14ec5510ec65a079cd80d8add2eda5e *electron-v43.7.5-win32-arm64-pdb.zip -245111a9bbf1f1acf99a4c0212a7cf1226eea5cb4bcf20326802e9b5b50e2e4c *electron-v43.7.5-win32-arm64-symbols.zip -90386280bc7e4ac5d451e43e26a7c76ed1c8bcdc0206ec50762c7e4f09c59c28 *electron-v43.7.5-win32-arm64-toolchain-profile.zip -996f250e86611b2e5245b8ac09a0375f24d6e343292799a6c9bf7334391fd9c9 *electron-v43.7.5-win32-arm64.zip -cfd279ac0110c4b128627eaffed83e0d3dc3b70d6d8d6a54ca5254bef5a79d15 *electron-v43.7.5-win32-ia32-pdb.zip -1b52b17efc00fab5317d191e0b871fd8e324983dcddfcc6829c867e1baf5dc9f *electron-v43.7.5-win32-ia32-symbols.zip -90386280bc7e4ac5d451e43e26a7c76ed1c8bcdc0206ec50762c7e4f09c59c28 *electron-v43.7.5-win32-ia32-toolchain-profile.zip -86897584b0b36a7a55280a33bcd2040115d3651b7ea753f23ab5097286947482 *electron-v43.7.5-win32-ia32.zip -c7531d071950a50b3d8b55ec403bdcbfdd9a8ba62f91a18a88de7a638084525a *electron-v43.7.5-win32-x64-pdb.zip -1891dfd0d60dc76bbf83601a354c866642ce1fdf5b9a2063666990fa99424799 *electron-v43.7.5-win32-x64-symbols.zip -90386280bc7e4ac5d451e43e26a7c76ed1c8bcdc0206ec50762c7e4f09c59c28 *electron-v43.7.5-win32-x64-toolchain-profile.zip -7acfa0646793f912ff983c8db8c3a145dc18ee40fe3d11a01840fd59cb76e5a2 *electron-v43.7.5-win32-x64.zip -81e05df0600cb229dbdb080e5200ddf3532d0c4b5a668e2cba5bc5683f805098 *electron.d.ts -c3f967e6e0fcada1d2dd24b08f190b02aaf243a014531901e82b70417193c48b *ffmpeg-v43.7.5-darwin-arm64.zip -a6860ea4f0ec3954ae2686ae2ff2fd72cb4793f7229fe0d0c9c8efd0487e7297 *ffmpeg-v43.7.5-darwin-x64.zip -ed1d9f6af263524c00ff955f50bf289e19c881625384ff421c773ef2aeb8a82d *ffmpeg-v43.7.5-linux-arm64.zip -001847bd19549781850fd6f69941a54f92356ed26bca02892e9642637215668d *ffmpeg-v43.7.5-linux-armv7l.zip -6638429b92e6d213b78114923585526aff88dbe25f91ace7f2bd5228fac08de5 *ffmpeg-v43.7.5-linux-x64.zip -5290184f135527f4b58636e14869688a9fad6249cfe9f3608c9046e06efc6660 *ffmpeg-v43.7.5-mas-arm64.zip -785ededd318692376a61ed854a95fe9103ad2f7616f64b5e3b96ce34eaecdad1 *ffmpeg-v43.7.5-mas-x64.zip -1bbd64356da38ad2c8b9dd254046b0491e07d72e27952ee7f49331191a26bdb1 *ffmpeg-v43.7.5-win32-arm64.zip -7e9044d58cf7695e5549f73a4d0a54a7b2e24af930ff69195765c890346361f2 *ffmpeg-v43.7.5-win32-ia32.zip -b1b2597a18e68c70d0f65c46cdd0f0e10cb847e227e3e34cdbe6a6eee0e663c0 *ffmpeg-v43.7.5-win32-x64.zip -47b695db7f9dedebbdeec47070622da4312574f38e8ca8d77ef94c827a522a2f *hunspell_dictionaries.zip -198993602d37429e707e494f781eea876de3e9fd85d016c01b1f7432a747b418 *libcxx-objects-v43.7.5-linux-arm64.zip -80b6639adb398b42622ec0644a3a9c9e3f696309825537a817d42cf92c0a4f2e *libcxx-objects-v43.7.5-linux-armv7l.zip -dcb37cf5b0b1941e0304379dfea3587a3ba39167d8edca9e2ab749f54349586d *libcxx-objects-v43.7.5-linux-x64.zip -88eeecbe3a86626bc8b9677d764e831b13589449126b8b5aa84cb291e38f220e *libcxx_headers.zip -88a69e434f5a001028891091b32535b7b4e95c7764e4a7597112467042615d9b *libcxxabi_headers.zip -cdf1bb3bfa3c2a95ad463f9abd0cb3c94f39b9e5eb317eced9715d6496829c34 *mksnapshot-v43.7.5-darwin-arm64.zip -08e2460962d8729e8fa36bf30758d943ef4059f99e2d41dfd329193b51f94960 *mksnapshot-v43.7.5-darwin-x64.zip -d4544b339553ba5722aeaa53c22345183ae6d041e6330de0b0f3ef21d9af2046 *mksnapshot-v43.7.5-linux-arm64-x64.zip -ab060e6272e2e536732921eccef686560268b02edf832d63c3cada562cb423e7 *mksnapshot-v43.7.5-linux-armv7l-x64.zip -1a271d78fbb4922c02043538d53a4f87fe4e1c8bd58e7a78a29aeffb2cb742f2 *mksnapshot-v43.7.5-linux-x64.zip -42e489bd90f30581941143a2285861c69316bd3a603320bbe32ef217b8650adc *mksnapshot-v43.7.5-mas-arm64.zip -c2082d5d7f90e866760d2a6424bab260b3c757cc00884e1f00d87cad6d53a453 *mksnapshot-v43.7.5-mas-x64.zip -5665055067d7f53d459f0c63884648eb7dcfbf64b417b8c0b0738b7133b97812 *mksnapshot-v43.7.5-win32-arm64-x64.zip -7c882bd39f45cc9e1112f7b949b443305e959fad57292a9f9b97c3e45d502ce8 *mksnapshot-v43.7.5-win32-ia32.zip -bff2442315ad1482db15f6230f7f98308a0b758ab46b0b6f53ff0ace715e96ce *mksnapshot-v43.7.5-win32-x64.zip +b9b7d9ff132e9655a574f03edfb01add8d43a8b0d3a79320c226f6aa99c922ea *chromedriver-v43.7.7-darwin-arm64.zip +f3b26877642739e6c668e6368daada33bb1dfcf4d5fec34143195e184e984569 *chromedriver-v43.7.7-darwin-x64.zip +091707f83e4191f36848152d664c80c255572dfe36a042250ca94037c06959d2 *chromedriver-v43.7.7-linux-arm64.zip +9c3dc509c12709522a22e3c4eb3270907050391fe05d89f68ab5ea689f9aa5b2 *chromedriver-v43.7.7-linux-armv7l.zip +7ea6157ec70b065ef6a7f77fc1822dc6441f5702b0f99660e26cfca1897ecfa4 *chromedriver-v43.7.7-linux-x64.zip +cad78ce6d3399821fde0cc5701109334409aace9b2c82ab8f4c0d3fffa152906 *chromedriver-v43.7.7-mas-arm64.zip +42221fd4d828376802e644178717f1b0626bd1188d45199e958e17ab520ff9ee *chromedriver-v43.7.7-mas-x64.zip +d2c67dad42cfdcf17fce6eac8bba81502c4b2d9cd90cc16f189b1da71dde8ca3 *chromedriver-v43.7.7-win32-arm64.zip +79aedd7837ac32ec19f98677901def62d5cdbbe63d90fe5f25ca131f8609a843 *chromedriver-v43.7.7-win32-ia32.zip +8b81ca5a4add14593548f5b4042a028e4c50758d61c5a244741ca6d07be3c95d *chromedriver-v43.7.7-win32-x64.zip +aabd65bdb7a4fbd80c114af429392aa5a126382883c7c2e8f3ee11acf2e4ce08 *electron-api.json +649e6c16d4650ec4f3037daa285b9f8e72b91742b43810bba80ca5e8bfb8cfe7 *electron-v43.7.7-darwin-arm64-dsym-snapshot.zip +941f6adb5eb6dbce25db81158a8f19fd5b4b24bcc97ea5986bb901f822b93a54 *electron-v43.7.7-darwin-arm64-dsym.tar.xz +34f7acfef1ff41f81b63895f010b866c1f39ac22e0d700445eb6e9ee595276c4 *electron-v43.7.7-darwin-arm64-symbols.zip +9327d8ba5bc9e279d1a2f7da90235301c65a2e80eb4ad3bc5610d28d483340f9 *electron-v43.7.7-darwin-arm64.zip +649e6c16d4650ec4f3037daa285b9f8e72b91742b43810bba80ca5e8bfb8cfe7 *electron-v43.7.7-darwin-x64-dsym-snapshot.zip +2a0fc941390038cad45ef580cf65ee333f61f6e4504b9433447ed8fa3585e234 *electron-v43.7.7-darwin-x64-dsym.tar.xz +6f003f93738843419063adba7dbac56ac1dcfc79dcbeee39d700c335d34d831a *electron-v43.7.7-darwin-x64-symbols.zip +cbed66567d55db4a2bffad0bb6ee9795ca0037ad241039f7473fe75680d10905 *electron-v43.7.7-darwin-x64.zip +ba871fb65de9fbfd71f9aaafc1ac278434f497ee921e9f27fee9e0a5000e9eef *electron-v43.7.7-linux-arm64-debug.zip +d13b1f995845a5a138c35100944dc1264b98be3dd5540253640a66fc388d233d *electron-v43.7.7-linux-arm64-symbols.zip +16071038a9677d0f00b11d3d0f0b7b4ea3250987727aa73aa563b87789f2d5ab *electron-v43.7.7-linux-arm64.zip +bb2295977fc6d33adce9f62def5dfaa19de0e1bd29e5bbd366e0cc35d1a57a09 *electron-v43.7.7-linux-armv7l-debug.zip +07c0739157f9674827c7d55976016972cff70e0876d68287bdf24fca54d778bd *electron-v43.7.7-linux-armv7l-symbols.zip +088c2817c1b5928c5bea706cc60aa05ea328e6d1d8bd549fda75388a3552482a *electron-v43.7.7-linux-armv7l.zip +0f20d65a6f671b1966de12d3f71c2d96a1a65e7ba8c1d534b60bc08c7f82b950 *electron-v43.7.7-linux-x64-debug.zip +a5fb797b973514622f37154d5088132705ce86641f05e6b1e858269a86961868 *electron-v43.7.7-linux-x64-symbols.zip +4d0a48398c444258dbcf2f5f83b49ca5bc53583130f354e0c299dad5b22b5271 *electron-v43.7.7-linux-x64.zip +649e6c16d4650ec4f3037daa285b9f8e72b91742b43810bba80ca5e8bfb8cfe7 *electron-v43.7.7-mas-arm64-dsym-snapshot.zip +dc850f562fbf669db1d9940d458c7ba9790392cff18d95ebaec1fe94a8d583ff *electron-v43.7.7-mas-arm64-dsym.tar.xz +61cf1a0dc10498aff1d1eefa9a5b96350cba32734bdc9f7f40724f9aa17d03d9 *electron-v43.7.7-mas-arm64-symbols.zip +987eebfdc29638b9066e5484b0dcb59e7096627edebad722c7649f8472f2c8a0 *electron-v43.7.7-mas-arm64.zip +649e6c16d4650ec4f3037daa285b9f8e72b91742b43810bba80ca5e8bfb8cfe7 *electron-v43.7.7-mas-x64-dsym-snapshot.zip +8caa427ef3e1dd3f1832a3b782ace268999afcf011540fe05a62c957917487ea *electron-v43.7.7-mas-x64-dsym.tar.xz +91f67bf1b2deedf84355d98095a6060de69fb64c01caf223a35e0edef38aee55 *electron-v43.7.7-mas-x64-symbols.zip +f5eaad26bda0bc133f8d0af0f3a77d2e16adf2572306bec611424c3bab876acf *electron-v43.7.7-mas-x64.zip +b7aa250996ba4aadcbb115e951c6a46f19f246ee6178cffc6b115088189fc1d5 *electron-v43.7.7-win32-arm64-pdb.zip +e63955e73f754a249db46a2c312fd904b6405018650d22b8a16037a9c10270e0 *electron-v43.7.7-win32-arm64-symbols.zip +90386280bc7e4ac5d451e43e26a7c76ed1c8bcdc0206ec50762c7e4f09c59c28 *electron-v43.7.7-win32-arm64-toolchain-profile.zip +d3751b5046350eee0cfd88e7709697c0ef05db93bb3435195c2d0cec50f0ad1f *electron-v43.7.7-win32-arm64.zip +2e8d41b27d3c2394bfeb4ec00904d7049c8a7ccaa105e678179a983cdfb319db *electron-v43.7.7-win32-ia32-pdb.zip +67ea48dcfdcca2f7b854fbe3689d56fe047ca9a2aee0808422ef313e0bc4b52a *electron-v43.7.7-win32-ia32-symbols.zip +90386280bc7e4ac5d451e43e26a7c76ed1c8bcdc0206ec50762c7e4f09c59c28 *electron-v43.7.7-win32-ia32-toolchain-profile.zip +a170eeedf4a216b3b0672b151294e7aef0b4fdc15ae5c207ea3f9f37c4a14c42 *electron-v43.7.7-win32-ia32.zip +3b65b14be1594bba502283ae02e9af54b55b1894f3f6be961c608c9e80a74179 *electron-v43.7.7-win32-x64-pdb.zip +37d27ae6956d20fde7fa1218453ad9514c106f17cf30634c452302a7453f189f *electron-v43.7.7-win32-x64-symbols.zip +90386280bc7e4ac5d451e43e26a7c76ed1c8bcdc0206ec50762c7e4f09c59c28 *electron-v43.7.7-win32-x64-toolchain-profile.zip +97dcb75065444ef031b9b6ea814ccd2109b97934fffb0c503a555d4737ca79cc *electron-v43.7.7-win32-x64.zip +d78a11ef458b68672c35957f0252396a1fa19e73a8ca3b9178e2e75cd090453d *electron.d.ts +9cd0907e7c7c652cc6f519c8e7d75cee26bed06394bb1d51fabdb9d3ada33c67 *ffmpeg-v43.7.7-darwin-arm64.zip +fc2ecfa9bb7ab0131e073f138923ee58f01b88941e366a0be1bad821ed5386bd *ffmpeg-v43.7.7-darwin-x64.zip +ed1d9f6af263524c00ff955f50bf289e19c881625384ff421c773ef2aeb8a82d *ffmpeg-v43.7.7-linux-arm64.zip +001847bd19549781850fd6f69941a54f92356ed26bca02892e9642637215668d *ffmpeg-v43.7.7-linux-armv7l.zip +6638429b92e6d213b78114923585526aff88dbe25f91ace7f2bd5228fac08de5 *ffmpeg-v43.7.7-linux-x64.zip +cbbd298f4548e53200d6b0321bea708963e94acc699059721642183301752109 *ffmpeg-v43.7.7-mas-arm64.zip +7943078d8e3ee924f1cad1354a29aa3aceaf0b7b7414df3a71924ea9c8906223 *ffmpeg-v43.7.7-mas-x64.zip +c8ec52d6bbda2789c678211a76ffc6eaf1dc5faebe1269fc4d6aafa7fbc5510f *ffmpeg-v43.7.7-win32-arm64.zip +37f7bf4b42615e880f971d8714f2b1f4629c06bd8bbd73c8993a8713f8d3628f *ffmpeg-v43.7.7-win32-ia32.zip +df14da964d36f36c50030004bb5e93a3b5fac9e4ddd6dd4f0e4ecdd49456ee4f *ffmpeg-v43.7.7-win32-x64.zip +1987f0c54ac21514090440f4dc981245cae34b71e95ddbeffd5bcb5da9a4982e *hunspell_dictionaries.zip +cd2a8fc7973a60a60f1612d87528e07eb047fad61106f240f4e39bffe9b66969 *libcxx-objects-v43.7.7-linux-arm64.zip +76dc80ad01bf2f0da658d5e58e256bcbcfc5b5e03d5ce25c3a43b9073d0e2a4f *libcxx-objects-v43.7.7-linux-armv7l.zip +5df32a0f3b4e11575d619dfc30bd614238a616f87303142b37bcf62b6206047f *libcxx-objects-v43.7.7-linux-x64.zip +f318b7480e1e9b6149cfa83dedee6027a381f4dc7a4b99af89dddbab4f6d0261 *libcxx_headers.zip +600988a1000006cb10d8b88727c6877dfe3e0cbcb49baee317551d8131593c0c *libcxxabi_headers.zip +080d43c0521b53dc9fffe91b17f9592518230aa0c819bac3e7a6aff484126895 *mksnapshot-v43.7.7-darwin-arm64.zip +3a4cf3ca11eb4f98e66e9ef111f7be82d5d517851ef0882d71bf25ef3f5e4adb *mksnapshot-v43.7.7-darwin-x64.zip +052aa030567fe51301f4ba7c52098583868296f0ea91fa1a78158f763bec1332 *mksnapshot-v43.7.7-linux-arm64-x64.zip +de81d082e286d93e6c2baa6a2ce4b5ccf36dbf5fab512c1a8274e4ad1d6ba660 *mksnapshot-v43.7.7-linux-armv7l-x64.zip +1a222e37532fb300237eedfc6eae2a836f9b8ccf988af64507c88178a9ad3a41 *mksnapshot-v43.7.7-linux-x64.zip +4aaa40fcf9c283f9c3106523fcaff9301dac65b7d36434aa1970def061c82a20 *mksnapshot-v43.7.7-mas-arm64.zip +2a46e79869eaaee734f7f70ef5650bdba3b72cce26a201c8f1a87e15364efd16 *mksnapshot-v43.7.7-mas-x64.zip +8de088472502ed3a26b5a56b016849add2cc694eb2acc76e599d582c675ca8e1 *mksnapshot-v43.7.7-win32-arm64-x64.zip +e73d7a9ada185a6182bdf6872f6c5a7ae22f18a1f7efdb9e4fcb7b21b21b0c53 *mksnapshot-v43.7.7-win32-ia32.zip +4878c2cf511a54a3d38cab480b0f3a2699f84e0d0bdf80b7f300cfbe867ca8b5 *mksnapshot-v43.7.7-win32-x64.zip diff --git a/cgmanifest.json b/cgmanifest.json index a0e0e7e6ae0e55..4e86b48bb615aa 100644 --- a/cgmanifest.json +++ b/cgmanifest.json @@ -529,13 +529,13 @@ "git": { "name": "electron", "repositoryUrl": "https://github.com/electron/electron", - "commitHash": "5338ed09bfdcf1bbd2e6bab722c2620efe8c4790", - "tag": "43.7.5" + "commitHash": "ddb762f50fbd70384d0385302697e60ada549024", + "tag": "43.7.7" } }, "isOnlyProductionDependency": true, "license": "MIT", - "version": "43.7.5" + "version": "43.7.7" }, { "component": { diff --git a/cli/src/commands/agent_stop.rs b/cli/src/commands/agent_stop.rs index 4d617c42d70bc9..c5a033c7ecbb6a 100644 --- a/cli/src/commands/agent_stop.rs +++ b/cli/src/commands/agent_stop.rs @@ -80,7 +80,12 @@ pub async fn agent_stop(ctx: CommandContext, args: AgentStopArgs) -> Result Migrations > Migrate with Agent +user-invocable: true +disable-model-invocation: true +--- + +# Migrate Agent Customizations + +Guide the user through migrating the inventory included in the invoking prompt. The source locations and valid target folders in that prompt were evaluated for the selected harness. Treat them as authoritative: do not substitute hardcoded destination paths or migrate to a folder that is not listed. + +## Required Migration Context + +Before doing anything else, verify that the invoking prompt includes all of the following VS Code-generated values: + +- Selected harness +- Migration telemetry flow +- Recovery bundle folder and filesystem path +- Customizations that need migration +- Valid target folders reported by the selected harness + +If any value is missing, do not inspect, copy, move, convert, or delete customizations. Explain that this workflow must be started from **Agent Customizations > Migrations** by selecting **Migrate with Agent**, then stop. Do not ask the user to manually reconstruct or paste the missing context. + +Read [migration techniques](./references/migration-techniques.md) before proposing changes. + +## Safety Contract + +1. Never modify a customization before creating a recovery bundle. +2. Use the recovery bundle folder supplied in the invoking prompt. VS Code created and is watching this folder; do not move it or substitute another location. +3. In the recovery bundle, create: + - `migration-log.md`, containing the selected harness, scope, timestamps, source and destination paths, commands or tools used, validation results, and every user decision. + - `backups/`, containing a byte-for-byte copy of every file before its first modification or deletion. + - `restore.md`, containing exact steps for restoring the backups and reverting newly created files. + - `migration-results.json`, containing only the privacy-safe aggregate schema described in Finish. VS Code watches this file for the final migration outcome. +4. Append to the log after every attempted operation, including failures and rollbacks. +5. Approval to migrate a group authorizes destination writes only; it does not authorize deleting source files, removing source MCP entries, or clearing location settings. Preserve sources by default. After the destination is validated, ask separately for explicit approval for each kind of cleanup. If runtime validation is not possible, explain what was and was not verified, keep the source, and ask whether to defer cleanup. +6. Stop on an unexpected source change, destination conflict, invalid configuration, failed backup, or failed validation. Explain the problem instead of guessing. +7. Never include customization names, paths, contents, MCP configuration, or other user data in telemetry. +8. Classify each inventory item exactly once using its supplied category and scope. Do not offer the same item again in another group; if groups overlap, explain the overlap and use the item's inventory category to decide where it is handled. + +## Workflow + +### 1. Choose Scope + +Ask whether to migrate: + +- user customizations, +- workspace customizations, or +- both. + +Do not act on an unselected scope. + +If workspace customizations are selected, ask whether the user wants a pull request. If they do, keep workspace changes focused, include the validation evidence and migration rationale in the pull request, and do not include a recovery bundle stored outside the repository. + +### 2. Review the Plan + +Classify the supplied inventory by its reported category and scope, then handle applicable items in this order: + +1. prompt files that should become skills; +2. VS Code profile user-data agents, instructions, and skills; +3. MCP servers; +4. workspace or user customizations at locations defined by settings `chat.agentSkillsLocations`, `chat.instructionsFilesLocations`, and `chat.agentFilesLocations`. + +The groups are mutually exclusive for an inventory item. In particular, a file reported under `configuredLocations` belongs in group 4, not group 2, even if its type is agent, instruction, or skill. If a previously skipped item would otherwise reappear in a later group, do not ask about it again unless the user requests reconsideration. + +Before each group: + +- explain why the migration is useful; +- list the source and harness-reported destination locations; +- describe changes that cannot be preserved; +- state explicitly that approval covers destination writes only and that source cleanup will require a separate approval; +- ask for confirmation to proceed with that group. + +Work one group at a time. Do not request approval for all writes at once. + +### 3. Migrate Prompt Files to Skills + +Explain the compatibility reason for converting each prompt to a skill. Convert each selected prompt into a skill directory with a `SKILL.md`, preserving supported name, description, argument guidance, invocation semantics, and body content. Add `disable-model-invocation: true` when needed to preserve an explicit-invocation-only prompt. Record unsupported frontmatter in the log for review; do not silently drop behavior. + +Validate that each skill has valid frontmatter, a meaningful description, and a folder name that matches the skill name. Distinguish structural validation from runtime validation: test discovery and representative invocation in the selected destination harness when possible. A file inspection or a test against a different harness is not proof that the destination harness loads the skill. If runtime validation requires a new session or is otherwise unavailable, say so, leave the source in place, and defer cleanup unless the user explicitly chooses otherwise after hearing the limitation. + +### 4. Migrate User Data + +Explain that Agent Host doesn't read the VS Code profile user data folder. It only reads the harness's user folders. Copy selected agents, instructions, and skills to a compatible listed destination without silently changing their contents. +Keep user-scope items in user scope. Explain that approval to copy does not authorize deleting the VS Code source or changing sync/location settings; ask for those separately after validation. + +### 5. Migrate MCP Servers + +Explain that moving MCP configuration lets the selected Agent Host load the server directly. Review every server separately, including destination conflicts and properties the destination format cannot preserve. Call out that a disabled user server might become enabled after migration. + +Write and statically validate the destination before considering source cleanup. Leave unselected, unsupported, and conflicting servers unchanged. Test that migrated servers are discovered and can start in the selected destination harness; spawning a command directly can verify process behavior, but does not by itself prove that the harness discovers or loads the configuration. If destination-harness testing is unavailable, report that limitation and keep source entries. + +Approval for this group authorizes destination writes only. After successful destination validation, ask separately whether to remove each migrated or already-equivalent source entry. Do not remove a source entry merely because its destination entry was written or because the user approved the group. + +### 6. Customizations at Custom Locations + +Files in custom locations defined by `chat.agentSkillsLocations`, `chat.instructionsFilesLocations`, and `chat.agentFilesLocations` can continue to work when the selected harness is connected to VS Code. Moving them to a harness-reported standard location may improve portability or consistency, but is not automatically required for compatibility. Explain this trade-off before asking whether to migrate. + +Handle only items reported as `configuredLocations` and not already handled in another group. Do not re-offer files the user explicitly skipped. Treat moving a file, deleting its old copy, and clearing or changing the location setting as separate actions; obtain explicit approval for each cleanup action after validating the destination. + +### 7. Finish + +Before the final summary, write `migration-results.json` in the recovery bundle: + +```json +{ + "version": 1, + "migrationFlowId": "", + "cancelled": false, + "results": [ + { + "category": "promptFiles", + "scope": "workspace", + "customizationType": "skill", + "outcome": "migrated", + "count": 1 + } + ] +} +``` + +Aggregate results by: + +- category: `promptFiles`, `userData`, `configuredLocations`, or `mcpServers`; +- scope: `user` or `workspace`; +- customization type: `agent`, `instructions`, `skill`, or `mcpServer`; +- outcome: `migrated`, `skipped`, or `failed`. + +The total reported for each category, scope, and customization type must not exceed the inventory supplied by VS Code. The result file must never include names, paths, contents, configuration values, error messages, or other user data. + +Write the validated JSON to the exact recovery bundle supplied by VS Code. Do not write a result file when no migration telemetry flow was supplied. + +If the user stops the workflow, set `cancelled` to `true`, record completed work with its actual outcome, mark the remaining inventory as `skipped`, and still validate and write the result file before the final cancellation summary. + +Summarize: + +- migrated, skipped, and failed items; +- created, modified, deleted, and retained files; +- validation performed; +- cleanup still pending; +- recovery bundle and restoration instructions; +- pull request URL when one was requested. + +Keep the recovery bundle until the user confirms the migrated customizations work. + +For a requested pull request, verify the final diff contains only approved workspace changes, exclude the recovery bundle, and include the migration rationale and actual validation performed. Do not claim runtime validation when only static checks or direct process tests were possible. diff --git a/extensions/copilot/assets/prompts/skills/migrate-customizations/references/migration-techniques.md b/extensions/copilot/assets/prompts/skills/migrate-customizations/references/migration-techniques.md new file mode 100644 index 00000000000000..be4ded8047d3a5 --- /dev/null +++ b/extensions/copilot/assets/prompts/skills/migrate-customizations/references/migration-techniques.md @@ -0,0 +1,82 @@ +# Agent Customization Migration Techniques + +Use these techniques together with the source inventory and target folders supplied by VS Code. Harness-reported destinations take precedence over examples in general documentation. + +## General Technique + +- Back up every source before writing. +- Group approval authorizes destination writes only. Preserve each source by default; ask for explicit cleanup approval separately, after destination validation. +- Keep user and workspace scope unchanged unless the user explicitly chooses otherwise. +- Classify each inventory item once by its supplied category and scope. Do not offer a skipped item again in another group unless the user asks to reconsider it. +- In a multi-root workspace, keep each migrated customization in the corresponding workspace root. +- Verify discovery and representative behavior in the destination environment. Remote and Dev Container user folders belong to that environment. +- Migrated and original copies do not synchronize. Remove the original only after validation. +- Separate structural checks (file shape, frontmatter, or valid JSON) from runtime checks in the selected harness. A direct server-process test is useful evidence about that process, but does not prove the harness discovers or loads its configuration. If destination-harness testing is unavailable, report the limitation and keep the source unless the user explicitly chooses otherwise after being informed. + +## Prompt Files to Skills + +Agent Host harnesses do not load `*.prompt.md` files. Convert a prompt to `/SKILL.md`. + +- Preserve supported `name`, `description`, and argument guidance. +- Preserve invocation semantics. Add `disable-model-invocation: true` when needed to retain an explicit-invocation-only prompt. +- Choose a valid, unique skill folder name. +- Record unsupported prompt frontmatter rather than silently discarding it. +- Keep the prompt body focused as a reusable workflow. +- Validate the skill's frontmatter and invoke it with a representative request. +- Do not treat structural validation as proof of runtime discovery. Test the skill in the selected destination harness when possible; if testing requires a new session, defer it and keep the prompt until the user has been told runtime validation is pending. + +## User Data Locations + +VS Code profile user data are Local-only. Move agents, instructions, and skills to a destination reported by the selected harness. + +- Preserve file type, name, and content unless a compatibility issue requires an explained change. +- Keep workspace files in their originating repository root. +- Explain that harness user folders might not participate in VS Code Settings Sync. +- Treat copying a file, deleting the source, and clearing or changing a location setting as separate decisions. Group migration approval authorizes the copy only. +- `chat.promptFilesLocations` entries are prompt-to-skill migrations, not ordinary location moves. + +## MCP Servers + +Migrate each MCP server independently. + +- Review source and destination JSON before writing. +- Detect duplicate server names and non-equivalent destination entries. +- Explain how every source property and variable will migrate by using the table below. +- A disabled user server might become enabled at the destination; review enablement before starting a session. +- Write and statically validate the destination entry before runtime testing or considering source cleanup. +- Leave unsupported, invalid, conflicting, and unselected servers unchanged. +- Do not remove a source entry solely because an equivalent destination entry already exists. After successful validation, ask separately before removing each migrated or redundant source entry. + +| Source configuration | Migration result | What to do | +|----------------------|------------------|------------| +| Standard `command`, `args`, `env`, `url`, and `headers` values | Migrate automatically and add `tools: ["*"]` | Inspect the server's tools. Replace `*` with the specific tools the agent should invoke when access to every server tool is not appropriate. | +| `${workspaceFolder}`, `${workspaceRoot}`, `${workspaceFolderBasename}`, `${workspaceRootFolderName}`, `${cwd}`, or `${pathSeparator}` | Resolve the variable and write its current value | Review the resulting value before sharing the destination file or using it on another machine. | +| `gallery`, `version`, `dev`, or `sandboxEnabled` | Migrate while removing these properties | Explain the removal and decide how to handle updates, development behavior, or sandboxing after migration. | +| `${input:...}`, `${config:...}`, `${command:...}`, or other interactive VS Code variables | Do not migrate automatically | Reconfigure the value for the destination harness. Never copy a resolved secret into the MCP file. | +| `${env:NAME}` | Do not migrate automatically | Use `$NAME`, `${NAME}`, or `${NAME:-default}`, and define the variable in the Agent Host environment. | +| `cwd` | Do not migrate automatically | Add `cwd` manually only when the server requires it, and verify the path on the Agent Host machine. | +| `envFile` | Do not migrate automatically | Export the required variables in the Agent Host environment and reference them from `env`. Never copy secret values into the MCP file. | +| SSE transport | Do not migrate automatically | Use `type: "sse"` only when the server does not support Streamable HTTP. SSE is deprecated. | +| A VS Code `oauth` object | Do not migrate automatically | Remove the nested object to use OAuth discovery, or translate supported client settings to the flat destination OAuth fields. Authenticate when prompted. | +| Environment variables with `null` values | Do not migrate automatically | Remove the entry or provide a supported value. | +| Additional VS Code-specific properties | Do not migrate automatically | Remove or replace the unsupported property before retrying. | +| A different server with the same name in the destination or another workspace root | Stop without changing the source | Rename or remove the conflicting server, and then retry. | + +Input variables require manual configuration. VS Code can prompt for values such as API keys through `${input:api-key}`, but the destination MCP format does not use that input flow. Ask the user how the value should be supplied securely in the Agent Host environment. + +## Custom Locations + +Agents, skill, and instruction files located in custom locations defined by settings `chat.agentSkillsLocations`, `chat.instructionsFilesLocations`, and `chat.agentFilesLocations` can continue to work when the selected harness is connected to VS Code. Moving them to a harness-reported standard location may improve portability or consistency, but is not automatically required for compatibility. Explain the trade-off rather than presenting the move as mandatory. + +Handle only items reported under `configuredLocations` and not already handled under another inventory category. Do not re-ask about files the user explicitly skipped. Treat copying/moving a file, deleting its old copy, and clearing or changing the location setting as separate actions requiring separate approval. + +## Verification + +After each migration group: + +1. Confirm the item appears in the Agent Customizations editor for the selected harness. +2. Start a session with that harness and run a representative task. +3. Confirm MCP servers start and expose the expected tools. +4. Review version-control changes for workspace migrations. +5. Report structural validation separately from runtime validation. If the selected harness cannot be exercised in this session, mark runtime validation as pending and keep source files and entries. +6. Only after successful destination validation, ask for explicit approval before removing old files, MCP entries, or settings. Approval to migrate is not approval to clean up. diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index 19df88d7dfee90..99a95abe5c57fc 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -1,12 +1,12 @@ { "name": "copilot-chat", - "version": "0.69.0", + "version": "0.70.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "copilot-chat", - "version": "0.69.0", + "version": "0.70.0", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { @@ -143,7 +143,7 @@ "engines": { "node": ">=22.14.0", "npm": ">=9.0.0", - "vscode": "^1.141.0" + "vscode": "^1.142.0" } }, "node_modules/@anthropic-ai/sdk": { @@ -266,9 +266,9 @@ } }, "node_modules/@azure/core-client/node_modules/@typespec/ts-http-runtime": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.2.tgz", - "integrity": "sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz", + "integrity": "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A==", "dev": true, "license": "MIT", "dependencies": { @@ -277,7 +277,7 @@ "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@azure/core-http-compat": { @@ -387,9 +387,9 @@ } }, "node_modules/@azure/core-util/node_modules/@typespec/ts-http-runtime": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.2.tgz", - "integrity": "sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz", + "integrity": "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A==", "license": "MIT", "dependencies": { "http-proxy-agent": "^7.0.0", @@ -397,7 +397,7 @@ "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@azure/identity": { @@ -520,9 +520,9 @@ } }, "node_modules/@azure/logger/node_modules/@typespec/ts-http-runtime": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.2.tgz", - "integrity": "sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz", + "integrity": "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A==", "license": "MIT", "dependencies": { "http-proxy-agent": "^7.0.0", @@ -530,7 +530,7 @@ "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@azure/msal-browser": { diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 285b63128c4aac..b4c41dac063d58 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -2,7 +2,7 @@ "name": "copilot-chat", "displayName": "GitHub Copilot", "description": "AI chat features powered by Copilot", - "version": "0.69.0", + "version": "0.70.0", "build": "1", "completionsCoreVersion": "1.378.1799", "internalLargeStorageAriaKey": "ec712b3202c5462fb6877acae7f1f9d7-c19ad55e-3e3c-4f99-984b-827f6d95bd9e-6917", @@ -22,7 +22,7 @@ "icon": "assets/copilot.png", "pricing": "Trial", "engines": { - "vscode": "^1.141.0", + "vscode": "^1.142.0", "npm": ">=9.0.0", "node": ">=22.14.0" }, @@ -6866,6 +6866,14 @@ "copilotcli" ] }, + { + "path": "./assets/prompts/skills/migrate-customizations/SKILL.md", + "sessionTypes": [ + "copilotcli", + "claude", + "codex" + ] + }, { "path": "./assets/prompts/skills/init/SKILL.md", "sessionTypes": [ diff --git a/extensions/copilot/src/platform/promptFiles/test/node/builtinSkills.spec.ts b/extensions/copilot/src/platform/promptFiles/test/node/builtinSkills.spec.ts index 3cc7a60db09e89..6e519420008b90 100644 --- a/extensions/copilot/src/platform/promptFiles/test/node/builtinSkills.spec.ts +++ b/extensions/copilot/src/platform/promptFiles/test/node/builtinSkills.spec.ts @@ -60,4 +60,47 @@ suite('built-in skills', () => { expect(skill).not.toContain('extensions_manage({'); expect(skill).not.toContain('run `/extensions`'); }); + + test('contributes the customization migration skill to Agent Host sessions', () => { + const manifest = JSON.parse(fs.readFileSync(path.join(copilotRoot, 'package.json'), 'utf-8')) as ICopilotPackage; + const contributions = manifest.contributes?.chatSkills?.filter(skill => skill.path.endsWith('/migrate-customizations/SKILL.md')); + const skill = fs.readFileSync( + path.join(copilotRoot, 'assets', 'prompts', 'skills', 'migrate-customizations', 'SKILL.md'), + 'utf-8', + ); + expect(skill).not.toContain('report_customization_migration'); + + expect({ + contributions, + requiredContent: [ + 'name: migrate-customizations', + 'user-invocable: true', + 'disable-model-invocation: true', + 'Agent Customizations > Migrations', + 'Do not ask the user to manually reconstruct or paste the missing context', + './references/migration-techniques.md', + 'migration-log.md', + 'restore.md', + 'pull request', + 'migration-results.json', + ].filter(anchor => skill.includes(anchor)), + }).toEqual({ + contributions: [{ + path: './assets/prompts/skills/migrate-customizations/SKILL.md', + sessionTypes: ['copilotcli', 'claude', 'codex'], + }], + requiredContent: [ + 'name: migrate-customizations', + 'user-invocable: true', + 'disable-model-invocation: true', + 'Agent Customizations > Migrations', + 'Do not ask the user to manually reconstruct or paste the missing context', + './references/migration-techniques.md', + 'migration-log.md', + 'restore.md', + 'pull request', + 'migration-results.json', + ], + }); + }); }); diff --git a/extensions/git/src/git.ts b/extensions/git/src/git.ts index 204f4924509634..0ad28a71fa68e0 100644 --- a/extensions/git/src/git.ts +++ b/extensions/git/src/git.ts @@ -979,13 +979,18 @@ const coAuthorRegex = /^Co-authored-by:\s*(.+?)\s*<([^>]+)>\s*$/gim; export function parseCoAuthors(message: string): CoAuthor[] { const coAuthors: CoAuthor[] = []; + const emails = new Set(); let match; coAuthorRegex.lastIndex = 0; while ((match = coAuthorRegex.exec(message)) !== null) { const name = match[1].trim(); const email = match[2].trim(); - if (name && email) { + + // Squash merge commit messages repeat the trailers of each squashed commit + const emailKey = email.toLowerCase(); + if (name && email && !emails.has(emailKey)) { + emails.add(emailKey); coAuthors.push({ name, email }); } } diff --git a/extensions/git/src/test/git.test.ts b/extensions/git/src/test/git.test.ts index b9c08fb907fa07..974afb66e7f803 100644 --- a/extensions/git/src/test/git.test.ts +++ b/extensions/git/src/test/git.test.ts @@ -642,6 +642,32 @@ suite('git', () => { [{ name: 'Jane Doe', email: 'jane@example.com' }] ); }); + + test('duplicate co-authors (squash merge)', function () { + assert.deepStrictEqual( + parseCoAuthors([ + 'Fix bug (#123)', + '', + '* First change', + '', + 'Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>', + '', + '* Second change', + '', + 'Co-authored-by: Jane Doe ', + 'Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>', + '', + '---------', + '', + 'Co-authored-by: Copilot <223556219+copilot@users.noreply.github.com>', + 'Co-authored-by: Jane ' + ].join('\n')), + [ + { name: 'Copilot', email: '223556219+Copilot@users.noreply.github.com' }, + { name: 'Jane Doe', email: 'jane@example.com' } + ] + ); + }); }); suite('splitInChunks', () => { diff --git a/package-lock.json b/package-lock.json index af47f54f3629a2..d3044e0bc7d383 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "code-oss-dev", - "version": "1.141.0", + "version": "1.142.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-oss-dev", - "version": "1.141.0", + "version": "1.142.0", "hasInstallScript": true, "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.82.0", "@devcontainers/cli": "0.88.0", - "@github/copilot-sdk": "1.0.17-preview.0", + "@github/copilot-sdk": "1.0.17-preview.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", @@ -1170,9 +1170,9 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.17-preview.0.tgz", - "integrity": "sha512-XYoM7Jv+KOw28i+SbZ8hddpkSNjFOeBGZX7aGl+dRWzRFGOUlS4wpmaH33I3EvywfXRj48CO/6/0FabX98fk7Q==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.17-preview.4.tgz", + "integrity": "sha512-tt5pyDLUjiaxJSMCU2HRDLRSFOA4mIFpI6+360EC0jqNDmhlaeJy88YwO6zDO77D21l8lGt8INBj+IYMbPb30Q==", "license": "MIT", "dependencies": { "koffi": "3.2.1", @@ -1183,20 +1183,20 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@github/copilot-sdk-darwin-arm64": "1.0.17-preview.0", - "@github/copilot-sdk-darwin-x64": "1.0.17-preview.0", - "@github/copilot-sdk-linux-arm64": "1.0.17-preview.0", - "@github/copilot-sdk-linux-x64": "1.0.17-preview.0", - "@github/copilot-sdk-linuxmusl-arm64": "1.0.17-preview.0", - "@github/copilot-sdk-linuxmusl-x64": "1.0.17-preview.0", - "@github/copilot-sdk-win32-arm64": "1.0.17-preview.0", - "@github/copilot-sdk-win32-x64": "1.0.17-preview.0" + "@github/copilot-sdk-darwin-arm64": "1.0.17-preview.4", + "@github/copilot-sdk-darwin-x64": "1.0.17-preview.4", + "@github/copilot-sdk-linux-arm64": "1.0.17-preview.4", + "@github/copilot-sdk-linux-x64": "1.0.17-preview.4", + "@github/copilot-sdk-linuxmusl-arm64": "1.0.17-preview.4", + "@github/copilot-sdk-linuxmusl-x64": "1.0.17-preview.4", + "@github/copilot-sdk-win32-arm64": "1.0.17-preview.4", + "@github/copilot-sdk-win32-x64": "1.0.17-preview.4" } }, "node_modules/@github/copilot-sdk-darwin-arm64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-arm64/-/copilot-sdk-darwin-arm64-1.0.17-preview.0.tgz", - "integrity": "sha512-p7J5iCeq6ntw4GD42AK7Kmck9jyhVjKZFFH2W8G8B76fopYSBnvMX+LxdfLDkvOHb0OMY91rG0KA9EImZLkiiA==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-arm64/-/copilot-sdk-darwin-arm64-1.0.17-preview.4.tgz", + "integrity": "sha512-cufab0CWxFH0wwIw1/dlPNx4gFP/PJoL4+tUQ/d6lZ+SWX4/2VQgRJCQ97qe9Nqql3YWDikiJTTHXQ55xkmmRA==", "cpu": [ "arm64" ], @@ -1207,9 +1207,9 @@ ] }, "node_modules/@github/copilot-sdk-darwin-x64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-x64/-/copilot-sdk-darwin-x64-1.0.17-preview.0.tgz", - "integrity": "sha512-5whrJOGWrfDZz97VH2TavhWPppNbf9E19IcGtF2DhxBCdWklLWhBfSDCMzrWvpiUnukHSJA925sKQCoThJDKZg==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-x64/-/copilot-sdk-darwin-x64-1.0.17-preview.4.tgz", + "integrity": "sha512-4kj7nU3oXCFFm24d6FijBkuiLTCBtfiI8qEHsDcSxfgPdPnwV+9osT/EAZRKPTh3DYGwqTO7i+bIHEHgogd+Wg==", "cpu": [ "x64" ], @@ -1220,9 +1220,9 @@ ] }, "node_modules/@github/copilot-sdk-linux-arm64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-arm64/-/copilot-sdk-linux-arm64-1.0.17-preview.0.tgz", - "integrity": "sha512-Dm1Q+/SIU4V1/+VBI2zUfTHcrKBAhuMY204WjuVxyry9BBdrAe98XuZuaGv8+ztbWOsIQ/Vhdcbj7PkEBHetrQ==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-arm64/-/copilot-sdk-linux-arm64-1.0.17-preview.4.tgz", + "integrity": "sha512-aOHcV5YiFGhtxh+H3qhOuMzktyhwuepGl/jWkWu2vTLy2oztpMnxmoRgwJaj9NdNQoiM8PO13YjbFhbb3TGaOA==", "cpu": [ "arm64" ], @@ -1236,9 +1236,9 @@ ] }, "node_modules/@github/copilot-sdk-linux-x64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-x64/-/copilot-sdk-linux-x64-1.0.17-preview.0.tgz", - "integrity": "sha512-UG37BcakUmUh5C7Av1ab2smsFJiitev9mWH3vohaAaCYujj/Zb4CRkTVezVA8KLkkdnq67R7AOQSvTFArb0NoA==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-x64/-/copilot-sdk-linux-x64-1.0.17-preview.4.tgz", + "integrity": "sha512-W98/SrLJXVk5xn8GgvUxNs8wHM10nPe5Ff3fU9vKb4Kb0aS2KyxoAhC4eoNn8VRB2XDW2wFTBvUJAutCiWGL+Q==", "cpu": [ "x64" ], @@ -1252,9 +1252,9 @@ ] }, "node_modules/@github/copilot-sdk-linuxmusl-arm64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-arm64/-/copilot-sdk-linuxmusl-arm64-1.0.17-preview.0.tgz", - "integrity": "sha512-DgwS8F4IAra48ML6jWprk1HeJAANf7FdjCRIViVAuhaVLvdjbPi/7pYTQGLtwTcxN63O4cG+/1mPKf2roz83YA==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-arm64/-/copilot-sdk-linuxmusl-arm64-1.0.17-preview.4.tgz", + "integrity": "sha512-hym02Fv/JuqjmmMqs4E+XFEgx8nzIpIdMLSr5CFpMLWVVKQYBhY8x6r+03qXhfutUWlcoL5f8gK/pdOR23k9jw==", "cpu": [ "arm64" ], @@ -1268,9 +1268,9 @@ ] }, "node_modules/@github/copilot-sdk-linuxmusl-x64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-x64/-/copilot-sdk-linuxmusl-x64-1.0.17-preview.0.tgz", - "integrity": "sha512-9T2ik5qK5akLqVhIyvGVicjcumKGOivvuLaAPYU+210MV+t70u9BGQ0a3Vy3tlgDPGHos/js72ZL2RUo6hmFZg==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-x64/-/copilot-sdk-linuxmusl-x64-1.0.17-preview.4.tgz", + "integrity": "sha512-IhjdLXtUSV8lSZiWsciGHD2iwv3nS2pAwLMywfgB1GozouBDIfWZJlbTUX5Jqv4JeZjE7lpvD5e+Sc11HL6kQQ==", "cpu": [ "x64" ], @@ -1284,9 +1284,9 @@ ] }, "node_modules/@github/copilot-sdk-win32-arm64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-arm64/-/copilot-sdk-win32-arm64-1.0.17-preview.0.tgz", - "integrity": "sha512-7nSjNxKXQWCEsf+iDQMEW8dr8q4SkQAimemb0U03fqD3jj7JA6Z/XvmAIFb/Cf+RWqTLexHw0O3HjFs9vF5MNQ==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-arm64/-/copilot-sdk-win32-arm64-1.0.17-preview.4.tgz", + "integrity": "sha512-MxJJiT2DoDXi3a6dPY/DwMbJPweK75ywlIIbK6Agnof055euC4st3bKMUXz8Nuq4pWzXU3HEWFUVWnNr/63TNw==", "cpu": [ "arm64" ], @@ -1297,9 +1297,9 @@ ] }, "node_modules/@github/copilot-sdk-win32-x64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-x64/-/copilot-sdk-win32-x64-1.0.17-preview.0.tgz", - "integrity": "sha512-4pa51WL3KZgouFrODCngtmGw3yavDIIEwiK+cGAA7jH45uFu5EpPnuVHlpFIrAU/fa80ZSbiX5KkiigP+EpLow==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-x64/-/copilot-sdk-win32-x64-1.0.17-preview.4.tgz", + "integrity": "sha512-NGC8k/WHw6lP6O6h/nJiw3lBaaaMjb1hu+urr+Wpa9EP9a/PFL4rWXpxixgQT4qym7EAVY+EE1vSTA8jCi7cAA==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 80d724d281016d..cbabf9bd0f8784 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", - "version": "1.141.0", - "distro": "a620eb1a35f4f1994abbd67502c0b2c3b8e73ee0", + "version": "1.142.0", + "distro": "a0e7521b2a1e3289e4118fad155277b200aebd35", "author": { "name": "Microsoft Corporation" }, @@ -13,7 +13,7 @@ "test": "node -e \"console.error('Run a test script from the scripts folder, for example: ./scripts/test.sh --run .'); process.exit(1)\"", "test-browser": "npx playwright install && node test/unit/browser/index.js", "test-browser-no-install": "node test/unit/browser/index.js", - "test-node": "mocha test/unit/node/index.js --delay --ui=tdd --timeout=5000 --exit", + "test-node": "mocha test/unit/node/index.js --delay --ui=tdd --exit", "test-extension": "vscode-test", "test-build-scripts": "cd build && npm run test", "test-agent-host-e2e": "node scripts/test-agent-host-e2e.ts", @@ -102,7 +102,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.82.0", "@devcontainers/cli": "0.88.0", - "@github/copilot-sdk": "1.0.17-preview.0", + "@github/copilot-sdk": "1.0.17-preview.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", @@ -325,5 +325,5 @@ "@vscode/windows-ca-certs@0.3.4": true, "@vscode/windows-process-tree@0.8.0": true }, - "copilotRuntimeVersion": "1.0.92-0" + "copilotRuntimeVersion": "1.0.92-4" } diff --git a/remote/package-lock.json b/remote/package-lock.json index f0b8fce08d9de0..0300e36651d483 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "dependencies": { "@devcontainers/cli": "0.88.0", - "@github/copilot-sdk": "1.0.17-preview.0", + "@github/copilot-sdk": "1.0.17-preview.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/mxc-sdk": "0.9.0", @@ -73,9 +73,9 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.17-preview.0.tgz", - "integrity": "sha512-XYoM7Jv+KOw28i+SbZ8hddpkSNjFOeBGZX7aGl+dRWzRFGOUlS4wpmaH33I3EvywfXRj48CO/6/0FabX98fk7Q==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.17-preview.4.tgz", + "integrity": "sha512-tt5pyDLUjiaxJSMCU2HRDLRSFOA4mIFpI6+360EC0jqNDmhlaeJy88YwO6zDO77D21l8lGt8INBj+IYMbPb30Q==", "license": "MIT", "dependencies": { "koffi": "3.2.1", @@ -86,20 +86,20 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@github/copilot-sdk-darwin-arm64": "1.0.17-preview.0", - "@github/copilot-sdk-darwin-x64": "1.0.17-preview.0", - "@github/copilot-sdk-linux-arm64": "1.0.17-preview.0", - "@github/copilot-sdk-linux-x64": "1.0.17-preview.0", - "@github/copilot-sdk-linuxmusl-arm64": "1.0.17-preview.0", - "@github/copilot-sdk-linuxmusl-x64": "1.0.17-preview.0", - "@github/copilot-sdk-win32-arm64": "1.0.17-preview.0", - "@github/copilot-sdk-win32-x64": "1.0.17-preview.0" + "@github/copilot-sdk-darwin-arm64": "1.0.17-preview.4", + "@github/copilot-sdk-darwin-x64": "1.0.17-preview.4", + "@github/copilot-sdk-linux-arm64": "1.0.17-preview.4", + "@github/copilot-sdk-linux-x64": "1.0.17-preview.4", + "@github/copilot-sdk-linuxmusl-arm64": "1.0.17-preview.4", + "@github/copilot-sdk-linuxmusl-x64": "1.0.17-preview.4", + "@github/copilot-sdk-win32-arm64": "1.0.17-preview.4", + "@github/copilot-sdk-win32-x64": "1.0.17-preview.4" } }, "node_modules/@github/copilot-sdk-darwin-arm64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-arm64/-/copilot-sdk-darwin-arm64-1.0.17-preview.0.tgz", - "integrity": "sha512-p7J5iCeq6ntw4GD42AK7Kmck9jyhVjKZFFH2W8G8B76fopYSBnvMX+LxdfLDkvOHb0OMY91rG0KA9EImZLkiiA==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-arm64/-/copilot-sdk-darwin-arm64-1.0.17-preview.4.tgz", + "integrity": "sha512-cufab0CWxFH0wwIw1/dlPNx4gFP/PJoL4+tUQ/d6lZ+SWX4/2VQgRJCQ97qe9Nqql3YWDikiJTTHXQ55xkmmRA==", "cpu": [ "arm64" ], @@ -110,9 +110,9 @@ ] }, "node_modules/@github/copilot-sdk-darwin-x64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-x64/-/copilot-sdk-darwin-x64-1.0.17-preview.0.tgz", - "integrity": "sha512-5whrJOGWrfDZz97VH2TavhWPppNbf9E19IcGtF2DhxBCdWklLWhBfSDCMzrWvpiUnukHSJA925sKQCoThJDKZg==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-x64/-/copilot-sdk-darwin-x64-1.0.17-preview.4.tgz", + "integrity": "sha512-4kj7nU3oXCFFm24d6FijBkuiLTCBtfiI8qEHsDcSxfgPdPnwV+9osT/EAZRKPTh3DYGwqTO7i+bIHEHgogd+Wg==", "cpu": [ "x64" ], @@ -123,9 +123,9 @@ ] }, "node_modules/@github/copilot-sdk-linux-arm64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-arm64/-/copilot-sdk-linux-arm64-1.0.17-preview.0.tgz", - "integrity": "sha512-Dm1Q+/SIU4V1/+VBI2zUfTHcrKBAhuMY204WjuVxyry9BBdrAe98XuZuaGv8+ztbWOsIQ/Vhdcbj7PkEBHetrQ==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-arm64/-/copilot-sdk-linux-arm64-1.0.17-preview.4.tgz", + "integrity": "sha512-aOHcV5YiFGhtxh+H3qhOuMzktyhwuepGl/jWkWu2vTLy2oztpMnxmoRgwJaj9NdNQoiM8PO13YjbFhbb3TGaOA==", "cpu": [ "arm64" ], @@ -139,9 +139,9 @@ ] }, "node_modules/@github/copilot-sdk-linux-x64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-x64/-/copilot-sdk-linux-x64-1.0.17-preview.0.tgz", - "integrity": "sha512-UG37BcakUmUh5C7Av1ab2smsFJiitev9mWH3vohaAaCYujj/Zb4CRkTVezVA8KLkkdnq67R7AOQSvTFArb0NoA==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-x64/-/copilot-sdk-linux-x64-1.0.17-preview.4.tgz", + "integrity": "sha512-W98/SrLJXVk5xn8GgvUxNs8wHM10nPe5Ff3fU9vKb4Kb0aS2KyxoAhC4eoNn8VRB2XDW2wFTBvUJAutCiWGL+Q==", "cpu": [ "x64" ], @@ -155,9 +155,9 @@ ] }, "node_modules/@github/copilot-sdk-linuxmusl-arm64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-arm64/-/copilot-sdk-linuxmusl-arm64-1.0.17-preview.0.tgz", - "integrity": "sha512-DgwS8F4IAra48ML6jWprk1HeJAANf7FdjCRIViVAuhaVLvdjbPi/7pYTQGLtwTcxN63O4cG+/1mPKf2roz83YA==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-arm64/-/copilot-sdk-linuxmusl-arm64-1.0.17-preview.4.tgz", + "integrity": "sha512-hym02Fv/JuqjmmMqs4E+XFEgx8nzIpIdMLSr5CFpMLWVVKQYBhY8x6r+03qXhfutUWlcoL5f8gK/pdOR23k9jw==", "cpu": [ "arm64" ], @@ -171,9 +171,9 @@ ] }, "node_modules/@github/copilot-sdk-linuxmusl-x64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-x64/-/copilot-sdk-linuxmusl-x64-1.0.17-preview.0.tgz", - "integrity": "sha512-9T2ik5qK5akLqVhIyvGVicjcumKGOivvuLaAPYU+210MV+t70u9BGQ0a3Vy3tlgDPGHos/js72ZL2RUo6hmFZg==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-x64/-/copilot-sdk-linuxmusl-x64-1.0.17-preview.4.tgz", + "integrity": "sha512-IhjdLXtUSV8lSZiWsciGHD2iwv3nS2pAwLMywfgB1GozouBDIfWZJlbTUX5Jqv4JeZjE7lpvD5e+Sc11HL6kQQ==", "cpu": [ "x64" ], @@ -187,9 +187,9 @@ ] }, "node_modules/@github/copilot-sdk-win32-arm64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-arm64/-/copilot-sdk-win32-arm64-1.0.17-preview.0.tgz", - "integrity": "sha512-7nSjNxKXQWCEsf+iDQMEW8dr8q4SkQAimemb0U03fqD3jj7JA6Z/XvmAIFb/Cf+RWqTLexHw0O3HjFs9vF5MNQ==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-arm64/-/copilot-sdk-win32-arm64-1.0.17-preview.4.tgz", + "integrity": "sha512-MxJJiT2DoDXi3a6dPY/DwMbJPweK75ywlIIbK6Agnof055euC4st3bKMUXz8Nuq4pWzXU3HEWFUVWnNr/63TNw==", "cpu": [ "arm64" ], @@ -200,9 +200,9 @@ ] }, "node_modules/@github/copilot-sdk-win32-x64": { - "version": "1.0.17-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-x64/-/copilot-sdk-win32-x64-1.0.17-preview.0.tgz", - "integrity": "sha512-4pa51WL3KZgouFrODCngtmGw3yavDIIEwiK+cGAA7jH45uFu5EpPnuVHlpFIrAU/fa80ZSbiX5KkiigP+EpLow==", + "version": "1.0.17-preview.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-x64/-/copilot-sdk-win32-x64-1.0.17-preview.4.tgz", + "integrity": "sha512-NGC8k/WHw6lP6O6h/nJiw3lBaaaMjb1hu+urr+Wpa9EP9a/PFL4rWXpxixgQT4qym7EAVY+EE1vSTA8jCi7cAA==", "cpu": [ "x64" ], diff --git a/remote/package.json b/remote/package.json index 12797db326185f..9f718de08d7811 100644 --- a/remote/package.json +++ b/remote/package.json @@ -4,7 +4,7 @@ "private": true, "dependencies": { "@devcontainers/cli": "0.88.0", - "@github/copilot-sdk": "1.0.17-preview.0", + "@github/copilot-sdk": "1.0.17-preview.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/mxc-sdk": "0.9.0", @@ -78,5 +78,5 @@ "@vscode/windows-ca-certs@0.3.4": true, "@vscode/windows-process-tree@0.8.0": true }, - "copilotRuntimeVersion": "1.0.92-0" + "copilotRuntimeVersion": "1.0.92-4" } diff --git a/src/vs/base/common/event.ts b/src/vs/base/common/event.ts index cf5fd39b7389b1..d455ebf06940ed 100644 --- a/src/vs/base/common/event.ts +++ b/src/vs/base/common/event.ts @@ -1293,7 +1293,7 @@ export class Emitter { let stack: Stacktrace | undefined; if (this._leakWarningThreshold !== undefined && this._size >= Math.ceil(this._leakWarningThreshold * 0.2)) { const leakageMon = this._getLeakageMonitor(); - if (leakageMon) { + if (leakageMon && this._size + 1 >= leakageMon.threshold) { // check and record this emitter for potential leakage contained.stack = Stacktrace.create(); removeMonitor = leakageMon.check(contained.stack, this._size + 1); diff --git a/src/vs/base/parts/contextmenu/electron-browser/contextmenu.ts b/src/vs/base/parts/contextmenu/electron-browser/contextmenu.ts index 7b1971d0aa7ebc..f5187f617ea1f9 100644 --- a/src/vs/base/parts/contextmenu/electron-browser/contextmenu.ts +++ b/src/vs/base/parts/contextmenu/electron-browser/contextmenu.ts @@ -17,7 +17,7 @@ export function popup(items: IContextMenuItem[], options?: IPopupOptions, onHide const itemId = args[0] as number; const context = args[1] as IContextMenuEvent; const item = processedItems[itemId]; - item.click?.(context); + item?.click?.(context); }; ipcRenderer.once(onClickChannel, onClickChannelHandler); @@ -27,8 +27,6 @@ export function popup(items: IContextMenuItem[], options?: IPopupOptions, onHide return; } - ipcRenderer.removeListener(onClickChannel, onClickChannelHandler); - onHide?.(); }); diff --git a/src/vs/base/parts/contextmenu/electron-main/contextmenu.ts b/src/vs/base/parts/contextmenu/electron-main/contextmenu.ts index 988c43285f1420..a61213ef710340 100644 --- a/src/vs/base/parts/contextmenu/electron-main/contextmenu.ts +++ b/src/vs/base/parts/contextmenu/electron-main/contextmenu.ts @@ -20,6 +20,8 @@ export function registerContextMenuListener(): void { // It turns out that the menu gets GC'ed if not referenced anymore // As such we drag it into this scope so that it is not being GC'ed if (menu) { + // Complete the one-shot click listener even when no item was selected. + event.sender.send(onClickChannel, -1); event.sender.send(CONTEXT_MENU_CLOSE_CHANNEL, contextMenuId); } } diff --git a/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteController.ts b/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteController.ts index abe335105607cd..515445a9c28655 100644 --- a/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteController.ts +++ b/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteController.ts @@ -38,6 +38,7 @@ import { MessageController } from '../../message/browser/messageController.js'; import { PreferredPasteConfiguration } from './copyPasteContribution.js'; import { DefaultTextPasteOrDropEditProvider } from './defaultProviders.js'; import { createCombinedWorkspaceEdit, sortEditsByYieldTo } from './edit.js'; +import { PasteEditSession } from './pasteEditSession.js'; import { PostEditWidgetManager } from './postEditWidget.js'; export const changePasteTypeCommandId = 'editor.changePasteType'; @@ -60,11 +61,6 @@ type PasteEditWithProvider = DocumentPasteEdit & { }; -interface DocumentPasteWithProviderEditsSession { - edits: readonly PasteEditWithProvider[]; - dispose(): void; -} - export type PastePreference = | { readonly only: HierarchicalKind } | { readonly preferences: readonly HierarchicalKind[] } @@ -339,7 +335,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi const disposables = new DisposableStore(); const cts = disposables.add(new CancellationTokenSource(pToken)); - disposables.add(editorStateCts.token.onCancellationRequested(() => cts.cancel())); + const editorStateListener = disposables.add(editorStateCts.token.onCancellationRequested(() => cts.cancel())); const token = cts.token; try { @@ -359,8 +355,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi triggerKind: DocumentPasteTriggerKind.Automatic, }; - const editSession = await this.getPasteEdits(supportedProviders, dataTransfer, model, selections, context, token); - disposables.add(editSession); + const editSession = disposables.add(await this.getPasteEdits(supportedProviders, dataTransfer, model, selections, context, token)); if (token.isCancellationRequested) { return; } @@ -372,7 +367,9 @@ export class CopyPasteController extends Disposable implements IEditorContributi if (editSession.edits.length) { const canShowWidget = editor.getOption(EditorOption.pasteAs).showPasteSelector === 'afterPaste'; - return this._postPasteWidgetManager.applyEditAndShowIfNeeded(selections, { activeEditIndex: this.getInitialActiveEditIndex(model, editSession.edits), allEdits: editSession.edits }, canShowWidget, async (edit, resolveToken) => { + // Applying the paste changes editor state itself. The widget manager handles cancellation during resolve. + disposables.delete(editorStateListener); + return await this._postPasteWidgetManager.applyEditAndShowIfNeeded(selections, { activeEditIndex: this.getInitialActiveEditIndex(model, editSession.edits), allEdits: editSession.edits }, canShowWidget, async (edit, resolveToken) => { if (!edit.provider.resolveDocumentPasteEdit) { return edit; } @@ -388,7 +385,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi edit.additionalEdit = resolved.additionalEdit; } return edit; - }, token); + }, token, editSession); } await this.applyDefaultPasteHandler(dataTransfer, metadata, token, clipboardEvent); @@ -443,28 +440,26 @@ export class CopyPasteController extends Disposable implements IEditorContributi triggerKind: DocumentPasteTriggerKind.PasteAs, only: preference && 'only' in preference ? preference.only : undefined, }; - let editSession = disposables.add(await this.getPasteEdits(supportedProviders, dataTransfer, model, selections, context, tokenSource.token)); + const editSession = disposables.add(await this.getPasteEdits(supportedProviders, dataTransfer, model, selections, context, tokenSource.token)); if (tokenSource.token.isCancellationRequested) { return; } // Filter out any edits that don't match the requested kind + let edits = editSession.edits; if (preference) { - editSession = { - edits: editSession.edits.filter(edit => { - if ('only' in preference) { - return preference.only.contains(edit.kind); - } else if ('preferences' in preference) { - return preference.preferences.some(preference => preference.contains(edit.kind)); - } else { - return preference.providerId === edit.provider.id; - } - }), - dispose: editSession.dispose - }; + edits = edits.filter(edit => { + if ('only' in preference) { + return preference.only.contains(edit.kind); + } else if ('preferences' in preference) { + return preference.preferences.some(preference => preference.contains(edit.kind)); + } else { + return preference.providerId === edit.provider.id; + } + }); } - if (!editSession.edits.length) { + if (!edits.length) { if (preference) { this.showPasteAsNoEditMessage(selections, preference); } @@ -473,7 +468,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi let pickedEdit: DocumentPasteEdit | undefined; if (preference) { - pickedEdit = editSession.edits.at(0); + pickedEdit = edits.at(0); } else { type ItemWithEdit = IQuickPickItem & { edit?: DocumentPasteEdit }; const configureDefaultItem: ItemWithEdit = { @@ -484,7 +479,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi const selected = await this._quickInputService.pick( [ - ...editSession.edits.map((edit): ItemWithEdit => ({ + ...edits.map((edit): ItemWithEdit => ({ label: edit.title, description: edit.kind?.value, edit, @@ -596,7 +591,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi } } - private async getPasteEdits(providers: readonly DocumentPasteEditProvider[], dataTransfer: VSDataTransfer, model: ITextModel, selections: readonly Selection[], context: DocumentPasteContext, token: CancellationToken): Promise { + private async getPasteEdits(providers: readonly DocumentPasteEditProvider[], dataTransfer: VSDataTransfer, model: ITextModel, selections: readonly Selection[], context: DocumentPasteContext, token: CancellationToken): Promise> { const disposables = new DisposableStore(); const results = await raceCancellation( @@ -618,10 +613,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi const edits = coalesce(results ?? []).flat().filter(edit => { return !context.only || context.only.contains(edit.kind); }); - return { - edits: sortEditsByYieldTo(edits), - dispose: () => disposables.dispose() - }; + return new PasteEditSession(sortEditsByYieldTo(edits), disposables); } private async applyDefaultPasteHandler(dataTransfer: VSDataTransfer, metadata: CopyMetadata | undefined, token: CancellationToken, clipboardEvent: ClipboardEvent | undefined) { diff --git a/src/vs/editor/contrib/dropOrPasteInto/browser/pasteEditSession.ts b/src/vs/editor/contrib/dropOrPasteInto/browser/pasteEditSession.ts new file mode 100644 index 00000000000000..895841356d6376 --- /dev/null +++ b/src/vs/editor/contrib/dropOrPasteInto/browser/pasteEditSession.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { DocumentPasteEdit } from '../../../common/languages.js'; + +/** Owns cached paste edits until they are disposed or transferred to another session. */ +export class PasteEditSession extends Disposable { + private readonly _disposables = this._register(new MutableDisposable()); + + constructor(readonly edits: readonly T[], disposables: IDisposable | undefined) { + super(); + this._disposables.value = disposables; + } + + /** Transfers the edits to a new owner. Disposing this session will no longer release them. */ + take(): PasteEditSession { + return new PasteEditSession(this.edits, this._disposables.clearAndLeak()); + } +} diff --git a/src/vs/editor/contrib/dropOrPasteInto/browser/postEditWidget.ts b/src/vs/editor/contrib/dropOrPasteInto/browser/postEditWidget.ts index 735768eb1758e5..0e09fd57b8f4fc 100644 --- a/src/vs/editor/contrib/dropOrPasteInto/browser/postEditWidget.ts +++ b/src/vs/editor/contrib/dropOrPasteInto/browser/postEditWidget.ts @@ -28,6 +28,7 @@ import { DocumentDropEdit, DocumentPasteEdit } from '../../../common/languages.j import { TrackedRangeStickiness } from '../../../common/model.js'; import { CodeEditorStateFlag, EditorStateCancellationTokenSource } from '../../editorState/browser/editorState.js'; import { createCombinedWorkspaceEdit } from './edit.js'; +import { PasteEditSession } from './pasteEditSession.js'; import './postEditWidget.css'; @@ -47,6 +48,8 @@ class PostEditWidget extends Dis readonly allowEditorOverflow = true; readonly suppressMouseDown = true; + private readonly _editSession: PasteEditSession | undefined; + private domNode!: HTMLElement; private button!: Button; @@ -59,14 +62,16 @@ class PostEditWidget extends Dis private readonly showCommand: ShowCommand, private readonly range: Range, private readonly edits: EditSet, - private readonly onSelectNewEdit: (editIndex: number) => void, + private readonly onSelectNewEdit: (editIndex: number, editSession: PasteEditSession | undefined) => void, private readonly additionalActions: readonly IAction[], + editSessionOwner: PasteEditSession | undefined, @IContextKeyService contextKeyService: IContextKeyService, @IKeybindingService private readonly _keybindingService: IKeybindingService, @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, ) { super(); + this._editSession = editSessionOwner ? this._register(editSessionOwner.take()) : undefined; this.create(); this.visibleContext = visibleContext.bindTo(contextKeyService); @@ -140,7 +145,8 @@ class PostEditWidget extends Dis const i = this.edits.allEdits.findIndex(edit => edit === item); if (i !== this.edits.activeEditIndex) { - return this.onSelectNewEdit(i); + // Transfer the edits before undo disposes this widget. + return this.onSelectNewEdit(i, this._editSession?.take()); } }, }, anchor, this.editor.getDomNode() ?? undefined, this.additionalActions); @@ -169,7 +175,7 @@ export class PostEditWidgetManager this.clear())); } - public async applyEditAndShowIfNeeded(ranges: readonly Range[], edits: EditSet, canShowWidget: boolean, resolve: (edit: T, token: CancellationToken) => Promise, token: CancellationToken) { + public async applyEditAndShowIfNeeded(ranges: readonly Range[], edits: EditSet, canShowWidget: boolean, resolve: (edit: T, token: CancellationToken) => Promise, token: CancellationToken, editSessionOwner?: PasteEditSession) { if (!ranges.length || !this._editor.hasModel()) { return; } @@ -180,14 +186,19 @@ export class PostEditWidgetManager { - const model = this._editor.getModel(); - if (!model) { - return; - } + const onDidSelectEdit = async (newEditIndex: number, editSession: PasteEditSession | undefined) => { + try { + this.clear(); + const model = this._editor.getModel(); + if (!model) { + return; + } - await model.undo(); - this.applyEditAndShowIfNeeded(ranges, { activeEditIndex: newEditIndex, allEdits: edits.allEdits }, canShowWidget, resolve, token); + await model.undo(); + await this.applyEditAndShowIfNeeded(ranges, { activeEditIndex: newEditIndex, allEdits: edits.allEdits }, canShowWidget, resolve, token, editSession); + } finally { + editSession?.dispose(); + } }; const handleError = (e: Error, message: string) => { @@ -197,7 +208,7 @@ export class PostEditWidgetManager 1) { - this.show(editRange ?? primaryRange, edits, onDidSelectEdit); + this.show(editRange ?? primaryRange, edits, onDidSelectEdit, editSessionOwner); } } - public show(range: Range, edits: EditSet, onDidSelectEdit: (newIndex: number) => void) { + public show(range: Range, edits: EditSet, onDidSelectEdit: (newIndex: number, editSession: PasteEditSession | undefined) => void, editSessionOwner?: PasteEditSession) { this.clear(); if (this._editor.hasModel()) { - this._currentWidget.value = this._instantiationService.createInstance(PostEditWidget, this._id, this._editor, this._visibleContext, this._showCommand, range, edits, onDidSelectEdit, this._getAdditionalActions()); + this._currentWidget.value = this._instantiationService.createInstance(PostEditWidget, this._id, this._editor, this._visibleContext, this._showCommand, range, edits, onDidSelectEdit, this._getAdditionalActions(), editSessionOwner); } } diff --git a/src/vs/editor/contrib/dropOrPasteInto/test/browser/copyPasteController.test.ts b/src/vs/editor/contrib/dropOrPasteInto/test/browser/copyPasteController.test.ts new file mode 100644 index 00000000000000..7267431088b843 --- /dev/null +++ b/src/vs/editor/contrib/dropOrPasteInto/test/browser/copyPasteController.test.ts @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * 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 sinon from 'sinon'; +import { DeferredPromise } from '../../../../../base/common/async.js'; +import { createStringDataTransferItem, VSDataTransfer } from '../../../../../base/common/dataTransfer.js'; +import { HierarchicalKind } from '../../../../../base/common/hierarchicalKind.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IProgressService } from '../../../../../platform/progress/common/progress.js'; +import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; +import { IBulkEditService, ResourceEdit, ResourceTextEdit } from '../../../../browser/services/bulkEditService.js'; +import { DocumentPasteEditProvider, WorkspaceEdit } from '../../../../common/languages.js'; +import { withAsyncTestCodeEditor } from '../../../../test/browser/testCodeEditor.js'; +import { SnippetParser } from '../../../snippet/browser/snippetParser.js'; +import { CopyPasteController } from '../../browser/copyPasteController.js'; + +suite('CopyPasteController - paste edit session', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + for (const endInteraction of ['dismiss', 'cursor', 'content', 'model', 'dispose', 'noSelector', 'singleEdit', 'cancelReplacement', 'failedUndo'] as const) { + let name = `resolves alternatives after the initial paste and releases edits on ${endInteraction}`; + switch (endInteraction) { + case 'noSelector': name = 'releases edits after paste when the selector is disabled'; break; + case 'singleEdit': name = 'releases edits after paste when there are no alternatives'; break; + case 'cancelReplacement': name = 'releases transferred edits when alternative resolution is cancelled'; break; + case 'failedUndo': name = 'releases transferred edits when undo fails'; break; + } + test(name, async () => { + await withAsyncTestCodeEditor('', { pasteAs: { enabled: true, showPasteSelector: endInteraction === 'noSelector' ? 'never' : 'afterPaste' } }, async (editor, _viewModel, instantiationService) => { + instantiationService.stub(createDecorator<{ add(): () => void }>('IEditorCancelService'), { add: () => () => { } }); + let selectEdit: ((index: number) => Promise) | undefined; + instantiationService.stub(IActionWidgetService, new class extends mock() { + override show: IActionWidgetService['show'] = (_user, _preview, items, delegate) => { + selectEdit = async index => { await delegate.onSelect(items[index].item!); }; + }; + override hide() { } + }); + instantiationService.stub(IBulkEditService, new class extends mock() { + override async apply(edit: ResourceEdit[] | WorkspaceEdit) { + const textEdits = (Array.isArray(edit) ? edit : edit.edits).map(edit => { + assert.ok(ResourceTextEdit.is(edit)); + return { range: edit.textEdit.range, text: new SnippetParser().parse(edit.textEdit.text).toString() }; + }); + editor.getModel().pushStackElement(); + editor.getModel().pushEditOperations(editor.getSelections(), textEdits, () => null); + editor.getModel().pushStackElement(); + return { isApplied: true, ariaSummary: '' }; + } + }); + instantiationService.stub(IQuickInputService, new class extends mock() { }); + instantiationService.stub(IProgressService, new class extends mock() { }); + + let released = 0; + let resolves = 0; + const resolveStarted = new DeferredPromise(); + const finishResolve = new DeferredPromise(); + const plainKind = new HierarchicalKind('text.plain'); + const provider: DocumentPasteEditProvider = { + copyMimeTypes: [], + pasteMimeTypes: ['text/plain'], + providedPasteEditKinds: [plainKind], + async provideDocumentPasteEdits() { + return { + edits: [ + { title: 'Resolve test', insertText: 'UNRESOLVED', kind: new HierarchicalKind('test'), yieldTo: [{ kind: plainKind }] }, + { title: 'Plain text', insertText: 'PLAIN', kind: plainKind } + ].slice(endInteraction === 'singleEdit' ? 1 : 0), + dispose: () => { ++released; } + }; + }, + async resolveDocumentPasteEdit(edit) { + assert.strictEqual(released, 0, 'Provider edits must still be cached during resolve'); + ++resolves; + if (endInteraction === 'cancelReplacement' && edit.title === 'Resolve test') { + resolveStarted.complete(); + await finishResolve.p; + } + return { ...edit, insertText: edit.title === 'Resolve test' ? 'RESOLVED' : edit.insertText }; + } + }; + const controller = disposables.add(instantiationService.createInstance(CopyPasteController, editor)); + const dataTransfer = new VSDataTransfer(); + dataTransfer.append('text/plain', createStringDataTransferItem('PLAIN')); + // The test editor has no clipboard view, so start the inline paste directly. + // eslint-disable-next-line local/code-no-bracket-notation-for-identifiers + controller['doPasteInline']([provider], editor.getSelections(), dataTransfer, undefined, undefined); + await controller.finishedPaste(); + if (endInteraction === 'noSelector' || endInteraction === 'singleEdit') { + assert.deepStrictEqual({ text: editor.getValue(), released, resolves }, { text: 'PLAIN', released: 1, resolves: 1 }); + return; + } + assert.deepStrictEqual({ text: editor.getValue(), released, resolves }, { text: 'PLAIN', released: 0, resolves: 1 }); + + controller.changePasteType(); + assert.ok(selectEdit); + if (endInteraction === 'failedUndo') { + const undo = sinon.stub(editor.getModel(), 'undo').rejects(new Error('Undo failed')); + try { + await assert.rejects(selectEdit(1), /Undo failed/); + } finally { + undo.restore(); + } + controller.clearWidgets(); + assert.strictEqual(released, 1); + return; + } + if (endInteraction === 'cancelReplacement') { + const selection = selectEdit(1); + await resolveStarted.p; + editor.getModel().setValue('changed'); + finishResolve.complete(); + await selection; + controller.clearWidgets(); + assert.deepStrictEqual({ text: editor.getValue(), released }, { text: 'changed', released: 1 }); + return; + } + await selectEdit(1); + assert.deepStrictEqual({ text: editor.getValue(), released, resolves }, { text: 'RESOLVED', released: 0, resolves: 2 }); + + controller.changePasteType(); + await selectEdit(0); + assert.deepStrictEqual({ text: editor.getValue(), released, resolves }, { text: 'PLAIN', released: 0, resolves: 3 }); + + switch (endInteraction) { + case 'dismiss': controller.clearWidgets(); break; + case 'cursor': editor.setPosition({ lineNumber: 1, column: 2 }); break; + case 'content': editor.getModel().setValue('changed'); break; + case 'model': editor.setModel(null); break; + case 'dispose': controller.dispose(); break; + } + controller.clearWidgets(); + assert.strictEqual(released, 1); + }); + }); + } +}); diff --git a/src/vs/platform/agentHost/common/agentHostSessionType.ts b/src/vs/platform/agentHost/common/agentHostSessionType.ts index ff096518865abf..2513c175d0a7da 100644 --- a/src/vs/platform/agentHost/common/agentHostSessionType.ts +++ b/src/vs/platform/agentHost/common/agentHostSessionType.ts @@ -8,6 +8,18 @@ import { type AgentProvider } from './agent.js'; const LOCAL_AGENT_HOST_SESSION_TYPE_PREFIX = 'agent-host-'; const REMOTE_AGENT_HOST_SESSION_TYPE_PREFIX = 'remote-'; +/** Identifies the Copilot agent family without changing the host's provider ID or implying runtime capabilities. */ +export function isCopilotAgentHostProvider(provider: AgentProvider | undefined): boolean { + return provider === 'copilotcli' || provider === 'copilot'; +} + +/** Identifies local and remote Copilot agent-host session types, preserving their existing URI schemes. */ +export function isCopilotAgentHostSessionType(sessionType: string): boolean { + const provider = parseAgentHostHarness(sessionType); + return provider !== undefined && isCopilotAgentHostProvider(provider) + && (!isRemoteAgentHostSessionType(sessionType) || parseRemoteAgentHostSessionTypeAuthority(sessionType, provider) !== undefined); +} + /** * Builds the unique per-connection identifier for a remote agent host. * diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index a11b4896998e47..72f57ecb90656a 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -988,6 +988,7 @@ export interface IAgentService { * with {@link unsubscribe} when the subscription is released. When * provided, `isActive` is checked before registering the subscriber so a * request cancelled during asynchronous resolution cannot pin the resource. + * Callers must distinguish lost subscription ownership from a missing resource. */ subscribe(resource: URI, clientId: string, isActive?: () => boolean): Promise; diff --git a/src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts b/src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts index 9d4afd43e1e8ee..de0677e3b9db1d 100644 --- a/src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts +++ b/src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts @@ -17,6 +17,7 @@ import { createDecorator } from '../../instantiation/common/instantiation.js'; import { RemoteAgentHostsEnabledSettingId } from './remoteAgentHostService.js'; import { IReplayedTaskHistory } from './taskEventReplay.js'; import { SessionStatus } from './state/sessionState.js'; +import { SessionModelInfo } from './state/protocol/state.js'; /** Configuration key gating the cloud-sandbox connection path. Disabled by default. */ export const CloudSandboxEnabledSettingId = 'chat.agentHost.cloudSandbox.enabled'; @@ -122,6 +123,8 @@ export interface ICloudSandboxDiscoveredSession { readonly updatedAt?: string; /** Last reported activity; this does not establish environment availability or session flags. */ readonly status?: SessionStatus; + /** Mission Control's archive state, independent of the sandbox's AHP session flags. */ + readonly isArchived?: boolean; } /** Build the synthesized remote-agent-host address for a sandbox environment. */ @@ -233,6 +236,11 @@ export interface ICloudSandboxConnectionRequest { export const ICloudSandboxApiService = createDecorator('cloudSandboxApiService'); +export interface ICloudSandboxModelCatalog { + readonly models: readonly SessionModelInfo[]; + readonly defaultModel?: string; +} + /** Account identity and control-plane APIs for sandbox credentials, discovery, and persisted history. */ export interface ICloudSandboxApiService { readonly _serviceBrand: undefined; @@ -243,6 +251,9 @@ export interface ICloudSandboxApiService { /** Resolves an opaque, credential-free account key using the same identity as task requests. */ getAccountKey(): Promise; + /** Account-authorized cloud model catalog, available before a sandbox is provisioned. */ + listModels(token: CancellationToken): Promise; + /** * Mint a fresh client Web PubSub connection token for a new logical connection. May resolve to a * "waking" result the caller should retry. @@ -277,6 +288,9 @@ export interface ICloudSandboxApiService { /** Rename the Mission Control task without waking the sandbox. */ renameTask(taskId: string, title: string, token: CancellationToken): Promise; + /** Archive or unarchive the Mission Control task without waking the sandbox. */ + setTaskArchived(taskId: string, archived: boolean, token: CancellationToken): Promise; + /** * Read a task's persisted AHP history and fold it back into session and chat state. Served by * Mission Control's mirror, so it works without the sandbox. `undefined` when there is none. diff --git a/src/vs/platform/agentHost/common/meta/agentSlashCommandOutputMeta.ts b/src/vs/platform/agentHost/common/meta/agentSlashCommandOutputMeta.ts new file mode 100644 index 00000000000000..97b72eef836d24 --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/agentSlashCommandOutputMeta.ts @@ -0,0 +1,7 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export type { ISlashCommandResource } from './vscode/agentSlashCommandOutputMeta.js'; +export { readSlashCommandResource, toSlashCommandResourceMeta } from './vscode/agentSlashCommandOutputMeta.js'; diff --git a/src/vs/platform/agentHost/common/meta/copilotd/cloudSandboxProjectMeta.ts b/src/vs/platform/agentHost/common/meta/copilotd/cloudSandboxProjectMeta.ts index 9231c100531a27..6fdf65b818c9f8 100644 --- a/src/vs/platform/agentHost/common/meta/copilotd/cloudSandboxProjectMeta.ts +++ b/src/vs/platform/agentHost/common/meta/copilotd/cloudSandboxProjectMeta.ts @@ -13,6 +13,7 @@ export interface ICloudSandboxProject { readonly path: string; readonly git: boolean; readonly status: 'ready' | 'cloning' | 'failed'; + readonly progress?: number; readonly remoteUrl?: string; readonly error?: string; } @@ -37,6 +38,9 @@ function readProject(value: unknown): ICloudSandboxProject | undefined { path: value.path, git: value.git, status, + progress: status === 'cloning' && typeof value.progress === 'number' && Number.isFinite(value.progress) + ? Math.min(100, Math.max(0, value.progress)) + : undefined, remoteUrl: typeof value.remoteUrl === 'string' ? value.remoteUrl : undefined, error: typeof value.error === 'string' ? value.error : undefined, }; diff --git a/src/vs/platform/agentHost/common/meta/mcpCustomizationMeta.ts b/src/vs/platform/agentHost/common/meta/mcpCustomizationMeta.ts index 6828264841808f..3d849ec889c898 100644 --- a/src/vs/platform/agentHost/common/meta/mcpCustomizationMeta.ts +++ b/src/vs/platform/agentHost/common/meta/mcpCustomizationMeta.ts @@ -4,4 +4,4 @@ *--------------------------------------------------------------------------------------------*/ export type { McpServerSource } from './vscode/mcpCustomizationMeta.js'; -export { readMcpServerSource, withMcpServerSourceMeta } from './vscode/mcpCustomizationMeta.js'; +export { readMcpServerDisplayName, readMcpServerSource, withMcpServerDisplayNameMeta, withMcpServerSourceMeta } from './vscode/mcpCustomizationMeta.js'; diff --git a/src/vs/platform/agentHost/common/meta/vscode/agentSandboxPolicyMeta.ts b/src/vs/platform/agentHost/common/meta/vscode/agentSandboxPolicyMeta.ts index 97542935ee5f9e..3a5584e76b1186 100644 --- a/src/vs/platform/agentHost/common/meta/vscode/agentSandboxPolicyMeta.ts +++ b/src/vs/platform/agentHost/common/meta/vscode/agentSandboxPolicyMeta.ts @@ -16,20 +16,30 @@ export interface ISessionSandboxPolicy { readonly allowDevToolAccess?: boolean; readonly sandboxMcpServers?: boolean; readonly sandboxLspServers?: boolean; + readonly authenticateGit?: boolean; + readonly authenticateGh?: boolean; readonly failClosed?: boolean; } -/** Missing or unsupported metadata carries no assertion about the host's policy. */ +/** Reads trusted host policy metadata; missing or non-object metadata carries no policy assertion. */ export function readSessionSandboxPolicy(source: { readonly _meta?: Record } | undefined): ISessionSandboxPolicy | undefined { const value = source?._meta?.[sandboxPolicyKey]; if (!isObject(value)) { return undefined; } - const { enabled, allowBypass, allowOutbound, allowLocalNetwork, allowDevToolAccess, sandboxMcpServers, sandboxLspServers, failClosed } = value as Record; - if (typeof enabled !== 'boolean' || (allowBypass !== undefined && typeof allowBypass !== 'boolean') || (allowOutbound !== undefined && typeof allowOutbound !== 'boolean') || (allowLocalNetwork !== undefined && typeof allowLocalNetwork !== 'boolean') || (allowDevToolAccess !== undefined && typeof allowDevToolAccess !== 'boolean') || (sandboxMcpServers !== undefined && typeof sandboxMcpServers !== 'boolean') || (sandboxLspServers !== undefined && typeof sandboxLspServers !== 'boolean') || (failClosed !== undefined && typeof failClosed !== 'boolean')) { - return undefined; - } - return { enabled, ...(allowBypass !== undefined ? { allowBypass } : {}), ...(allowOutbound !== undefined ? { allowOutbound } : {}), ...(allowLocalNetwork !== undefined ? { allowLocalNetwork } : {}), ...(allowDevToolAccess !== undefined ? { allowDevToolAccess } : {}), ...(sandboxMcpServers !== undefined ? { sandboxMcpServers } : {}), ...(sandboxLspServers !== undefined ? { sandboxLspServers } : {}), ...(failClosed !== undefined ? { failClosed } : {}) }; + const { enabled, allowBypass, allowOutbound, allowLocalNetwork, allowDevToolAccess, sandboxMcpServers, sandboxLspServers, authenticateGit, authenticateGh, failClosed } = value as ISessionSandboxPolicy; + return { + enabled, + ...(allowBypass !== undefined ? { allowBypass } : {}), + ...(allowOutbound !== undefined ? { allowOutbound } : {}), + ...(allowLocalNetwork !== undefined ? { allowLocalNetwork } : {}), + ...(allowDevToolAccess !== undefined ? { allowDevToolAccess } : {}), + ...(sandboxMcpServers !== undefined ? { sandboxMcpServers } : {}), + ...(sandboxLspServers !== undefined ? { sandboxLspServers } : {}), + ...(authenticateGit !== undefined ? { authenticateGit } : {}), + ...(authenticateGh !== undefined ? { authenticateGh } : {}), + ...(failClosed !== undefined ? { failClosed } : {}), + }; } export function withSessionSandboxPolicy(meta: Record | undefined, policy: ISessionSandboxPolicy | undefined): Record { @@ -43,6 +53,8 @@ export function withSessionSandboxPolicy(meta: Record | undefin ...(policy.allowDevToolAccess !== undefined ? { allowDevToolAccess: policy.allowDevToolAccess } : {}), ...(policy.sandboxMcpServers !== undefined ? { sandboxMcpServers: policy.sandboxMcpServers } : {}), ...(policy.sandboxLspServers !== undefined ? { sandboxLspServers: policy.sandboxLspServers } : {}), + ...(policy.authenticateGit !== undefined ? { authenticateGit: policy.authenticateGit } : {}), + ...(policy.authenticateGh !== undefined ? { authenticateGh: policy.authenticateGh } : {}), ...(policy.failClosed !== undefined ? { failClosed: policy.failClosed } : {}), } : undefined, }; diff --git a/src/vs/platform/agentHost/common/meta/vscode/agentSlashCommandOutputMeta.ts b/src/vs/platform/agentHost/common/meta/vscode/agentSlashCommandOutputMeta.ts new file mode 100644 index 00000000000000..daddfb65948823 --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/vscode/agentSlashCommandOutputMeta.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isObject } from '../../../../../base/common/types.js'; +import { URI } from '../../../../../base/common/uri.js'; + +const slashCommandResourceKey = 'vscode.slashCommandResource'; + +export interface ISlashCommandResource { + readonly resource: URI; + readonly preview: boolean; +} + +/** Reads an optional resource to open for a user-invoked slash command. */ +export function readSlashCommandResource(source: { readonly _meta?: Record }): ISlashCommandResource | undefined { + const value = source._meta?.[slashCommandResourceKey]; + if (!isObject(value)) { + return undefined; + } + const { resource, preview } = value as Record; + if (typeof resource !== 'string' || typeof preview !== 'boolean') { + return undefined; + } + try { + return { resource: URI.parse(resource, true), preview }; + } catch { + return undefined; + } +} + +export function toSlashCommandResourceMeta(resource: URI, preview: boolean): Record { + return { [slashCommandResourceKey]: { resource: resource.toString(), preview } }; +} diff --git a/src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts b/src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts index 257cf48a442ce5..5b854324187ee6 100644 --- a/src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts +++ b/src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts @@ -6,6 +6,8 @@ import type { McpServerCustomization } from '../../state/protocol/state.js'; const sourceKey = 'agentHost.mcpServerSource'; +const displayNameKey = 'vscode.mcpServerDisplayName'; +const maxDisplayNameLength = 512; export type McpServerSource = | 'user' // Defined in user-level configuration. @@ -36,3 +38,22 @@ export function withMcpServerSourceMeta(meta: Record | undefine } return { ...(meta ?? {}), [sourceKey]: source }; } + +/** Reads an optional Connector catalog name without changing the MCP server's runtime identity. */ +export function readMcpServerDisplayName(customization: McpServerCustomization | undefined): string | undefined { + return normalizeMcpServerDisplayName(customization?._meta?.[displayNameKey]); +} + +/** Records an optional Connector catalog name while preserving every other metadata entry. */ +export function withMcpServerDisplayNameMeta(meta: Record | undefined, displayName: string | undefined): Record | undefined { + const normalized = normalizeMcpServerDisplayName(displayName); + return normalized === undefined ? meta : { ...(meta ?? {}), [displayNameKey]: normalized }; +} + +function normalizeMcpServerDisplayName(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 && trimmed.length <= maxDisplayNameLength ? trimmed : undefined; +} diff --git a/src/vs/platform/agentHost/common/sandboxConfigSchema.ts b/src/vs/platform/agentHost/common/sandboxConfigSchema.ts index 6dd55b2bd59528..ae7bff150697a6 100644 --- a/src/vs/platform/agentHost/common/sandboxConfigSchema.ts +++ b/src/vs/platform/agentHost/common/sandboxConfigSchema.ts @@ -32,6 +32,8 @@ export const enum AgentHostSandboxKey { AllowUnsandboxedCommands = 'allowUnsandboxedCommands', SandboxMcpServers = 'sandboxMcpServers', SandboxLspServers = 'sandboxLspServers', + AuthenticateGit = 'authenticateGit', + AuthenticateGh = 'authenticateGh', AllowDevToolAccess = 'allowDevToolAccess', UserConfiguredPaths = 'fileSystem.userConfiguredPaths', LinuxFileSystem = 'fileSystem.linux', @@ -50,6 +52,8 @@ export type ISandboxConfigValue = Partial<{ [AgentHostSandboxKey.AllowUnsandboxedCommands]: boolean; [AgentHostSandboxKey.SandboxMcpServers]: boolean; [AgentHostSandboxKey.SandboxLspServers]: boolean; + [AgentHostSandboxKey.AuthenticateGit]: boolean; + [AgentHostSandboxKey.AuthenticateGh]: boolean; [AgentHostSandboxKey.AllowDevToolAccess]: boolean; [AgentHostSandboxKey.UserConfiguredPaths]: IAgentSandboxUserConfiguredPaths; [AgentHostSandboxKey.LinuxFileSystem]: IAgentSandboxFileSystemSetting; @@ -97,6 +101,14 @@ export const sandboxConfigSchema = createSchema({ type: 'boolean', title: localize('agentHost.config.sandbox.sandboxLspServers.title', "Sandbox LSP Servers"), }, + [AgentHostSandboxKey.AuthenticateGit]: { + type: 'boolean', + title: localize('agentHost.config.sandbox.authenticateGit.title', "Authenticate git"), + }, + [AgentHostSandboxKey.AuthenticateGh]: { + type: 'boolean', + title: localize('agentHost.config.sandbox.authenticateGh.title', "Authenticate gh"), + }, [AgentHostSandboxKey.AllowDevToolAccess]: { type: 'boolean', title: localize('agentHost.config.sandbox.allowDevToolAccess.title', "Allow Dev Tool Access"), @@ -166,6 +178,8 @@ export const sandboxSettingIdToAgentHostKey: Readonly; /** - * Channel of the session's default chat, as the recorded history named it. - * - * Resolved from the history rather than derived locally: the host writes whatever channel - * convention it uses (today `/chat`), which need not match the URI a client would - * build for the same chat. + * The host-announced default chat, or a sole recorded chat consistent with all recorded catalogue evidence. + * History without either retains the legacy `/chat` fallback. */ readonly defaultChat: string; /** Timestamp of the last persisted event, ISO 8601. */ @@ -75,6 +67,7 @@ export interface IReplayedTaskHistory { /** Per-session accumulator used while decoding the transport layer. */ interface ISessionReplayState { readonly envelopes: ActionEnvelope[]; + readonly eventsBySeq: Map>; modifiedAt: string; nextSeq: number; reassembler: Reassembler; @@ -154,7 +147,8 @@ function seedChatState(chatChannel: string, modifiedAt: string): ChatState { } /** - * Decode the persisted transport layer into ordered envelopes, grouped by session. + * Decode the persisted transport layer into ordered envelopes, grouped by session, ignoring exact + * duplicate records within each mirror epoch. * * Throws on a genuine sequence gap or a corrupt record — a history that cannot be trusted must * not be shown as if it were complete. @@ -176,10 +170,13 @@ function decodeEvents(events: readonly unknown[]): Map 0; + entry.eventsBySeq.clear(); entry.nextSeq = seq; entry.reassembler = new Reassembler(); } + entry.eventsBySeq.set(seq, value); + entry.modifiedAt = at; entry.nextSeq += 1; let reassembled: unknown; @@ -227,9 +227,7 @@ function decodeEvents(events: readonly unknown[]): Map/chat`, not the `ahp-chat://` URI a client builds), so matching on a scheme would - * silently drop every chat action. The live subscriptions route the same way. + * Routed by action type, not channel scheme, so host-defined chat URIs are preserved. * * A session may own several peer chats, so each chat channel found in the history gets its own fold * — discovered from the envelopes rather than assumed, which keeps forked and peer chats intact. @@ -237,19 +235,18 @@ function decodeEvents(events: readonly unknown[]): Map(); - // The host announces its default chat via `session/defaultChatChanged`; until then the - // deterministic `/chat` is the convention it writes. - let defaultChat = `${session}/chat`; + let hasChatCatalogueEvidence = false; for (const envelope of entry.envelopes) { const channel = envelope.channel; const action: StateAction = envelope.action; if (action.type.startsWith('session/') && channel === session) { + hasChatCatalogueEvidence ||= action.type === ActionType.SessionChatAdded + || action.type === ActionType.SessionChatRemoved + || action.type === ActionType.SessionChatUpdated + || action.type === ActionType.SessionChatsReordered; state = sessionReducer(state, action as SessionAction); - if (action.type === ActionType.SessionDefaultChatChanged) { - defaultChat = (action as SessionDefaultChatChangedAction).defaultChat || `${session}/chat`; - } continue; } if (action.type.startsWith('chat/')) { @@ -260,8 +257,13 @@ function foldSession(session: string, entry: ISessionReplayState): IReplayedSess // are intentionally skipped. } - // A session whose history never announced its chats still owns a default chat, so surface an - // empty one rather than a session that appears to have no conversation at all. + const [recordedChat] = chats.keys(); + const unambiguousChat = chats.size === 1 + && (!hasChatCatalogueEvidence || state.chats.length === 1) + && state.chats.every(chat => + chat.resource === recordedChat && (!chat.origin || chat.origin.kind === ChatOriginKind.User)) + ? recordedChat : undefined; + const defaultChat = state.defaultChat || unambiguousChat || `${session}/chat`; if (!chats.has(defaultChat)) { chats.set(defaultChat, seedChatState(defaultChat, entry.modifiedAt)); } diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 899607fbabe2d8..ab6d32f84f6425 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -1941,17 +1941,17 @@ export class AgentHostStateManager extends Disposable { config: preserveProviderBackedRootConfigValues(this._rootState, action.config), }; } + let singleChatToSynchronize: { readonly chat: URI; readonly isRead: boolean } | undefined; if (action.type === ActionType.SessionIsReadChanged && action.isRead) { const state = this._sessionStates.get(channel)?.state; const aggregateChats = state?.chats.filter(chat => isChatInSessionReadAggregate(chat.resource, chat.origin, chat.interactivity)) ?? []; if (aggregateChats.length > 1 && aggregateChats.some(chat => !isSessionStatusRead(chat.status))) { action = { ...action, isRead: false }; } else if (aggregateChats.length === 1 && !isSessionStatusRead(aggregateChats[0].status)) { - this.dispatchServerAction(aggregateChats[0].resource, { type: ActionType.ChatIsReadChanged, isRead: true }); + singleChatToSynchronize = { chat: aggregateChats[0].resource, isRead: true }; } } - let sessionToMarkUnread: URI | undefined; - let singleChatToSynchronize: { readonly chat: URI; readonly isRead: boolean } | undefined; + let sessionReadStateToSynchronize: { readonly session: URI; readonly isRead: boolean } | undefined; // Apply to state if (isRootAction(action)) { // `RootConfigChanged` can be a true no-op: the reducer merges/replaces @@ -2036,10 +2036,20 @@ export class AgentHostStateManager extends Disposable { this._pruneChatCanvases(channel, new Set(newChat.canvases?.map(canvas => canvas.resource) ?? [])); } this._onChatStateChanged(sessionKey, channel, chat, newChat); - if (chatAction.type === ActionType.ChatIsReadChanged && !chatAction.isRead && isChatInSessionReadAggregate(channel, chatEntry.summary.origin, chatEntry.summary.interactivity)) { + if (chatAction.type === ActionType.ChatIsReadChanged && isChatInSessionReadAggregate(channel, chatEntry.summary.origin, chatEntry.summary.interactivity)) { const session = this._sessionStates.get(sessionKey)?.state; - if (session && isSessionStatusRead(session.status)) { - sessionToMarkUnread = sessionKey; + if (session) { + const sessionIsRead = isSessionStatusRead(session.status); + const chatWasRead = isSessionStatusRead(chat.status); + const chatIsRead = isSessionStatusRead(newChat.status); + if (!chatIsRead && sessionIsRead) { + sessionReadStateToSynchronize = { session: sessionKey, isRead: false }; + } else if (chatIsRead && !chatWasRead && !sessionIsRead + && session.chats + .filter(chat => isChatInSessionReadAggregate(chat.resource, chat.origin, chat.interactivity)) + .every(chat => isSessionStatusRead(chat.status))) { + sessionReadStateToSynchronize = { session: sessionKey, isRead: true }; + } } } resultingState = newChat; @@ -2129,8 +2139,11 @@ export class AgentHostStateManager extends Disposable { if (singleChatToSynchronize) { this.dispatchServerAction(singleChatToSynchronize.chat, { type: ActionType.ChatIsReadChanged, isRead: singleChatToSynchronize.isRead }); } - if (sessionToMarkUnread) { - this.dispatchServerAction(sessionToMarkUnread, { type: ActionType.SessionIsReadChanged, isRead: false }); + if (sessionReadStateToSynchronize) { + this.dispatchServerAction(sessionReadStateToSynchronize.session, { + type: ActionType.SessionIsReadChanged, + isRead: sessionReadStateToSynchronize.isRead, + }); } return resultingState; diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index a2ac0f3104443c..a6eea10c13d132 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -1493,10 +1493,12 @@ export class AgentService extends Disposable implements IAgentService { * to this service so the group stays decoupled from the concrete host. */ private _createSessionServerToolAccessor(): IAgentServiceSessionServerToolAccessor { + const isWorkspaceless = (session: URI) => readSessionWorkspaceless(this._stateManager.getSessionState(session.toString())?._meta); return { getAutomaticTitleGenerationStrategy: session => this._titleController.getAutomaticTitleGenerationStrategy(session), + isWorkspaceless, canConvertWorkspace: session => this._providerService.getProviderForSession(session)?.agentHostCapabilities.workspaceConversion === true - && readSessionWorkspaceless(this._stateManager.getSessionState(session.toString())?._meta), + && isWorkspaceless(session), supportsChatWorkingDirectories: session => !!this._providerService.getProviderForSession(session)?.getDescriptor().capabilities?.multipleWorkingDirectories, listSessions: () => this.listSessions(), getSession: session => this._getSessionMetadata(session), diff --git a/src/vs/platform/agentHost/node/agentServiceFoundation.ts b/src/vs/platform/agentHost/node/agentServiceFoundation.ts index f99218cf21ee7f..c36a9d4c72c55e 100644 --- a/src/vs/platform/agentHost/node/agentServiceFoundation.ts +++ b/src/vs/platform/agentHost/node/agentServiceFoundation.ts @@ -32,6 +32,7 @@ export class AgentServiceCallbackAdapter implements IAgentServiceCallbackBinder readonly sessionServerToolAccessor: IAgentServiceSessionServerToolAccessor = { getAutomaticTitleGenerationStrategy: session => this.value.sessionServerToolAccessor.getAutomaticTitleGenerationStrategy(session), + isWorkspaceless: session => this.value.sessionServerToolAccessor.isWorkspaceless(session), canConvertWorkspace: session => this.value.sessionServerToolAccessor.canConvertWorkspace(session), supportsChatWorkingDirectories: session => this.value.sessionServerToolAccessor.supportsChatWorkingDirectories(session), listSessions: () => this.value.sessionServerToolAccessor.listSessions(), diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 88fe924af5f5b3..aa4bb617b0c65f 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -114,6 +114,7 @@ import { CopilotMcpToolRoutingCache, getMcpRoutingCacheKey, type ICopilotMcpRout import { getCopilotCustomizationCommandHandler } from './copilotCustomizationCommandDisplay.js'; import { renderCopilotSlashCommandOutput, type RuntimeSlashCommandInfo } from './copilotSlashCommand.js'; import { CopilotSandboxPolicyDisplay } from './copilotSandboxPolicyDisplay.js'; +import { toSlashCommandResourceMeta } from '../../common/meta/agentSlashCommandOutputMeta.js'; import { createCopilotFailureCorrelation, reportCopilotModelCallFailure, reportCopilotSdkSessionError } from './copilotFailureTelemetry.js'; import { reportCopilotTodoStoreOperation } from './copilotTodoStoreTelemetry.js'; import { ModelCallTurnCorrelation } from './modelCallTurnCorrelation.js'; @@ -175,6 +176,10 @@ interface IPendingMcpAuthRequest { readonly toolCalls: IMcpAuthToolCall[]; } +type SubagentTurnOutcome = + | { readonly kind: 'failed'; readonly message: string; readonly durationMs: number | undefined } + | { readonly kind: 'cancelled'; readonly durationMs: number | undefined }; + interface IMcpAuthToolCall { readonly turnId: string; readonly toolCallId: string; @@ -948,6 +953,7 @@ export class CopilotAgentSession extends Disposable { private readonly _autoModeResolvedByToolCallId = new Map>(); private readonly _activeSubagentAgentIds = new Set(); private readonly _subagentTaskCompletionSchedulers = this._register(new DisposableMap()); + private readonly _pendingSubagentLifecycleCompletions = new Map(); private readonly _subagentTaskCompletionDelay: number; /** Bumped when a child starts a model round so a task snapshot taken earlier cannot complete it; entries are dropped on completion. */ private readonly _subagentActivityRevisions = new Map(); @@ -1276,6 +1282,7 @@ export class CopilotAgentSession extends Disposable { /** One-shot SDK callbacks, keyed by request id; answering one delivers a token but does not confirm acceptance. */ private readonly _pendingMcpAuthRequests = new PendingRequestRegistry(); private _requiresConnectorConfigurationRefresh = false; + private readonly _mcpServerDisplayNames = new Map(); /** * Retains challenge metadata and its latest callback id so token delivery can report Starting. * Connected and needs-auth statuses remain the final lifecycle authority. @@ -1831,6 +1838,7 @@ export class CopilotAgentSession extends Disposable { private _completeSubagentTurn(agentId: string | undefined, toolCallId?: string): void { if (agentId) { this._subagentTaskCompletionSchedulers.deleteAndDispose(agentId); + this._pendingSubagentLifecycleCompletions.delete(agentId); this._subagentActivityRevisions.delete(agentId); if (!this._activeSubagentAgentIds.delete(agentId)) { return; @@ -1870,18 +1878,7 @@ export class CopilotAgentSession extends Disposable { this._autoModeResolvedByToolCallId.delete(parentToolCallId); } - private _scheduleSubagentTurnCompletion(agentId: string, toolCallId?: string): void { - if (!this._activeSubagentAgentIds.has(agentId)) { - return; - } - const parentToolCallId = toolCallId ?? this._parentToolCallIdsByAgentId.get(agentId); - if (!parentToolCallId) { - return; - } - if (this._subagentTaskCompletionDelay <= 0) { - this._completeSubagentTurn(agentId, parentToolCallId); - return; - } + private _getOrCreateSubagentCompletionScheduler(agentId: string): RunOnceScheduler { let scheduler = this._subagentTaskCompletionSchedulers.get(agentId); if (!scheduler) { const confirmation = new RunOnceScheduler(() => { @@ -1901,9 +1898,84 @@ export class CopilotAgentSession extends Disposable { scheduler = confirmation; this._subagentTaskCompletionSchedulers.set(agentId, scheduler); } + return scheduler; + } + + private _scheduleSubagentTurnCompletion(agentId: string, toolCallId?: string, outcome?: SubagentTurnOutcome): void { + if (!this._activeSubagentAgentIds.has(agentId)) { + return; + } + const parentToolCallId = toolCallId ?? this._parentToolCallIdsByAgentId.get(agentId); + if (!parentToolCallId) { + return; + } + if (this._subagentTaskCompletionDelay <= 0) { + this._settleSubagentTurn(agentId, parentToolCallId, outcome); + return; + } + const scheduler = this._getOrCreateSubagentCompletionScheduler(agentId); scheduler.schedule(); } + private _scheduleSubagentLifecycleCompletion(agentId: string | undefined, toolCallId: string, outcome?: SubagentTurnOutcome): void { + const mappedAgentId = agentId ?? [...this._parentToolCallIdsByAgentId].find(([, parentToolCallId]) => parentToolCallId === toolCallId)?.[0]; + if (!mappedAgentId) { + if (this._rootTurnIdBySubagentToolCallId.has(toolCallId)) { + this._emitSubagentOutcome(toolCallId, outcome); + this._completeSubagentTurn(undefined, toolCallId); + } + return; + } + if (!this._activeSubagentAgentIds.has(mappedAgentId)) { + return; + } + this._pendingSubagentLifecycleCompletions.set(mappedAgentId, { + toolCallId, + activityRevision: this._subagentActivityRevisions.get(mappedAgentId), + outcome, + }); + this._getOrCreateSubagentCompletionScheduler(mappedAgentId).schedule(); + } + + /** Completes an active child turn, first ending it as failed or cancelled when it did not succeed. */ + private _settleSubagentTurn(agentId: string, toolCallId: string, outcome: SubagentTurnOutcome | undefined): void { + if (this._activeSubagentAgentIds.has(agentId)) { + this._emitSubagentOutcome(toolCallId, outcome); + } + this._completeSubagentTurn(agentId, toolCallId); + } + + /** Task status is authoritative; a pending lifecycle event only supplies details the status lacks. */ + private _taskOutcome(task: Extract>['tasks'][number], { type: 'agent' }>): SubagentTurnOutcome | undefined { + const pending = this._pendingSubagentLifecycleCompletions.get(task.id)?.outcome; + switch (task.status) { + case 'failed': + return pending?.kind === 'failed' ? pending : { kind: 'failed', message: task.error ?? localize('copilotAgent.subagentFailed', "Subagent failed."), durationMs: task.activeTimeMs }; + case 'cancelled': + return pending?.kind === 'cancelled' ? pending : { kind: 'cancelled', durationMs: task.activeTimeMs }; + default: + return undefined; + } + } + + /** Ends the child turn as failed or cancelled; the completion that follows then finds no active turn. */ + private _emitSubagentOutcome(toolCallId: string, outcome: SubagentTurnOutcome | undefined): void { + if (outcome?.kind === 'failed') { + this._emitAction({ + type: ActionType.ChatError, + turnId: this._turnId, + duration: outcome.durationMs ?? 0, + part: createErrorResponsePart({ errorType: 'subagentFailed', message: outcome.message }), + }, toolCallId); + } else if (outcome?.kind === 'cancelled') { + this._emitAction({ + type: ActionType.ChatTurnCancelled, + turnId: this._turnId, + duration: outcome.durationMs ?? 0, + }, toolCallId); + } + } + private _observeTokenUsage(parentToolCallId: string | undefined, eventId: string, model: string | undefined, scope: 'direct-model' | 'compaction', tokens: UsageContext, reasoningEffort?: string, apiCallId?: string): void { const recordId = apiCallId ? `api:${apiCallId}` : eventId ? `event:${eventId}` : undefined; const identity = recordId ? `${scope}\0${recordId}` : undefined; @@ -1961,10 +2033,12 @@ export class CopilotAgentSession extends Disposable { return false; } this._publishBackgroundWork(tasks.tasks); + const agentTaskIds = new Set(); for (const task of tasks.tasks) { if (task.type !== 'agent') { continue; } + agentTaskIds.add(task.id); const displayName = subagentChatTitle(task.description, task.displayName); if (displayName && !this._subagentDisplayNamesByAgentId.get(task.id)?.trim()) { this._subagentDisplayNamesByAgentId.set(task.id, displayName); @@ -1975,14 +2049,29 @@ export class CopilotAgentSession extends Disposable { if (task.status === 'idle' || task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') { const scheduler = this._subagentTaskCompletionSchedulers.get(task.id); if (scheduler && !scheduler.isScheduled()) { - this._completeSubagentTurn(task.id, task.toolCallId); + this._settleSubagentTurn(task.id, task.toolCallId, this._taskOutcome(task)); } else if (!scheduler) { - this._scheduleSubagentTurnCompletion(task.id, task.toolCallId); + this._scheduleSubagentTurnCompletion(task.id, task.toolCallId, this._taskOutcome(task)); } } else { + this._pendingSubagentLifecycleCompletions.delete(task.id); this._subagentTaskCompletionSchedulers.deleteAndDispose(task.id); } } + for (const [agentId, pending] of this._pendingSubagentLifecycleCompletions) { + if (agentTaskIds.has(agentId)) { + continue; + } + if (pending.activityRevision !== this._subagentActivityRevisions.get(agentId)) { + this._pendingSubagentLifecycleCompletions.delete(agentId); + this._subagentTaskCompletionSchedulers.deleteAndDispose(agentId); + continue; + } + const scheduler = this._subagentTaskCompletionSchedulers.get(agentId); + if (scheduler && !scheduler.isScheduled()) { + this._settleSubagentTurn(agentId, pending.toolCallId, pending.outcome); + } + } return true; }); } @@ -2510,6 +2599,19 @@ export class CopilotAgentSession extends Disposable { }, parentToolCallId, trustedRootTurn); } + private _emitSlashCommandOutput(content: string, meta?: Record): void { + const turn = this._currentTurn.value; + if (!meta || !turn) { + this._emitMarkdownDelta(content, undefined, true); + return; + } + this._emitAction({ + type: ActionType.ChatResponsePart, + turnId: turn.id, + part: { kind: ResponsePartKind.SystemNotification, content: { markdown: content }, _meta: meta }, + }, undefined, true); + } + /** Emits a reasoning delta, similar to {@link _emitMarkdownDelta} but for reasoning parts. */ private _emitReasoningDelta(content: string, parentToolCallId?: string): void { if (parentToolCallId === undefined && this._shouldDropLateRootTurnEvent('assistant.reasoning_delta')) { @@ -3112,6 +3214,7 @@ export class CopilotAgentSession extends Disposable { handleExitPlanModeRequest: this._guarded((request, invocation) => this._handleExitPlanModeRequest(request, invocation), { approved: false } satisfies CopilotExitPlanModeResponse, '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'), + setMcpServerDisplayNames: displayNames => this._setMcpServerDisplayNames(displayNames), 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: toolSearchActive => this._createClientSdkTools(toolSearchActive), @@ -3123,6 +3226,17 @@ export class CopilotAgentSession extends Disposable { }; } + private _setMcpServerDisplayNames(displayNames: ReadonlyMap): void { + this._mcpServerDisplayNames.clear(); + for (const [serverName, displayName] of displayNames) { + this._mcpServerDisplayNames.set(serverName, displayName); + const state = this._mcpCustomizations.stateForServer(serverName); + if (state) { + this._mcpCustomizations.applyOne({ name: serverName, displayName, state }); + } + } + } + /** Resolves only matching, currently pending SDK authentication callbacks. */ async resolveMcpAuthentication(params: AuthenticateParams): Promise { let resolved = false; @@ -3206,6 +3320,7 @@ export class CopilotAgentSession extends Disposable { }); this._mcpCustomizations.applyOne({ name: request.serverName, + displayName: this._mcpServerDisplayNames.get(request.serverName), state: { kind: McpServerStatus.AuthRequired, ...auth, @@ -3727,14 +3842,17 @@ export class CopilotAgentSession extends Disposable { } const output = await runtimeSlashCommand.getOutput?.(slashCommand.rest, result); const renderedOutput = output ? renderCopilotSlashCommandOutput(output) : undefined; + const outputMeta = output?.kind === 'link' && output.openInEditor + ? toSlashCommandResourceMeta(output.resource, output.preview === true) + : undefined; switch (result.kind) { case 'text': - this._emitMarkdownDelta(renderedOutput ?? (result.markdown === true ? result.text : escapeMarkdownSyntaxTokens(result.text)), undefined, true); + this._emitSlashCommandOutput(renderedOutput ?? (result.markdown === true ? result.text : escapeMarkdownSyntaxTokens(result.text)), outputMeta); break; case 'completed': { const message = renderedOutput ?? result.message; if (message) { - this._emitMarkdownDelta(message, undefined, true); + this._emitSlashCommandOutput(message, outputMeta); } break; } @@ -7049,6 +7167,14 @@ export class CopilotAgentSession extends Disposable { this._updateSubagentModel(e.data.toolCallId, e.data.model); })); + this._register(wrapper.onSubagentCompleted(e => { + this._scheduleSubagentLifecycleCompletion(e.agentId, e.data.toolCallId, e.data.cancelled ? { kind: 'cancelled', durationMs: e.data.durationMs } : undefined); + })); + + this._register(wrapper.onSubagentFailed(e => { + this._scheduleSubagentLifecycleCompletion(e.agentId, e.data.toolCallId, { kind: 'failed', message: e.data.error, durationMs: e.data.durationMs }); + })); + this._register(wrapper.onSubagentConfigured(e => { const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); if (!parentToolCallId) { @@ -7832,6 +7958,7 @@ export class CopilotAgentSession extends Disposable { : {}; return { name: server.name, + displayName: this._mcpServerDisplayNames.get(server.name), state: this._translateSdkMcpStatus(server.name, server.status, server.error, hasPendingAuthentication), ...(server.status === 'pending' && !hasPendingAuthentication ? { allowAuthRequiredToStarting: true } : {}), enabled: server.status !== 'disabled' && server.status !== 'not_configured', @@ -8622,6 +8749,7 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onTurnStart(e => { if (e.agentId) { this._subagentActivityRevisions.set(e.agentId, (this._subagentActivityRevisions.get(e.agentId) ?? 0) + 1); + this._pendingSubagentLifecycleCompletions.delete(e.agentId); this._subagentTaskCompletionSchedulers.deleteAndDispose(e.agentId); } const turn = this._currentTurn.value; diff --git a/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts b/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts index 4548df067b801e..f614b36ce4e2dc 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts @@ -225,7 +225,7 @@ type ModelCallTurnCorrelatedClassification = { "interaction_id": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Identifier that correlates events in an interaction." }, "engagement_id": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Identifier that correlates events in an engagement." }, "duration_ms": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Wall-clock duration of the tool execution in milliseconds.", "isMeasurement": true }, - "result_token_count": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Token count of the model-facing tool result, measured with the model tokenizer. Present only for successful results.", "isMeasurement": true }, + "result_token_count": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Local runtime estimate of the tokens in the tool's explicit model-facing text output (o200k_base scaled by a per-model multiplier); not an exact model-tokenizer count or billed usage. Reported only for successful text results up to 20 KiB without binary or structured-reference content; absence does not mean zero.", "isMeasurement": true }, "binary_result_count": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of binary result parts returned by the tool.", "isMeasurement": true }, "binary_result_total_bytes": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Total byte size of binary result parts returned by the tool.", "isMeasurement": true } } diff --git a/src/vs/platform/agentHost/node/copilot/copilotSandboxDiagnostics.ts b/src/vs/platform/agentHost/node/copilot/copilotSandboxDiagnostics.ts index ab0074ae2bdfcf..6c1425c145e9ec 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSandboxDiagnostics.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSandboxDiagnostics.ts @@ -45,8 +45,9 @@ export class CopilotSandboxDiagnostics extends Disposable { const network = config.userPolicy?.network; reasons = support.capabilities.filter(capability => !capability.supported && ( capability.name === 'shell' + || capability.name === 'filesystem_enumeration' || capability.name === 'network' && network?.allowOutbound === true - || capability.name === 'network_filtering' && network?.proxy !== undefined + || capability.name === 'network_filtering' || capability.name === 'denied_paths' && !!config.userPolicy?.filesystem?.deniedPaths?.length )).map(capability => capability.reason || localize('copilot.sandbox.capabilityUnavailable', "The sandbox capability '{0}' is unavailable on this host.", capability.name)); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotSandboxPolicy.ts b/src/vs/platform/agentHost/node/copilot/copilotSandboxPolicy.ts index 1a89bf5c573ad0..d42e0b217a7995 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSandboxPolicy.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSandboxPolicy.ts @@ -30,6 +30,8 @@ export function projectCopilotSandboxPolicy(data: Extract; handleUserInputRequest(request: UserInputRequest, invocation: UserInputInvocation): Promise; handleElicitationRequest(context: ElicitationContext): Promise; + setMcpServerDisplayNames(displayNames: ReadonlyMap): void; handleMcpAuthRequest(request: McpAuthRequest, context: McpAuthContext): Promise; requestUnsandboxedCommandConfirmation(request: IUnsandboxedCommandConfirmationRequest): Promise; handlePreToolUse(input: PreToolUseHookInput): Promise; @@ -706,7 +707,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { } }; if (plan.kind === 'create') { - return this._createSession(plan, config, sandboxConfig); + return this._createSession(plan, config, sandboxConfig, runtime); } let fallbackPlan = plan; @@ -714,7 +715,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { const session = AgentSession.uri('copilotcli', plan.sessionId); try { const raw = await this._resumeSession(session, plan, config); - return this._finalizeSession(raw, sandboxConfig, plan, plan.fallback.model?.id, config); + return this._finalizeSession(raw, sandboxConfig, plan, plan.fallback.model?.id, config, runtime); } catch (err) { let resumeError = err; const errCode = getCopilotSdkErrorCode(resumeError); @@ -726,7 +727,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { this._logService.warn(`[Copilot:${plan.sessionId}] Stored custom agent '${plan.resolvedAgentName}' was not found; retrying resume without a custom agent`); try { const raw = await this._resumeSession(session, fallbackPlan, fallbackConfig); - return this._finalizeSession(raw, sandboxConfig, fallbackPlan, fallbackPlan.fallback.model?.id, fallbackConfig); + return this._finalizeSession(raw, sandboxConfig, fallbackPlan, fallbackPlan.fallback.model?.id, fallbackConfig, runtime); } catch (retryErr) { resumeError = retryErr; this._logService.warn(`[Copilot:${plan.sessionId}] SDK resumeSession without custom agent failed: code=${getCopilotSdkErrorCode(retryErr)}, message=${getErrorMessage(retryErr)}`); @@ -747,7 +748,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { model: fallbackPlan.fallback.model, longContextWindow: fallbackPlan.fallback.longContextWindow, freeLongContext: fallbackPlan.fallback.freeLongContext, - }, fallbackConfig, sandboxConfig); + }, fallbackConfig, sandboxConfig, runtime); this._sessionOpenTelemetry.sdkResumeFallbackCreated(session); this._logService.info(`[Copilot:${plan.sessionId}] Fallback createSession succeeded`); return wrapper; @@ -776,7 +777,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { return this._otelService.withTraceContext(this._otelService.getSessionTraceContext(sessionId, sessionUri), fn); } - private async _createSession(plan: ICopilotCreateSessionLaunchPlan, config: ResumeSessionConfig, sandboxConfig: (session: CopilotSessionWrapper['session']) => Promise): Promise { + private async _createSession(plan: ICopilotCreateSessionLaunchPlan, config: ResumeSessionConfig, sandboxConfig: (session: CopilotSessionWrapper['session']) => Promise, runtime: ICopilotSessionRuntime): Promise { const raw = await this._withTraceContext(plan.sessionId, () => plan.client.createSession({ ...config, sessionId: plan.sessionId, @@ -787,15 +788,15 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { ...(plan.resolvedAgentName ? { agent: plan.resolvedAgentName } : {}), workingDirectory: plan.workingDirectory?.fsPath, })); - return this._finalizeSession(raw, sandboxConfig, plan, plan.model?.id, config); + return this._finalizeSession(raw, sandboxConfig, plan, plan.model?.id, config, runtime); } - private async _finalizeSession(raw: CopilotSessionWrapper['session'], sandboxConfig: (session: CopilotSessionWrapper['session']) => Promise, plan: CopilotSessionLaunchPlan, modelId: string | undefined, config: ResumeSessionConfig): Promise { + private async _finalizeSession(raw: CopilotSessionWrapper['session'], sandboxConfig: (session: CopilotSessionWrapper['session']) => Promise, plan: CopilotSessionLaunchPlan, modelId: string | undefined, config: ResumeSessionConfig, runtime: ICopilotSessionRuntime): Promise { plan.stageRecorder?.mark('finalize'); try { await this._applyScriptSafety(raw, plan.sessionId); await sandboxConfig(raw); - await this._reconcileCopilotConnectors(raw, plan); + runtime.setMcpServerDisplayNames(await this._reconcileCopilotConnectors(raw, plan)); } catch (err) { // Nothing owns `raw` until it is wrapped below, so a fail-closed launch has // to disconnect it here or the runtime keeps an orphaned session alive. @@ -809,15 +810,16 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { return new CopilotSessionWrapper(raw, config.requestCanvasRenderer === true, { providers: config.providers, models: config.models }, this._logService); } - private async _reconcileCopilotConnectors(session: CopilotSessionWrapper['session'], plan: CopilotSessionLaunchPlan): Promise { + private async _reconcileCopilotConnectors(session: CopilotSessionWrapper['session'], plan: CopilotSessionLaunchPlan): Promise> { + const displayNames = new Map(); if (this._configurationService.getRootValue(platformRootSchema, AgentHostMcpConnectorsEnabledConfigKey) !== true) { - return; + return displayNames; } try { const capabilities = await session.rpc.connectors.getCapabilities(); if (capabilities.availability !== 'enabled') { this._logService.info(`[Copilot:${plan.sessionId}] Connector MCP reconciliation unavailable: ${capabilities.availability}`); - return; + return displayNames; } const token = plan.githubCredentials.token; const auth = await session.rpc.gitHubAuth.getStatus(); @@ -837,13 +839,18 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { } if (!account?.selectionId) { this._logService.warn(`[Copilot:${plan.sessionId}] Connector MCP reconciliation skipped because the session account could not be resolved`); - return; + return displayNames; } const status = await session.rpc.connectors.reconcile({ accountId: account.selectionId, refreshCatalog: true }); + const connectorDisplayNames = new Map(status.catalog?.connectors.map(connector => [connector.name, connector.displayName.trim() || connector.name])); + for (const server of status.runtimeServers) { + displayNames.set(server.runtimeServerId, connectorDisplayNames.get(server.connectorName) ?? server.connectorName); + } this._logService.info(`[Copilot:${plan.sessionId}] Reconciled ${status.runtimeServers.length} connector MCP server(s) through the Copilot runtime`); } catch (error) { this._logService.warn(`[Copilot:${plan.sessionId}] Connector MCP reconciliation failed; continuing without connector tools: ${getErrorMessage(error)}`); } + return displayNames; } /** diff --git a/src/vs/platform/agentHost/node/copilot/copilotSlashCommand.ts b/src/vs/platform/agentHost/node/copilot/copilotSlashCommand.ts index f31d5ec5b1ef29..2b0be375fed2c2 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSlashCommand.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSlashCommand.ts @@ -13,7 +13,7 @@ export type CopilotSlashCommandResult = Awaited { - if (shouldLogFailedRequest(method, params, err)) { + if (err instanceof SubscriptionCancelledError) { + this._logService.trace(`[ProtocolServer] Request '${method}' id=${id} cancelled`, err.message); + } else if (shouldLogFailedRequest(method, params, err)) { this._logService.error(`[ProtocolServer] Request '${method}' failed`, err); } client.transport.send(jsonRpcErrorFrom(id, err)); diff --git a/src/vs/platform/agentHost/node/sessionSandbox.ts b/src/vs/platform/agentHost/node/sessionSandbox.ts index 50f357226fd802..b4104836d45ac6 100644 --- a/src/vs/platform/agentHost/node/sessionSandbox.ts +++ b/src/vs/platform/agentHost/node/sessionSandbox.ts @@ -38,7 +38,7 @@ export function getSessionSandboxConfig(configuration: IAgentConfigurationServic } /** Resolves the owning session's toggle overrides without changing global settings. */ -export function getSessionSandboxOverrides(configuration: IAgentConfigurationService, session: string): Pick { +export function getSessionSandboxOverrides(configuration: IAgentConfigurationService, session: string): Pick { session = resolveAgentHostSession(URI.parse(session)).toString(); const raw = configuration.getSessionConfigValues(session)?.[SessionConfigKey.SandboxEnabled]; const selection = platformSessionSchema.validate(SessionConfigKey.SandboxEnabled, raw) ? raw : undefined; @@ -54,6 +54,8 @@ export function getSessionSandboxOverrides(configuration: IAgentConfigurationSer const allowDevToolAccess = SandboxSettingsResolutionHelper.resolveAllowAccess(undefined, policy?.allowDevToolAccess); const sandboxMcpServers = SandboxSettingsResolutionHelper.resolveSandboxServers(undefined, policy?.sandboxMcpServers); const sandboxLspServers = SandboxSettingsResolutionHelper.resolveSandboxServers(undefined, policy?.sandboxLspServers); + const authenticateGit = SandboxSettingsResolutionHelper.resolveAllowAccess(undefined, policy?.authenticateGit); + const authenticateGh = SandboxSettingsResolutionHelper.resolveAllowAccess(undefined, policy?.authenticateGh); return { ...(enabled !== undefined ? { [AgentHostSandboxKey.Enabled]: enabled, @@ -64,5 +66,7 @@ export function getSessionSandboxOverrides(configuration: IAgentConfigurationSer ...(allowDevToolAccess !== undefined ? { [AgentHostSandboxKey.AllowDevToolAccess]: allowDevToolAccess } : {}), ...(sandboxMcpServers !== undefined ? { [AgentHostSandboxKey.SandboxMcpServers]: sandboxMcpServers } : {}), ...(sandboxLspServers !== undefined ? { [AgentHostSandboxKey.SandboxLspServers]: sandboxLspServers } : {}), + ...(authenticateGit !== undefined ? { [AgentHostSandboxKey.AuthenticateGit]: authenticateGit } : {}), + ...(authenticateGh !== undefined ? { [AgentHostSandboxKey.AuthenticateGh]: authenticateGh } : {}), }; } diff --git a/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts b/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts index 08fdde060172a3..ef1c1cc6f3640f 100644 --- a/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts +++ b/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts @@ -10,7 +10,7 @@ import { URI } from '../../../../base/common/uri.js'; import { AgentSession } from '../../common/agent.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; import { isCustomizationEnabled } from '../../common/customizationEnablement.js'; -import { McpServerSource, readMcpServerSource, withMcpServerSourceMeta } from '../../common/meta/mcpCustomizationMeta.js'; +import { McpServerSource, readMcpServerDisplayName, readMcpServerSource, withMcpServerDisplayNameMeta, withMcpServerSourceMeta } from '../../common/meta/mcpCustomizationMeta.js'; import { CustomizationLoadStatus, CustomizationType, McpServerStatus, type AhpMcpUiHostCapabilities, type Customization, type CustomizationEnablement, type McpServerCustomization, type McpServerState } from '../../common/state/protocol/channels-session/state.js'; import { DEFAULT_MCP_APP, DEFAULT_MCP_APP_CAPABILITIES } from '../../common/state/protocol/mcpAppDefaults.js'; import { parseChatUri } from '../../common/state/sessionState.js'; @@ -26,6 +26,8 @@ import { AgentHostStateManager, IAgentHostStateManager } from '../agentHostState export interface ISdkMcpServer { /** Server name (used both as the customization name and the channel suffix). */ readonly name: string; + /** Optional Connector catalog name that does not participate in runtime identity. */ + readonly displayName?: string; readonly source?: McpServerSource; /** Configuration file URI. Omitted on lifecycle updates; null clears a previously known source. */ readonly sourceUri?: string | null; @@ -131,6 +133,7 @@ export interface IMcpCustomizationControllerOptions { interface ILiveEntry { readonly serverName: string; + readonly displayName?: string; readonly state: McpServerState; readonly enabled: boolean; readonly publishedId: string; @@ -223,7 +226,7 @@ export class McpCustomizationController extends Disposable { if (entry.topLevelId === undefined) { continue; } - out.push(this._buildTopLevel(entry.topLevelId, entry.serverName, entry.state, entry.enabled, readMcpServerSource(entry.topLevelCustomization), entry.topLevelCustomization?.uri)); + out.push(this._buildTopLevel(entry.topLevelId, entry.serverName, entry.displayName, entry.state, entry.enabled, readMcpServerSource(entry.topLevelCustomization), entry.topLevelCustomization?.uri)); } return out; } @@ -359,6 +362,7 @@ export class McpCustomizationController extends Disposable { const previous = this._live.get().get(server.name); const state = this._stateForUpdate(previous?.state, server.state, server.allowAuthRequiredToStarting === true); const enabled = server.enabled ?? previous?.enabled ?? true; + const displayName = server.displayName ?? previous?.displayName; // Once promoted to a top-level entry, stay top-level for the // session — flipping back to a child mid-stream would orphan the // previously-published top-level id. @@ -372,8 +376,8 @@ export class McpCustomizationController extends Disposable { const childContainerId = published?.childContainerId ?? (retainChildIdentity ? previous?.childContainerId : undefined); if (childId !== undefined) { const stateChanged = force || previous?.topLevelId !== undefined || previous?.publishedId !== childId || previous?.childContainerId !== childContainerId || !equals(previous?.state, state); - if (stateChanged || previous?.enabled !== enabled) { - this._setLiveEntry(server.name, { serverName: server.name, state, enabled, publishedId: childId, topLevelId: undefined, childContainerId }, tx); + if (stateChanged || previous?.enabled !== enabled || previous?.displayName !== displayName) { + this._setLiveEntry(server.name, { serverName: server.name, displayName, state, enabled, publishedId: childId, topLevelId: undefined, childContainerId }, tx); } if (!stateChanged) { return; @@ -390,10 +394,11 @@ export class McpCustomizationController extends Disposable { } const source = server.source ?? readMcpServerSource(previous?.topLevelCustomization); const sourceUri = server.sourceUri !== undefined ? server.sourceUri : previous?.topLevelCustomization?.uri; - const customization = this._buildTopLevel(topLevelId, server.name, state, enabled, source, sourceUri); + const customization = this._buildTopLevel(topLevelId, server.name, displayName, state, enabled, source, sourceUri); + const resolvedDisplayName = readMcpServerDisplayName(customization); const customizationChanged = force || previous?.topLevelId !== topLevelId || !equals(previous?.topLevelCustomization, customization); if (customizationChanged || previous?.enabled !== enabled) { - this._setLiveEntry(server.name, { serverName: server.name, state, enabled, publishedId: topLevelId, topLevelId, topLevelCustomization: customization }, tx); + this._setLiveEntry(server.name, { serverName: server.name, displayName: resolvedDisplayName, state, enabled, publishedId: topLevelId, topLevelId, topLevelCustomization: customization }, tx); } if (!customizationChanged) { return; @@ -511,7 +516,7 @@ export class McpCustomizationController extends Disposable { return buildMcpChannel(this._chatUri, serverName); } - private _buildTopLevel(id: string, serverName: string, state: McpServerState, enabled: boolean, source?: McpServerSource, sourceUri?: string | null): McpServerCustomization { + private _buildTopLevel(id: string, serverName: string, displayName: string | undefined, state: McpServerState, enabled: boolean, source?: McpServerSource, sourceUri?: string | null): McpServerCustomization { const channel = this._buildChannel(serverName, state); const owningPluginUri = this.pluginMcpServerSources?.get(serverName); // Per AHP spec, `mcpApp` is a static capability declaration — @@ -525,7 +530,7 @@ export class McpCustomizationController extends Disposable { const existing = getMcpServerCustomizations(this._stateManager.getSessionState(this._sessionUri.toString())?.customizations ?? []) .find(customization => customization.id === id); // `SessionCustomizationUpdated` replaces the whole customization, so keep opaque entries owned by others. - const meta = withMcpServerSourceMeta(existing?._meta, source); + const meta = withMcpServerDisplayNameMeta(withMcpServerSourceMeta(existing?._meta, source), displayName); const uri = (sourceUri === undefined ? existing?.uri : sourceUri) ?? this._mintTopLevelId(serverName); const customization: McpServerCustomization = { type: CustomizationType.McpServer, diff --git a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts index 6696a71173f34d..ed09ff791af56e 100644 --- a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts @@ -308,6 +308,7 @@ export interface IPreparedChatWorkingDirectory { /** AgentService-owned operations used by the session server-tool group. */ export interface IAgentServiceSessionServerToolAccessor { readonly getAutomaticTitleGenerationStrategy: (session?: ProtocolURI) => AutomaticTitleGenerationStrategy; + readonly isWorkspaceless: (session: URI) => boolean; readonly canConvertWorkspace: (session: URI) => boolean; /** * Whether the session's provider supports multiple working directories, so a @@ -1839,6 +1840,9 @@ export function createSessionServerToolGroup(accessor?: ISessionServerToolAccess return toolName !== SessionServerToolName.RenameChat || accessor?.getAutomaticTitleGenerationStrategy(sessionUri) !== 'utility'; }, getDefinitionForSession(definition, sessionUri): IAgentServerToolDefinition { + if (definition.name === SessionServerToolName.SetWorkspace && accessor?.isWorkspaceless(URI.parse(sessionUri)) === true) { + return { ...definition, deferLoading: false }; + } if (definition.name === SessionServerToolName.CreateSession) { // Without multi-root support a current-session chat cannot get its own folder. return accessor?.supportsChatWorkingDirectories(URI.parse(sessionUri)) === false diff --git a/src/vs/platform/agentHost/test/common/agentHostSessionType.test.ts b/src/vs/platform/agentHost/test/common/agentHostSessionType.test.ts index 3cf209d292ccef..11e072a68b18c8 100644 --- a/src/vs/platform/agentHost/test/common/agentHostSessionType.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostSessionType.test.ts @@ -5,11 +5,36 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { findRemoteAgentHostSessionTypeAuthority, isRemoteAgentHostSessionType, parseAgentHostHarness, parseRemoteAgentHostHarness, parseRemoteAgentHostSessionTypeAuthority, remoteAgentHostSessionTypeAuthorityPrefix, remoteAgentHostSessionTypeId } from '../../common/agentHostSessionType.js'; +import { findRemoteAgentHostSessionTypeAuthority, isCopilotAgentHostProvider, isCopilotAgentHostSessionType, isRemoteAgentHostSessionType, parseAgentHostHarness, parseRemoteAgentHostHarness, parseRemoteAgentHostSessionTypeAuthority, remoteAgentHostSessionTypeAuthorityPrefix, remoteAgentHostSessionTypeId } from '../../common/agentHostSessionType.js'; suite('agentHostSessionType', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('recognizes both Copilot provider IDs without matching other providers', () => { + assert.deepStrictEqual( + ['copilotcli', 'copilot', 'claude', 'codex', 'copilotcloud', undefined].map(isCopilotAgentHostProvider), + [true, true, false, false, false, false], + ); + }); + + test('recognizes local and remote Copilot session types without changing their schemes', () => { + assert.deepStrictEqual([ + 'agent-host-copilotcli', + 'agent-host-copilot', + 'remote-dev-box-copilotcli', + 'remote-cloudsandbox_environment-one-copilot', + 'agent-host-claude', + 'remote-dev-box-codex', + 'copilotcli', + 'copilot', + 'copilotcloud', + 'remote--copilot', + 'remote-dev-box-copilot-other', + ].map(isCopilotAgentHostSessionType), [ + true, true, true, true, false, false, false, false, false, false, false, + ]); + }); + test('remoteAgentHostSessionTypeId pins the wire format', () => { assert.deepStrictEqual([ remoteAgentHostSessionTypeId('foo', 'copilot'), diff --git a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts index e69da2ead2257e..a2f54c305e6172 100644 --- a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts +++ b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts @@ -9,10 +9,11 @@ import { type AgentFusionPhaseStatus, isPresentationOnlyToolCall, readToolCallMe import { AgentSystemNotificationKind, type AgentFusionProgressStatus, readAgentSystemNotificationMeta, toAgentSystemNotificationMeta } from '../../common/meta/agentSystemNotificationMeta.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; import { readSessionSandboxPolicy, withSessionSandboxPolicy } from '../../common/meta/agentSandboxPolicyMeta.js'; +import { readSlashCommandResource, toSlashCommandResourceMeta } from '../../common/meta/agentSlashCommandOutputMeta.js'; import { readChatInputState, withChatInputState } from '../../common/meta/agentHostChatInputState.js'; import { createEditorInlineChatInstruction, createTerminalChatInstruction, readChatSurfaceMeta, withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; import { readAgentCustomizationMeta, toAgentCustomizationMeta } from '../../common/meta/agentCustomizationMeta.js'; -import { readMcpServerSource, withMcpServerSourceMeta } from '../../common/meta/mcpCustomizationMeta.js'; +import { readMcpServerDisplayName, readMcpServerSource, withMcpServerDisplayNameMeta, withMcpServerSourceMeta } from '../../common/meta/mcpCustomizationMeta.js'; import { getCommandArgumentHint, getCompletionAction, readCompletionAttachmentMeta, toCommandCompletionAttachmentMeta, toSkillCompletionAttachmentMeta } from '../../common/meta/agentCompletionAttachmentMeta.js'; import { CustomizationType, MessageAttachmentKind, ToolCallStatus, hasReportedUsage, readSessionComparisonMetadata, readUsageInfoMeta, withSessionComparisonMetadata, type AgentCustomization, type ClientPluginCustomization, type ToolCallState, type UsageInfo } from '../../common/state/sessionState.js'; import { McpServerStatus, type McpServerCustomization, type SessionModelInfo, type SimpleMessageAttachment } from '../../common/state/protocol/state.js'; @@ -51,6 +52,20 @@ suite('Agent host _meta readers', () => { ], [restrictions, undefined, undefined, undefined]); }); + test('validates slash command resource metadata', () => { + const resource = URI.parse('agenthost-content:///reports/sandbox-policy.md'); + assert.deepStrictEqual([ + readSlashCommandResource({ _meta: toSlashCommandResourceMeta(resource, true) }), + ...[undefined, null, [], {}, { resource: 1, preview: true }, { resource: resource.toString(), preview: 'true' }, { resource: '/reports/policy.md', preview: true }] + .map(value => readSlashCommandResource({ _meta: { 'vscode.slashCommandResource': value } })), + readSlashCommandResource({}), + ].map(value => value ? { resource: value.resource.toString(), preview: value.preview } : undefined), [ + { resource: resource.toString(), preview: true }, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + undefined, + ]); + }); + suite('session sandbox policy', () => { const policies = [ undefined, @@ -122,8 +137,8 @@ suite('Agent host _meta readers', () => { })?.role, 'judge'); }); - test('validates MCP configuration sources and merges them into open metadata', () => { - const read = (meta: Record | undefined) => readMcpServerSource({ + test('validates MCP presentation metadata and preserves opaque entries', () => { + const server = (meta: Record | undefined) => ({ type: CustomizationType.McpServer, id: 'server', uri: 'mcp-top-level:server', @@ -132,18 +147,21 @@ suite('Agent host _meta readers', () => { _meta: meta, } satisfies McpServerCustomization); const opaque = { 'test.opaque': 'kept' }; + const merged = withMcpServerDisplayNameMeta(withMcpServerSourceMeta(opaque, 'user'), ' Mail '); assert.deepStrictEqual({ sources: [ - ...(['user', 'workspace', 'plugin', 'builtin', 'managed'] as const).map(source => read(withMcpServerSourceMeta(undefined, source))), - ...[undefined, 'unknown', 1, {}, ['user']].map(source => read({ 'agentHost.mcpServerSource': source })), - read(undefined), + ...(['user', 'workspace', 'plugin', 'builtin', 'managed'] as const).map(source => readMcpServerSource(server(withMcpServerSourceMeta(undefined, source)))), + ...[undefined, 'unknown', 1, {}, ['user']].map(source => readMcpServerSource(server({ 'agentHost.mcpServerSource': source }))), + readMcpServerSource(server(undefined)), ], - replaced: withMcpServerSourceMeta(withMcpServerSourceMeta(opaque, 'user'), 'workspace'), + displayNames: [' Mail ', '', 'x'.repeat(513), 1].map(displayName => readMcpServerDisplayName(server({ 'vscode.mcpServerDisplayName': displayName }))), + merged, unchanged: withMcpServerSourceMeta(opaque, undefined) === opaque, }, { sources: ['user', 'workspace', 'plugin', 'builtin', 'managed', undefined, undefined, undefined, undefined, undefined, undefined], - replaced: { 'test.opaque': 'kept', 'agentHost.mcpServerSource': 'workspace' }, + displayNames: ['Mail', undefined, undefined, undefined], + merged: { 'test.opaque': 'kept', 'agentHost.mcpServerSource': 'user', 'vscode.mcpServerDisplayName': 'Mail' }, unchanged: true, }); }); diff --git a/src/vs/platform/agentHost/test/common/cloudSandboxProjectMeta.test.ts b/src/vs/platform/agentHost/test/common/cloudSandboxProjectMeta.test.ts new file mode 100644 index 00000000000000..797399565aad2f --- /dev/null +++ b/src/vs/platform/agentHost/test/common/cloudSandboxProjectMeta.test.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { readCloudSandboxCloneResult, readCloudSandboxProjects } from '../../common/meta/cloudSandboxProjectMeta.js'; + +suite('CloudSandboxProjectMeta', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('reads finite clone percentages, clamps their range, and ignores progress after cloning', () => { + const project = { id: 'checkout', path: '/checkout', git: true }; + assert.deepStrictEqual({ + cloning: [undefined, 0, 58, 100, -1, 101, NaN, Infinity, '58'].map(progress => + readCloudSandboxCloneResult({ project: { ...project, status: 'cloning', progress } })?.progress), + otherStates: [undefined, 'ready', 'failed'].map(status => + readCloudSandboxCloneResult({ project: { ...project, status, progress: 58 } })?.progress), + root: readCloudSandboxProjects({ + agents: [], + _meta: { 'copilot.projectManagement': { available: true } }, + config: { + schema: { type: 'object', properties: {} }, + values: { copilot: { projects: [{ ...project, status: 'cloning', progress: 58 }] } }, + }, + })?.map(project => ({ status: project.status, progress: project.progress })), + }, { + cloning: [undefined, 0, 58, 100, 0, 100, undefined, undefined, undefined], + otherStates: [undefined, undefined, undefined], + root: [{ status: 'cloning', progress: 58 }], + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/taskEventReplay.test.ts b/src/vs/platform/agentHost/test/common/taskEventReplay.test.ts index 52b32a5c19e3db..f2b26a4172b2a5 100644 --- a/src/vs/platform/agentHost/test/common/taskEventReplay.test.ts +++ b/src/vs/platform/agentHost/test/common/taskEventReplay.test.ts @@ -11,11 +11,7 @@ import { parseTaskEventsResponse, replayTaskAhpEvents, TaskEventReplayError } fr const SESSION_A = 'ahp-session:/aaaaaaaa-0000-4000-8000-000000000001'; const SESSION_B = 'ahp-session:/bbbbbbbb-0000-4000-8000-000000000002'; -/** - * The default chat channel as Mission Control's recorded frames name it. Deliberately NOT - * `buildDefaultChatUri`: the host writes `/chat`, while a client builds - * `ahp-chat://default/` for the same chat. Replay must accept what was recorded. - */ +/** The legacy default chat convention; replay must also accept arbitrary host-provided channels. */ function defaultChat(sessionId: string): string { return `${sessionId}/chat`; } @@ -181,6 +177,86 @@ suite('Task event replay', () => { ['t1', 't2', 't3']); }); + test('ignores overlapping batches and interleaved duplicate deltas', () => { + const chat = defaultChat(SESSION_A); + const events = [ + event(SESSION_A, 1926, chat, turnStarted('t1', 'hello')), + event(SESSION_A, 1927, chat, { type: 'chat/responsePart', turnId: 't1', part: { kind: 'markdown', id: 'p1', content: '' } }), + event(SESSION_A, 1928, chat, { type: 'chat/delta', turnId: 't1', partId: 'p1', content: 'first ' }), + event(SESSION_A, 1929, chat, { type: 'chat/delta', turnId: 't1', partId: 'p1', content: 'second ' }), + event(SESSION_A, 1930, chat, { type: 'chat/delta', turnId: 't1', partId: 'p1', content: 'third' }), + event(SESSION_A, 1931, chat, turnComplete('t1')), + ]; + const history = replayTaskAhpEvents([ + ...events.slice(0, 4), + ...structuredClone(events.slice(2, 4)), + events[4], + structuredClone(events[2]), + events[5], + ]); + + assert.deepStrictEqual( + { + truncated: history?.truncated, + turns: history?.sessions[0].chats.get(chat)?.turns.map(t => ({ id: t.id, responseParts: t.responseParts })), + }, + { truncated: false, turns: [{ id: 't1', responseParts: [{ kind: 'markdown', id: 'p1', content: 'first second third' }] }] }); + }); + + test('ignores duplicate chunks without disrupting an in-flight group', () => { + const chat = defaultChat(SESSION_A); + const chunks = chunkedEvents(SESSION_A, 0, chat, turnStarted('t1', 'chunked'), 3); + const history = replayTaskAhpEvents([ + chunks[0], + structuredClone(chunks[0]), + chunks[1], + structuredClone(chunks[0]), + chunks[2], + ...structuredClone(chunks), + event(SESSION_A, 3, chat, turnComplete('t1')), + ]); + + assert.deepStrictEqual( + { truncated: history?.truncated, turns: history?.sessions[0].chats.get(chat)?.turns.map(t => t.id) }, + { truncated: false, turns: ['t1'] }); + }); + + test('does not rewind modifiedAt when skipping an older duplicate', () => { + const first = event(SESSION_A, 2, SESSION_A, titleChanged('first')); + const later = { ...event(SESSION_A, 3, SESSION_A, titleChanged('later')), at: '2026-08-04T13:00:00.000Z' }; + const history = replayTaskAhpEvents([first, later, structuredClone(first)]); + + assert.deepStrictEqual( + history?.sessions.map(s => ({ title: s.state.title, modifiedAt: s.modifiedAt })), + [{ title: 'later', modifiedAt: later.at }]); + }); + + test('clears duplicate tracking at a mirror restart', () => { + const repeated = event(SESSION_A, 2, SESSION_A, titleChanged('same in both epochs')); + const history = replayTaskAhpEvents([ + event(SESSION_A, 1, SESSION_A, titleChanged('first epoch')), + repeated, + event(SESSION_A, 3, SESSION_A, titleChanged('before restart')), + event(SESSION_A, 1, SESSION_A, titleChanged('second epoch')), + structuredClone(repeated), + event(SESSION_A, 3, SESSION_A, titleChanged('after restart')), + ]); + + assert.deepStrictEqual( + { truncated: history?.truncated, title: history?.sessions[0].state.title }, + { truncated: false, title: 'after restart' }); + }); + + test('rejects conflicting records instead of treating them as duplicates', () => { + const first = event(SESSION_A, 2, SESSION_A, titleChanged('first')); + for (const conflicting of [ + event(SESSION_A, 2, SESSION_A, titleChanged('different payload')), + { ...first, at: '2026-08-04T13:00:00.000Z' }, + ]) { + assert.throws(() => replayTaskAhpEvents([first, conflicting]), TaskEventReplayError); + } + }); + test('reports truncation from an epoch whose reassembler was replaced by a restart', () => { // The restart installs a fresh reassembler, so the incomplete group from the first epoch is // no longer buffered — the loss has to be remembered or the transcript reads as whole. @@ -239,6 +315,142 @@ suite('Task event replay', () => { { defaultChat: announced, turns: ['t1'] }); }); + test('uses the only recorded chat when the default was not announced', () => { + const chat = 'ahp-chat:/90b9344c160a544093d7a3ebf4089e3f'; + const history = replayTaskAhpEvents(completedTurn(SESSION_A, 0, chat, 't1', 'hello')); + const session = history?.sessions[0]; + + assert.deepStrictEqual({ + defaultChat: session?.defaultChat, + chats: [...(session?.chats.keys() ?? [])], + turns: session?.chats.get(session.defaultChat)?.turns.map(turn => turn.id), + }, { defaultChat: chat, chats: [chat], turns: ['t1'] }); + }); + + for (const origin of [ + { kind: 'tool', chat: 'ahp-chat:/main', toolCallId: 'tool-1' }, + { kind: 'sideChat', chat: 'ahp-chat:/main', turnId: 't0' }, + { kind: 'fork', chat: 'ahp-chat:/main', turnId: 't0' }, + ]) { + test(`does not infer the default from a sole recorded ${origin.kind} chat`, () => { + const peer = 'ahp-chat:/peer'; + const history = replayTaskAhpEvents([ + event(SESSION_A, 0, SESSION_A, { + type: 'session/chatAdded', + summary: { resource: peer, title: '', status: 1, modifiedAt: '2026-08-04T12:00:00.000Z', origin }, + }), + ...completedTurn(SESSION_A, 1, peer, 't1', 'peer conversation'), + ]); + const session = history?.sessions[0]; + + assert.deepStrictEqual({ + defaultChat: session?.defaultChat, + turns: session?.chats.get(session.defaultChat)?.turns, + peerTurns: session?.chats.get(peer)?.turns.map(turn => turn.id), + }, { defaultChat: defaultChat(SESSION_A), turns: [], peerTurns: ['t1'] }); + }); + } + + test('does not infer a default when the catalogue advertises another chat', () => { + const history = replayTaskAhpEvents([ + event(SESSION_A, 0, SESSION_A, { + type: 'session/chatAdded', + summary: { resource: 'ahp-chat:/main', title: '', status: 1, modifiedAt: '2026-08-04T12:00:00.000Z', origin: { kind: 'user' } }, + }), + ...completedTurn(SESSION_A, 1, 'ahp-chat:/peer', 't1', 'peer conversation'), + ]); + const session = history?.sessions[0]; + + assert.deepStrictEqual({ + defaultChat: session?.defaultChat, + turns: session?.chats.get(session.defaultChat)?.turns, + }, { defaultChat: defaultChat(SESSION_A), turns: [] }); + }); + + for (const kind of ['user', 'tool', 'sideChat', 'fork']) { + test(`does not infer the default from a removed ${kind} chat`, () => { + const chat = 'ahp-chat:/removed'; + const history = replayTaskAhpEvents([ + event(SESSION_A, 0, SESSION_A, { + type: 'session/chatAdded', + summary: { + resource: chat, title: '', status: 1, modifiedAt: '2026-08-04T12:00:00.000Z', + origin: { kind, chat: 'ahp-chat:/main', turnId: 't0', toolCallId: 'tool-1' }, + }, + }), + ...completedTurn(SESSION_A, 1, chat, 't1', 'removed conversation'), + event(SESSION_A, 3, SESSION_A, { type: 'session/chatRemoved', chat }), + ]); + const session = history?.sessions[0]; + + assert.deepStrictEqual({ + catalogue: session?.state.chats, + defaultChat: session?.defaultChat, + turns: session?.chats.get(session.defaultChat)?.turns, + }, { catalogue: [], defaultChat: defaultChat(SESSION_A), turns: [] }); + }); + } + + test('honours a chat removal even when its addition was not recorded', () => { + const chat = 'ahp-chat:/removed'; + const history = replayTaskAhpEvents([ + ...completedTurn(SESSION_A, 0, chat, 't1', 'removed conversation'), + event(SESSION_A, 2, SESSION_A, { type: 'session/chatRemoved', chat }), + ]); + const session = history?.sessions[0]; + + assert.deepStrictEqual({ + defaultChat: session?.defaultChat, + turns: session?.chats.get(session.defaultChat)?.turns, + }, { defaultChat: defaultChat(SESSION_A), turns: [] }); + }); + + test('uses the sole recorded user chat advertised in the catalogue', () => { + const chat = 'ahp-chat:/main'; + const history = replayTaskAhpEvents([ + event(SESSION_A, 0, SESSION_A, { + type: 'session/chatAdded', + summary: { resource: chat, title: '', status: 1, modifiedAt: '2026-08-04T12:00:00.000Z', origin: { kind: 'user' } }, + }), + ...completedTurn(SESSION_A, 1, chat, 't1', 'main conversation'), + ]); + const session = history?.sessions[0]; + + assert.deepStrictEqual({ + defaultChat: session?.defaultChat, + turns: session?.chats.get(session.defaultChat)?.turns.map(turn => turn.id), + }, { defaultChat: chat, turns: ['t1'] }); + }); + + test('does not replace an announced empty default with a peer chat', () => { + const announced = 'ahp-chat:/main'; + const peer = 'ahp-chat:/peer'; + const history = replayTaskAhpEvents([ + event(SESSION_A, 0, SESSION_A, { type: 'session/defaultChatChanged', defaultChat: announced }), + ...completedTurn(SESSION_A, 1, peer, 't1', 'peer conversation'), + ]); + const session = history?.sessions[0]; + + assert.deepStrictEqual({ + defaultChat: session?.defaultChat, + turns: session?.chats.get(session.defaultChat)?.turns, + peerTurns: session?.chats.get(peer)?.turns.map(turn => turn.id), + }, { defaultChat: announced, turns: [], peerTurns: ['t1'] }); + }); + + test('preserves separate chats when the recorded default is ambiguous', () => { + const history = replayTaskAhpEvents([ + ...completedTurn(SESSION_A, 0, 'ahp-chat:/first', 't1', 'first conversation'), + ...completedTurn(SESSION_A, 2, 'ahp-chat:/second', 't2', 'second conversation'), + ]); + const session = history?.sessions[0]; + + assert.deepStrictEqual({ + defaultChat: session?.defaultChat, + turns: [...(session?.chats.values() ?? [])].map(chat => chat.turns.map(turn => turn.id)), + }, { defaultChat: defaultChat(SESSION_A), turns: [['t1'], ['t2'], []] }); + }); + test('surfaces an empty default chat for a session with no chat history', () => { const history = replayTaskAhpEvents([event(SESSION_A, 0, SESSION_A, titleChanged('no chat yet'))]); diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index c4bb837c7e9f07..aa68c6c38da20e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -281,6 +281,82 @@ suite('AgentHostStateManager', () => { }); }); + test('marking the sole unread chat read also marks its session read', () => { + manager.createSession(makeSessionSummary()); + const actions: ActionEnvelope[] = []; + disposables.add(manager.onDidEmitEnvelope(action => actions.push(action))); + + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatIsReadChanged, isRead: true }); + + assert.deepStrictEqual({ + sessionIsRead: isSessionStatusRead(manager.getSessionState(sessionUri)!.status), + chatIsRead: isSessionStatusRead(manager.getChatState(sessionChatUri)!.status), + actions: actions + .filter(({ action }) => action.type === ActionType.ChatIsReadChanged || action.type === ActionType.SessionIsReadChanged) + .map(({ channel, action }) => ({ + channel, + type: action.type, + isRead: action.type === ActionType.ChatIsReadChanged || action.type === ActionType.SessionIsReadChanged ? action.isRead : undefined, + })), + }, { + sessionIsRead: true, + chatIsRead: true, + actions: [ + { channel: sessionChatUri, type: ActionType.ChatIsReadChanged, isRead: true }, + { channel: sessionUri, type: ActionType.SessionIsReadChanged, isRead: true }, + ], + }); + }); + + test('only the last unread aggregate chat promotes its session to read', () => { + manager.createSession(makeSessionSummary()); + const peer = buildChatUri(sessionUri, 'peer'); + const hidden = buildChatUri(sessionUri, 'hidden'); + manager.addChat(sessionUri, peer); + manager.addChat(sessionUri, hidden, { interactivity: ChatInteractivity.Hidden }); + manager.dispatchServerAction(peer, { type: ActionType.ChatIsReadChanged, isRead: false }); + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatIsReadChanged, isRead: true }); + manager.dispatchServerAction(hidden, { type: ActionType.ChatIsReadChanged, isRead: false }); + const actions: ActionEnvelope[] = []; + disposables.add(manager.onDidEmitEnvelope(action => actions.push(action))); + + manager.dispatchServerAction(peer, { type: ActionType.ChatIsReadChanged, isRead: true }); + const afterLastUnreadChat = { + session: isSessionStatusRead(manager.getSessionState(sessionUri)!.status), + defaultChat: isSessionStatusRead(manager.getChatState(sessionChatUri)!.status), + peer: isSessionStatusRead(manager.getChatState(peer)!.status), + hidden: isSessionStatusRead(manager.getChatState(hidden)!.status), + }; + manager.dispatchServerAction(sessionUri, { type: ActionType.SessionIsReadChanged, isRead: false }); + manager.dispatchServerAction(peer, { type: ActionType.ChatIsReadChanged, isRead: true }); + + assert.deepStrictEqual({ + afterLastUnreadChat, + afterRedundantRead: { + session: isSessionStatusRead(manager.getSessionState(sessionUri)!.status), + defaultChat: isSessionStatusRead(manager.getChatState(sessionChatUri)!.status), + peer: isSessionStatusRead(manager.getChatState(peer)!.status), + hidden: isSessionStatusRead(manager.getChatState(hidden)!.status), + }, + actions: actions + .filter(({ action }) => action.type === ActionType.ChatIsReadChanged || action.type === ActionType.SessionIsReadChanged) + .map(({ channel, action }) => ({ + channel, + type: action.type, + isRead: action.type === ActionType.ChatIsReadChanged || action.type === ActionType.SessionIsReadChanged ? action.isRead : undefined, + })), + }, { + afterLastUnreadChat: { session: true, defaultChat: true, peer: true, hidden: false }, + afterRedundantRead: { session: false, defaultChat: true, peer: true, hidden: false }, + actions: [ + { channel: peer, type: ActionType.ChatIsReadChanged, isRead: true }, + { channel: sessionUri, type: ActionType.SessionIsReadChanged, isRead: true }, + { channel: sessionUri, type: ActionType.SessionIsReadChanged, isRead: false }, + { channel: peer, type: ActionType.ChatIsReadChanged, isRead: true }, + ], + }); + }); + test('tool chats retain exact read state without affecting the session aggregate', () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionIsReadChanged, isRead: true }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index ac54390676d10f..9961f5d1cd22d3 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -83,7 +83,7 @@ import { AGENT_MERGE_CHANGESET_ID, buildBranchChangesetUri, buildSessionChangese import { type ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest } from '../../node/shared/copilotApiService.js'; import { getWorktreesRoot, WorktreeIsolation, WORKTREE_META_REPOSITORY_ROOT } from '../../node/shared/worktreeIsolation.js'; import { readSessionAdditionalWorktrees, writeSessionAdditionalWorktrees } from '../../node/shared/sessionAdditionalWorktrees.js'; -import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError } from '../../common/state/sessionProtocol.js'; +import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { ArtifactServerToolName, SessionServerToolName } from '../../common/serverToolNames.js'; import { buildMcpChannel } from '../../node/shared/mcpCustomizationController.js'; @@ -3759,7 +3759,7 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('applies independent read actions to the default chat only', async () => { + test('keeps the session unread when another chat is unread', async () => { const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); @@ -3794,6 +3794,42 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('promotes and persists the session when the last unread chat becomes read', async () => { + const database = new TestSessionDatabase(); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(database), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = new MockAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + registerTestAgentProvider(svc, agent); + const session = await svc.createSession({ provider: 'copilot' }); + const sessionUri = session.toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + const peerChat = buildChatUri(session, 'peer'); + const stateManager = getStateManager(svc); + stateManager.addChat(sessionUri, peerChat); + stateManager.dispatchServerAction(peerChat, { type: ActionType.ChatIsReadChanged, isRead: false }); + stateManager.dispatchServerAction(defaultChat, { type: ActionType.ChatIsReadChanged, isRead: true }); + const sessionReadPromise = Event.toPromise(Event.filter(svc.onDidAction, envelope => + envelope.action.type === ActionType.SessionIsReadChanged && envelope.action.isRead)); + + svc.dispatchAction(peerChat, { + type: ActionType.ChatIsReadChanged, + isRead: true, + }, 'test-client', 1); + await sessionReadPromise; + + assert.deepStrictEqual({ + sessionRead: isSessionStatusRead(stateManager.getSessionState(sessionUri)!.status), + defaultChatRead: isSessionStatusRead(stateManager.getChatState(defaultChat)!.status), + peerChatRead: isSessionStatusRead(stateManager.getChatState(peerChat)!.status), + persistedSessionRead: await database.getMetadata(AH_META_IS_READ_DB_KEY), + }, { + sessionRead: true, + defaultChatRead: true, + peerChatRead: true, + persistedSessionRead: 'true', + }); + }); + test('rejects independent read actions for an unknown chat', async () => { const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = new MockAgent('copilot'); @@ -15045,6 +15081,61 @@ suite('AgentService (node dispatcher)', () => { ); }); + test('superseding a turn changeset subscription during cold restore keeps the latest changeset available', async () => { + const localService = disposables.add(createTestAgentService( + new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), + { _serviceBrand: undefined } as IProductService, createNoopGitService(), + )); + registerTestAgentProvider(localService, copilotAgent); + const session = await localService.createSession({ provider: 'copilot' }); + const chat = buildDefaultChatUri(session); + const previous = buildTurnChangesetUri(chat, 'previous'); + const latest = buildTurnChangesetUri(chat, 'latest'); + const stateManager = getStateManager(localService); + stateManager.deleteSession(session.toString()); + const restoring = new DeferredPromise(); + const finishRestore = new DeferredPromise(); + copilotAgent.getChatCustomizations = async () => { + restoring.complete(); + await finishRestore.p; + return []; + }; + const emitted: ActionEnvelope[] = []; + disposables.add(localService.onDidAction(envelope => { + if (envelope.channel === previous || envelope.channel === latest) { + emitted.push(envelope); + } + })); + let isActive = true; + const subscribing = localService.subscribe(URI.parse(previous), 'client-turn', () => isActive); + const rejected = assert.rejects(subscribing, /Subscription cancelled/); + await restoring.p; + let snapshot: IStateSnapshot; + try { + isActive = false; + localService.unsubscribe(URI.parse(previous), 'client-turn'); + snapshot = await localService.subscribe(URI.parse(latest), 'client-turn'); + } finally { + finishRestore.complete(); + } + await rejected; + + assert.deepStrictEqual({ + snapshotResource: snapshot.resource, + previousAvailable: !!stateManager.getSnapshot(previous), + latestAvailable: !!stateManager.getSnapshot(latest), + previousPublished: emitted.some(envelope => envelope.channel === previous && envelope.action.type === ActionType.ChangesetContentChanged), + latestPublished: emitted.some(envelope => envelope.channel === latest && envelope.action.type === ActionType.ChangesetContentChanged), + }, { + snapshotResource: latest, + previousAvailable: true, + latestAvailable: true, + previousPublished: true, + latestPublished: true, + }); + localService.unsubscribe(URI.parse(latest), 'client-turn'); + }); + for (const cached of [undefined, [], ['file:///wd/cached.ts']]) { test(`subscribe to an uncommitted changeset returns ${cached ? 'cached' : 'uncached'} ${cached?.length ? 'files' : 'empty files'} before its refresh finishes`, async () => { const workingDirectory = URI.from({ scheme: Schemas.inMemory, path: '/wd' }); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index f3feabf2e7fd5d..98392482c7eee1 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -7469,6 +7469,55 @@ suite('AgentSideEffects', () => { }); }); + test('a child-scoped error ends the subagent turn as failed before its completion signal', () => { + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-search', agentName: 'search_code_subagent', agentDisplayName: 'Search code', agentDescription: 'Searches' }); + const error = { errorType: 'subagentFailed', message: 'Search failed' }; + agent.fireProgress({ + kind: 'action', + resource: URI.parse(defaultChatUri), + parentToolCallId: 'tc-search', + action: { type: ActionType.ChatError, turnId: 'turn-1', duration: 42, part: { kind: ResponsePartKind.Error, error } }, + }); + agent.fireProgress({ kind: 'subagent_completed', chat: URI.parse(defaultChatUri), toolCallId: 'tc-search' }); + + const subState = stateManager.getSessionState(buildSubagentChatUri(sessionUri.toString(), 'tc-search')); + assert.deepStrictEqual({ + activeTurn: subState?.activeTurn, + turnStates: subState?.turns.map(turn => turn.state), + error: subState?.turns[0]?.responseParts.find(part => part.kind === ResponsePartKind.Error), + }, { + activeTurn: undefined, + turnStates: [TurnState.Error], + error: { kind: ResponsePartKind.Error, error }, + }); + }); + + test('a child-scoped cancellation ends the subagent turn as cancelled before its completion signal', () => { + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-task', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' }); + agent.fireProgress({ + kind: 'action', + resource: URI.parse(defaultChatUri), + parentToolCallId: 'tc-task', + action: { type: ActionType.ChatTurnCancelled, turnId: 'turn-1', duration: 42 }, + }); + agent.fireProgress({ kind: 'subagent_completed', chat: URI.parse(defaultChatUri), toolCallId: 'tc-task' }); + + const subState = stateManager.getSessionState(buildSubagentChatUri(sessionUri.toString(), 'tc-task')); + assert.deepStrictEqual({ + activeTurn: subState?.activeTurn, + turnStates: subState?.turns.map(turn => turn.state), + }, { + activeTurn: undefined, + turnStates: [TurnState.Cancelled], + }); + }); + test('permission requests for inactive and unroutable subagents are denied', () => { setupSession(); startTurn('turn-1'); diff --git a/src/vs/platform/agentHost/test/node/capiReplayProxyPathNormalization.test.ts b/src/vs/platform/agentHost/test/node/capiReplayProxyPathNormalization.test.ts index 793bf414c2d1ec..1905eff5485355 100644 --- a/src/vs/platform/agentHost/test/node/capiReplayProxyPathNormalization.test.ts +++ b/src/vs/platform/agentHost/test/node/capiReplayProxyPathNormalization.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { mkdtempSync, readFileSync, rmSync } from 'fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { tmpdir, userInfo } from 'os'; import { join, posix, win32 } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; @@ -126,6 +126,50 @@ suite('CapiReplayProxy path normalization', () => { } }); + test('expands workdir file URI placeholders as file URIs', async () => { + const directory = mkdtempSync(join(tmpdir(), 'capi-replay-workdir-uri-')); + const fixturePath = join(directory, 'capture.yaml'); + const workDir = 'C:\\Temp\\workspace folder'; + writeFileSync(fixturePath, [ + 'version: 1', + 'dialect: anthropic', + 'exchanges:', + ' - request:', + ' model: claude-sonnet-5', + ' system: ${system}', + ' messages:', + ' - role: user', + ' content: attach', + ' response:', + ' content:', + ' - type: tool_use', + ' id: toolu_1', + ' name: set_workspace', + ' input:', + ' workspaceFolder: file://${workdir}', + ' stopReason: tool_use', + ].join('\n')); + const replay = new CapiReplayProxy({ fixturePath, mode: 'replay', workDir }); + try { + const response = await fetch(`${await replay.start()}/v1/messages`, { + method: 'POST', + body: JSON.stringify({ + model: 'claude-sonnet-5', + system: 'system', + messages: [{ role: 'user', content: 'attach' }], + }), + }); + const content = aggregateAnthropicSse(await response.text())?.content; + assert.deepStrictEqual(content?.map(block => block.type === 'tool_use' ? block.input : undefined), [{ + workspaceFolder: URI.file(workDir).toString(), + }]); + replay.assertNoReplayMismatches(); + } finally { + await replay.stop(); + rmSync(directory, { recursive: true, force: true }); + } + }); + test('normalizes truncated harness workspaces from session titles', async () => { const testDirectory = mkdtempSync(join(tmpdir(), 'capi-replay-path-normalization-')); const fixturePath = join(testDirectory, 'capture.yaml'); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 2ef9676cfbcd70..ddd113d2d3065a 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -1333,7 +1333,7 @@ function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationServic const launchPlan: CopilotSessionLaunchPlan = { kind: 'create', client: { - rpc: { account: new class extends mock() { }, sandbox: { getHostSupport: async () => ({ supported: true, capabilities: [] }) } }, + rpc: { account: new class extends mock() { }, sandbox: { proxyCa: new class extends mock() { }, getHostSupport: async () => ({ supported: true, capabilities: [] }) } }, createSession: async options => { createOptions = options; reportManagedSettings(options); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 7dbc98dd925e0c..b6b194552b6937 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -39,11 +39,12 @@ import type { ChatInputRequestWithPlanReview } from '../../common/agentHostPlanR import { AgentFeedbackAttachmentDisplayKind } from '../../common/meta/agentFeedbackAttachments.js'; import { ChatInputRequestPurpose, readChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js'; import { readToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; -import { readMcpServerSource } from '../../common/meta/mcpCustomizationMeta.js'; +import { readMcpServerDisplayName, readMcpServerSource } from '../../common/meta/mcpCustomizationMeta.js'; import { agentModelCallMetaKey, readAgentModelCallDiagnostics } from '../../common/meta/agentModelCallMeta.js'; import { readAgentRuntimeModelConfiguration } from '../../common/meta/agentModelConfigurationMeta.js'; import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, readAgentSystemNotificationMeta } from '../../common/meta/agentSystemNotificationMeta.js'; import { readAgentSandboxDiagnostics } from '../../common/meta/agentSandboxDiagnostics.js'; +import { toSlashCommandResourceMeta } from '../../common/meta/agentSlashCommandOutputMeta.js'; import { toSessionEvents } from './copilotTestEvents.js'; import { fusionTestData } from './copilotFusionTestEvents.js'; import { IDiffComputeService } from '../../common/diffComputeService.js'; @@ -1141,7 +1142,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { const launchPlanBase = { client: { - rpc: { account: new class extends mock() { }, sandbox: { getHostSupport: options?.getSandboxHostSupport ?? (async () => ({ supported: true, capabilities: [] })) } }, + rpc: { account: new class extends mock() { }, sandbox: { proxyCa: new class extends mock() { }, getHostSupport: options?.getSandboxHostSupport ?? (async () => ({ supported: true, capabilities: [] })) } }, createSession: async () => mockSession as unknown as CopilotSession, resumeSession: async () => mockSession as unknown as CopilotSession, }, @@ -4670,7 +4671,7 @@ suite('CopilotAgentSession', () => { content: 'Sandbox is disabled.', }, ] satisfies { name: string; result: MockCopilotSession['commandInvokeResult']; content: string }[]) { - test(`links to a Markdown file containing ${name} without a model turn`, async () => { + test(`provides a Markdown file to open containing ${name} without a model turn`, async () => { const { session, mockSession, signals, storedFileContents } = await createSandboxSession(); mockSession.commandInvokeResult = result; @@ -4678,7 +4679,7 @@ suite('CopilotAgentSession', () => { const actions = getActions(signals); const [resource] = storedFileContents.keys(); - assert.match(resource, /^inmemory:\/session-data\/test-session-1\/diagnostics\/[\da-f-]+\/sandbox-policy\.md$/); + assert.strictEqual(resource, 'inmemory:/session-data/test-session-1/diagnostics/sandbox-policy.md'); assert.deepStrictEqual({ commandListCalls: mockSession.commandListCalls, commandInvokeCalls: mockSession.commandInvokeCalls, @@ -4686,7 +4687,10 @@ suite('CopilotAgentSession', () => { files: [...storedFileContents], responseParts: actions .filter(a => a.type === ActionType.ChatResponsePart) - .map(a => a.part.kind === ResponsePartKind.Markdown ? a.part.content : a.part.kind), + .map(a => a.part.kind === ResponsePartKind.SystemNotification ? a.part.content : a.part.kind), + responseMetadata: actions + .filter(a => a.type === ActionType.ChatResponsePart) + .map(a => a.part.kind === ResponsePartKind.SystemNotification ? a.part._meta : undefined), turnComplete: actions .filter(a => a.type === ActionType.ChatTurnComplete) .map(a => a.turnId), @@ -4696,14 +4700,15 @@ suite('CopilotAgentSession', () => { commandInvokeCalls: [{ name: 'sandbox', input: 'policy' }], sendRequests: [], files: [[resource, content]], - responseParts: [`[Open Sandbox Policy](${resource}?vscodeLinkType%3Dmarkdown-preview)`], + responseParts: [{ markdown: `[Open Sandbox Policy](${resource}?vscodeLinkType%3Dmarkdown-preview)` }], + responseMetadata: [toSlashCommandResourceMeta(URI.parse(resource), true)], turnComplete: ['turn-policy'], hasActiveTurn: false, }); }); } - test('preserves previous policy snapshots across invocations and session disposal', async () => { + test('keeps only the latest policy in the same file across invocations and session disposal', async () => { const { session, mockSession, signals, storedFileContents } = await createSandboxSession(); mockSession.commandInvokeResult = { kind: 'text', text: '# First policy', markdown: true }; await session.send('/sandbox policy', undefined, 'turn-policy-1'); @@ -4711,15 +4716,15 @@ suite('CopilotAgentSession', () => { await session.send('/sandbox policy', undefined, 'turn-policy-2'); session.dispose(); - const resources = [...storedFileContents.keys()]; + const resource = 'inmemory:/session-data/test-session-1/diagnostics/sandbox-policy.md'; assert.deepStrictEqual({ - contents: [...storedFileContents.values()], + files: [...storedFileContents], responseParts: getActions(signals) .filter(a => a.type === ActionType.ChatResponsePart) - .map(a => a.part.kind === ResponsePartKind.Markdown ? a.part.content : a.part.kind), + .map(a => a.part.kind === ResponsePartKind.SystemNotification ? a.part.content : a.part.kind), }, { - contents: ['# First policy', '# Second policy'], - responseParts: resources.map(resource => `[Open Sandbox Policy](${resource}?vscodeLinkType%3Dmarkdown-preview)`), + files: [[resource, '# Second policy']], + responseParts: [1, 2].map(() => ({ markdown: `[Open Sandbox Policy](${resource}?vscodeLinkType%3Dmarkdown-preview)` })), }); }); @@ -4815,11 +4820,11 @@ suite('CopilotAgentSession', () => { contents: [...storedFileContents.values()], responseParts: getActions(signals) .filter(a => a.type === ActionType.ChatResponsePart) - .map(a => a.part.kind === ResponsePartKind.Markdown ? a.part.content : a.part.kind), + .map(a => a.part.kind === ResponsePartKind.SystemNotification ? a.part.content : a.part.kind), }, { commandInvokeCalls: [{ name: 'sandbox', input: 'policy ' }], contents: ['Sandbox is disabled.'], - responseParts: [...storedFileContents.keys()].map(resource => `[Open Sandbox Policy](${resource}?vscodeLinkType%3Dmarkdown-preview)`), + responseParts: [...storedFileContents.keys()].map(resource => ({ markdown: `[Open Sandbox Policy](${resource}?vscodeLinkType%3Dmarkdown-preview)` })), }); }); @@ -6593,6 +6598,172 @@ suite('CopilotAgentSession', () => { ]); }); + for (const eventType of ['subagent.completed', 'subagent.failed'] as const) { + test(`completes a taskless search subagent on ${eventType}`, async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + session.resetTurnState('turn-parent'); + const identity = { + toolCallId: 'tc-search', + agentName: 'search_code_subagent', + agentDisplayName: 'Search code', + }; + mockSession.fire('subagent.started', { + ...identity, + agentDescription: 'Search the codebase', + }, { agentId: 'search-agent' }); + + if (eventType === 'subagent.completed') { + mockSession.fire(eventType, identity, { agentId: 'search-agent' }); + } else { + mockSession.fire(eventType, { ...identity, error: 'Search failed', durationMs: 42 }, { agentId: 'search-agent' }); + } + await timeout(0); + + assert.deepStrictEqual({ + outcome: signals.flatMap<{ completed: string } | { error: unknown; duration: number; parentToolCallId: string | undefined }>(signal => signal.kind === 'subagent_completed' + ? [{ completed: signal.toolCallId }] + : signal.kind === 'action' && signal.action.type === ActionType.ChatError + ? [{ error: signal.action.part.error, duration: signal.action.duration, parentToolCallId: signal.parentToolCallId }] + : []), + taskListCalls: mockSession.backgroundTaskListCalls, + }, { + outcome: [ + ...(eventType === 'subagent.failed' + ? [{ error: { errorType: 'subagentFailed', message: 'Search failed' }, duration: 42, parentToolCallId: 'tc-search' }] + : []), + { completed: 'tc-search' }, + ], + taskListCalls: 1, + }); + }); + } + + suite('subagent turn outcomes', () => { + const identity = { toolCallId: 'tc-subagent', agentName: 'explore', agentDisplayName: 'Explore' }; + const agentTask = (status: 'idle' | 'failed' | 'cancelled', error?: string) => ({ + type: 'agent' as const, id: 'agent-1', toolCallId: 'tc-subagent', description: 'Explore tests', + status, agentType: 'explore', prompt: 'Explore tests', startedAt: new Date(0).toISOString(), + activeTimeMs: 1500, ...(error ? { error } : {}), + } satisfies Extract); + const outcomes = (signals: readonly AgentSignal[]) => signals.flatMap<{ completed: string } | { type: ActionType; duration: number; error?: unknown; parentToolCallId: string | undefined }>(signal => { + if (signal.kind === 'subagent_completed') { + return [{ completed: signal.toolCallId }]; + } + if (signal.kind === 'action' && (signal.action.type === ActionType.ChatError || signal.action.type === ActionType.ChatTurnCancelled)) { + return [{ + type: signal.action.type, + duration: signal.action.duration, + ...(signal.action.type === ActionType.ChatError ? { error: signal.action.part.error } : {}), + parentToolCallId: signal.parentToolCallId, + }]; + } + return []; + }); + + async function startSubagent() { + const created = await createAgentSession(disposables); + created.session.resetTurnState('turn-parent'); + created.mockSession.fire('subagent.started', { ...identity, agentDescription: 'Explore tests' }, { agentId: 'agent-1' }); + return created; + } + + test('ends a failed task subagent as an error using the task error', async () => { + const { mockSession, signals } = await startSubagent(); + mockSession.backgroundTasks = [agentTask('failed', 'Model request failed')]; + mockSession.fire('session.background_tasks_changed', {}); + await timeout(0); + + assert.deepStrictEqual(outcomes(signals), [ + { type: ActionType.ChatError, duration: 1500, error: { errorType: 'subagentFailed', message: 'Model request failed' }, parentToolCallId: 'tc-subagent' }, + { completed: 'tc-subagent' }, + ]); + }); + + test('prefers the subagent.failed details for a failed task subagent', async () => { + const { mockSession, signals } = await startSubagent(); + mockSession.backgroundTasks = [agentTask('failed')]; + mockSession.fire('subagent.failed', { ...identity, error: 'No response generated', durationMs: 42 }, { agentId: 'agent-1' }); + await timeout(0); + + assert.deepStrictEqual(outcomes(signals), [ + { type: ActionType.ChatError, duration: 42, error: { errorType: 'subagentFailed', message: 'No response generated' }, parentToolCallId: 'tc-subagent' }, + { completed: 'tc-subagent' }, + ]); + }); + + test('ends a cancelled task subagent as cancelled', async () => { + const { mockSession, signals } = await startSubagent(); + mockSession.backgroundTasks = [agentTask('cancelled')]; + mockSession.fire('session.background_tasks_changed', {}); + await timeout(0); + + assert.deepStrictEqual(outcomes(signals), [ + { type: ActionType.ChatTurnCancelled, duration: 1500, parentToolCallId: 'tc-subagent' }, + { completed: 'tc-subagent' }, + ]); + }); + + test('ends a taskless subagent torn down by the runtime as cancelled', async () => { + const { mockSession, signals } = await startSubagent(); + mockSession.fire('subagent.completed', { ...identity, cancelled: true, durationMs: 7 }, { agentId: 'agent-1' }); + await timeout(0); + + assert.deepStrictEqual(outcomes(signals), [ + { type: ActionType.ChatTurnCancelled, duration: 7, parentToolCallId: 'tc-subagent' }, + { completed: 'tc-subagent' }, + ]); + }); + + test('keeps an idle task subagent successful', async () => { + const { mockSession, signals } = await startSubagent(); + mockSession.backgroundTasks = [agentTask('idle')]; + mockSession.fire('subagent.completed', identity, { agentId: 'agent-1' }); + await timeout(0); + + assert.deepStrictEqual(outcomes(signals), [{ completed: 'tc-subagent' }]); + }); + }); + + test('completes a taskless subagent only after the completion quiet period', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables, { subagentTaskCompletionDelay: 20 }); + session.resetTurnState('turn-parent'); + const identity = { toolCallId: 'tc-search', agentName: 'search_code_subagent', agentDisplayName: 'Search code' }; + mockSession.fire('subagent.started', { ...identity, agentDescription: 'Search the codebase' }, { agentId: 'search-agent' }); + mockSession.fire('subagent.completed', identity, { agentId: 'search-agent' }); + const completed = () => signals.filter(signal => signal.kind === 'subagent_completed').map(signal => signal.toolCallId); + const beforeQuietPeriod = completed(); + await timeout(60); + + assert.deepStrictEqual({ beforeQuietPeriod, afterQuietPeriod: completed() }, { beforeQuietPeriod: [], afterQuietPeriod: ['tc-search'] }); + }); + + test('keeps a task-backed subagent active when its lifecycle completion races a running task', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables, { subagentTaskCompletionDelay: 20 }); + session.resetTurnState('turn-parent'); + const identity = { toolCallId: 'tc-subagent', agentName: 'explore', agentDisplayName: 'Explore' }; + mockSession.fire('subagent.started', { ...identity, agentDescription: 'Explore tests' }, { agentId: 'agent-1' }); + mockSession.backgroundTasks = [{ + type: 'agent', id: 'agent-1', toolCallId: 'tc-subagent', description: 'Explore tests', + status: 'running', agentType: 'explore', prompt: 'Explore tests', startedAt: new Date(0).toISOString(), + }]; + mockSession.fire('subagent.completed', identity, { agentId: 'agent-1' }); + await timeout(60); + + assert.deepStrictEqual(signals.filter(signal => signal.kind === 'subagent_completed'), []); + }); + + test('keeps a taskless subagent active when it starts another round before the quiet period ends', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables, { subagentTaskCompletionDelay: 20 }); + session.resetTurnState('turn-parent'); + const identity = { toolCallId: 'tc-search', agentName: 'search_code_subagent', agentDisplayName: 'Search code' }; + mockSession.fire('subagent.started', { ...identity, agentDescription: 'Search the codebase' }, { agentId: 'search-agent' }); + mockSession.fire('subagent.completed', identity, { agentId: 'search-agent' }); + mockSession.fire('assistant.turn_start', { turnId: 'next-round' }, { agentId: 'search-agent' }); + await timeout(60); + + assert.deepStrictEqual(signals.filter(signal => signal.kind === 'subagent_completed'), []); + }); + test('waits for subagent events to settle before completing an inactive task', async () => { const { session, mockSession, signals } = await createAgentSession(disposables, { subagentTaskCompletionDelay: 50 }); session.resetTurnState('turn-parent'); @@ -7399,7 +7570,7 @@ suite('CopilotAgentSession', () => { const data = { model: 'gpt-5.5', inputTokens: 5 } as SessionEventPayload<'assistant.usage'>['data']; mockSession.fire('assistant.usage', data, { agentId: 'child', id: 'child-usage-1' }); const first = session.getTurnTokenUsage('child-turn-1', 'child-tool'); - // Completion now flows through background-task reconciliation, not the subagent.completed event. + // Task-backed completion flows through background-task reconciliation. mockSession.backgroundTasks = [{ type: 'agent', id: 'child', toolCallId: 'child-tool', description: 'Explore', status: 'completed', agentType: 'explore', prompt: 'Explore', startedAt: new Date(0).toISOString(), @@ -9910,10 +10081,12 @@ suite('CopilotAgentSession', () => { [AgentHostSandboxKey.SandboxLspServers, true], [AgentHostSandboxKey.AllowDevToolAccess, false], [AgentHostSandboxKey.AllowLocalNetwork, false], + [AgentHostSandboxKey.AuthenticateGit, false], + [AgentHostSandboxKey.AuthenticateGh, false], ] as const) { test(`per-request sandbox: enforces ${key} and restores local choices on ${platform}`, async () => { for (const local of [false, true]) { - const sandboxPolicy: { enabled: boolean; sandboxMcpServers?: boolean; sandboxLspServers?: boolean; allowDevToolAccess?: boolean; allowLocalNetwork?: boolean } = { enabled: true }; + const sandboxPolicy: { enabled: boolean; sandboxMcpServers?: boolean; sandboxLspServers?: boolean; allowDevToolAccess?: boolean; allowLocalNetwork?: boolean; authenticateGit?: boolean; authenticateGh?: boolean } = { enabled: true }; const { session, mockSession } = await createAgentSession(disposables, { platform, sandboxPolicy, @@ -9929,7 +10102,9 @@ suite('CopilotAgentSession', () => { sandboxPolicy[key] = managed; await session.send('hello', undefined, `turn-${index}`); const applied = mockSession.sandboxConfigUpdates.at(-1) as SandboxConfig; - values.push(key === AgentHostSandboxKey.AllowLocalNetwork ? applied.userPolicy?.network?.allowLocalNetwork : applied[key]); + values.push(key === AgentHostSandboxKey.AuthenticateGit ? applied.auth?.git + : key === AgentHostSandboxKey.AuthenticateGh ? applied.auth?.gh + : key === AgentHostSandboxKey.AllowLocalNetwork ? applied.userPolicy?.network?.allowLocalNetwork : applied[key]); } assert.deepStrictEqual(values, [managedValue, local, local]); } @@ -20151,6 +20326,36 @@ Use the attached image as context. assert.strictEqual(session.requiresMcpLaunchConfigurationRefresh, true); }); + test('republishes pending connector authentication when display names become available', async () => { + const runtimeServerId = 'github-copilot-connector-94d26095770df60673dd'; + const resource = 'https://api.github.com'; + const { session, runtime, waitForSignal } = await createAgentSession(disposables); + + const authPromise = runtime.handleMcpAuthRequest({ + requestId: 'auth-connector', + serverName: runtimeServerId, + serverUrl: resource, + reason: 'upscope', + }, { sessionId: 'test-session-1' }); + await waitForSignal(signal => isAction(signal, ActionType.SessionCustomizationUpdated)); + const beforeDisplayName = readMcpServerDisplayName(session.topLevelMcpCustomizations()[0]); + runtime.setMcpServerDisplayNames(new Map([[runtimeServerId, 'GitHub']])); + const [server] = session.topLevelMcpCustomizations(); + await session.resolveMcpAuthentication({ resource, scopes: [], token: 'connector-token' }); + + assert.deepStrictEqual({ + beforeDisplayName, + name: server.name, + displayName: readMcpServerDisplayName(server), + result: await authPromise, + }, { + beforeDisplayName: undefined, + name: runtimeServerId, + displayName: 'GitHub', + result: { kind: 'token', accessToken: 'connector-token' }, + }); + }); + test('MCP authentication only clears auth-required after all matching server challenges resolve', async () => { const { session, runtime, signals, mockSession } = await createAgentSession(disposables); const firstAuth = runtime.handleMcpAuthRequest({ diff --git a/src/vs/platform/agentHost/test/node/copilotSandboxDiagnostics.test.ts b/src/vs/platform/agentHost/test/node/copilotSandboxDiagnostics.test.ts index 3fc79d6445046b..f2e3e7e1928455 100644 --- a/src/vs/platform/agentHost/test/node/copilotSandboxDiagnostics.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSandboxDiagnostics.test.ts @@ -50,7 +50,7 @@ suite('CopilotSandboxDiagnostics', () => { ]); }); - test('only reports capabilities used by the configured sandbox', async () => { + test('reports network filtering without a proxy and gates network and denied paths on configuration', async () => { const manager = createStateManager(); const diagnostics = store.add(new CopilotSandboxDiagnostics(session, async () => ({ supported: true, @@ -72,11 +72,34 @@ suite('CopilotSandboxDiagnostics', () => { }, }); assert.deepStrictEqual([unused, readAgentSandboxDiagnostics(manager.getSessionSummary(session)!)], [ - undefined, + ['Proxy unsupported.'], ['Install slirp4netns.', 'Proxy unsupported.', 'Denied paths unsupported.'], ]); }); + test('reports unavailable network filtering and filesystem enumeration from host capabilities without user policy', async () => { + const manager = createStateManager(); + const networkFilteringReason = 'This Windows host cannot run a sandbox proxy. See https://aka.ms/ghcp-sandbox-os-support'; + const filesystemEnumerationReason = 'This Windows host cannot grant enumerate-only filesystem access. See https://aka.ms/ghcp-sandbox-os-support'; + const diagnostics = store.add(new CopilotSandboxDiagnostics(session, async () => ({ + supported: true, + capabilities: [ + { name: 'network', supported: true }, + { name: 'network_filtering', supported: false, reason: networkFilteringReason }, + { name: 'denied_paths', supported: true }, + { name: 'shell', supported: true }, + { name: 'filesystem_enumeration', supported: false, reason: filesystemEnumerationReason }, + ], + }), manager, new NullLogService())); + + await diagnostics.update({ enabled: true }); + + assert.deepStrictEqual(readAgentSandboxDiagnostics(manager.getSessionSummary(session)!), [ + networkFilteringReason, + filesystemEnumerationReason, + ]); + }); + test('reports unavailable shell support and supplies missing diagnostic text', async () => { const manager = createStateManager(); const diagnostics = store.add(new CopilotSandboxDiagnostics(session, async () => ({ diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index 5c70f26075b093..802e3d32be1420 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -52,6 +52,7 @@ const testRuntime: ICopilotSessionRuntime = { 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'); }, + setMcpServerDisplayNames: () => { }, handleMcpAuthRequest: async () => { throw new Error('Unexpected MCP auth request'); }, requestUnsandboxedCommandConfirmation: async () => false, handlePreToolUse: async () => { }, @@ -150,7 +151,7 @@ function createTestLauncher(managedSettingsPermissions?: IAgentHostManagedSettin suite('CopilotSessionLauncher sandbox policy', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - function setup(kind: 'create' | 'resume', reportPolicy = true, enforced = false, allowBypass = false, allowOutbound?: boolean, sandboxUpdateError?: Error, sandboxToggles?: Pick, allowLocalNetwork?: boolean) { + function setup(kind: 'create' | 'resume', reportPolicy = true, enforced = false, allowBypass = false, allowOutbound?: boolean, sandboxUpdateError?: Error, sandboxToggles?: Pick, allowLocalNetwork?: boolean) { const manager = store.add(new AgentHostStateManager(new NullLogService())); const configuration = store.add(new AgentConfigurationService(manager, new NullLogService())); const owner = 'copilot:/sess-1'; @@ -176,6 +177,11 @@ suite('CopilotSessionLauncher sandbox policy', () => { if (allowLocalNetwork === false && options.sandboxConfig?.userPolicy?.network?.allowLocalNetwork === true) { throw new Error('Local network access violates managed policy'); } + for (const field of ['git', 'gh'] as const) { + if (sandboxToggles?.auth?.[field] === false && options.sandboxConfig?.auth?.[field] === true) { + throw new Error(`${field} authentication violates managed policy`); + } + } for (const [key, managedValue] of [['sandboxMcpServers', true], ['sandboxLspServers', true], ['allowDevToolAccess', false]] as const) { if (sandboxToggles?.[key] === managedValue && options.sandboxConfig && options.sandboxConfig[key] !== managedValue) { throw new Error(`${key} violates managed policy`); @@ -187,13 +193,14 @@ suite('CopilotSessionLauncher sandbox policy', () => { } }, } as unknown as CopilotSession; + const { auth, ...sandboxOptions } = sandboxToggles ?? {}; const initialize = (config: ResumeSessionConfig | undefined) => { captured = config; if (reportPolicy) { config?.onEvent?.({ id: 'resolved', parentId: null, timestamp: '2026-01-01T00:00:00Z', type: 'session.managed_settings_resolved', ephemeral: true, - data: { source: 'server', serverManaged: true, deviceManaged: false, failClosed: false, bypassPermissionsDisabled: false, managedKeys: enforced ? ['sandbox'] : [], settings: enforced ? { sandbox: { enabled: true, allowBypass, ...sandboxToggles, userPolicy: { network: { ...(allowOutbound !== undefined ? { allowOutbound } : {}), ...(allowLocalNetwork !== undefined ? { allowLocalNetwork } : {}) } } } } : {} }, + data: { source: 'server', serverManaged: true, deviceManaged: false, failClosed: false, bypassPermissionsDisabled: false, managedKeys: enforced ? ['sandbox'] : [], settings: enforced ? { sandbox: { enabled: true, allowBypass, ...sandboxOptions, ...(auth ? { auth: { ...auth } } : {}), userPolicy: { network: { ...(allowOutbound !== undefined ? { allowOutbound } : {}), ...(allowLocalNetwork !== undefined ? { allowLocalNetwork } : {}) } } } } : {} }, }); } return raw; @@ -216,6 +223,28 @@ suite('CopilotSessionLauncher sandbox policy', () => { } for (const kind of ['create', 'resume'] as const) { + for (const [key, field] of [ + ['authenticateGit', 'git'], + ['authenticateGh', 'gh'], + ] as const) { + test(`${kind} applies managed ${field} authentication denial and preserves local restrictions`, async () => { + for (const local of [undefined, false, true]) { + for (const managed of [undefined, false, true]) { + const fixture = setup(kind, true, true, false, undefined, undefined, { auth: { [field]: managed } }); + fixture.configuration.updateRootConfig({ sandbox: { enabled: 'on', ...(local !== undefined ? { [key]: local } : {}) } }); + store.add(await fixture.launcher.launch(fixture.plan, testRuntime)); + assert.deepStrictEqual({ + applied: fixture.updates.filter(update => update.sandboxConfig).map(update => update.sandboxConfig?.auth), + stored: fixture.configuration.getRootConfigValues()?.sandbox, + }, { + applied: [{ git: true, gh: true, [field]: managed === false ? false : local ?? true }], + stored: { enabled: 'on', ...(local !== undefined ? { [key]: local } : {}) }, + }); + } + } + }); + } + for (const [format, error] of [ ['structured', Object.assign(new Error('Managed sandbox conflict'), { data: { code: 'managed_sandbox_policy_conflict' } })], ['message', new Error('Sandbox configuration update violates managed policy. Contact your administrator for more information.')], @@ -686,6 +715,7 @@ suite('CopilotSessionLauncher BYOK proxy lifecycle', () => { test('reconciles connector MCP servers through the Copilot runtime before launch completes', async () => { const connectorCalls: string[] = []; + let connectorDisplayNames: ReadonlyMap = new Map(); let featureFlags: Record | undefined; const session = { sessionId: 'connector-session', @@ -709,6 +739,11 @@ suite('CopilotSessionLauncher BYOK proxy lifecycle', () => { return { apiVersion: 1, availability: 'enabled' as const, + catalog: { + revision: 1, + refreshedAtMs: 1, + connectors: [{ name: 'mail', displayName: 'Work IQ Mail', status: 'connected' as const, runtimeServerIds: ['connector-mail'] }], + }, runtimeServers: [{ runtimeServerId: 'connector-mail', connectorName: 'mail', status: 'connected' as const }], pendingConnections: 0, }; @@ -755,14 +790,19 @@ suite('CopilotSessionLauncher BYOK proxy lifecycle', () => { model: undefined, }; - const launched = await launcher.launch(plan, testRuntime); + const launched = await launcher.launch(plan, { + ...testRuntime, + setMcpServerDisplayNames: displayNames => connectorDisplayNames = new Map(displayNames), + }); try { assert.deepStrictEqual({ featureFlags, connectorCalls, + connectorDisplayName: connectorDisplayNames.get('connector-mail'), }, { featureFlags: { CONNECTORS: true, TGREP: false, CONTENT_EXCLUSION: true, MANAGED_MCP_SERVERS: true }, connectorCalls: ['capabilities', 'auth', 'accounts', 'reconcile:account-1:true'], + connectorDisplayName: 'Work IQ Mail', }); } finally { launched.dispose(); @@ -835,7 +875,7 @@ suite('CopilotSessionLauncher shared session config', () => { resumeConfigs.push(config); return session; }, - rpc: { account: new class extends mock() { }, sandbox: { getHostSupport: async () => ({ supported: true, capabilities: [] }) } }, + rpc: { account: new class extends mock() { }, sandbox: { proxyCa: new class extends mock() { }, getHostSupport: async () => ({ supported: true, capabilities: [] }) } }, }; const managedSettingsPermissions: IAgentHostManagedSettingsPermissions = { disableBypassPermissionsMode: 'disable', @@ -1180,7 +1220,7 @@ suite('CopilotSessionLauncher canvas config', () => { return session; }, resumeSession: async () => { throw new Error('Unexpected resume'); }, - rpc: { account: new class extends mock() { }, sandbox: { getHostSupport: async () => ({ supported: true, capabilities: [] }) } }, + rpc: { account: new class extends mock() { }, sandbox: { proxyCa: new class extends mock() { }, getHostSupport: async () => ({ supported: true, capabilities: [] }) } }, } as unknown as CopilotClient; const launcher = createTestLauncher(undefined, { [AgentHostCanvasesEnabledConfigKey]: true }); const plan: CopilotSessionLaunchPlan = { @@ -1231,7 +1271,7 @@ suite('CopilotSessionLauncher canvas config', () => { return session; }, resumeSession: async () => { throw new Error('Unexpected resume'); }, - rpc: { account: new class extends mock() { }, sandbox: { getHostSupport: async () => ({ supported: true, capabilities: [] }) } }, + rpc: { account: new class extends mock() { }, sandbox: { proxyCa: new class extends mock() { }, getHostSupport: async () => ({ supported: true, capabilities: [] }) } }, } as unknown as CopilotClient; const launcher = createTestLauncher(); const plan: CopilotSessionLaunchPlan = { @@ -1295,7 +1335,7 @@ suite('CopilotSessionLauncher resume fallback', () => { resumeSession: async () => { throw new TestSdkError(message, code); }, - rpc: { account: new class extends mock() { }, sandbox: { getHostSupport: async () => ({ supported: true, capabilities: [] }) } }, + rpc: { account: new class extends mock() { }, sandbox: { proxyCa: new class extends mock() { }, getHostSupport: async () => ({ supported: true, capabilities: [] }) } }, }; return { launcher: createTestLauncher(undefined, {}, logService, sessionOpenTelemetry), @@ -2200,7 +2240,7 @@ suite('CopilotSessionLauncher auto tier', () => { }, } as unknown as CopilotSession; const client: CopilotSessionLaunchPlan['client'] = { - rpc: { account: new class extends mock() { }, sandbox: { getHostSupport: async () => ({ supported: true, capabilities: [] }) } }, + rpc: { account: new class extends mock() { }, sandbox: { proxyCa: new class extends mock() { }, getHostSupport: async () => ({ supported: true, capabilities: [] }) } }, createSession: async config => { capiCalls.push(config.capi); return session; diff --git a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts index ad46d1debb25e5..c7fea634b4ada5 100644 --- a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts @@ -253,7 +253,7 @@ suite('CopilotSlashCommandCompletionProvider', () => { label: 'sandbox policy', _meta: { command: 'sandbox', - description: 'Show the effective sandbox policy for this session', + description: 'Open the latest effective sandbox policy for this session in the editor', }, }, }]); @@ -294,7 +294,7 @@ suite('CopilotSlashCommandCompletionProvider', () => { const commands = new CopilotSlashCommandProvider(async () => [sandboxCommand], undefined, new NullLogService()); assert.deepStrictEqual((await commands.getSlashCommands()).map(command => ({ name: command.name, input: command.input, - })), [{ name: 'sandbox', input: { hint: '', choices: [{ name: 'policy', description: 'Show the effective sandbox policy for this session' }] } }]); + })), [{ name: 'sandbox', input: { hint: '', choices: [{ name: 'policy', description: 'Open the latest effective sandbox policy for this session in the editor' }] } }]); }); test('offers only policy even when the SDK only advertises configuration choices', async () => { diff --git a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md index 8ae447899c1a1f..cfe6b962a26e46 100644 --- a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md +++ b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md @@ -55,29 +55,6 @@ The session's combined changes sometimes omit edits from one of its two chats, f scripts\test-integration.bat --run src/vs/platform/agentHost/test/node/e2e/providers/codexAgentHostE2E.integrationTest.ts --grep "session changeset aggregates provider edits from default and peer chats" ``` -### Copilot managed telemetry changes require a host restart - -An administrator can change telemetry policy while a user has an Agent Host running. -After the runtime has exported a turn under the original policy, creating another -session with a changed telemetry service name fails instead of starting the chat. -Restarting the host lets the same new policy work, including message-content capture. - -- Test: `new sessions honor changed managed telemetry without restarting` in `providers/copilotOtelAgentHostE2E.integrationTest.ts`. -- Scope: Copilot, record and replay. Reproduced locally on macOS with SDK `1.0.16` / runtime `1.0.90`; other platforms remain unvalidated. -- Expected: the second session completes without a manual restart, and its decoded inference span contains its actual user message under service B, correlated using the provider session ID reported over AHP. -- Observed: the second turn fails with `Managed telemetry conflicts with the already selected OTel configuration`. The runtime deliberately permits only one effective telemetry configuration per process; suppressing the error or retaining policy A is not a fix. -- Controls: unchanged-policy sessions and an explicit host restart both pass with capture enabled. Runtime `1.0.89-3` from SDK `1.0.15-preview.3`, tested through the current host's runtime-path override, completes the first turn but exports no inference spans. It is not a green baseline for the full telemetry contract. -- Expected failure: runs by default and accepts only the second session's known configuration-conflict error, tracked by [github/copilot-agent-runtime#24069](https://github.com/github/copilot-agent-runtime/pull/24069). A successful second turn and export fail the test as an unexpected pass, requiring removal of the marker. Setup, replay, and other failures remain failures. -- After that recognized failure only, teardown permits unused future model responses; all observed requests must still match the recording. -- Fixture provenance: the two trivial model responses were generated with an explicit restart between turns, then the restart was removed and the warm failure was confirmed in strict replay. The permanent warm scenario must never restart the host. -- Reproduce: - - ```bash - ./scripts/test-integration.sh --run \ - src/vs/platform/agentHost/test/node/e2e/providers/copilotOtelAgentHostE2E.integrationTest.ts \ - --grep "new sessions honor changed managed telemetry without restarting" - ``` - ### Binary writes to client-hosted files are corrupted An agent host can address files that live on a connected client and send symmetric AHP filesystem operations back to that client. When the host writes binary content this way, bytes that are not valid UTF-8 are replaced before they reach the client, so images and other binary files can be corrupted. @@ -589,17 +566,6 @@ A client can synchronize a question's answer while the user edits it, then submi - Gate: `context.runKnownIssueTests`. Explicit final-answer replacement and cancellation remain enabled. - Reproduce: `AGENT_HOST_RUN_KNOWN_ISSUES=1 AGENT_HOST_REPLAY_RECORD=1 ./scripts/test-integration.sh --runGlob '**/e2e/providers/*AgentHostE2E.integrationTest.js' --grep 'input drafts: submitting uses'`. -### Workspace-less sessions cannot offer their workspace attachment tool - -A user can start a conversation without a workspace and later ask to attach a project folder. Copilot and Codex support workspace conversion, but the new session does not advertise the `set_workspace` tool, even after completing a normal turn. The agent therefore cannot start the attachment workflow. - -- Test: `workspace conversion: a workspaceless session advertises its attachment tool`. -- Scope: observed on macOS with Copilot and Codex; gated on all platforms. Claude does not support workspace conversion. -- Expected: the materialized workspace-less session advertises `set_workspace`. -- Observed: `serverTools` omits the tool; a recording asking Copilot to attach a folder confirms that the model does not have it. -- Gate: `context.runKnownIssueTests`. -- Reproduce: `AGENT_HOST_RUN_KNOWN_ISSUES=1 AGENT_HOST_REPLAY_RECORD=1 ./scripts/test-integration.sh --runGlob '**/e2e/providers/*AgentHostE2E.integrationTest.js' --grep 'workspace conversion: a workspaceless session advertises'`. - ### Workspace membership changes are lost after a host restart A user can add or remove a folder in an Editor Window multi-root session. The host accepts the change, but restarting it restores the original folder set. Added folders disappear and removed folders return, so file completions and subsequent chats can use a different workspace than the user selected. diff --git a/src/vs/platform/agentHost/test/node/e2e/README.md b/src/vs/platform/agentHost/test/node/e2e/README.md index d1ecc0f9db76b2..184dec9ddea9bf 100644 --- a/src/vs/platform/agentHost/test/node/e2e/README.md +++ b/src/vs/platform/agentHost/test/node/e2e/README.md @@ -118,7 +118,7 @@ The residual case is `providerHostOnlyTest(...)`: per-provider, but no model tra | `captures/*.yaml` | Committed model fixtures, plus one shared strict empty fixture for tests that declare no model traffic. | | `conformance/__snapshots__/`, `providers/__snapshots__/` | Semantic AHP snapshots (`*.traffic.ahp.yaml`) and assembled-prompt snapshots (`*.prompt.md`), resolved relative to the entry point that registered the test. | | `providers/copilotPromptsE2E.integrationTest.ts` | The provider request-body boundary: the complete model request body the bundled Copilot CLI sends, read off a replayed turn. See [Prompt snapshots](#prompt-snapshots). | -| `providers/copilotOtelAgentHostE2E.integrationTest.ts` | Native Copilot telemetry: Agent Host file export, managed content capture across sessions, and policy changes after a host restart. The warm policy-change gap is tracked in `KNOWN_ISSUES.md`. | +| `providers/copilotOtelAgentHostE2E.integrationTest.ts` | Native Copilot telemetry: Agent Host file export, managed content capture across sessions, and policy changes with and without a host restart. | | `coverage/summary.json` | Checked-in line coverage of the host implementation. | | `coverage/protocol-surface.json` | Checked-in coverage of the AHP contract itself. | | [`KNOWN_ISSUES.md`](./KNOWN_ISSUES.md) | Inventory and reevaluation process for disabled or conditional tests. | @@ -212,6 +212,8 @@ Each elision has a reason, and dropping any of them would make the assertion eit A single text block left after removing reasoning is compared as bare text, matching the replay codec's representation. Multiple text blocks and mixed text/tool content retain their structure. +Runtime-authored change notices are elided from user text. A standalone user message containing only those recognized notices is omitted as well; user questions, empty authored messages, assistant messages, and tool-result wiring remain asserted. + A mismatch fails the test as `[capi-replay] N model request mismatch(es)` and prints both projections. It usually means the capture is stale — the prompt or the host's prompt assembly changed without a re-record — so **re-record it** (see [Updating snapshots and fixtures](#updating-snapshots-and-fixtures)). Never hand-edit the request block to match. If a capture genuinely cannot be refreshed, add its test title to `STALE_RECORDED_REQUEST_EXCEPTIONS` in `agentHostE2ETestHarness.ts` with a `KNOWN_ISSUES.md` entry. --- @@ -285,7 +287,7 @@ On Windows, test-server cleanup records descendants before requesting graceful s The complete-suite runner parallelizes above this lease: conformance, Claude, Codex, Copilot, and Copilot OTel each run in an isolated test process with their own server lease. Tests within one entrypoint stay serial and continue sharing servers, preserving the lifecycle and fixture-window invariants while letting the independent entrypoints overlap. Managed-telemetry tests use a fresh lease and loopback collector per test because their policy and exporter configuration are process-scoped. -Copilot OTel leases clear inherited generic and trace-specific OTLP certificate/key variables in the child environment because both managed telemetry and the Agent Host file-export path use plain HTTP loopback collectors. The parent process environment is unchanged. +Copilot OTel leases clear inherited generic and trace-specific OTLP certificate/key variables in the child environment because both managed telemetry and the Agent Host file-export path use plain HTTP loopback collectors. They also set `OTEL_BSP_SCHEDULE_DELAY=100` so native span batching does not consume the bounded export-readiness wait or inherit a developer's longer export schedule. The tests still wait for and assert the actual exported spans; the parent process environment is unchanged. The file-export test also sets `OTEL_BSP_SCHEDULE_DELAY=100` in its child environment so native SDK batching does not race the ten-second span-polling budget. It still waits for the actual SDK and host spans in the exported file; neither the polling deadline nor the required spans are relaxed. diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-workspace-conversion-first-prompt-converts-a-workspaceless-session.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-workspace-conversion-first-prompt-converts-a-workspaceless-session.yaml new file mode 100644 index 00000000000000..4d153adb04fe09 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-workspace-conversion-first-prompt-converts-a-workspaceless-session.yaml @@ -0,0 +1,147 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: 'First use the available user-input tool exactly once to ask which workspace setup to use, with one choice: "file://${workdir} directly without isolation". After I answer, call set_workspace with that workspace and isolation false as the turn''s final tool call. Never run a shell command. When the host automatically continues after attaching the workspace, do not inspect, read, or modify it and do not call another tool; reply exactly "converted".' + response: + content: + - type: text + text: Got it — I’ll ask that single workspace-choice question now. + - type: tool_use + id: toolcall_0 + name: request_user_input + input: + questions: + - header: Workspace + id: workspace_setup + question: Which workspace setup should I use? + options: + - label: file://${workdir} directly without isolation + description: Attach that exact folder directly with isolation disabled. + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: 'First use the available user-input tool exactly once to ask which workspace setup to use, with one choice: "file://${workdir} directly without isolation". After I answer, call set_workspace with that workspace and isolation false as the turn''s final tool call. Never run a shell command. When the host automatically continues after attaching the workspace, do not inspect, read, or modify it and do not call another tool; reply exactly "converted".' + - role: assistant + content: Got it — I’ll ask that single workspace-choice question now. + - role: assistant + content: + - type: tool_use + name: request_user_input + input: + questions: + - header: Workspace + id: workspace_setup + question: Which workspace setup should I use? + options: + - label: file://${workdir} directly without isolation + description: Attach that exact folder directly with isolation disabled. + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"answers":{"workspace_setup":{"answers":["file://${workdir} directly without isolation"]}}}' + response: + content: + - type: text + text: Thanks — attaching that exact workspace now with isolation disabled. + - type: tool_use + id: toolcall_1 + name: set_workspace + input: + workspaceFolder: file://${workdir} + isolation: false + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: 'First use the available user-input tool exactly once to ask which workspace setup to use, with one choice: "file://${workdir} directly without isolation". After I answer, call set_workspace with that workspace and isolation false as the turn''s final tool call. Never run a shell command. When the host automatically continues after attaching the workspace, do not inspect, read, or modify it and do not call another tool; reply exactly "converted".' + - role: assistant + content: Got it — I’ll ask that single workspace-choice question now. + - role: assistant + content: + - type: tool_use + name: request_user_input + input: + questions: + - header: Workspace + id: workspace_setup + question: Which workspace setup should I use? + options: + - label: file://${workdir} directly without isolation + description: Attach that exact folder directly with isolation disabled. + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"answers":{"workspace_setup":{"answers":["file://${workdir} directly without isolation"]}}}' + - role: assistant + content: Thanks — attaching that exact workspace now with isolation disabled. + - role: assistant + content: + - type: tool_use + name: set_workspace + input: + workspaceFolder: file://${workdir} + isolation: false + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: Workspace will be set to file://${workdir} after this turn ends. End this turn now without calling more tools or replying; the host will continue the original task automatically in the selected workspace. + response: + content: [] + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: 'First use the available user-input tool exactly once to ask which workspace setup to use, with one choice: "file://${workdir} directly without isolation". After I answer, call set_workspace with that workspace and isolation false as the turn''s final tool call. Never run a shell command. When the host automatically continues after attaching the workspace, do not inspect, read, or modify it and do not call another tool; reply exactly "converted".' + - role: assistant + content: Got it — I’ll ask that single workspace-choice question now. + - role: assistant + content: + - type: tool_use + name: request_user_input + input: + questions: + - header: Workspace + id: workspace_setup + question: Which workspace setup should I use? + options: + - label: file://${workdir} directly without isolation + description: Attach that exact folder directly with isolation disabled. + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '{"answers":{"workspace_setup":{"answers":["file://${workdir} directly without isolation"]}}}' + - role: assistant + content: Thanks — attaching that exact workspace now with isolation disabled. + - role: assistant + content: + - type: tool_use + name: set_workspace + input: + workspaceFolder: file://${workdir} + isolation: false + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: Workspace will be set to file://${workdir} after this turn ends. End this turn now without calling more tools or replying; the host will continue the original task automatically in the selected workspace. + - role: user + content: The current session is now attached to ${workdir}. Continue the user's original task in this workspace. Do not request another session or workspace conversion. + response: + content: converted + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-workspace-conversion-first-prompt-converts-a-workspaceless-session.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-workspace-conversion-first-prompt-converts-a-workspaceless-session.yaml new file mode 100644 index 00000000000000..61e6b9e3f1323f --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-workspace-conversion-first-prompt-converts-a-workspaceless-session.yaml @@ -0,0 +1,122 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: 'First use the available user-input tool exactly once to ask which workspace setup to use, with one choice: "file://${workdir} directly without isolation". After I answer, call set_workspace with that workspace and isolation false as the turn''s final tool call. Never run a shell command. When the host automatically continues after attaching the workspace, do not inspect, read, or modify it and do not call another tool; reply exactly "converted".' + response: + content: + - type: tool_use + id: toolcall_0 + name: ask_user + input: + question: Which workspace setup should I use? + choices: + - file://${workdir} directly without isolation + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: 'First use the available user-input tool exactly once to ask which workspace setup to use, with one choice: "file://${workdir} directly without isolation". After I answer, call set_workspace with that workspace and isolation false as the turn''s final tool call. Never run a shell command. When the host automatically continues after attaching the workspace, do not inspect, read, or modify it and do not call another tool; reply exactly "converted".' + - role: assistant + content: + - type: thinking + - type: tool_use + name: ask_user + input: + question: Which workspace setup should I use? + choices: + - file://${workdir} directly without isolation + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'User selected: file://${workdir} directly without isolation' + response: + content: + - type: tool_use + id: toolcall_1 + name: set_workspace + input: + workspaceFolder: file://${workdir} + isolation: false + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: 'First use the available user-input tool exactly once to ask which workspace setup to use, with one choice: "file://${workdir} directly without isolation". After I answer, call set_workspace with that workspace and isolation false as the turn''s final tool call. Never run a shell command. When the host automatically continues after attaching the workspace, do not inspect, read, or modify it and do not call another tool; reply exactly "converted".' + - role: assistant + content: + - type: thinking + - type: tool_use + name: ask_user + input: + question: Which workspace setup should I use? + choices: + - file://${workdir} directly without isolation + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'User selected: file://${workdir} directly without isolation' + - role: assistant + content: + - type: tool_use + name: set_workspace + input: + workspaceFolder: file://${workdir} + isolation: false + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: Workspace will be set to file://${workdir} after this turn ends. End this turn now without calling more tools or replying; the host will continue the original task automatically in the selected workspace. + response: + content: converted + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: 'First use the available user-input tool exactly once to ask which workspace setup to use, with one choice: "file://${workdir} directly without isolation". After I answer, call set_workspace with that workspace and isolation false as the turn''s final tool call. Never run a shell command. When the host automatically continues after attaching the workspace, do not inspect, read, or modify it and do not call another tool; reply exactly "converted".' + - role: assistant + content: + - type: thinking + - type: tool_use + name: ask_user + input: + question: Which workspace setup should I use? + choices: + - file://${workdir} directly without isolation + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'User selected: file://${workdir} directly without isolation' + - role: assistant + content: + - type: tool_use + name: set_workspace + input: + workspaceFolder: file://${workdir} + isolation: false + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: Workspace will be set to file://${workdir} after this turn ends. End this turn now without calling more tools or replying; the host will continue the original task automatically in the selected workspace. + - role: assistant + content: converted + - role: user + content: The current session is now attached to ${workdir}. Continue the user's original task in this workspace. Do not request another session or workspace conversion. + response: + content: I've attached the session to the workspace at [ahp-workspace-conversion-bthroQ](${workdir}). However, I don't see an original task described yet beyond the workspace setup itself — could you let me know what you'd like me to work on in this workspace? + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/capiReplayProxy.ts b/src/vs/platform/agentHost/test/node/e2e/harness/capiReplayProxy.ts index 80b8e74a8424eb..f571952e434377 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/capiReplayProxy.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/capiReplayProxy.ts @@ -1093,6 +1093,7 @@ export class CapiReplayProxy { match => match.startsWith('/private') ? canonicalWorkingDirectory : this._workingDirectory!, ); } + result = replaceAll(result, `file://${WORKDIR_PLACEHOLDER}`, URI.file(this._workingDirectory).toString()); result = replaceAll(result, `/private${WORKDIR_PLACEHOLDER}`, canonicalWorkingDirectory); result = replaceAll(result, WORKDIR_PLACEHOLDER, this._workingDirectory); if (suffix) { diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/modelRequestProjection.ts b/src/vs/platform/agentHost/test/node/e2e/harness/modelRequestProjection.ts index 4e6f69ac7ac1ee..13eb10b4624b13 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/modelRequestProjection.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/modelRequestProjection.ts @@ -182,7 +182,12 @@ function projectContent(content: unknown): unknown { export function projectModelRequest(request: IReadableAnthropicRequest): IProjectedModelRequest { return { system: request.system, - messages: request.messages.map(message => ({ role: message.role, content: projectContent(message.content) })), + messages: request.messages + .filter(message => !(message.role === 'user' + && typeof message.content === 'string' + && message.content.length > 0 + && elideChangeNotices(message.content) === '')) + .map(message => ({ role: message.role, content: projectContent(message.content) })), }; } diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md index 404e3521c26e7f..51b968e02e1d7e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md @@ -345,7 +345,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "input_schema": { "type": "object", "properties": { @@ -371,7 +371,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -517,28 +517,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", + "claude-haiku-4.5", + "claude-opus-4.5", + "claude-opus-4.6", "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", "claude-sonnet-4.6", - "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md index 76a42ecad71cce..fe0ab29e30585a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md @@ -345,7 +345,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "input_schema": { "type": "object", "properties": { @@ -371,7 +371,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -517,28 +517,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", + "claude-haiku-4.5", + "claude-opus-4.5", + "claude-opus-4.6", "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", "claude-sonnet-4.6", - "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md index 0fa2c17aeff0bc..035a60333311ce 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md @@ -345,7 +345,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "input_schema": { "type": "object", "properties": { @@ -371,7 +371,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -517,28 +517,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", + "claude-haiku-4.5", + "claude-opus-4.5", + "claude-opus-4.6", "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", "claude-sonnet-4.6", - "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md index 6ebe420b998309..9b9e665afe276e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md @@ -345,7 +345,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "input_schema": { "type": "object", "properties": { @@ -371,7 +371,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -517,28 +517,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", + "claude-haiku-4.5", + "claude-opus-4.5", + "claude-opus-4.6", "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", "claude-sonnet-4.6", - "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md index 3694fe3a98cb3b..4ee567f27b2523 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md @@ -345,7 +345,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "input_schema": { "type": "object", "properties": { @@ -371,7 +371,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -517,28 +517,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", + "claude-haiku-4.5", + "claude-opus-4.5", + "claude-opus-4.6", "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", "claude-sonnet-4.6", - "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5_5.prompt.md index 6c5e3bcd56bd84..6c248ea61eaf78 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5_5.prompt.md @@ -345,7 +345,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "input_schema": { "type": "object", "properties": { @@ -371,7 +371,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -517,28 +517,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", + "claude-haiku-4.5", + "claude-opus-4.5", + "claude-opus-4.6", "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", "claude-sonnet-4.6", - "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md index 78a03e17aacf77..7cbe0ffee2765d 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md @@ -345,7 +345,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "input_schema": { "type": "object", "properties": { @@ -371,7 +371,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -517,28 +517,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", + "claude-haiku-4.5", + "claude-opus-4.5", + "claude-opus-4.6", "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", "claude-sonnet-4.6", - "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md index 52f08a3851d51e..94ef868348559f 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md @@ -345,7 +345,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "input_schema": { "type": "object", "properties": { @@ -371,7 +371,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -517,28 +517,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", + "claude-haiku-4.5", + "claude-opus-4.5", + "claude-opus-4.6", "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", "claude-sonnet-4.6", - "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md index c0db54245d19fc..b7577fc290b479 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md @@ -345,7 +345,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "input_schema": { "type": "object", "properties": { @@ -371,7 +371,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -517,28 +517,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", + "claude-haiku-4.5", + "claude-opus-4.5", + "claude-opus-4.6", "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", "claude-sonnet-4.6", - "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md index 639eda1c1ba2b4..145da0c7adfb9e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md @@ -353,7 +353,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "parameters": { "type": "object", "properties": { @@ -379,7 +379,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -531,28 +531,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", + "claude-haiku-4.5", + "claude-opus-4.5", + "claude-opus-4.6", "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", "claude-sonnet-4.6", - "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md index 458bf717ee4a92..9f6b57d183ee40 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md @@ -314,7 +314,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "parameters": { "type": "object", "properties": { @@ -340,7 +340,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -492,28 +492,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", + "claude-haiku-4.5", + "claude-opus-4.5", + "claude-opus-4.6", "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", "claude-sonnet-4.6", - "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md index 2592a3e4f8e524..395c89013ca59a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md @@ -353,7 +353,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "parameters": { "type": "object", "properties": { @@ -379,7 +379,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -531,28 +531,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", + "claude-haiku-4.5", + "claude-opus-4.5", + "claude-opus-4.6", "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", "claude-sonnet-4.6", - "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md index 93ce8d3c9c5238..e0893455776445 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md @@ -353,7 +353,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "parameters": { "type": "object", "properties": { @@ -379,7 +379,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -531,28 +531,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", + "claude-haiku-4.5", + "claude-opus-4.5", + "claude-opus-4.6", "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", "claude-sonnet-4.6", - "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md index 15a3f6a5ac8d93..633d147af14e34 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md @@ -314,7 +314,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "parameters": { "type": "object", "properties": { @@ -340,7 +340,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -492,28 +492,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", + "claude-haiku-4.5", + "claude-opus-4.5", + "claude-opus-4.6", "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", "claude-sonnet-4.6", - "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md index 94eca0759fa409..7692ed33178390 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md @@ -314,7 +314,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "parameters": { "type": "object", "properties": { @@ -340,7 +340,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -492,28 +492,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", + "claude-haiku-4.5", + "claude-opus-4.5", + "claude-opus-4.6", "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", "claude-sonnet-4.6", - "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md index 697b694f55acb4..0c0190cf34cdbe 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md @@ -353,7 +353,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "parameters": { "type": "object", "properties": { @@ -379,7 +379,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -531,28 +531,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", + "claude-haiku-4.5", + "claude-opus-4.5", + "claude-opus-4.6", "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", "claude-sonnet-4.6", - "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md index bc3267784b27c9..40d489e135fd57 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md @@ -333,7 +333,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "parameters": { "type": "object", "properties": { @@ -359,7 +359,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -511,28 +511,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", + "claude-haiku-4.5", + "claude-opus-4.5", + "claude-opus-4.6", "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", "claude-sonnet-4.6", - "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md index 47387dd0c55f4b..59bde33a7472dc 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md @@ -333,7 +333,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "parameters": { "type": "object", "properties": { @@ -359,7 +359,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -511,28 +511,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", + "claude-haiku-4.5", + "claude-opus-4.5", + "claude-opus-4.6", "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", "claude-sonnet-4.6", - "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md index 9e56a932f11c0a..d432d581ca2e98 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md @@ -333,7 +333,7 @@ }, { "name": "write_agent", - "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation as a new user turn.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message will be queued and delivered after the current turn completes.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", + "description": "Sends a message to one or more running or idle background agents, delivered as a new user turn in each agent's conversation.\n* Use write_agent only for same-task follow-ups where retained context is useful. For unrelated work or the next independent batch item, use a fresh task when available rather than repurposing an existing agent.\n* Messages are delivered directly into the agent's conversation.\n* If the agent is idle (finished its last turn), it will wake up and process the message as its next turn.\n* If the agent is running, the message steers its current turn at the next processing opportunity.\n* Use agent_id for one recipient; use agent_ids for a small explicit set of known recipients; use scope only when the same message applies to every currently visible sibling or child agent.\n* For peer-to-peer conversations: send your message with write_agent, then end your turn. The other agent's reply will arrive as your next turn automatically.", "parameters": { "type": "object", "properties": { @@ -359,7 +359,7 @@ }, "message": { "type": "string", - "description": "The message to send to the selected agent or agents. Each recipient will process this as a new conversation turn." + "description": "The message to send to the selected agent or agents. Steers a running turn or starts the idle agent's next turn." } }, "required": [ @@ -511,28 +511,28 @@ "model": { "type": "string", "enum": [ - "claude-sonnet-5", - "claude-opus-5.5", - "claude-opus-5", + "claude-haiku-4.5", + "claude-opus-4.5", + "claude-opus-4.6", "claude-opus-4.7", + "claude-opus-5", + "claude-opus-5.5", + "claude-sonnet-4.5", "claude-sonnet-4.6", - "claude-haiku-4.5", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.3-codex", - "gpt-5-mini", + "claude-sonnet-5", + "gemini-2.0-flash", + "gpt-4o", + "gpt-4o-mini", "gpt-5", "gpt-5-codex", + "gpt-5-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", - "claude-sonnet-4.5", - "claude-opus-4.5", - "claude-opus-4.6", - "gemini-2.0-flash", - "gpt-4o", - "gpt-4o-mini" + "gpt-5.3-codex", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra" ], "description": "Optional model override. Specify a value only when the user's current request or applicable persistent custom instructions (including global instructions) explicitly require that value for the subagent. Do not reuse values from earlier requests or infer unspecified values from the parent configuration. The runtime resolves `/subagents` preferences when these fields are omitted; do not copy them merely because they appear in ``." }, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/copilotOtelAgentHostE2E.integrationTest.ts b/src/vs/platform/agentHost/test/node/e2e/providers/copilotOtelAgentHostE2E.integrationTest.ts index 633a5d635c5e62..64704c9f3809c6 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/copilotOtelAgentHostE2E.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/copilotOtelAgentHostE2E.integrationTest.ts @@ -21,12 +21,11 @@ import { AgentHostE2EServerLease, createRealSession, driveTurnToCompletion, remo import { fetchSessionWithChat, TestProtocolClient } from '../../serverIntegrationTestHelpers.js'; import type { CapiReplayProxy } from '../harness/capiReplayProxy.js'; import { normalizeVolatileText } from '../harness/capiWireCodec.js'; -import { assertExpectedFailure } from '../harness/expectedFailure.js'; import { COPILOT_CONFIG } from './copilotTestConfiguration.js'; -const RECORD = process.env['AGENT_HOST_REPLAY_RECORD'] === '1' || process.env['AGENT_HOST_UPDATE_SNAPSHOTS'] === '1'; - -const clearedOtlpTlsEnv = { +const otelTestEnv = { + // Keep native batch export within the bounded wait, independent of inherited or default scheduling. + OTEL_BSP_SCHEDULE_DELAY: '100', OTEL_EXPORTER_OTLP_CERTIFICATE: '', OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE: '', OTEL_EXPORTER_OTLP_CLIENT_KEY: '', @@ -48,12 +47,11 @@ suite('Agent Host E2E — Copilot managed telemetry', function () { let workspace: string; let collector: ILocalOtlpHttpReceiver; let initialized: boolean; - let allowUnconsumedResponses: boolean; teardown(async function () { this.timeout(120_000); try { - await lease?.release(createdSessions, this.currentTest?.state === 'failed', { allowUnconsumedResponses }); + await lease?.release(createdSessions, this.currentTest?.state === 'failed'); } finally { try { await lease?.dispose(); @@ -71,7 +69,6 @@ suite('Agent Host E2E — Copilot managed telemetry', function () { spans.length = 0; decodeErrors.length = 0; initialized = false; - allowUnconsumedResponses = false; const directory = await mkdtemp(join(tmpdir(), 'copilot-managed-otel-')); tempDirs.push(directory); workspace = join(directory, 'workspace'); @@ -88,7 +85,7 @@ suite('Agent Host E2E — Copilot managed telemetry', function () { endpoint = collector.baseUrl; lease = new AgentHostE2EServerLease(COPILOT_CONFIG, { env: { - ...clearedOtlpTlsEnv, + ...otelTestEnv, COPILOT_CACHE_HOME: cacheHome, COPILOT_MANAGED_SETTINGS_CACHE: 'true', COPILOT_OTEL_ENABLED: 'false', @@ -177,14 +174,10 @@ suite('Agent Host E2E — Copilot managed telemetry', function () { await completeCapturedTurn('otel-policy-a', 'otel-capture-second', await setPolicy('otel-policy-a')); }); - (RECORD ? test.skip : test)('new sessions honor changed managed telemetry without restarting', async function () { + test('new sessions honor changed managed telemetry without restarting', async function () { this.timeout(180_000); await completeCapturedTurn('otel-policy-a', 'otel-policy-first', await setPolicy('otel-policy-a')); - await assertExpectedFailure('github/copilot-agent-runtime#24069', - /^Session error while driving otel-policy-second: sendFailed: Request session\.create failed with message: Standalone managed telemetry preparation failed: Managed telemetry conflicts with the already selected OTel configuration$/, async () => { - await completeCapturedTurn('otel-policy-b', 'otel-policy-second', await setPolicy('otel-policy-b')); - }); - allowUnconsumedResponses = true; + await completeCapturedTurn('otel-policy-b', 'otel-policy-second', await setPolicy('otel-policy-b')); }); test('new sessions honor changed managed telemetry after restarting', async function () { @@ -211,14 +204,12 @@ suite('Agent Host E2E — Copilot OTel file exporter', function () { exportFile = join(directory, 'spans.jsonl'); lease = new AgentHostE2EServerLease(COPILOT_CONFIG, { env: { - ...clearedOtlpTlsEnv, + ...otelTestEnv, COPILOT_OTEL_ENABLED: 'true', COPILOT_OTEL_DB_SPAN_EXPORTER_ENABLED: 'true', COPILOT_OTEL_EXPORTER_TYPE: 'file', COPILOT_OTEL_FILE_EXPORTER_PATH: exportFile, OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: 'true', - // Keep SDK batching comfortably inside the file-export polling budget. - OTEL_BSP_SCHEDULE_DELAY: '100', }, }); }); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/workspaceConversionSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/workspaceConversionSuite.ts index b2250c89e8f905..73b91723591c45 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/workspaceConversionSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/workspaceConversionSuite.ts @@ -9,10 +9,14 @@ import { tmpdir } from 'os'; import { join } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { AgentHostGlobalAutoApproveEnabledConfigKey } from '../../../../common/agentHostSchema.js'; +import { SessionServerToolName } from '../../../../common/serverToolNames.js'; import type { SubscribeResult } from '../../../../common/state/protocol/commands.js'; import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; +import { ActionType } from '../../../../common/state/sessionActions.js'; import { buildDefaultChatUri, readSessionWorkspaceless, ROOT_STATE_URI, type SessionState } from '../../../../common/state/sessionState.js'; -import { driveChatTurnToCompletion, resolveGitHubToken } from '../harness/agentHostE2ETestHarness.js'; +import { driveChatTurnToCompletion, getMarkdownResponseText, resolveGitHubToken } from '../harness/agentHostE2ETestHarness.js'; +import { getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; import type { IAgentHostE2ETestContext } from './e2eTestContext.js'; export function defineWorkspaceConversionTests(context: IAgentHostE2ETestContext): void { @@ -21,9 +25,8 @@ export function defineWorkspaceConversionTests(context: IAgentHostE2ETestContext } const { config } = context; - // The conversion-capable providers currently omit set_workspace from this session's tools. - (context.runKnownIssueTests ? test : test.skip)('workspace conversion: a workspaceless session advertises its attachment tool', async function () { - this.timeout(120_000); + test('workspace conversion: first prompt converts a workspaceless session', async function () { + this.timeout(180_000); const workspace = mkdtempSync(join(tmpdir(), 'ahp-workspace-conversion-')); context.tempDirs.push(workspace); context.client.setWorkingDirectory(workspace); @@ -33,20 +36,109 @@ export function defineWorkspaceConversionTests(context: IAgentHostE2ETestContext await context.client.call('authenticate', { channel: ROOT_STATE_URI, resource: 'https://api.github.com', token: config.githubToken ?? resolveGitHubToken(), }); + await context.client.call('subscribe', { channel: ROOT_STATE_URI }); + context.client.clearReceived(); + context.client.dispatch({ + channel: ROOT_STATE_URI, + clientSeq: 1, + action: { + type: ActionType.RootConfigChanged, + config: { [AgentHostGlobalAutoApproveEnabledConfigKey]: true }, + }, + }); + await context.client.waitForNotification(notification => + isActionNotification(notification, ActionType.RootConfigChanged) + && getActionEnvelope(notification).channel === ROOT_STATE_URI + && getActionEnvelope(notification).origin?.clientSeq === 1, + ); const session = URI.from({ scheme: config.scheme, path: `/${generateUuid()}` }).toString(); await context.client.call('createSession', { - channel: session, provider: config.provider, workingDirectories: [], config: config.sessionConfig, + channel: session, provider: config.provider, config: config.sessionConfig, }); context.createdSessions.push(session); - await context.client.call('subscribe', { channel: session }); + const initialSession = await context.client.call('subscribe', { channel: session }); + const initialSetWorkspace = (initialSession.snapshot!.state as SessionState).serverTools?.find(tool => tool.name === SessionServerToolName.SetWorkspace); + assert.ok(initialSetWorkspace, 'workspaceless session should advertise set_workspace'); const chat = buildDefaultChatUri(session); await context.client.call('subscribe', { channel: chat }); - await driveChatTurnToCompletion(context.client, chat, 'scratch-ready', 'Reply exactly "ready".', 1); + context.client.clearReceived(); + context.client.dispatch({ + channel: session, + clientSeq: 1, + action: { + type: ActionType.SessionConfigChanged, + config: { + ...(config.inputRequestMode ? { mode: config.inputRequestMode } : {}), + }, + }, + }); + await context.client.waitForNotification(notification => + isActionNotification(notification, ActionType.SessionConfigChanged) + && getActionEnvelope(notification).channel === session, + ); + + const turnId = 'turn-workspace-conversion'; + const workspaceUri = URI.file(workspace).toString(); + const turn = await driveChatTurnToCompletion( + context.client, + chat, + turnId, + `First use the available user-input tool exactly once to ask which workspace setup to use, with one choice: "${workspaceUri} directly without isolation". After I answer, call set_workspace with that workspace and isolation false as the turn's final tool call. Never run a shell command. When the host automatically continues after attaching the workspace, do not inspect, read, or modify it and do not call another tool; reply exactly "converted".`, + 2, + ); + await context.client.waitForNotification(notification => { + if (!isActionNotification(notification, ActionType.SessionWorkingDirectoryReplaced) + || getActionEnvelope(notification).channel !== session) { + return false; + } + return (getActionEnvelope(notification).action as { replacement: string }).replacement === workspaceUri; + }, 90_000); + await context.client.waitForNotification(notification => { + if (!isActionNotification(notification, ActionType.ChatTurnComplete) + || getActionEnvelope(notification).channel !== chat) { + return false; + } + return (getActionEnvelope(notification).action as { turnId: string }).turnId !== turnId; + }, 90_000); + + const setWorkspaceCalled = context.client.receivedNotifications(notification => { + if (!isActionNotification(notification, ActionType.ChatToolCallStart) + || getActionEnvelope(notification).channel !== chat) { + return false; + } + const action = getActionEnvelope(notification).action as { turnId: string; toolName: string }; + return action.turnId === turnId && action.toolName === SessionServerToolName.SetWorkspace; + }).length > 0; const result = await context.client.call('subscribe', { channel: session }); const state = result.snapshot!.state as SessionState; assert.deepStrictEqual({ + sawWorkspaceQuestion: turn.sawInputRequest, + setWorkspaceCalled, workspaceless: readSessionWorkspaceless(state._meta), - canAttachWorkspace: state.serverTools?.some(tool => tool.name === 'set_workspace'), - }, { workspaceless: true, canAttachWorkspace: true }); + workingDirectories: state.workingDirectories, + canAttachWorkspace: state.serverTools?.some(tool => tool.name === SessionServerToolName.SetWorkspace), + mentionsConversion: getMarkdownResponseText(context.client).toLowerCase().includes('converted'), + }, { + sawWorkspaceQuestion: true, + setWorkspaceCalled: true, + workspaceless: false, + workingDirectories: [workspaceUri], + canAttachWorkspace: false, + mentionsConversion: true, + }); + context.client.clearReceived(); + context.client.dispatch({ + channel: ROOT_STATE_URI, + clientSeq: 2, + action: { + type: ActionType.RootConfigChanged, + config: { [AgentHostGlobalAutoApproveEnabledConfigKey]: false }, + }, + }); + await context.client.waitForNotification(notification => + isActionNotification(notification, ActionType.RootConfigChanged) + && getActionEnvelope(notification).channel === ROOT_STATE_URI + && getActionEnvelope(notification).origin?.clientSeq === 2, + ); }); } diff --git a/src/vs/platform/agentHost/test/node/modelRequestProjection.test.ts b/src/vs/platform/agentHost/test/node/modelRequestProjection.test.ts index 16dc525be2f5b9..efbfa64d1c2f8e 100644 --- a/src/vs/platform/agentHost/test/node/modelRequestProjection.test.ts +++ b/src/vs/platform/agentHost/test/node/modelRequestProjection.test.ts @@ -186,6 +186,36 @@ suite('modelRequestProjection', () => { ), false); }); + test('standalone runtime change notices do not change the retained conversation', () => { + const notice = '\nPlan mode is no longer active.\n'; + const messages = [ + { role: 'user', content: 'Write a plan.' }, + { role: 'assistant', content: 'Plan approved.' }, + { role: 'user', content: 'What did the plan say?' }, + ]; + assert.deepStrictEqual(projectModelRequest(request([ + messages[0], + { role: 'user', content: notice }, + messages[1], + messages[2], + ])), projectModelRequest(request(messages))); + }); + + test('notice elision preserves empty user input, assistant messages, and tool results', () => { + const notice = '\nPlan mode is no longer active.\n'; + assert.deepStrictEqual(projectModelRequest(request([ + { role: 'user', content: '' }, + { role: 'assistant', content: notice }, + { role: 'user', content: `${notice}\nKeep this question.` }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolcall_0', content: notice }] }, + ])).messages, [ + { role: 'user', content: '' }, + { role: 'assistant', content: '' }, + { role: 'user', content: 'Keep this question.' }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolcall_0', content: TOOL_RESULT_PLACEHOLDER }] }, + ]); + }); + test('a tool input matches regardless of key order', () => { // The `input` is JSON the model produced; its key order is not // guaranteed to survive a re-record or a YAML round-trip, and comparing diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index dfa706516c88d2..7c9b5c463db482 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -46,7 +46,7 @@ import { AGENT_HOST_CLIENT_CONNECTION_HISTORY_RETENTION, AgentHostClientConnecti import { AgentHostManagedSettingsService } from '../../node/agentHostManagedSettingsService.js'; import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; import { buildAnnotationsUri } from '../../common/annotationsUri.js'; -import { buildSessionChangesetUri } from '../../common/changesetUri.js'; +import { buildSessionChangesetUri, buildTurnChangesetUri } from '../../common/changesetUri.js'; import { MockDevContainerService } from '../common/mockDevContainerService.js'; import { McpAuthRequiredReason } from '../../common/state/protocol/channels-session/state.js'; @@ -1638,14 +1638,18 @@ suite('ProtocolServerHandler', () => { await Promise.resolve(); transport.simulateClose(); await agentService.subscribeBarrier.complete(); - await Promise.resolve(); + await handler.whenIdle(); assert.deepStrictEqual({ + response: findResponse(transport.sent, 2), subscribes: agentService.subscribeCalls, unsubscribes: agentService.unsubscribeCalls, + errorCount: logService.errorCount, }, { + response: { jsonrpc: '2.0', id: 2, error: { code: JSON_RPC_INTERNAL_ERROR, message: `Subscription cancelled: ${sessionUri}` } }, subscribes: [{ resource: sessionUri, clientId: 'client-1' }], unsubscribes: [{ resource: sessionUri, clientId: 'client-1' }], + errorCount: 0, }); }); @@ -1709,13 +1713,15 @@ suite('ProtocolServerHandler', () => { const [first, second] = await Promise.all([firstResponse, secondResponse]); assert.deepStrictEqual({ - firstFailed: hasKey(first, { error: true }), + firstError: hasKey(first, { error: true }) ? first.error : undefined, secondSucceeded: hasKey(second, { result: true }), + errorCount: logService.errorCount, subscribes: agentService.subscribeCalls, unsubscribes: agentService.unsubscribeCalls, }, { - firstFailed: true, + firstError: { code: JSON_RPC_INTERNAL_ERROR, message: `Subscription cancelled: ${sessionUri}` }, secondSucceeded: true, + errorCount: 0, subscribes: [ { resource: sessionUri, clientId: 'client-1' }, { resource: sessionUri, clientId: 'client-1' }, @@ -1724,6 +1730,134 @@ suite('ProtocolServerHandler', () => { }); }); + test('superseded turn changeset subscriptions are cancelled even when their resources exist', async () => { + stateManager.createSession(makeSessionSummary()); + const turns = ['previous-1', 'previous-2', 'latest'].map(turnId => buildTurnChangesetUri(defaultChatUri, turnId)); + const barrier = new DeferredPromise(); + for (const channel of turns) { + stateManager.registerChangeset(channel); + } + for (const channel of turns.slice(0, -1)) { + agentService.subscribeBarriers.set(channel, barrier); + } + const transport = connectClient('client-1'); + transport.sent.length = 0; + const responses = turns.map((_, index) => waitForResponse(transport, index + 2)); + const emitted: ActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(envelope => emitted.push(envelope))); + + for (const [index, channel] of turns.entries()) { + transport.simulateMessage(request(index + 2, 'subscribe', { channel })); + if (index < turns.length - 1) { + transport.simulateMessage(notification('unsubscribe', { channel })); + } + } + await responses[2]; + for (const channel of turns.slice(0, -1)) { + stateManager.dispatchServerAction(channel, { type: ActionType.ChangesetContentChanged, files: [] }); + stateManager.dispatchServerAction(channel, { type: ActionType.ChangesetStatusChanged, status: ChangesetStatus.Ready }); + } + await barrier.complete(); + const results = await Promise.all(responses); + + assert.deepStrictEqual({ + errors: results.slice(0, -1).map(result => hasKey(result, { error: true }) ? result.error : undefined), + latestResource: hasKey(results[2], { result: true }) ? (results[2].result as SubscribeResult).snapshot?.resource : undefined, + supersededResourcesExist: turns.slice(0, -1).map(channel => !!stateManager.getSnapshot(channel)), + emitted: emitted.map(envelope => ({ channel: envelope.channel, type: envelope.action.type })), + deliveredActions: findNotifications(transport.sent, 'action').length, + unsubscribes: agentService.unsubscribeCalls, + errorCount: logService.errorCount, + }, { + errors: turns.slice(0, -1).map(channel => ({ code: JSON_RPC_INTERNAL_ERROR, message: `Subscription cancelled: ${channel}` })), + latestResource: turns[2], + supersededResourcesExist: [true, true], + emitted: turns.slice(0, -1).flatMap(channel => [ + { channel, type: ActionType.ChangesetContentChanged }, + { channel, type: ActionType.ChangesetStatusChanged }, + ]), + deliveredActions: 0, + unsubscribes: turns.slice(0, -1).map(resource => ({ resource, clientId: 'client-1' })), + errorCount: 0, + }); + }); + + test('unsubscribe cancels a subscribe whose snapshot resolves successfully afterwards', async () => { + stateManager.createSession(makeSessionSummary()); + const barrier = new DeferredPromise(); + agentService.subscribe = async () => barrier.p; + const transport = connectClient('client-1'); + transport.sent.length = 0; + const response = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'subscribe', { channel: sessionUri })); + transport.simulateMessage(notification('unsubscribe', { channel: sessionUri })); + await barrier.complete(stateManager.getSnapshot(sessionUri)!); + + assert.deepStrictEqual({ + response: await response, + errorCount: logService.errorCount, + unsubscribes: agentService.unsubscribeCalls, + }, { + response: { jsonrpc: '2.0', id: 2, error: { code: JSON_RPC_INTERNAL_ERROR, message: `Subscription cancelled: ${sessionUri}` } }, + errorCount: 0, + unsubscribes: [{ resource: sessionUri, clientId: 'client-1' }], + }); + }); + + test('subscribe still reports and logs genuinely missing resources', async () => { + const transport = connectClient('client-1'); + transport.sent.length = 0; + const channel = buildTurnChangesetUri(defaultChatUri, 'missing'); + const response = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'subscribe', { channel })); + + assert.deepStrictEqual({ + response: await response, + errorCount: logService.errorCount, + }, { + response: { jsonrpc: '2.0', id: 2, error: { code: AHP_SESSION_NOT_FOUND, message: `Resource not found: ${channel}` } }, + errorCount: 1, + }); + }); + + test('subscribe preserves and logs protocol failures while it still owns the subscription', async () => { + const error = new ProtocolError(AhpErrorCodes.AuthRequired, 'Authentication required', { resources: [] }); + agentService.subscribe = async () => { throw error; }; + const transport = connectClient('client-1'); + transport.sent.length = 0; + const response = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'subscribe', { channel: sessionUri })); + + assert.deepStrictEqual({ + response: await response, + errorCount: logService.errorCount, + }, { + response: { jsonrpc: '2.0', id: 2, error: { code: error.code, message: error.message, data: error.data } }, + errorCount: 1, + }); + }); + + test('unsubscribe cancels a subscribe whose restore rejects with a protocol error afterwards', async () => { + const barrier = new DeferredPromise(); + agentService.subscribe = async () => barrier.p; + const transport = connectClient('client-1'); + transport.sent.length = 0; + const response = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'subscribe', { channel: sessionUri })); + transport.simulateMessage(notification('unsubscribe', { channel: sessionUri })); + await barrier.error(new ProtocolError(AhpErrorCodes.AuthRequired, 'Authentication required', { resources: [] })); + + assert.deepStrictEqual({ + response: await response, + errorCount: logService.errorCount, + unsubscribes: agentService.unsubscribeCalls, + }, { + response: { jsonrpc: '2.0', id: 2, error: { code: JSON_RPC_INTERNAL_ERROR, message: `Subscription cancelled: ${sessionUri}` } }, + errorCount: 0, + unsubscribes: [{ resource: sessionUri, clientId: 'client-1' }], + }); + }); + test('client action is dispatched and echoed', async () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); diff --git a/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts b/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts index 1d78dc6653675d..7797670fa5d40d 100644 --- a/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts +++ b/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts @@ -217,6 +217,33 @@ suite('buildSandboxConfigForSdk', () => { }); }); + test('defaults credential authentication to true and respects independent choices on every platform', () => { + for (const platform of ['linux', 'darwin', 'win32'] as const) { + for (const authenticateGit of [undefined, false, true]) { + for (const authenticateGh of [undefined, false, true]) { + const config: ISandboxConfigValue = { + [AgentHostSandboxKey.AuthenticateGit]: authenticateGit, + [AgentHostSandboxKey.AuthenticateGh]: authenticateGh, + }; + assert.deepStrictEqual([ + buildSandboxConfigForSdk(platform, { ...config, enabled: AgentSandboxEnabledValue.On }), + buildSandboxConfigForSdk(platform, { ...config, enabled: AgentSandboxEnabledValue.Off }), + ], [ + { ...expectedSandboxConfig(), auth: { git: authenticateGit ?? true, gh: authenticateGh ?? true } }, + undefined, + ]); + } + } + } + }); + + test('validates credential preferences as optional boolean host settings', () => { + for (const key of [AgentHostSandboxKey.AuthenticateGit, AgentHostSandboxKey.AuthenticateGh]) { + assert.deepStrictEqual([{}, { [key]: true }, { [key]: false }, { [key]: 'false' }, { [key]: null }] + .map(config => sandboxConfigSchema.validate(AgentHostSandboxConfigKey.Sandbox, config)), [true, true, true, false, false]); + } + }); + }); suite('filesystem policy', () => { diff --git a/src/vs/platform/agentHost/test/node/sessionSandbox.test.ts b/src/vs/platform/agentHost/test/node/sessionSandbox.test.ts index b09a4c9838cecb..34b5bf556fb738 100644 --- a/src/vs/platform/agentHost/test/node/sessionSandbox.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionSandbox.test.ts @@ -80,8 +80,8 @@ suite('Session sandbox configuration', () => { }); }); - test('ignores missing or malformed sandbox policy metadata', () => { - const values = [undefined, null, [], true, {}, { enabled: 'true' }, { enabled: true, allowBypass: 'false' }, { enabled: true, allowOutbound: 'false' }, { enabled: true, allowLocalNetwork: 'false' }, { enabled: true, allowDevToolAccess: 'false' }, { enabled: true, sandboxMcpServers: 'true' }, { enabled: true, sandboxLspServers: 'true' }]; + test('ignores missing or non-object sandbox policy metadata', () => { + const values = [undefined, null, [], true, 'policy', 0]; assert.deepStrictEqual(values.map(value => readSessionSandboxPolicy({ _meta: { 'vscode.resolvedSandboxPolicy': value } })), values.map(() => undefined)); }); @@ -348,6 +348,53 @@ suite('Session sandbox configuration', () => { ]); }); + for (const [key, field] of [ + [AgentHostSandboxKey.AuthenticateGit, 'git'], + [AgentHostSandboxKey.AuthenticateGh, 'gh'], + ] as const) { + test(`resolved ${key} policy survives serialization and clears when omitted or malformed`, () => { + const { manager, configuration, create } = setupSession(); + const owner = create('credentials'); + const apply = (value: boolean | string | undefined) => { + configuration.setSessionSandboxPolicy(owner, projectCopilotSandboxPolicy({ + source: 'server', serverManaged: true, deviceManaged: false, + failClosed: false, bypassPermissionsDisabled: false, managedKeys: ['sandbox'], + settings: { sandbox: { enabled: true, allowBypass: false, auth: value === undefined ? {} : { [field]: value } } }, + }, owner, new NullLogService())); + return readSessionSandboxPolicy(JSON.parse(JSON.stringify(manager.getSessionState(owner)))); + }; + assert.deepStrictEqual([true, false, undefined, 'true'].map(apply), [ + { enabled: true, allowBypass: false, [key]: true }, + { enabled: true, allowBypass: false, [key]: false }, + { enabled: true, allowBypass: false }, + { enabled: true, allowBypass: false }, + ]); + }); + + test(`managed ${key} denial wins without widening local choices for owners, peers and subagents`, () => { + const { manager, configuration, create } = setupSession(); + const owner = create('credentials'); + const peer = buildChatUri(owner, 'peer'); + manager.addChat(owner, peer); + for (const local of [undefined, false, true]) { + const sandbox = { enabled: 'on', ...(local !== undefined ? { [key]: local } : {}) }; + configuration.updateRootConfig({ sandbox }); + for (const managed of [undefined, false, true, false, undefined]) { + configuration.setSessionSandboxPolicy(owner, { enabled: false, [key]: managed }); + const sessions = [owner, peer, buildSubagentSessionUri(owner, 'child')]; + assert.deepStrictEqual({ + values: sessions.map(session => (['linux', 'darwin', 'win32'] as const).map(platform => + buildSandboxConfigForSdk(platform, getSessionSandboxConfig(configuration, session))?.auth?.[field])), + stored: configuration.getRootConfigValues()?.sandbox, + }, { + values: sessions.map(() => (['linux', 'darwin', 'win32'] as const).map(() => managed === false ? false : local ?? true)), + stored: sandbox, + }); + } + } + }); + } + for (const [key, managedValue] of [ [AgentHostSandboxKey.SandboxMcpServers, true], [AgentHostSandboxKey.SandboxLspServers, true], diff --git a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts index 28ef74b7200524..484c2ec7dddb6e 100644 --- a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts @@ -73,6 +73,7 @@ suite('SessionServerTools', () => { const depths = overrides?.depths ?? new Map(); return { getAutomaticTitleGenerationStrategy: overrides?.getAutomaticTitleGenerationStrategy ?? (() => 'deferred'), + isWorkspaceless: overrides?.isWorkspaceless ?? (() => true), canConvertWorkspace: overrides?.canConvertWorkspace ?? (() => true), supportsChatWorkingDirectories: overrides?.supportsChatWorkingDirectories ?? (() => true), listSessions: overrides?.listSessions ?? (async () => [sessionMeta('s1', SessionStatus.InProgress, workspace)]), @@ -352,6 +353,39 @@ suite('SessionServerTools', () => { } }); + test('set_workspace deferral follows whether the session is workspaceless', () => { + const stateManager = new AgentHostStateManager(new NullLogService()); + const workspacelessSession = 'copilot:/workspaceless'; + const workspaceSession = 'copilot:/workspace'; + for (const resource of [workspacelessSession, workspaceSession]) { + stateManager.createSession({ + resource, + provider: 'copilot', + title: 'Session', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + }); + } + const host = new AgentServerToolHost(stateManager, [ + createSessionServerToolGroup(createAccessor({ isWorkspaceless: session => session.toString() === workspacelessSession })), + ]); + + host.advertise(workspacelessSession); + host.advertise(workspaceSession); + + assert.deepStrictEqual({ + baseDefinition: sessionServerToolDefinitions.find(tool => tool.name === SessionServerToolName.SetWorkspace)?.deferLoading, + workspacelessDefinition: host.getDefinitionsForSession(workspacelessSession).find(tool => tool.name === SessionServerToolName.SetWorkspace)?.deferLoading, + workspaceDefinition: host.getDefinitionsForSession(workspaceSession).find(tool => tool.name === SessionServerToolName.SetWorkspace)?.deferLoading, + }, { + baseDefinition: true, + workspacelessDefinition: false, + workspaceDefinition: true, + }); + stateManager.dispose(); + }); + test('set_workspace is not advertised or executable when the provider cannot change working directory', async () => { const stateManager = new AgentHostStateManager(new NullLogService()); const session = 'claude:/s1'; diff --git a/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts b/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts index 026a5538d3f258..f6647e63ddbeff 100644 --- a/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts @@ -10,7 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { NullLogService } from '../../../../../platform/log/common/log.js'; import { AgentSession } from '../../../common/agent.js'; import { isCustomizationEnabled } from '../../../common/customizationEnablement.js'; -import { readMcpServerSource, withMcpServerSourceMeta } from '../../../common/meta/mcpCustomizationMeta.js'; +import { readMcpServerDisplayName, readMcpServerSource, withMcpServerSourceMeta } from '../../../common/meta/mcpCustomizationMeta.js'; import { ActionType } from '../../../common/state/protocol/common/actions.js'; import { CustomizationEnablementKind, CustomizationLoadStatus, CustomizationType, McpAuthRequiredReason, McpServerStatus, SessionStatus, type Customization, type CustomizationEnablement, type McpServerCustomization, type McpServerState, type PluginCustomization } from '../../../common/state/protocol/channels-session/state.js'; import { buildChatUri } from '../../../common/state/sessionState.js'; @@ -158,15 +158,15 @@ suite('McpCustomizationController', () => { assert.deepStrictEqual(controller.topLevelCustomizations(), []); }); - test('retains source through lifecycle updates and republishes source-only inventory changes', () => { + test('retains presentation metadata through lifecycle updates and republishes inventory changes', () => { const { controller, actions } = harness(store); store.add(controller); const snapshot = () => controller.topLevelCustomizations().map(item => ({ - id: item.id, source: readMcpServerSource(item), state: item.state.kind, + id: item.id, displayName: readMcpServerDisplayName(item), source: readMcpServerSource(item), state: item.state.kind, })); controller.applyOne(server('search', starting())); - controller.applyAll([{ ...server('search', starting()), source: 'user' }]); + controller.applyAll([{ ...server('search', starting()), displayName: 'Connector Search', source: 'user' }]); const afterInventory = snapshot(); controller.applyOne(server('search', ready())); const afterLifecycle = snapshot(); @@ -177,11 +177,13 @@ suite('McpCustomizationController', () => { afterLifecycle, afterSourceChange: snapshot(), publishedSources: actions.flatMap(action => action.type === ActionType.SessionCustomizationUpdated && action.customization.type === CustomizationType.McpServer ? [readMcpServerSource(action.customization)] : []), + publishedDisplayNames: actions.flatMap(action => action.type === ActionType.SessionCustomizationUpdated && action.customization.type === CustomizationType.McpServer ? [readMcpServerDisplayName(action.customization)] : []), }, { - afterInventory: [{ id: 'mcp-top-level:copilot:session-1:search', source: 'user', state: McpServerStatus.Starting }], - afterLifecycle: [{ id: 'mcp-top-level:copilot:session-1:search', source: 'user', state: McpServerStatus.Ready }], - afterSourceChange: [{ id: 'mcp-top-level:copilot:session-1:search', source: 'workspace', state: McpServerStatus.Ready }], + afterInventory: [{ id: 'mcp-top-level:copilot:session-1:search', displayName: 'Connector Search', source: 'user', state: McpServerStatus.Starting }], + afterLifecycle: [{ id: 'mcp-top-level:copilot:session-1:search', displayName: 'Connector Search', source: 'user', state: McpServerStatus.Ready }], + afterSourceChange: [{ id: 'mcp-top-level:copilot:session-1:search', displayName: 'Connector Search', source: 'workspace', state: McpServerStatus.Ready }], publishedSources: [undefined, 'user', 'user', 'workspace'], + publishedDisplayNames: [undefined, 'Connector Search', 'Connector Search', 'Connector Search'], }); }); diff --git a/src/vs/platform/policy/common/copilotManagedSettings.ts b/src/vs/platform/policy/common/copilotManagedSettings.ts index d796c25dd6371a..bedb6b1ec02e9d 100644 --- a/src/vs/platform/policy/common/copilotManagedSettings.ts +++ b/src/vs/platform/policy/common/copilotManagedSettings.ts @@ -75,6 +75,12 @@ export const COPILOT_SANDBOX_MCP_SERVERS_KEY = 'sandbox.sandboxMcpServers'; /** Managed-settings key that requires LSP servers to remain sandboxed. */ export const COPILOT_SANDBOX_LSP_SERVERS_KEY = 'sandbox.sandboxLspServers'; +/** Managed-settings key that restricts HTTPS Git authentication inside the sandbox. */ +export const COPILOT_SANDBOX_AUTH_GIT_KEY = 'sandbox.auth.git'; + +/** Managed-settings key that restricts GitHub CLI authentication inside the sandbox. */ +export const COPILOT_SANDBOX_AUTH_GH_KEY = 'sandbox.auth.gh'; + /** Managed-settings key that permits explicitly bypassing the sandbox. */ export const COPILOT_SANDBOX_ALLOW_BYPASS_KEY = 'sandbox.allowBypass'; @@ -99,6 +105,8 @@ export const MANAGED_SETTINGS_CONTROL_DEFINITIONS: IManagedSettingsPolicyDefinit [COPILOT_SANDBOX_ENABLED_KEY]: { type: 'boolean' }, [COPILOT_SANDBOX_MCP_SERVERS_KEY]: { type: 'boolean' }, [COPILOT_SANDBOX_LSP_SERVERS_KEY]: { type: 'boolean' }, + [COPILOT_SANDBOX_AUTH_GIT_KEY]: { type: 'boolean' }, + [COPILOT_SANDBOX_AUTH_GH_KEY]: { type: 'boolean' }, [COPILOT_SANDBOX_ALLOW_BYPASS_KEY]: { type: 'boolean' }, [COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY]: { type: 'boolean' }, [COPILOT_SANDBOX_ALLOW_LOCAL_NETWORK_KEY]: { type: 'boolean' }, @@ -458,7 +466,7 @@ export interface IManagedSettingsPick { * a lower channel. Telemetry instead selects the highest-priority block in its entirety, including * empty or unrecognized server/file object blocks; native delivery observes declared flat keys only. * Omitted leaves cannot inherit from a weaker managed source. - * Sandbox enablement is force-on-wins; bypass and outbound access are deny-wins so UI consumers + * Sandbox enablement is force-on-wins; bypass, access and credential authentication are deny-wins so UI consumers * cannot discard a sandbox restriction from another channel. * * The parameter order matches the precedence so call sites read top-to-bottom. Centralizing the @@ -499,7 +507,7 @@ export function pickManagedSettings(nativeMdm: ManagedSettingsData | undefined, if (existing) { existing.contributions.push({ channel, value }); if (((key === COPILOT_SANDBOX_ENABLED_KEY || key === COPILOT_SANDBOX_MCP_SERVERS_KEY || key === COPILOT_SANDBOX_LSP_SERVERS_KEY) && value === true && existing.value !== true) - || ((key === COPILOT_SANDBOX_ALLOW_BYPASS_KEY || key === COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY || key === COPILOT_SANDBOX_ALLOW_LOCAL_NETWORK_KEY || key === COPILOT_SANDBOX_ALLOW_DEV_TOOL_ACCESS_KEY) && value === false && existing.value !== false)) { + || ((key === COPILOT_SANDBOX_ALLOW_BYPASS_KEY || key === COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY || key === COPILOT_SANDBOX_ALLOW_LOCAL_NETWORK_KEY || key === COPILOT_SANDBOX_ALLOW_DEV_TOOL_ACCESS_KEY || key === COPILOT_SANDBOX_AUTH_GIT_KEY || key === COPILOT_SANDBOX_AUTH_GH_KEY) && value === false && existing.value !== false)) { existing.value = value; existing.source = channel; } diff --git a/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts b/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts index dff6be4b27c163..c286959936afb6 100644 --- a/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts +++ b/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts @@ -8,7 +8,7 @@ import { IStringDictionary } from '../../../../base/common/collections.js'; import { IPolicyData } from '../../../../base/common/defaultAccount.js'; import { ManagedSettingsData } from '../../../../base/common/policy.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { collectManagedSettingsDefinitions, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, COPILOT_MODEL_KEY, COPILOT_OTEL_CAPTURE_IDENTITY_KEY, COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ALLOW_DEV_TOOL_ACCESS_KEY, COPILOT_SANDBOX_ALLOW_LOCAL_NETWORK_KEY, COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY, COPILOT_SANDBOX_ENABLED_KEY, COPILOT_SANDBOX_LSP_SERVERS_KEY, COPILOT_SANDBOX_MCP_SERVERS_KEY, COPILOT_TOP_LEVEL_MODEL_KEY, MANAGED_SETTINGS_CONTROL_DEFINITIONS, hasManagedSettingsDefinitions, managedModelValue, managedSettingsDisabledValue, managedSettingValue, normalizeManagedSettings, projectManagedSettings, pickManagedSettings, resolveForceRemoteSettingsRefresh } from '../../common/copilotManagedSettings.js'; +import { collectManagedSettingsDefinitions, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, COPILOT_MODEL_KEY, COPILOT_OTEL_CAPTURE_IDENTITY_KEY, COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ALLOW_DEV_TOOL_ACCESS_KEY, COPILOT_SANDBOX_ALLOW_LOCAL_NETWORK_KEY, COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY, COPILOT_SANDBOX_AUTH_GH_KEY, COPILOT_SANDBOX_AUTH_GIT_KEY, COPILOT_SANDBOX_ENABLED_KEY, COPILOT_SANDBOX_LSP_SERVERS_KEY, COPILOT_SANDBOX_MCP_SERVERS_KEY, COPILOT_TOP_LEVEL_MODEL_KEY, MANAGED_SETTINGS_CONTROL_DEFINITIONS, hasManagedSettingsDefinitions, managedModelValue, managedSettingsDisabledValue, managedSettingValue, normalizeManagedSettings, projectManagedSettings, pickManagedSettings, resolveForceRemoteSettingsRefresh } from '../../common/copilotManagedSettings.js'; import { PolicyDefinition } from '../../common/policy.js'; suite('Copilot managed settings projection', () => { @@ -447,7 +447,7 @@ suite('Copilot managed settings precedence (pickManagedSettings)', () => { }); } - for (const key of [COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY, COPILOT_SANDBOX_ALLOW_LOCAL_NETWORK_KEY, COPILOT_SANDBOX_ALLOW_DEV_TOOL_ACCESS_KEY]) { + for (const key of [COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY, COPILOT_SANDBOX_ALLOW_LOCAL_NETWORK_KEY, COPILOT_SANDBOX_ALLOW_DEV_TOOL_ACCESS_KEY, COPILOT_SANDBOX_AUTH_GIT_KEY, COPILOT_SANDBOX_AUTH_GH_KEY]) { test(`${key} is deny-wins across every channel combination`, () => { const values = [undefined, false, true]; for (const native of values) { @@ -517,6 +517,18 @@ suite('Copilot managed settings precedence (pickManagedSettings)', () => { ]); }); + for (const [field, key] of [['git', COPILOT_SANDBOX_AUTH_GIT_KEY], ['gh', COPILOT_SANDBOX_AUTH_GH_KEY]]) { + test(`sandbox ${field} authentication projects only canonical nested boolean values`, () => { + assert.deepStrictEqual([false, true, 'false'].map(value => projectManagedSettings( + normalizeManagedSettings({ sandbox: { auth: { [field]: value } } }), MANAGED_SETTINGS_CONTROL_DEFINITIONS, + )), [ + { [key]: false }, + { [key]: true }, + {}, + ]); + }); + } + test('sandbox local network access projects only canonical nested boolean values', () => { assert.deepStrictEqual([false, true, 'false'].map(allowLocalNetwork => projectManagedSettings( normalizeManagedSettings({ sandbox: { userPolicy: { network: { allowLocalNetwork } } } }), MANAGED_SETTINGS_CONTROL_DEFINITIONS, diff --git a/src/vs/platform/sandbox/common/settings.ts b/src/vs/platform/sandbox/common/settings.ts index 36a93a0ed94f98..478b61953c73de 100644 --- a/src/vs/platform/sandbox/common/settings.ts +++ b/src/vs/platform/sandbox/common/settings.ts @@ -34,6 +34,8 @@ export const enum AgentSandboxSettingId { AgentSandboxAllowUnsandboxedCommands = 'chat.agent.sandbox.allowUnsandboxedCommands', AgentSandboxMcpServers = 'chat.agent.sandbox.mcpServers', AgentSandboxLspServers = 'chat.agent.sandbox.lspServers', + AgentSandboxAuthenticateGit = 'chat.agent.sandbox.credentials.authenticategit', + AgentSandboxAuthenticateGh = 'chat.agent.sandbox.credentials.authenticategh', AgentSandboxAllowDevToolAccess = 'chat.agent.sandbox.fileSystem.allowDevToolAccess', AgentSandboxUserConfiguredPaths = 'chat.agent.sandbox.fileSystem.userConfiguredPaths', AgentSandboxRetryWithAllowNetworkRequests = 'chat.agent.sandbox.retryWithAllowNetworkRequests', diff --git a/src/vs/platform/terminal/common/capabilities/capabilities.ts b/src/vs/platform/terminal/common/capabilities/capabilities.ts index 2f10ef1fa11865..c7a6f40da53a85 100644 --- a/src/vs/platform/terminal/common/capabilities/capabilities.ts +++ b/src/vs/platform/terminal/common/capabilities/capabilities.ts @@ -239,6 +239,7 @@ export interface ICommandDetectionCapability { */ getCwdForLine(line: number): string | undefined; getCommandForLine(line: number): ITerminalCommand | ICurrentPartialCommand | undefined; + clearCommands(): void; handlePromptStart(options?: IHandleCommandOptions): void; handleContinuationStart(): void; handleContinuationEnd(): void; @@ -294,6 +295,7 @@ export interface IPartialCommandDetectionCapability { readonly type: TerminalCapability.PartialCommandDetection; readonly commands: readonly IMarker[]; readonly onCommandFinished: Event; + clearCommands(): void; } interface IBaseTerminalCommand { diff --git a/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts b/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts index 5f54e9ef3681dd..490d129fa72755 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts @@ -204,6 +204,12 @@ export class CommandDetectionCapability extends Disposable implements ICommandDe } } + clearCommands(): void { + if (this._commands.length > 0) { + this._onCommandInvalidated.fire(this._commands.splice(0)); + } + } + setContinuationPrompt(value: string): void { this._promptInputModel.setContinuationPrompt(value); } diff --git a/src/vs/platform/terminal/common/capabilities/partialCommandDetectionCapability.ts b/src/vs/platform/terminal/common/capabilities/partialCommandDetectionCapability.ts index 5a631692fe70eb..7aed0021d62432 100644 --- a/src/vs/platform/terminal/common/capabilities/partialCommandDetectionCapability.ts +++ b/src/vs/platform/terminal/common/capabilities/partialCommandDetectionCapability.ts @@ -78,4 +78,8 @@ export class PartialCommandDetectionCapability extends DisposableStore implement // Remove them this._commands.splice(this._commands.length - count, count); } + + clearCommands(): void { + this._commands.length = 0; + } } diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index 75f014d00e2f4d..ab94bff7230d0c 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -15,7 +15,7 @@ import { ResolveSessionConfigResult, SessionConfigValueItem } from '../../platfo import { AgentCustomization, Customization, McpServerStatus, RootConfigState, type CustomizationEnablement, type McpServerState, type RootState, type TextRange } from '../../platform/agentHost/common/state/protocol/state.js'; import { type CustomizationDisabledReason } from '../../platform/agentHost/common/customizationEnablement.js'; import { type McpServerSource } from '../../platform/agentHost/common/meta/mcpCustomizationMeta.js'; -import { ISessionsProvider } from '../services/sessions/common/sessionsProvider.js'; +import { ISessionConfigProvider, ISessionsProvider } from '../services/sessions/common/sessionsProvider.js'; import { ISessionAgentRef } from '../services/sessions/common/session.js'; import type { AgentMergeSessionOverrides, AgentMergeSessionState } from '../../platform/agentHost/common/agentMerge.js'; import type { ISessionSandboxPolicy } from '../../platform/agentHost/common/meta/agentSandboxPolicyMeta.js'; @@ -98,6 +98,8 @@ export interface IAgentHostGroup { export interface IAgentHostMcpServer { readonly id: string; readonly name: string; + /** Optional Connector catalog name for presentation only. */ + readonly displayName?: string; readonly source?: McpServerSource; readonly enabled: boolean; readonly enablement?: readonly CustomizationEnablement[]; @@ -389,6 +391,10 @@ export function isAgentHostProvider(provider: ISessionsProvider): provider is IA return isAgentHostProviderId(provider.id); } +export function getSessionConfigProvider(provider: ISessionsProvider): ISessionConfigProvider | undefined { + return provider.sessionConfig ?? (isAgentHostProvider(provider) ? provider : undefined); +} + /** * Checks whether a provider ID is for an agent host provider * (`local-agent-host` or any `agenthost-*` provider). diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index 9504c788036e58..87799d6e0f2126 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -61,6 +61,7 @@ import { CHAT_SETUP_ACTION_ID } from '../../../../workbench/contrib/chat/browser import { AGENTIC_SIGN_IN_COMMAND_ID } from '../../../common/sessionCommands.js'; import { SessionsChatPetAchievementBadges } from './chatPetAchievementBadges.js'; import { CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID } from '../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; +import { CHAT_PET_CHANGE_COLOR_COMMAND_ID } from '../../../../workbench/contrib/chat/browser/chatPetColors.js'; // --- Account Menu Items --- // const AccountMenu = Menus.AccountMenu; @@ -736,11 +737,13 @@ export class TitleBarAccountWidget extends BaseActionViewItem { } } - panelStore.add(this.instantiationService.createInstance(SessionsChatPetAchievementBadges, panel, () => { - this.hoverService.hideHover(true); - this.clickPanelDisposable.clear(); - void this.commandService.executeCommand(CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID); - })); + if (!this.chatEntitlementService.sentiment.hidden) { + panelStore.add(this.instantiationService.createInstance(SessionsChatPetAchievementBadges, panel, tab => { + this.hoverService.hideHover(true); + this.clickPanelDisposable.clear(); + void this.commandService.executeCommand(tab === 'color' ? CHAT_PET_CHANGE_COLOR_COMMAND_ID : CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID); + })); + } if (this.shouldShowCopilotDashboardHover()) { const footer = append(panel, $('section.sessions-account-titlebar-panel-footer', { diff --git a/src/vs/sessions/contrib/accountMenu/browser/chatPetAchievementBadges.ts b/src/vs/sessions/contrib/accountMenu/browser/chatPetAchievementBadges.ts index bae9e834fdd28f..648a25c2f132fa 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/chatPetAchievementBadges.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/chatPetAchievementBadges.ts @@ -16,8 +16,10 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE, renderChatPetAchievementPreview } from '../../../../workbench/contrib/chat/browser/chatPetAchievementPreview.js'; -import { chatPetAchievements, ChatPetAccessoryId, ChatPetAchievementId, IChatPetAchievement } from '../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; -import { ChatPetVariant, IChatPetService } from '../../../../workbench/contrib/chat/browser/chatPetService.js'; +import { chatPetAchievements, ChatPetAccessoryId, ChatPetAchievementId, getChatPetAchievementRewardLabels, IChatPetAchievement } from '../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; +import { IChatPetService } from '../../../../workbench/contrib/chat/browser/chatPetService.js'; +import { ChatPetColor } from '../../../../workbench/contrib/chat/browser/chatPetColors.js'; +import { ChatPetCustomizationTab } from '../../../../workbench/contrib/chat/browser/chatPetAchievementsEditorInput.js'; export interface ISessionsChatPetAchievementBadge { readonly achievement: IChatPetAchievement; @@ -43,7 +45,7 @@ export class SessionsChatPetAchievementBadges extends Disposable { constructor( parent: HTMLElement, - private readonly onOpenAchievements: () => void, + private readonly onOpenCustomization: (tab: ChatPetCustomizationTab) => void, @IChatPetService private readonly chatPetService: IChatPetService, @IThemeService private readonly themeService: IThemeService, @IHoverService private readonly hoverService: IHoverService, @@ -58,17 +60,17 @@ export class SessionsChatPetAchievementBadges extends Disposable { this.chatPetService.enabled.read(reader), this.chatPetService.unlockedAchievements.read(reader), ); - const variant = this.chatPetService.variant.read(reader); + const color = this.chatPetService.color.read(reader); const selectedAccessory = this.chatPetService.selectedAccessory.read(reader); themeChanged.read(reader); - this.render(badges, selectedAccessory, variant); + this.render(badges, selectedAccessory, color); })); } - private render(badges: readonly ISessionsChatPetAchievementBadge[] | undefined, selectedAccessory: ChatPetAccessoryId | undefined, variant: ChatPetVariant): void { + private render(badges: readonly ISessionsChatPetAchievementBadge[] | undefined, selectedAccessory: ChatPetAccessoryId | undefined, color: ChatPetColor): void { const activeElement = DOM.getActiveElement(); - const focusedAccessoryId = DOM.isHTMLElement(activeElement) - ? activeElement.closest('.sessions-chat-pet-achievement-badge')?.dataset.accessoryId + const focusedAchievementId = DOM.isHTMLElement(activeElement) + ? activeElement.closest('.sessions-chat-pet-achievement-badge')?.dataset.achievementId : undefined; const restoreViewAchievementsFocus = DOM.isHTMLElement(activeElement) && activeElement.closest('.sessions-chat-pet-achievement-badges-actions') !== null; let focusTarget: HTMLElement | undefined; @@ -95,17 +97,17 @@ export class SessionsChatPetAchievementBadges extends Disposable { item.setAttribute('aria-label', localize('sessionsChatPetBadgeLockedLabel', "Locked secret achievement badge")); } const badgeElement = unlocked - ? this.createUnlockedBadgeButton(item, achievement, accessory.id, selectedAccessory === accessory.id) + ? this.createUnlockedBadgeButton(item, achievement, accessory?.id, !!accessory && selectedAccessory === accessory.id) : DOM.append(item, DOM.$('span.sessions-chat-pet-achievement-badge.locked', { 'aria-hidden': 'true' })); - if (accessory.id === focusedAccessoryId) { + if (achievement.id === focusedAchievementId) { focusTarget = badgeElement; } const canvas = DOM.append(badgeElement, DOM.$('canvas.sessions-chat-pet-achievement-badge-preview')) as HTMLCanvasElement; canvas.width = CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE; canvas.height = CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE; canvas.setAttribute('aria-hidden', 'true'); - this.renderDisposables.add(renderChatPetAchievementPreview(canvas, accessory, unlocked, variant, this.themeService, this.logService)); - this.renderDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), badgeElement, unlocked ? accessory.label : localize('sessionsChatPetBadgeLocked', "Locked"))); + this.renderDisposables.add(renderChatPetAchievementPreview(canvas, accessory, unlocked, color, this.themeService, this.logService)); + this.renderDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), badgeElement, unlocked ? getChatPetAchievementRewardLabels(achievement).join(', ') : localize('sessionsChatPetBadgeLocked', "Locked"))); } const actions = DOM.append(this.element, DOM.$('.sessions-chat-pet-achievement-badges-actions')); const viewAchievements = this.renderDisposables.add(new Button(actions, { @@ -114,7 +116,7 @@ export class SessionsChatPetAchievementBadges extends Disposable { ariaLabel: localize('sessionsChatPetViewAchievementsAriaLabel', "View Pet Achievements"), })); viewAchievements.label = localize('sessionsChatPetViewAchievements', "View Achievements"); - this.renderDisposables.add(viewAchievements.onDidClick(() => this.onOpenAchievements())); + this.renderDisposables.add(viewAchievements.onDidClick(() => this.onOpenCustomization('achievements'))); if (restoreViewAchievementsFocus) { focusTarget = viewAchievements.element; } @@ -128,23 +130,33 @@ export class SessionsChatPetAchievementBadges extends Disposable { } } - private createUnlockedBadgeButton(parent: HTMLElement, achievement: IChatPetAchievement, accessoryId: ChatPetAccessoryId, selected: boolean): HTMLElement { - const accessory = achievement.accessories[0]; + private createUnlockedBadgeButton(parent: HTMLElement, achievement: IChatPetAchievement, accessoryId: ChatPetAccessoryId | undefined, selected: boolean): HTMLElement { + const reward = getChatPetAchievementRewardLabels(achievement).join(', '); const button = this.renderDisposables.add(new Button(parent, { - ariaLabel: selected - ? localize('sessionsChatPetBadgeSelectedLabel', "{0} achievement badge: {1}, wearing", achievement.title, accessory.label) - : localize('sessionsChatPetBadgeLabel', "{0} achievement badge: wear {1}", achievement.title, accessory.label), + ariaLabel: achievement.colorCustomization + ? localize('sessionsChatPetBadgeColorLabel', "{0} achievement badge: change Blobby's color", achievement.title) + : selected + ? localize('sessionsChatPetBadgeSelectedLabel', "{0} achievement badge: {1}, wearing", achievement.title, reward) + : localize('sessionsChatPetBadgeLabel', "{0} achievement badge: wear {1}", achievement.title, reward), })); button.element.classList.add('sessions-chat-pet-achievement-badge'); button.element.classList.toggle('wearing', selected); - button.element.dataset.accessoryId = accessoryId; - button.element.setAttribute('aria-pressed', String(selected)); + button.element.dataset.achievementId = achievement.id; + if (accessoryId) { + button.element.dataset.accessoryId = accessoryId; + button.element.setAttribute('aria-pressed', String(selected)); + } this.renderDisposables.add(button.onDidClick(() => { + if (achievement.colorCustomization) { + this.chatPetService.markAchievementSeen(achievement.id); + this.onOpenCustomization('color'); + return; + } if (this.chatPetService.selectedAccessory.get() === accessoryId) { return; } this.chatPetService.setAccessory(accessoryId); - status(localize('sessionsChatPetBadgeHatSelected', "VS Code pet is now wearing {0}", accessory.label)); + status(localize('sessionsChatPetBadgeHatSelected', "VS Code pet is now wearing {0}", reward)); })); return button.element; } diff --git a/src/vs/sessions/contrib/accountMenu/test/browser/account.contribution.test.ts b/src/vs/sessions/contrib/accountMenu/test/browser/account.contribution.test.ts index ad2da507795f2a..31f709b735e782 100644 --- a/src/vs/sessions/contrib/accountMenu/test/browser/account.contribution.test.ts +++ b/src/vs/sessions/contrib/accountMenu/test/browser/account.contribution.test.ts @@ -19,6 +19,8 @@ import { TestThemeService } from '../../../../../platform/theme/test/common/test import { CHAT_SETUP_ACTION_ID } from '../../../../../workbench/contrib/chat/browser/actions/chatActions.js'; import { ChatPetAccessoryId, ChatPetAccessoryIds, ChatPetAchievementId, ChatPetAchievementIds } from '../../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; import { ChatPetVariant, IChatPetService } from '../../../../../workbench/contrib/chat/browser/chatPetService.js'; +import { ChatPetCustomizationTab } from '../../../../../workbench/contrib/chat/browser/chatPetAchievementsEditorInput.js'; +import { NullHoverService } from '../../../../../platform/hover/test/browser/nullHoverService.js'; import { Menus } from '../../../../browser/menus.js'; import { getChatGPTRateLimitResetHover, shouldShowAccountPanelSummary } from '../../browser/account.contribution.js'; import { getSessionsChatPetAchievementBadges, SessionsChatPetAchievementBadges } from '../../browser/chatPetAchievementBadges.js'; @@ -101,6 +103,7 @@ suite('Sessions - Account Menu', () => { { id: ChatPetAchievementIds.ChatReferenceOpened, unlocked: false }, { id: ChatPetAchievementIds.UsefulOutputCopied, unlocked: false }, { id: ChatPetAchievementIds.AutopilotEnabled, unlocked: false }, + { id: ChatPetAchievementIds.Blobby, unlocked: false }, ], partial: [ { id: ChatPetAchievementIds.FirstChatMessage, unlocked: true }, @@ -116,6 +119,7 @@ suite('Sessions - Account Menu', () => { { id: ChatPetAchievementIds.ChatReferenceOpened, unlocked: false }, { id: ChatPetAchievementIds.UsefulOutputCopied, unlocked: false }, { id: ChatPetAchievementIds.AutopilotEnabled, unlocked: false }, + { id: ChatPetAchievementIds.Blobby, unlocked: false }, ], }); }); @@ -131,7 +135,7 @@ suite('Sessions - Account Menu', () => { override readonly enabled = constObservable(true); override readonly unlockedAchievements = constObservable([ChatPetAchievementIds.FirstChatMessage]); override readonly selectedAccessory = selectedAccessory; - override readonly variant = variant; + override readonly color = variant; override setAccessory(accessory: ChatPetAccessoryId | undefined): void { selected = accessory; @@ -181,4 +185,29 @@ suite('Sessions - Account Menu', () => { focusedAction: 'View Pet Achievements', }); }); + + test('routes True Name and View Achievements through the account owner navigation callback', () => { + const parent = mainWindow.document.createElement('div'); + store.add(toDisposable(() => parent.remove())); + const openedTabs: ChatPetCustomizationTab[] = []; + const seen: ChatPetAchievementId[] = []; + const chatPetService = new class extends mock() { + override readonly enabled = constObservable(true); + override readonly unlockedAchievements = constObservable([ChatPetAchievementIds.Blobby]); + override readonly selectedAccessory = constObservable(undefined); + override readonly color = constObservable('stable'); + override markAchievementSeen(id: ChatPetAchievementId): boolean { seen.push(id); return true; } + }(); + store.add(new SessionsChatPetAchievementBadges( + parent, + tab => openedTabs.push(tab), + chatPetService, + new TestThemeService(), + NullHoverService, + new NullLogService(), + )); + parent.querySelector('[data-achievement-id="blobby"]')?.click(); + parent.querySelector('.sessions-chat-pet-achievement-badges-actions .monaco-button')?.click(); + assert.deepStrictEqual({ openedTabs, seen }, { openedTabs: ['color', 'achievements'], seen: [ChatPetAchievementIds.Blobby] }); + }); }); diff --git a/src/vs/sessions/contrib/canvases/common/sessionCanvas.ts b/src/vs/sessions/contrib/canvases/common/sessionCanvas.ts index 01eee9e355ab92..d298f5ce28db07 100644 --- a/src/vs/sessions/contrib/canvases/common/sessionCanvas.ts +++ b/src/vs/sessions/contrib/canvases/common/sessionCanvas.ts @@ -21,19 +21,30 @@ export interface ISessionCanvasReference { readonly canvas: URI; } +export function getSessionCanvasReferenceKey(reference: ISessionCanvasReference): string { + return `${reference.providerId}\u0000${reference.session.toString()}\u0000${reference.chat.toString()}\u0000${reference.canvas.toString()}`; +} + export interface ISessionCanvasTarget { readonly session: ISession; readonly chat: IChat; readonly canvas: ISessionCanvas; } +export interface ISessionCanvasReopenTarget { + readonly reference: ISessionCanvasReference; + readonly canvas: ISessionCanvas; +} + export const ISessionCanvasService = createDecorator('sessionCanvasService'); export interface ISessionCanvasService { readonly _serviceBrand: undefined; readonly enabled: IObservable; + readonly reopenableCanvases: IObservable; getTarget(reference: ISessionCanvasReference, reader?: IReader): ISessionCanvasTarget | undefined; isActiveOwner(reference: ISessionCanvasReference, reader?: IReader): boolean; + reopenCanvas(reference: ISessionCanvasReference): Promise; } function createInputResource(reference: ISessionCanvasReference): URI { @@ -67,7 +78,7 @@ export class SessionCanvasInput extends EditorInput { override get editorId(): string { return SessionCanvasInput.EDITOR_ID; } override get capabilities(): EditorInputCapabilities { return EditorInputCapabilities.Readonly | EditorInputCapabilities.Singleton | EditorInputCapabilities.ForceReveal; } override getName(): string { return this.canvas.get()?.title ?? localize('canvas.editorName', "Canvas"); } - override getDescription(): string { return localize('canvas.editorDescription', "Closing this tab hides the canvas until the agent opens it again."); } + override getDescription(): string { return localize('canvas.editorDescription', "Closing this tab hides the canvas. Reopen it from the Add Tab menu while it remains available."); } override getIcon(): ThemeIcon { return Codicon.preview; } override getTitle(_verbosity?: Verbosity): string { return this.getName(); } override canReopen(): boolean { return false; } diff --git a/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasActions.ts b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasActions.ts new file mode 100644 index 00000000000000..135b7d688cf6c4 --- /dev/null +++ b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasActions.ts @@ -0,0 +1,134 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; +import { MenuRegistry } from '../../../../platform/actions/common/actions.js'; +import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext } from '../../../../workbench/common/contextkeys.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { Menus } from '../../../browser/menus.js'; +import { getSessionCanvasReferenceKey, ISessionCanvasReference, ISessionCanvasReopenTarget, ISessionCanvasService } from '../common/sessionCanvas.js'; + +export const REOPEN_SESSION_CANVAS_COMMAND_ID = 'workbench.action.agentSessions.reopenCanvas'; + +const reopenCanvasWhen = ContextKeyExpr.and( + ChatContextKeys.enabled, + IsSessionsWindowContext, + IsAuxiliaryWindowContext.toNegated(), + IsTopRightEditorGroupContext, +); + +class CanvasAddTabActionRegistration extends Disposable { + + private readonly menuRegistration = this._register(new MutableDisposable()); + private title: string | undefined; + private order: number | undefined; + + constructor( + readonly commandId: string, + reference: ISessionCanvasReference, + canvasService: ISessionCanvasService, + ) { + super(); + this._register(CommandsRegistry.registerCommand(commandId, () => canvasService.reopenCanvas(reference))); + } + + update(title: string, order: number): void { + if (this.title === title && this.order === order) { + return; + } + this.title = title; + this.order = order; + this.menuRegistration.value = MenuRegistry.appendMenuItem(Menus.SessionsEditorTabsBarAddTab, { + command: { + id: this.commandId, + title, + icon: Codicon.preview, + }, + group: 'navigation', + order, + when: reopenCanvasWhen, + }); + } +} + +export function registerSessionCanvasAddTabActions(canvasService: ISessionCanvasService): IDisposable { + const store = new DisposableStore(); + const registrations = store.add(new DisposableMap()); + let commandSequence = 0; + store.add(autorun(reader => { + const targets = canvasService.reopenableCanvases.read(reader); + const labels = getCanvasInstanceLabels(targets); + const activeKeys = new Set(); + for (let index = 0; index < targets.length; index++) { + const target = targets[index]; + const key = getSessionCanvasReferenceKey(target.reference); + activeKeys.add(key); + let registration = registrations.get(key); + if (!registration) { + registration = new CanvasAddTabActionRegistration( + `${REOPEN_SESSION_CANVAS_COMMAND_ID}.${++commandSequence}`, + target.reference, + canvasService, + ); + registrations.set(key, registration); + } + registration.update(labels[index], 4 + index); + } + for (const key of [...registrations.keys()]) { + if (!activeKeys.has(key)) { + registrations.deleteAndDispose(key); + } + } + })); + return store; +} + +function getCanvasInstanceLabels(targets: readonly ISessionCanvasReopenTarget[]): string[] { + const titleCounts = new Map(); + const instanceIdCounts = new Map(); + for (const { canvas } of targets) { + titleCounts.set(canvas.title, (titleCounts.get(canvas.title) ?? 0) + 1); + if (canvas.instanceId) { + instanceIdCounts.set(canvas.instanceId, (instanceIdCounts.get(canvas.instanceId) ?? 0) + 1); + } + } + + const labels = new Array(targets.length); + const usedLabels = new Set(); + for (let index = 0; index < targets.length; index++) { + const { canvas } = targets[index]; + if (titleCounts.get(canvas.title) === 1) { + labels[index] = canvas.title; + usedLabels.add(canvas.title); + } + } + + const titleIndexes = new Map(); + for (let index = 0; index < targets.length; index++) { + const { canvas } = targets[index]; + if (titleCounts.get(canvas.title) === 1) { + continue; + } + + const titleIndex = (titleIndexes.get(canvas.title) ?? 0) + 1; + titleIndexes.set(canvas.title, titleIndex); + const instanceLabel = canvas.instanceId && instanceIdCounts.get(canvas.instanceId) === 1 + ? canvas.instanceId + : String(titleIndex); + let label = localize('canvas.instanceTitle', "{0} ({1})", canvas.title, instanceLabel); + let collisionIndex = 2; + while (usedLabels.has(label)) { + label = localize('canvas.instanceTitleCollision', "{0} ({1}, {2})", canvas.title, instanceLabel, String(collisionIndex++)); + } + labels[index] = label; + usedLabels.add(label); + } + return labels; +} diff --git a/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasEditor.ts b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasEditor.ts index 8a3671294f0339..33b093095e2494 100644 --- a/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasEditor.ts +++ b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasEditor.ts @@ -295,7 +295,7 @@ export class SessionCanvasEditor extends EditorPane { const help = [ localize('canvas.help.overview', "This canvas is a private page owned by the active conversation."), localize('canvas.help.navigation', "Tab moves through page controls. Use to leave the page."), - localize('canvas.help.close', "Closing the tab hides this canvas. A newly created canvas can open another tab."), + localize('canvas.help.close', "Closing the tab hides this canvas. Use the Add Tab menu in the editor toolbar and choose its title to reopen it while it remains available."), ].join('\n\n'); this.semanticText = localize('canvas.reading', "Reading accessible canvas content…"); return new AccessibleContentProvider( diff --git a/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasService.ts b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasService.ts index 06cf625f862a56..8b57171d47375c 100644 --- a/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasService.ts +++ b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasService.ts @@ -5,7 +5,7 @@ import { Event } from '../../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js'; -import { autorun, IReader, observableFromEvent } from '../../../../base/common/observable.js'; +import { autorun, derived, IReader, observableFromEvent, observableSignal } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { CanvasesEnabledSettingId } from '../../../../platform/agentHost/common/agentService.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -15,18 +15,20 @@ import { IEditorService } from '../../../../workbench/services/editor/common/edi import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { IChat, ISession, ISessionCanvas } from '../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; -import { ISessionCanvasReference, ISessionCanvasService, ISessionCanvasTarget, SessionCanvasInput } from '../common/sessionCanvas.js'; +import { getSessionCanvasReferenceKey, ISessionCanvasReference, ISessionCanvasReopenTarget, ISessionCanvasService, ISessionCanvasTarget, SessionCanvasInput } from '../common/sessionCanvas.js'; export class SessionCanvasService extends Disposable implements ISessionCanvasService { declare readonly _serviceBrand: undefined; readonly enabled; + readonly reopenableCanvases; private readonly _inputs = this._register(new DisposableMap()); private readonly _inputLifetimes = this._register(new DisposableMap()); private readonly _dismissed = new Map(); private readonly _presented = new Set(); - private readonly _programmaticCloses = new Set(); + private readonly _programmaticCloses = new Set(); + private readonly _dismissedChanged = observableSignal(this); constructor( @ISessionsService private readonly sessionsService: ISessionsService, @@ -45,6 +47,21 @@ export class SessionCanvasService extends Disposable implements ISessionCanvasSe !entitlementService.sentiment.hidden && configurationService.getValue(CanvasesEnabledSettingId) === true ); + this.reopenableCanvases = derived(this, reader => { + this._dismissedChanged.read(reader); + if (!this.enabled.read(reader)) { + return []; + } + + const reopenable: ISessionCanvasReopenTarget[] = []; + for (const reference of this._dismissed.values()) { + const target = this.getTarget(reference, reader); + if (target?.canvas.source !== undefined) { + reopenable.push({ reference, canvas: target.canvas }); + } + } + return reopenable; + }); this._register(sessionsManagementService.onDidChangeSessions(event => { for (const session of event.removed) { this._removeSession(session); @@ -67,14 +84,13 @@ export class SessionCanvasService extends Disposable implements ISessionCanvasSe canvas: canvas.resource, }; const input = this._getOrCreateInput(reference, canvas); - const key = canvasKey(reference); + const key = getSessionCanvasReferenceKey(reference); activeKeys.add(key); input.setCanvas(canvas); if (canvas.source === undefined || this._dismissed.has(key) || this._presented.has(key)) { continue; } - this._presented.add(key); - void this.editorService.openEditor(input, { pinned: true, revealIfOpened: true, preserveFocus: false }) + void this._openInput(key, input) .catch(error => this.logService.error('[SessionCanvasService] Failed to reveal canvas', error)); } } @@ -82,7 +98,7 @@ export class SessionCanvasService extends Disposable implements ISessionCanvasSe if (activeSession && activeChat && canvases !== undefined) { for (const [key, dismissed] of this._dismissed) { if (ownsChat(dismissed, activeSession, activeChat) && !activeKeys.has(key)) { - this._dismissed.delete(key); + this._deleteDismissed(key); } } } @@ -90,7 +106,7 @@ export class SessionCanvasService extends Disposable implements ISessionCanvasSe for (const [key, input] of this._inputs) { const ownsActiveChat = !!activeSession && !!activeChat && ownsChat(input.reference, activeSession, activeChat); if (!enabled || (ownsActiveChat && (!supported || (canvases !== undefined && !activeKeys.has(key))))) { - void this._closeInput(key, input); + void this._closeInput(key, input).catch(error => this.logService.error('[SessionCanvasService] Failed to close canvas', error)); } } })); @@ -114,8 +130,40 @@ export class SessionCanvasService extends Disposable implements ISessionCanvasSe return this.enabled.read(reader) && this.getTarget(reference, reader) !== undefined; } + async reopenCanvas(reference: ISessionCanvasReference): Promise { + const key = getSessionCanvasReferenceKey(reference); + const reopenable = this.reopenableCanvases.get().find(candidate => getSessionCanvasReferenceKey(candidate.reference) === key); + if (!reopenable) { + return; + } + + const input = this._getOrCreateInput(reference, reopenable.canvas); + input.setCanvas(reopenable.canvas); + this._deleteDismissed(key); + await this._openInput(key, input); + } + + private async _openInput(key: string, input: SessionCanvasInput): Promise { + this._presented.add(key); + try { + const pane = await this.editorService.openEditor(input, { pinned: true, revealIfOpened: true, preserveFocus: false }); + const opened = pane || this.editorService.findEditors(input.resource).some(identifier => !identifier.editor.isDisposed() && identifier.editor.matches(input)); + if (!opened) { + throw new Error('Canvas editor failed to open'); + } + } catch (error) { + if (this._inputs.get(key) === input) { + this._presented.delete(key); + if (this.enabled.get() && !input.isDisposed()) { + this._rememberDismissed(key, input.reference); + } + } + throw error; + } + } + private _getOrCreateInput(reference: ISessionCanvasReference, canvas: ISessionCanvas): SessionCanvasInput { - const key = canvasKey(reference); + const key = getSessionCanvasReferenceKey(reference); let input = this._inputs.get(key); if (input && !input.isDisposed()) { return input; @@ -125,16 +173,19 @@ export class SessionCanvasService extends Disposable implements ISessionCanvasSe const lifetime = new DisposableStore(); this._inputLifetimes.set(key, lifetime); lifetime.add(Event.once(input.onWillDispose)(() => { - if (!this._programmaticCloses.delete(key)) { - this._dismissed.set(key, reference); + const isCurrentInput = this._inputs.get(key) === input; + if (isCurrentInput && !this._programmaticCloses.delete(input)) { + this._rememberDismissed(key, reference); } - if (this._inputs.get(key) === input) { + if (isCurrentInput) { this._inputs.deleteAndLeak(key); } if (this._inputLifetimes.get(key) === lifetime) { this._inputLifetimes.deleteAndLeak(key); } - this._presented.delete(key); + if (isCurrentInput) { + this._presented.delete(key); + } lifetime.dispose(); })); return input; @@ -143,21 +194,21 @@ export class SessionCanvasService extends Disposable implements ISessionCanvasSe private _removeSession(session: ISession): void { for (const [key, dismissed] of this._dismissed) { if (ownsSession(dismissed, session)) { - this._dismissed.delete(key); + this._deleteDismissed(key); } } for (const [key, input] of this._inputs) { if (ownsSession(input.reference, session)) { - void this._closeInput(key, input); + void this._closeInput(key, input).catch(error => this.logService.error('[SessionCanvasService] Failed to close canvas', error)); } } } private async _closeInput(key: string, input: SessionCanvasInput): Promise { - if (this._programmaticCloses.has(key)) { + if (this._programmaticCloses.has(input)) { return; } - this._programmaticCloses.add(key); + this._programmaticCloses.add(input); const lifetime = this._inputLifetimes.get(key); if (this._inputs.get(key) === input) { this._inputs.deleteAndLeak(key); @@ -165,19 +216,30 @@ export class SessionCanvasService extends Disposable implements ISessionCanvasSe if (lifetime && this._inputLifetimes.get(key) === lifetime) { this._inputLifetimes.deleteAndLeak(key); } - this._dismissed.delete(key); + this._deleteDismissed(key); this._presented.delete(key); - await this.editorService.closeEditors(this.editorService.findEditors(input.resource), { preserveFocus: true }); - if (!input.isDisposed()) { - input.dispose(); + try { + await this.editorService.closeEditors(this.editorService.findEditors(input.resource), { preserveFocus: true }); + } finally { + if (!input.isDisposed()) { + input.dispose(); + } + lifetime?.dispose(); + this._programmaticCloses.delete(input); } - lifetime?.dispose(); - this._programmaticCloses.delete(key); } -} -function canvasKey(reference: ISessionCanvasReference): string { - return `${reference.providerId}\u0000${reference.session.toString()}\u0000${reference.chat.toString()}\u0000${reference.canvas.toString()}`; + private _rememberDismissed(key: string, reference: ISessionCanvasReference): void { + this._dismissed.delete(key); + this._dismissed.set(key, reference); + this._dismissedChanged.trigger(undefined); + } + + private _deleteDismissed(key: string): void { + if (this._dismissed.delete(key)) { + this._dismissedChanged.trigger(undefined); + } + } } function ownsSession(reference: ISessionCanvasReference, session: ISession): boolean { diff --git a/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvases.contribution.ts b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvases.contribution.ts index 8a3992bfcdf6ce..2648d7bb1d5ea2 100644 --- a/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvases.contribution.ts +++ b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvases.contribution.ts @@ -17,6 +17,7 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { ISessionCanvasService, SessionCanvasInput } from '../common/sessionCanvas.js'; +import { registerSessionCanvasAddTabActions } from './sessionCanvasActions.js'; import { SessionCanvasEditor, SessionCanvasFocusedContext } from './sessionCanvasEditor.js'; import { SessionCanvasService } from './sessionCanvasService.js'; @@ -32,9 +33,10 @@ class SessionCanvasesContribution extends Disposable implements IWorkbenchContri static readonly ID = 'sessions.contrib.canvases'; constructor( - @ISessionCanvasService _canvasService: ISessionCanvasService, + @ISessionCanvasService canvasService: ISessionCanvasService, ) { super(); + this._register(registerSessionCanvasAddTabActions(canvasService)); for (const type of [AccessibleViewType.Help, AccessibleViewType.View]) { this._register(AccessibleViewRegistry.register({ type, diff --git a/src/vs/sessions/contrib/canvases/test/electron-browser/sessionCanvasService.test.ts b/src/vs/sessions/contrib/canvases/test/electron-browser/sessionCanvasService.test.ts index 83b78a8dae47b5..79071908803a9d 100644 --- a/src/vs/sessions/contrib/canvases/test/electron-browser/sessionCanvasService.test.ts +++ b/src/vs/sessions/contrib/canvases/test/electron-browser/sessionCanvasService.test.ts @@ -4,27 +4,36 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DeferredPromise } from '../../../../../base/common/async.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { isIMenuItem, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; import { CanvasesEnabledSettingId } from '../../../../../platform/agentHost/common/agentService.js'; +import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationChangeEvent } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext } from '../../../../../workbench/common/contextkeys.js'; +import { IEditorIdentifier, ITextDiffEditorPane } from '../../../../../workbench/common/editor.js'; +import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { IChatEntitlementService } from '../../../../../workbench/services/chat/common/chatEntitlementService.js'; import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; +import { Menus } from '../../../../browser/menus.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IChat, ISessionCanvas, ISessionCapabilities } from '../../../../services/sessions/common/session.js'; import { IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { SessionCanvasInput } from '../../common/sessionCanvas.js'; +import { registerSessionCanvasAddTabActions, REOPEN_SESSION_CANVAS_COMMAND_ID } from '../../electron-browser/sessionCanvasActions.js'; import { SessionCanvasService } from '../../electron-browser/sessionCanvasService.js'; suite('SessionCanvasService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - function createHarness(canvasesEnabled = true) { + function createHarness(canvasesEnabled = true, initialCanvases?: readonly ISessionCanvas[]) { const sessionResource = URI.parse('agent-host-session:/session'); const chatResource = URI.parse('agent-host-chat:/session/main'); const canvas: ISessionCanvas = { @@ -33,7 +42,7 @@ suite('SessionCanvasService', () => { title: 'Preview', source: URI.parse('https://example.test/preview'), }; - const canvases = observableValue('canvases', [canvas]); + const canvases = observableValue('canvases', initialCanvases ?? [canvas]); const chat = upcastPartial({ resource: chatResource, canvases }); const activeChat = observableValue('activeChat', chat); const capabilities = observableValue('capabilities', { supportsCanvases: true, supportsMultipleChats: false }); @@ -49,18 +58,29 @@ suite('SessionCanvasService', () => { const sessionsManagementService = upcastPartial({ onDidChangeSessions: sessionChanges.event }); const opened: SessionCanvasInput[] = []; const openOptions: unknown[] = []; + const openSettled: Promise[] = []; + let openEditorHandler = (input: SessionCanvasInput) => Promise.resolve(upcastPartial({ input })); + let findEditorsHandler = (_resource: URI): readonly IEditorIdentifier[] => []; + let closeEditorsHandler = () => Promise.resolve(); + const closeSettled: Promise[] = []; let closeCount = 0; const editorService = new class extends mock() { - override async openEditor(...args: unknown[]): Promise { - opened.push(args[0] as SessionCanvasInput); + override openEditor(...args: unknown[]): Promise { + const input = args[0] as SessionCanvasInput; + opened.push(input); openOptions.push(args[1]); - return undefined; + const result = openEditorHandler(input); + openSettled.push(result.then(() => undefined, () => undefined)); + return result; } - override findEditors(): never[] { - return []; + override findEditors(...args: unknown[]): readonly IEditorIdentifier[] { + return findEditorsHandler(args[0] as URI); } - override async closeEditors(): Promise { + override closeEditors(): Promise { closeCount++; + const result = closeEditorsHandler(); + closeSettled.push(result.then(() => undefined, () => undefined)); + return result; } }(); const entitlementService = upcastPartial({ @@ -68,7 +88,7 @@ suite('SessionCanvasService', () => { onDidChangeSentiment: Event.None, }); const configurationService = new TestConfigurationService({ [CanvasesEnabledSettingId]: canvasesEnabled }); - store.add(new SessionCanvasService( + const canvasService = store.add(new SessionCanvasService( sessionsService, sessionsManagementService, editorService, @@ -82,7 +102,10 @@ suite('SessionCanvasService', () => { affectsConfiguration: key => key === CanvasesEnabledSettingId, })); }; - return { activeSession, canvas, canvases, opened, openOptions, session, sessionChanges, get closeCount() { return closeCount; }, setCanvasesEnabled }; + const setOpenEditorHandler = (handler: (input: SessionCanvasInput) => Promise) => openEditorHandler = handler; + const setFindEditorsHandler = (handler: (resource: URI) => readonly IEditorIdentifier[]) => findEditorsHandler = handler; + const setCloseEditorsHandler = (handler: () => Promise) => closeEditorsHandler = handler; + return { activeChat, activeSession, canvas, canvasService, canvases, chat, closeSettled, opened, openOptions, openSettled, session, sessionChanges, get closeCount() { return closeCount; }, setCanvasesEnabled, setCloseEditorsHandler, setFindEditorsHandler, setOpenEditorHandler }; } test('automatically reveals a newly opened canvas', () => { @@ -105,6 +128,239 @@ suite('SessionCanvasService', () => { assert.deepStrictEqual({ opened: harness.opened.length, closed: harness.closeCount }, { opened: 1, closed: 1 }); }); + test('reopens a dismissed canvas from the Add Tab action state', async () => { + const { canvasService, opened, openOptions } = createHarness(); + const original = opened[0]; + const reopenableInitially = canvasService.reopenableCanvases.get(); + + original.dispose(); + const reopenableAfterDismiss = canvasService.reopenableCanvases.get(); + await canvasService.reopenCanvas(reopenableAfterDismiss[0].reference); + + assert.deepStrictEqual({ + reopenableInitially: reopenableInitially.length, + reopenableAfterDismiss: reopenableAfterDismiss.map(target => ({ + title: target.canvas.title, + instanceId: target.canvas.instanceId, + })), + reopenableAfterOpen: canvasService.reopenableCanvases.get().length, + openCount: opened.length, + reopenedResource: opened[1].reference.canvas.toString(), + reopenedWithNewInput: opened[1] !== original, + openOptions: openOptions[1], + }, { + reopenableInitially: 0, + reopenableAfterDismiss: [{ title: 'Preview', instanceId: 'preview' }], + reopenableAfterOpen: 0, + openCount: 2, + reopenedResource: 'agent-host-canvas:/preview', + reopenedWithNewInput: true, + openOptions: { pinned: true, revealIfOpened: true, preserveFocus: false }, + }); + }); + + test('ignores a disposed matching editor when opening resolves undefined', async () => { + const { canvasService, opened, setFindEditorsHandler, setOpenEditorHandler } = createHarness(); + const disposedInput = opened[0]; + disposedInput.dispose(); + const reopenable = canvasService.reopenableCanvases.get()[0]; + setOpenEditorHandler(() => Promise.resolve(undefined)); + setFindEditorsHandler(() => [upcastPartial({ editor: disposedInput, groupId: 1 })]); + + await assert.rejects(canvasService.reopenCanvas(reopenable.reference), /Canvas editor failed to open/); + + assert.deepStrictEqual({ + openCount: opened.length, + reopenable: canvasService.reopenableCanvases.get().map(target => target.canvas.resource.toString()), + }, { + openCount: 2, + reopenable: ['agent-host-canvas:/preview'], + }); + }); + + test('cleans up after close rejection so the same canvas can open again', async () => { + const { canvas, canvasService, canvases, closeSettled, opened, setCloseEditorsHandler } = createHarness(); + const original = opened[0]; + setCloseEditorsHandler(() => Promise.reject(new Error('close failed'))); + + canvases.set([], undefined); + await closeSettled[closeSettled.length - 1]; + setCloseEditorsHandler(() => Promise.resolve()); + canvases.set([canvas], undefined); + + assert.deepStrictEqual({ + originalDisposed: original.isDisposed(), + openCount: opened.length, + openedWithNewInput: opened[1] !== original, + reopenable: canvasService.reopenableCanvases.get().length, + }, { + originalDisposed: true, + openCount: 2, + openedWithNewInput: true, + reopenable: 0, + }); + }); + + test('accepts an undefined result when the canvas editor is already open', async () => { + const { canvasService, opened, setFindEditorsHandler, setOpenEditorHandler } = createHarness(); + opened[0].dispose(); + const reopenable = canvasService.reopenableCanvases.get()[0]; + let openedInput: SessionCanvasInput | undefined; + setOpenEditorHandler(input => { + openedInput = input; + return Promise.resolve(undefined); + }); + setFindEditorsHandler(() => openedInput + ? [upcastPartial({ editor: openedInput, groupId: 1 })] + : []); + + await canvasService.reopenCanvas(reopenable.reference); + + assert.deepStrictEqual({ + openCount: opened.length, + reopenable: canvasService.reopenableCanvases.get().length, + }, { + openCount: 2, + reopenable: 0, + }); + }); + + test('restores dismissal when reopening fails after switching chats', async () => { + const { activeChat, canvasService, chat, opened, setOpenEditorHandler } = createHarness(); + opened[0].dispose(); + const reopenable = canvasService.reopenableCanvases.get()[0]; + const failedOpen = new DeferredPromise(); + setOpenEditorHandler(() => failedOpen.p); + + const reopenRejected = assert.rejects(canvasService.reopenCanvas(reopenable.reference), /open failed/); + activeChat.set(upcastPartial({ + resource: URI.parse('agent-host-chat:/session/other'), + canvases: observableValue('otherCanvases', []), + }), undefined); + await failedOpen.error(new Error('open failed')); + await reopenRejected; + activeChat.set(chat, undefined); + + assert.deepStrictEqual({ + openCount: opened.length, + reopenable: canvasService.reopenableCanvases.get().map(target => target.canvas.resource.toString()), + }, { + openCount: 2, + reopenable: ['agent-host-canvas:/preview'], + }); + }); + + test('does not let an older failed open clear a newer presentation', async () => { + const { canvas, canvasService, canvases, opened, setOpenEditorHandler } = createHarness(); + opened[0].dispose(); + const reopenable = canvasService.reopenableCanvases.get()[0]; + const failedOpen = new DeferredPromise(); + setOpenEditorHandler(() => failedOpen.p); + + const reopenRejected = assert.rejects(canvasService.reopenCanvas(reopenable.reference), /open failed/); + canvases.set([], undefined); + setOpenEditorHandler(input => Promise.resolve(upcastPartial({ input }))); + canvases.set([canvas], undefined); + await failedOpen.error(new Error('open failed')); + await reopenRejected; + canvases.set([{ ...canvas, status: 'updated' }], undefined); + + assert.deepStrictEqual({ + openCount: opened.length, + reopenable: canvasService.reopenableCanvases.get().length, + }, { + openCount: 3, + reopenable: 0, + }); + }); + + test('offers an automatically failed canvas reveal for manual reopening', async () => { + const { canvas, canvasService, canvases, opened, openSettled, setOpenEditorHandler } = createHarness(); + const failedCanvas: ISessionCanvas = { + resource: URI.parse('agent-host-canvas:/failed'), + instanceId: 'failed', + title: 'Failed Preview', + source: URI.parse('https://example.test/failed'), + }; + const failedOpen = new DeferredPromise(); + setOpenEditorHandler(() => failedOpen.p); + + canvases.set([canvas, failedCanvas], undefined); + await failedOpen.error(new Error('automatic open failed')); + await openSettled.at(-1); + await Promise.resolve(); + canvases.set([canvas, { ...failedCanvas, status: 'retry' }], undefined); + + assert.deepStrictEqual({ + openCount: opened.length, + reopenable: canvasService.reopenableCanvases.get().map(target => target.canvas.resource.toString()), + }, { + openCount: 2, + reopenable: ['agent-host-canvas:/failed'], + }); + }); + + test('contributes titled canvas instances to the right pane Add Tab menu', async () => { + const canvases: ISessionCanvas[] = [ + { resource: URI.parse('agent-host-canvas:/preview-editor'), instanceId: 'editor', title: 'Preview', source: URI.parse('https://example.test/editor') }, + { resource: URI.parse('agent-host-canvas:/preview-sidebar'), instanceId: 'sidebar', title: 'Preview', source: URI.parse('https://example.test/sidebar') }, + { resource: URI.parse('agent-host-canvas:/preview-editor-title'), instanceId: 'dashboard', title: 'Preview (editor)', source: URI.parse('https://example.test/editor-title') }, + { resource: URI.parse('agent-host-canvas:/dashboard'), instanceId: 'dashboard', title: 'Dashboard', source: URI.parse('https://example.test/dashboard') }, + { resource: URI.parse('agent-host-canvas:/logs-numbered'), instanceId: undefined, title: 'Logs', source: URI.parse('https://example.test/logs-numbered') }, + { resource: URI.parse('agent-host-canvas:/logs-semantic'), instanceId: '1', title: 'Logs', source: URI.parse('https://example.test/logs-semantic') }, + ]; + const { canvasService, canvases: canvasStates, opened } = createHarness(true, canvases); + const registration = store.add(registerSessionCanvasAddTabActions(canvasService)); + for (const input of [...opened]) { + input.dispose(); + } + const getItems = () => MenuRegistry.getMenuItems(Menus.SessionsEditorTabsBarAddTab) + .filter(isIMenuItem) + .filter(item => item.command.id.startsWith(`${REOPEN_SESSION_CANVAS_COMMAND_ID}.`)) + .sort((first, second) => (first.order ?? 0) - (second.order ?? 0)); + const items = getItems(); + const initialCommandIds = items.map(item => item.command.id); + const when = items[0].when?.serialize() ?? ''; + canvasStates.set(canvases.map((canvas, index) => index === 1 ? { ...canvas, title: 'Preview Updated' } : canvas), undefined); + const refreshedItems = getItems(); + await CommandsRegistry.getCommand(items[1].command.id)!.handler(upcastPartial({})); + const remainingItems = getItems(); + const remainingTitles = remainingItems.map(item => typeof item.command.title === 'string' ? item.command.title : item.command.title.value); + const remainingCommandIds = remainingItems.map(item => item.command.id); + const staleCommandsRegistered = initialCommandIds.map(id => CommandsRegistry.getCommand(id) !== undefined); + registration.dispose(); + + assert.deepStrictEqual({ + titles: items.map(item => typeof item.command.title === 'string' ? item.command.title : item.command.title.value), + groups: items.map(item => item.group), + requiresChat: when.includes(ChatContextKeys.enabled.key), + requiresSessionsWindow: when.includes(IsSessionsWindowContext.key), + excludesAuxiliaryWindow: when.includes(`!${IsAuxiliaryWindowContext.key}`), + requiresRightPane: when.includes(IsTopRightEditorGroupContext.key), + reopenedResource: opened.at(-1)?.reference.canvas.toString(), + refreshedCommandIds: refreshedItems.map(item => item.command.id), + refreshedTitles: refreshedItems.map(item => typeof item.command.title === 'string' ? item.command.title : item.command.title.value), + remainingTitles, + initialCommandsRegisteredAfterReopen: staleCommandsRegistered, + itemsAfterDispose: getItems().length, + remainingCommandsAfterDispose: remainingCommandIds.map(id => CommandsRegistry.getCommand(id) !== undefined), + }, { + titles: ['Preview (editor, 2)', 'Preview (sidebar)', 'Preview (editor)', 'Dashboard', 'Logs (1)', 'Logs (1, 2)'], + groups: ['navigation', 'navigation', 'navigation', 'navigation', 'navigation', 'navigation'], + requiresChat: true, + requiresSessionsWindow: true, + excludesAuxiliaryWindow: true, + requiresRightPane: true, + reopenedResource: 'agent-host-canvas:/preview-sidebar', + refreshedCommandIds: initialCommandIds, + refreshedTitles: ['Preview', 'Preview Updated', 'Preview (editor)', 'Dashboard', 'Logs (1)', 'Logs (1, 2)'], + remainingTitles: ['Preview', 'Preview (editor)', 'Dashboard', 'Logs (1)', 'Logs (1, 2)'], + initialCommandsRegisteredAfterReopen: [true, false, true, true, true, true], + itemsAfterDispose: 0, + remainingCommandsAfterDispose: [false, false, false, false, false], + }); + }); + test('forgets a dismissed canvas when the provider removes its membership', () => { const { canvas, canvases, opened } = createHarness(); opened[0].dispose(); diff --git a/src/vs/sessions/contrib/chat/browser/agentHostInputCompletions.ts b/src/vs/sessions/contrib/chat/browser/agentHostInputCompletions.ts index 35fb1dfba82dca..1e57b6083d4636 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHostInputCompletions.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHostInputCompletions.ts @@ -299,13 +299,7 @@ export class AgentHostInputCompletionHandler extends AgentHostInputCompletionsBa const attachment = item.attachment; switch (attachment.kind) { case 'text': - return { - label: item.label ?? item.insertText, - insertText: item.insertText, - filterText: item.label ?? item.insertText, - range: replaceRange, - kind: CompletionItemKind.Text, - }; + return AgentHostInputCompletionHandler.buildTextCompletionItem(position, item); case 'command': { const action = getCompletionAction(attachment._meta); if (action) { diff --git a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts index 6d907176f7e784..ba6dbf25c7e506 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts @@ -13,7 +13,7 @@ import { toErrorMessage } from '../../../../base/common/errorMessage.js'; import { status } from '../../../../base/browser/ui/aria/aria.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; -import { autorun, constObservable, derived, derivedOpts, IObservable, IReader, observableValue } from '../../../../base/common/observable.js'; +import { autorun, constObservable, derived, derivedOpts, IObservable, IReader, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; @@ -24,6 +24,7 @@ import { IInstantiationService } from '../../../../platform/instantiation/common import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { AgentHostAutoAttachPullRequestsConfigKey } from '../../../../platform/agentHost/common/agentHostSchema.js'; import { ChatInputPills, StandardChatInputPillSources } from '../../../../workbench/contrib/chat/browser/chatInputPills.js'; import { createSessionPullRequestPillData, type IChatPullRequestPillEntry, type IChatPullRequestPillSection } from '../../../../workbench/contrib/chat/browser/sessionPullRequestPill.js'; import { diffStatsEqual, EMPTY_DIFF_STATS, IDiffStats } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; @@ -41,6 +42,7 @@ import { BRANCH_CHANGES_CHANGESET_ID, ChatOriginKind, IChat, SESSION_CHANGES_CHA import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { logSessionArtifactOpen } from '../../../common/sessionsTelemetry.js'; +import { isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; import { SessionBackgroundActivitiesControl } from './sessionBackgroundActivitiesControl.js'; import { SessionBackgroundShellsControl } from '../../../../workbench/contrib/chat/browser/sessionBackgroundShellsControl.js'; import { SessionBrowsersControl } from './sessionBrowsersControl.js'; @@ -397,7 +399,17 @@ export class SessionChatInputToolbar extends Disposable { const pillsEnabled = constObservable(true); this._browsers = this._register(instantiationService.createInstance(SessionBrowsersControl, this._session, this._chat, pillsEnabled, derived(reader => visibility.isVisible(SessionChatPillKind.Browsers, reader)))); - const gitHubReferences = derived(this, reader => getSessionGitHubReferences(this._session.read(reader), reader, this._chat.read(reader))); + const autoAssociatePullRequests = derived(this, reader => { + const session = this._session.read(reader); + const provider = session ? sessionsProvidersService.getProvider(session.providerId) : undefined; + return provider && isAgentHostProvider(provider) + ? observableFromEvent(this, provider.onDidChangeRootConfig, () => { + const config = provider.getRootConfig(); + return (config?.values[AgentHostAutoAttachPullRequestsConfigKey] ?? config?.schema.properties[AgentHostAutoAttachPullRequestsConfigKey]?.default) !== false; + }) + : constObservable(true); + }); + const gitHubReferences = derived(this, reader => getSessionGitHubReferences(this._session.read(reader), reader, this._chat.read(reader), autoAssociatePullRequests.read(reader).read(reader))); // The browsers pill already offers the pages it lists, so the artifacts and // references pills leave those websites out. diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index 2ad522ec744370..ecd7d1810cbbea 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -24,7 +24,7 @@ import { ITabDescriptor, TabbedActionListWidget } from '../../../../platform/act import { IMenuService, MenuItemAction } from '../../../../platform/actions/common/actions.js'; import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { TUNNEL_ADDRESS_PREFIX } from '../../../../platform/agentHost/common/tunnelAgentHost.js'; -import { devContainerSamples, devContainerSampleUri, findDevContainerSample, getDevContainerSampleUrl } from '../../../../platform/agentHost/common/devContainerSamples.js'; +import { DevContainerSample, devContainerSamples, devContainerSampleUri, findDevContainerSample, getDevContainerSampleUrl } from '../../../../platform/agentHost/common/devContainerSamples.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpression, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -61,6 +61,16 @@ import { registerPickerKeybindingPresentation } from './newChatPickerKeybinding. export type { IResolvedFolderWorkspace } from './sessionWorkspaceFallback.js'; +type DevContainerSampleSelectedEvent = { + sampleId: DevContainerSample['id']; +}; + +type DevContainerSampleSelectedClassification = { + owner: 'chrmarti'; + comment: 'Records accepted user selections of Dev Container samples in the Agents workspace picker, not container provisioning or session creation.'; + sampleId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The selected built-in sample catalog identifier: go, dotnet, node, php, python, or rust. Does not contain a workspace path or repository URL.' }; +}; + const FILTER_THRESHOLD = 10; /** @@ -1046,6 +1056,10 @@ export class WorkspacePicker extends Disposable { this._selectedDevContainerFolderUri = item.preferDevContainer ? item.folderUri : undefined; this._onDidSelectWorkspaceMode.fire({ folderUri: item.folderUri, preferDevContainer: item.preferDevContainer === true }); this._selectFolder(item.folderUri, true, item.providerId); + const sample = findDevContainerSample(item.folderUri); + if (sample) { + this.telemetryService.publicLog2('devContainerSampleSelected', { sampleId: sample.id }); + } return true; } return false; diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 879719b3d8663d..b8b19ef2057298 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -144,7 +144,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.repositorySelection', "When choosing a GitHub repository in the browser, search or enter a GitHub URL or owner/repository. Use the arrow keys to navigate results, Enter to select, and Escape to cancel. Sign in to GitHub if prompted.")); } if (accessor.get(IAgentHostFilterService).selectedHost?.sessionCreationProviderId) { - content.push(localize('sessionsChat.repositoryCreation', "This host creates a new environment for each session. Open Select Repository to choose its repository. Selecting a repository does not start an environment; sending your first message does. The harness label identifies the available agent, and Agent Default means the host chooses the model.")); + content.push(localize('sessionsChat.repositoryCreation', "This host creates a new environment for each session. Open Select Repository to choose its repository. Selecting a repository does not start an environment; sending your first message does. Before sending, use the mode picker to choose Interactive, Plan, or Autopilot, and the permissions picker to configure tool approvals. Open the model picker to choose a cloud model and its supported reasoning effort. Use the arrow keys to navigate options, Enter to select, and Escape to close a picker.")); } content.push(localize('sessionsChat.workspaceHandoff', "Opening Agents from an editor can suggest that editor's folder for a fresh session. Choosing a workspace yourself or starting another session cancels a pending suggestion. If an explicitly requested workspace cannot be selected, a notification offers Retry and Choose Workspace actions.")); content.push(localize('sessionsChat.branchPickerUpstream', "For a new Agent Host session in a Git repository, use Tab to reach the branch picker and press Enter or Space to choose a base branch. With New Worktree selected, the current branch's tracked upstream is selected by default when available; otherwise the default branch is selected. The picker lists the current branch's upstream and the current branch when it is not the default branch, then the default branch's remote-tracking branch and the default branch, then the other branches, with a separator between each group, and focuses the selected branch when it opens. Each branch is announced with its group name: Current Branch, Default Branch, or Branches. With Branch selected, the checked-out branch, which is the selected branch, is listed first when it is not the default branch, followed by the default branch in its own group, then the other branches. Use the arrow keys to choose a branch and press Enter to select it.")); @@ -164,6 +164,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.backgroundTint', "When a background image is set, use Tint Window to Match Background in the Command Palette or the background context menu to turn matching window colors on or off. A check mark means tinting is enabled. Turning it off keeps the background image and restores the original window colors.")); content.push(localize('sessionsChat.vscodePet', "Use the checked Pet item in the new-session view context menu, or type /vscode-pet, to show or hide the VS Code pet above the input. Drag it horizontally to reposition it, or use Tab to focus it and the left and right arrow keys to move it. Press Enter or Space to show it some love.")); content.push(localize('sessionsChat.vscodePetAchievements', "When the pet is enabled, the user account menu lists unlocked achievement badges before locked badges and provides a View Achievements button. A gold star on the pet announces a newly unlocked achievement; activate the pet while the star is visible to open Achievements.")); + content.push(localize('sessionsChat.blobby', "Type /blobby to unlock True Name without sending a chat request. If Blobby is off, it turns on; if it is already on, the Color tab opens. Change Color in the pet's context menu always opens Color. Stable and Insiders are free choices; True Name unlocks solid and custom colors. Activating the True Name achievement card or account badge also opens Color. Use the color picker or enter an opaque hex color such as #ff8800, then activate Apply Color. Unapplied custom colors are discarded when you leave the tab or close the modal.")); content.push(localize('sessionsChat.aquariumAction', "To show or hide the aquarium action on the new-session view, use the checked Aquarium item in the context menu outside the composer, or run the Toggle Aquarium Action Visibility command.")); content.push(localize('sessionsChat.dictation', "When dictation is configured, dictate your message into the input{0}. Tap to start and stop, or hold to dictate only while pressed. If the speech-to-text model is still preparing, activate the dictation control again to cancel.", '')); content.push(localize('sessionsChat.voiceMode', "Start or stop Voice Mode to interact with the agent using your microphone{0}.", '')); diff --git a/src/vs/sessions/contrib/chat/browser/slashCommands.ts b/src/vs/sessions/contrib/chat/browser/slashCommands.ts index fdbf5d65c400f8..5d04eba83a0838 100644 --- a/src/vs/sessions/contrib/chat/browser/slashCommands.ts +++ b/src/vs/sessions/contrib/chat/browser/slashCommands.ts @@ -17,6 +17,7 @@ import { Range } from '../../../../editor/common/core/range.js'; import { getWordAtText } from '../../../../editor/common/core/wordHelper.js'; import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; import { CommandsRegistry, ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ContextKeyExpr, ContextKeyExpression, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { localize } from '../../../../nls.js'; import { AICustomizationManagementCommands, AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; import { IChatSubmitRequestHandlerService, type IChatSubmitRequest, type IChatSubmitRequestHandler } from '../../../../workbench/contrib/chat/browser/chatSubmitRequestHandlerService.js'; @@ -27,6 +28,8 @@ import { getChatSessionType } from '../../../../workbench/contrib/chat/common/mo import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; import { ICustomizationHarnessService } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { IChatPetService } from '../../../../workbench/contrib/chat/browser/chatPetService.js'; +import { CHAT_PET_BLOBBY_COMMAND_ID } from '../../../../workbench/contrib/chat/browser/chatPetColors.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; /** * Static command ID used by completion items to trigger immediate slash command execution, * mirroring the pattern of core's `ChatSubmitAction` for `executeImmediately` commands. @@ -49,6 +52,7 @@ interface ISessionsSlashCommandData { readonly sortText?: string; readonly executeImmediately?: boolean; readonly supportsAgentHost?: boolean; + readonly when?: ContextKeyExpression; readonly execute: (args: string) => void; } @@ -79,6 +83,7 @@ export class SlashCommandHandler extends Disposable implements IChatSubmitReques @ISessionContext private readonly sessionContext: ISessionContext, @IChatPetService private readonly chatPetService: IChatPetService, @IChatSubmitRequestHandlerService submitRequestHandlerService: IChatSubmitRequestHandlerService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, ) { super(); this._commandDecorations = this._editor.createDecorationsCollection(); @@ -167,6 +172,14 @@ export class SlashCommandHandler extends Disposable implements IChatSubmitReques executeImmediately: true, execute: () => this.chatPetService.toggle(), }); + this._slashCommands.push({ + command: 'blobby', + detail: localize('slashCommand.blobby', "Show Blobby or open its color customization"), + sortText: 'z3_blobby', + executeImmediately: true, + when: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.Setup.hidden.negate()), + execute: () => this.commandService.executeCommand(CHAT_PET_BLOBBY_COMMAND_ID), + }); this._slashCommands.push({ command: 'agents', detail: localize('slashCommand.agents', "View and manage custom agents"), @@ -360,6 +373,9 @@ export class SlashCommandHandler extends Disposable implements IChatSubmitReques } private _isSlashCommandAvailable(command: ISessionsSlashCommandData): boolean { + if (command.when && !this.contextKeyService.contextMatchesRules(command.when)) { + return false; + } const activeSession = this.sessionContext.session.get(); return command.supportsAgentHost !== false || !activeSession diff --git a/src/vs/sessions/contrib/chat/test/browser/agentHostInputCompletions.test.ts b/src/vs/sessions/contrib/chat/test/browser/agentHostInputCompletions.test.ts index efcec8c3157552..4911b08c74039c 100644 --- a/src/vs/sessions/contrib/chat/test/browser/agentHostInputCompletions.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/agentHostInputCompletions.test.ts @@ -4,14 +4,64 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; +import { Event } from '../../../../../base/common/event.js'; +import { constObservable } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { Position } from '../../../../../editor/common/core/position.js'; +import { Range } from '../../../../../editor/common/core/range.js'; import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; +import { CompletionItem, CompletionItemKind } from '../../../../../editor/common/languages.js'; +import { createTextModel } from '../../../../../editor/test/common/testTextModel.js'; +import { withTestCodeEditor } from '../../../../../editor/test/browser/testCodeEditor.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { IChatInputCompletionItem, IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IChatRequestVariableEntry, toAgentHostCompletionVariableEntry, AgentHostCompletionReferenceKind } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; -import { getAgentHostCompletionAttachmentRange, getCommandArgumentHintPlaceholder } from '../../browser/agentHostInputCompletions.js'; +import { ISessionContext } from '../../../../services/sessions/browser/sessionContext.js'; +import { AgentHostInputCompletionHandler, getAgentHostCompletionAttachmentRange, getCommandArgumentHintPlaceholder } from '../../browser/agentHostInputCompletions.js'; +import { INewChatAttachments } from '../../browser/newChatContextAttachments.js'; + +class TestableAgentHostInputCompletionHandler extends AgentHostInputCompletionHandler { + buildItem(position: Position, item: IChatInputCompletionItem): CompletionItem | undefined { + return this._buildItem(position, item); + } +} suite('AgentHostInputCompletions', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('shows plain-text sandbox slash commands without adding an attachment', async () => { + const services = new ServiceCollection( + [ISessionContext, { _serviceBrand: undefined, session: constObservable(undefined) }], + [IChatSessionsService, new class extends mock() { }], + ); + const model = store.add(createTextModel('/', null, undefined, URI.parse('test:input'))); + await withTestCodeEditor(model, { serviceCollection: services }, async (editor, _viewModel, instantiationService) => { + const attachments = new class extends mock() { + override readonly onDidChangeContext = Event.None; + override readonly attachments = []; + }; + const handler = store.add(instantiationService.createInstance(TestableAgentHostInputCompletionHandler, editor, attachments, async () => true)); + assert.deepStrictEqual(handler.buildItem(new Position(1, 2), { + insertText: '/review', + label: 'Review the workspace', + start: { lineNumber: 1, column: 1 }, + end: { lineNumber: 1, column: 2 }, + attachment: { kind: 'text' }, + }), { + label: { label: '/review', description: 'Review the workspace' }, + insertText: '/review', + filterText: '/review', + range: { + insert: new Range(1, 1, 1, 2), + replace: new Range(1, 1, 1, 2), + }, + kind: CompletionItemKind.Text, + }); + }); + }); test('uses the accepted occurrence when duplicate slash tokens exist', () => { const text = 'first /rename then accepted /rename'; diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts index 6840e85d8d520c..a08e4414b62841 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { isManagedHoverTooltipHTMLElement } from '../../../../../base/browser/ui/hover/hover.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; -import { Event } from '../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { ImmortalReference, toDisposable } from '../../../../../base/common/lifecycle.js'; import { constObservable, derived, observableValue } from '../../../../../base/common/observable.js'; import { SubmenuAction, type IAction } from '../../../../../base/common/actions.js'; @@ -15,6 +15,8 @@ import { URI } from '../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { AgentHostAutoAttachPullRequestsConfigKey } from '../../../../../platform/agentHost/common/agentHostSchema.js'; +import { RootConfigState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; @@ -27,6 +29,7 @@ import { ISessionChatPillVisibilityService, SessionChatPillKind, SessionChatPill import { workbenchInstantiationService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; import { IWorkbenchGitHubService } from '../../../../../workbench/services/github/common/githubService.js'; import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; +import { IAgentHostSessionsProvider, LOCAL_AGENT_HOST_PROVIDER_ID, REMOTE_AGENT_HOST_PROVIDER_PREFIX } from '../../../../common/agentHostSessionsProvider.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { ISessionChangesStatsCache } from '../../../../services/sessions/common/sessionChangesStatsCache.js'; @@ -1530,6 +1533,82 @@ suite('SessionChatInputToolbar', () => { } } + for (const providerId of [LOCAL_AGENT_HOST_PROVIDER_ID, `${REMOTE_AGENT_HOST_PROVIDER_PREFIX}custom-host`]) { + test(`PR pills follow chat ownership and react to automatic association on ${providerId}`, () => { + const { instantiationService } = createServices(); + const onDidChangeRootConfig = store.add(new Emitter()); + const schema: RootConfigState['schema'] = { + type: 'object', properties: { [AgentHostAutoAttachPullRequestsConfigKey]: { type: 'boolean', title: 'Automatic Pull Request Association', default: true } }, + }; + let config: RootConfigState | undefined = { + schema, + values: { [AgentHostAutoAttachPullRequestsConfigKey]: false }, + }; + const provider = upcastPartial({ + id: providerId, + onDidChangeRootConfig: onDidChangeRootConfig.event, + getRootConfig: () => config, + getAgentMergeClientStateObservable: () => constObservable(undefined), + }); + instantiationService.stub(ISessionsProvidersService, 'getProvider', () => provider); + const ref = { owner: 'owner', repo: 'repo', number: 42, uri: URI.parse('https://github.com/owner/repo/pull/42'), createdByThisSession: true }; + instantiationService.stub(IGitHubService, upcastPartial({ + createPullRequestModelReference: () => new ImmortalReference(upcastPartial({ pullRequest: constObservable(undefined) })), + })); + const root = URI.file('/shared'); + const workspace = constObservable(upcastPartial({ + folders: [{ + root, workingDirectory: root, name: 'shared', description: undefined, + gitRepository: { uri: root, workTreeUri: undefined, baseBranchName: undefined, gitHubInfo: constObservable({ owner: ref.owner, repo: ref.repo, pullRequests: [ref] }) }, + }], + })); + const chat = (id: string) => upcastPartial({ + resource: URI.parse(`custom-chat://host/${id}`), workspace, title: constObservable(id), status: constObservable(SessionStatus.Completed), + changesets: constObservable([]), changes: constObservable([]), + }); + const main = chat('main'); + const peer = chat('peer'); + const artifacts = observableValue('artifacts', [{ + id: 'peer-pr', chat: peer.resource, kind: SessionArtifactKind.PullRequest, label: 'Peer PR', isArtifact: true, isGitHub: true, link: ref.uri, + }]); + const session = upcastPartial({ + providerId, sessionId: 'shared', resource: URI.parse('custom-session://host/shared'), workspace, artifacts, + mainChat: constObservable(main), chats: constObservable([main, peer]), + capabilities: constObservable({ supportsMultipleChats: true }), + }); + const toolbar = store.add(instantiationService.createInstance(SessionChatInputToolbar, false, undefined)); + const pill = () => toolbar.element.querySelector('.chat-dropdown-pill-button[aria-label^="Open Pull Request"]')?.getAttribute('aria-label') ?? null; + toolbar.setSession(session, main); + const restrictedMain = pill(); + toolbar.setSession(session, peer); + const restrictedPeer = pill(); + toolbar.setSession(session, main); + config = { schema, values: { [AgentHostAutoAttachPullRequestsConfigKey]: true } }; + onDidChangeRootConfig.fire(); + const automaticMain = pill(); + config = { schema, values: { [AgentHostAutoAttachPullRequestsConfigKey]: false } }; + onDidChangeRootConfig.fire(); + const restrictedAgain = pill(); + config = { + schema: { ...schema, properties: { [AgentHostAutoAttachPullRequestsConfigKey]: { type: 'boolean', title: 'Automatic Pull Request Association', default: false } } }, + values: {}, + }; + onDidChangeRootConfig.fire(); + const restrictedDefault = pill(); + config = undefined; + onDidChangeRootConfig.fire(); + const absentExtension = pill(); + assert.deepStrictEqual({ restrictedMain, restrictedPeer, automaticMain, restrictedAgain, restrictedDefault, absentExtension }, { + restrictedMain: null, + restrictedPeer: 'Open Pull Request #42: Peer PR', + automaticMain: 'Open Pull Request #42', + restrictedAgain: null, + restrictedDefault: null, + absentExtension: 'Open Pull Request #42', + }); + }); + } + test('pull request artifact removal reacts to capabilities, targets the owning session, and reports errors without hiding data', async () => { const instantiationService = workbenchInstantiationService(undefined, store); const ref: IGitHubPullRequestRef = { owner: 'microsoft', repo: 'vscode', number: 1, uri: URI.parse('https://github.com/microsoft/vscode/pull/1'), recordedReferenceId: 'pr-artifact' }; diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index a1cf01f01babd7..c3a9c4ea024d47 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -342,7 +342,7 @@ function createTestPicker( fileDialogService: Partial = {}, workspacesService: IWorkspacesService = { getRecentlyOpened: async () => ({ workspaces: [], files: [] }), onDidChangeRecentlyOpened: Event.None } as unknown as IWorkspacesService, recentWorkspacesService?: ISessionsRecentWorkspacesService, - options?: IWorkspacePickerOptions & { readonly configuration?: Record }, + options?: IWorkspacePickerOptions & { readonly configuration?: Record; readonly telemetryService?: ITelemetryService }, fileService: IFileService = upcastPartial({ onDidFilesChange: Event.None, onDidChangeFileSystemProviderRegistrations: Event.None, @@ -383,7 +383,7 @@ function createTestPicker( instantiationService.stub(IWorkspacesService, workspacesService); instantiationService.stub(ILogService, disposables.add(new NullLogService())); instantiationService.stub(ISessionsRecentWorkspacesService, recentWorkspacesService ?? disposables.add(instantiationService.createInstance(SessionsRecentWorkspacesService))); - instantiationService.stub(ITelemetryService, NullTelemetryService); + instantiationService.stub(ITelemetryService, options?.telemetryService ?? NullTelemetryService); instantiationService.stub(IHoverService, NullHoverService); if (agentHostFilterService) { instantiationService.stub(IAgentHostFilterService, agentHostFilterService); @@ -463,6 +463,138 @@ function assertSelectedProvider(picker: WorkspacePicker, expectedProviderId: str // ---- Tests ------------------------------------------------------------------ +suite('WorkspacePicker - Dev Container sample telemetry', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function setupPicker(options: IWorkspacePickerOptions = {}) { + const events: { name: string; data: object | undefined }[] = []; + const telemetryService = new class extends mock() { + override publicLog2(name: string, data?: object): void { + events.push({ name, data }); + } + }(); + const providersService = store.add(new MockSessionsProvidersService()); + const provider = { + ...createMockProvider('local-agent-host', { group: SESSION_WORKSPACE_GROUP_LOCAL }), + supportsLocalWorkspaces: true, + }; + providersService.setProviders([provider]); + const picker = createTestPicker(store, providersService, undefined, undefined, DispatchingWorkspacePicker, undefined, undefined, undefined, { + restoreFromSessions: false, + ...options, + telemetryService, + configuration: { + [DevContainerSamplesEnabledSettingId]: true, + [DevContainerAgentHostEnabledSettingId]: true, + }, + }); + assert.ok(picker instanceof DispatchingWorkspacePicker); + return { picker, provider, providersService, events, sampleEvents: () => events.filter(event => event.name === 'devContainerSampleSelected') }; + } + + for (const surface of ['submenu', 'filtered results', 'recent workspace'] as const) { + test(`reports only the catalog ID once per accepted selection from ${surface}`, async () => { + const { picker, provider, events, sampleEvents } = setupPicker(); + for (const sample of devContainerSamples) { + const uri = devContainerSampleUri(sample); + const eventCount = events.length; + let item: IWorkspacePickerItem | undefined; + if (surface === 'recent workspace') { + picker.setSelectedWorkspace(uri, { fireEvent: false, providerId: provider.id }); + item = picker.getItems().find(entry => extUri.isEqual(entry.item?.folderUri, uri))?.item; + } else { + const parent = picker.getItems().find(entry => entry.label === 'Dev Container Sample'); + if (surface === 'filtered results') { + item = parent?.filterItems?.find(entry => entry.label === sample.name)?.item; + } else { + const submenu = parent?.submenuActions?.[0]; + assert.ok(submenu instanceof SubmenuAction); + const action = submenu.actions.find(action => action.label === sample.name); + assert.ok(action); + await action.run(); + item = parent?.item; + } + } + assert.strictEqual(events.length, eventCount, 'Rendering, restoring, and invoking a submenu callback must not log an accepted selection'); + assert.ok(item); + assert.strictEqual(await picker.dispatchItem(item), true); + } + assert.deepStrictEqual({ + samples: sampleEvents(), + pickerEvents: events.filter(event => event.name === 'newChatPickerClosed'), + }, { + samples: devContainerSamples.map(sample => ({ name: 'devContainerSampleSelected', data: { sampleId: sample.id } })), + pickerEvents: devContainerSamples.map(() => ({ + name: 'newChatPickerClosed', + data: { + id: 'NewChatWorkspacePicker', + name: 'NewChatWorkspacePicker', + selectionChanged: surface !== 'recent workspace', + optionIdBefore: undefined, + optionIdAfter: undefined, + optionLabelBefore: undefined, + optionLabelAfter: undefined, + }, + })), + }); + }); + } + + test('does not report ordinary folders, malformed sample URIs, or programmatic selection', async () => { + const { picker, provider, sampleEvents } = setupPicker(); + const sampleUri = devContainerSampleUri(devContainerSamples[0]); + picker.setSelectedWorkspace(sampleUri, { providerId: provider.id }); + for (const uri of [ + URI.file('/agent-host/private-project'), + URI.file('/agent-host/vscode-remote-try-go'), + sampleUri.with({ authority: 'private-host' }), + sampleUri.with({ query: 'private-query' }), + sampleUri.with({ fragment: 'private-fragment' }), + sampleUri.with({ path: '/private-project' }), + ]) { + await picker.dispatchFolder(uri, provider.id); + } + assert.deepStrictEqual(sampleEvents(), []); + }); + + for (const decision of ['declined', 'failed', 'superseded'] as const) { + test(`does not report a ${decision} sample selection`, async () => { + const acceptance = new DeferredPromise(); + const pending = new DeferredPromise(); + const { picker, provider, sampleEvents } = setupPicker({ + canSelectWorkspace: () => { + pending.complete(); + return acceptance.p; + }, + }); + const selection = picker.dispatchFolder(devContainerSampleUri(devContainerSamples[0]), provider.id); + const rejected = decision === 'failed' ? assert.rejects(selection, /Selection failed/) : undefined; + await pending.p; + if (decision === 'superseded') { + picker.clearSelection(); + } + if (decision === 'failed') { + acceptance.error(new Error('Selection failed')); + await rejected; + } else { + acceptance.complete(decision === 'superseded'); + assert.strictEqual(await selection, false); + } + assert.deepStrictEqual(sampleEvents(), []); + }); + } + + test('does not report a sample selection from an unavailable provider', async () => { + const { picker, providersService, sampleEvents } = setupPicker(); + const provider = createMockProvider('agenthost-unavailable', { + connectionStatus: observableValue('status', RemoteAgentHostConnectionStatus.disconnected), + }); + providersService.setProviders([provider]); + await picker.dispatchFolder(devContainerSampleUri(devContainerSamples[0]), provider.id); + assert.deepStrictEqual(sampleEvents(), []); + }); +}); + suite('WorkspacePicker - Connection Status', () => { const disposables = new DisposableStore(); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts index 1005db7644cbce..32f4ebbfef1971 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts @@ -348,12 +348,14 @@ suite('SessionsChatAccessibilityHelp', () => { browserRepositorySearch: content.includes('When choosing a GitHub repository in the browser, search or enter a GitHub URL or owner/repository.'), repositoryCreation: content.includes('Open Select Repository to choose its repository.'), firstSend: content.includes('Selecting a repository does not start an environment; sending your first message does.'), - defaultModel: content.includes('Agent Default means the host chooses the model.'), + modeAndPermissions: content.includes('Before sending, use the mode picker to choose Interactive, Plan, or Autopilot, and the permissions picker to configure tool approvals.'), + modelAndReasoning: content.includes('Open the model picker to choose a cloud model and its supported reasoning effort.'), }, { browserRepositorySearch: isWeb, repositoryCreation: sessionCreationProviderId !== undefined, firstSend: sessionCreationProviderId !== undefined, - defaultModel: sessionCreationProviderId !== undefined, + modeAndPermissions: sessionCreationProviderId !== undefined, + modelAndReasoning: sessionCreationProviderId !== undefined, }); }); } diff --git a/src/vs/sessions/contrib/chat/test/browser/slashCommands.test.ts b/src/vs/sessions/contrib/chat/test/browser/slashCommands.test.ts index 874ccd91e8b113..45b13822ea6ff9 100644 --- a/src/vs/sessions/contrib/chat/test/browser/slashCommands.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/slashCommands.test.ts @@ -19,11 +19,16 @@ import { createTextModel } from '../../../../../editor/test/common/testTextModel import { withTestCodeEditor } from '../../../../../editor/test/browser/testCodeEditor.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { AICustomizationManagementCommands } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; import { IChatPetService } from '../../../../../workbench/contrib/chat/browser/chatPetService.js'; +import { CHAT_PET_BLOBBY_COMMAND_ID } from '../../../../../workbench/contrib/chat/browser/chatPetColors.js'; +import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { IChatSubmitRequestHandlerService } from '../../../../../workbench/contrib/chat/browser/chatSubmitRequestHandlerService.js'; import { SessionType } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ICustomizationHarnessService } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; @@ -54,6 +59,7 @@ suite('SlashCommandHandler', () => { }; const services = new ServiceCollection( [ICommandService, commandService], + [IContextKeyService, store.add(new ContextKeyService(new TestConfigurationService()))], [ISessionContext, { _serviceBrand: undefined, session }], [ICustomizationHarnessService, new class extends mock() { override readonly onDidChangeSlashCommands = Event.None; @@ -68,6 +74,9 @@ suite('SlashCommandHandler', () => { const model = store.add(createTextModel('', null, undefined, URI.from({ scheme: Schemas.sessionsChatInput, path: '/input' }))); await withTestCodeEditor(model, { serviceCollection: services }, async (editor, _viewModel, instantiationService) => { + const chatEnabled = ChatContextKeys.enabled.bindTo(instantiationService.get(IContextKeyService)); + const chatHidden = ChatContextKeys.Setup.hidden.bindTo(instantiationService.get(IContextKeyService)); + chatEnabled.set(true); const handler = store.add(instantiationService.createInstance(SlashCommandHandler, editor)); const agentHostHandled = await handler.tryHandle({ sessionResource: session.get()!.resource, @@ -75,6 +84,7 @@ suite('SlashCommandHandler', () => { sessionId: 'session', input: '/skills', }); + const agentHostBlobbyHandled = handler.tryExecuteSlashCommand('/blobby'); const localResource = URI.from({ scheme: Schemas.vscodeLocalChatSession, path: '/session' }); session.set(new class extends mock() { @@ -86,6 +96,7 @@ suite('SlashCommandHandler', () => { sessionId: 'session', input: '/skills', }); + const localBlobbyHandled = handler.tryExecuteSlashCommand('/blobby'); model.setValue('/'); const foreignModel = store.add(createTextModel('/', null, undefined, URI.from({ scheme: Schemas.sessionsChatInput, path: '/foreign-input' }))); @@ -94,18 +105,39 @@ suite('SlashCommandHandler', () => { const completionContext = { triggerKind: CompletionTriggerKind.Invoke } as const; const ownCompletions = await staticProvider.provideCompletionItems(model, new Position(1, 2), completionContext, CancellationToken.None); const foreignCompletions = await staticProvider.provideCompletionItems(foreignModel, new Position(1, 2), completionContext, CancellationToken.None); + chatHidden.set(true); + const hiddenCompletions = await staticProvider.provideCompletionItems(model, new Position(1, 2), completionContext, CancellationToken.None); + const hiddenBlobbyHandled = handler.tryExecuteSlashCommand('/blobby'); + chatHidden.set(false); + const restoredCompletions = await staticProvider.provideCompletionItems(model, new Position(1, 2), completionContext, CancellationToken.None); + chatEnabled.set(false); + const disabledCompletions = await staticProvider.provideCompletionItems(model, new Position(1, 2), completionContext, CancellationToken.None); assert.deepStrictEqual({ agentHostHandled, localHandled, + agentHostBlobbyHandled, + localBlobbyHandled, + hiddenBlobbyHandled, + hiddenBlobbyOffered: hiddenCompletions?.suggestions.some(item => item.label === '/blobby'), + restoredBlobbyOffered: restoredCompletions?.suggestions.some(item => item.label === '/blobby'), + disabledBlobbyHandled: handler.tryExecuteSlashCommand('/blobby'), + disabledBlobbyOffered: disabledCompletions?.suggestions.some(item => item.label === '/blobby'), commandCalls, ownCommands: ownCompletions?.suggestions.map(item => typeof item.label === 'string' ? item.label : item.label.label), foreignCommands: foreignCompletions?.suggestions, }, { agentHostHandled: false, localHandled: true, - commandCalls: [AICustomizationManagementCommands.OpenEditor], - ownCommands: ['/vscode-pet', '/agents', '/skills', '/instructions', '/hooks', '/models'], + agentHostBlobbyHandled: true, + localBlobbyHandled: true, + hiddenBlobbyHandled: false, + hiddenBlobbyOffered: false, + restoredBlobbyOffered: true, + disabledBlobbyHandled: false, + disabledBlobbyOffered: false, + commandCalls: [CHAT_PET_BLOBBY_COMMAND_ID, AICustomizationManagementCommands.OpenEditor, CHAT_PET_BLOBBY_COMMAND_ID], + ownCommands: ['/vscode-pet', '/blobby', '/agents', '/skills', '/instructions', '/hooks', '/models'], foreignCommands: undefined, }); }); diff --git a/src/vs/sessions/contrib/github/common/sessionGitHubReferences.ts b/src/vs/sessions/contrib/github/common/sessionGitHubReferences.ts index 5d21161e5e0fd4..dd40f3293588a4 100644 --- a/src/vs/sessions/contrib/github/common/sessionGitHubReferences.ts +++ b/src/vs/sessions/contrib/github/common/sessionGitHubReferences.ts @@ -60,14 +60,10 @@ function mergeGitHubReferences(recorded: readonly T[] } /** - * Resolves the pull requests and issues for the dedicated GitHub pills: recorded artifacts, independently of a - * workspace, and repository-discovered associations. Recorded references are left out, as the references pill - * lists them. - * Pass `chat` to use the repository associations of every folder of the chat's workspace instead of the - * session's primary folder; recorded pull requests from other repositories are then left out once the - * chat's repositories are known. + * Resolves dedicated GitHub pills from artifacts and workspace associations, scoped to `chat` when supplied. + * Without automatic PR association, chats only show their own PR artifacts; unowned legacy artifacts belong to the main chat. */ -export function getSessionGitHubReferences(session: ISession | undefined, reader: IReader | undefined, chat?: IChat): ISessionGitHubReferences { +export function getSessionGitHubReferences(session: ISession | undefined, reader: IReader | undefined, chat?: IChat, autoAssociatePullRequests = true): ISessionGitHubReferences { const chatWorkspace = chat?.workspace?.read(reader); // A chat reports the repository associations of each of its folders. const folderGitHubInfos = chatWorkspace @@ -76,10 +72,17 @@ export function getSessionGitHubReferences(session: ISession | undefined, reader const gitHubInfo = chatWorkspace ? folderGitHubInfos[0] : session?.workspace.read(reader)?.folders[0]?.gitRepository?.gitHubInfo.read(reader); const chatRepositories = chatWorkspace && folderGitHubInfos.length > 0 ? folderGitHubInfos : undefined; const artifacts = (session?.artifacts?.read(reader) ?? []).filter(artifact => !chat || !artifact.chat || isEqual(artifact.chat, chat.resource)); + const restrictPullRequestsToChat = !!chat && !autoAssociatePullRequests; + const chatPullRequestArtifacts = artifacts.filter(artifact => artifact.kind === SessionArtifactKind.PullRequest && artifact.isArtifact && parseGitHubArtifactLink(artifact) + && (!restrictPullRequestsToChat || (artifact.chat ? isEqual(artifact.chat, chat.resource) : isEqual(session?.mainChat?.read(reader)?.resource, chat.resource)))); + const chatPullRequestLinks = new Set(chatPullRequestArtifacts.flatMap(artifact => artifact.link ? [linkKey(artifact.link.toString())] : [])); // Providers may echo recorded references into their associations; those stay out of the dedicated pills too. const recordedReferenceIds = new Set(artifacts.filter(artifact => !artifact.isArtifact).map(artifact => artifact.id)); const isRecordedReference = (ref: { readonly recordedReferenceId?: string }) => !!ref.recordedReferenceId && recordedReferenceIds.has(ref.recordedReferenceId); const associatedPullRequests = (chatWorkspace ? folderGitHubInfos.flatMap(info => getGitHubPullRequestRefs(info)) : getGitHubPullRequestRefs(gitHubInfo)).flatMap(ref => { + if (restrictPullRequestsToChat && !chatPullRequestLinks.has(linkKey(ref.uri.toString()))) { + return []; + } if (!isRecordedReference(ref)) { return [ref]; } @@ -91,6 +94,9 @@ export function getSessionGitHubReferences(session: ISession | undefined, reader const pullRequests: IGitHubPullRequestRef[] = []; const issues: IGitHubIssueRef[] = []; for (const artifact of artifacts) { + if (restrictPullRequestsToChat && artifact.kind === SessionArtifactKind.PullRequest && !chatPullRequestArtifacts.includes(artifact)) { + continue; + } const parsed = artifact.isArtifact ? parseGitHubArtifactLink(artifact) : undefined; if (!parsed || !artifact.link) { continue; diff --git a/src/vs/sessions/contrib/github/test/common/sessionGitHubReferences.test.ts b/src/vs/sessions/contrib/github/test/common/sessionGitHubReferences.test.ts index bec7be2dcbb33f..f23624c5339e4a 100644 --- a/src/vs/sessions/contrib/github/test/common/sessionGitHubReferences.test.ts +++ b/src/vs/sessions/contrib/github/test/common/sessionGitHubReferences.test.ts @@ -187,4 +187,49 @@ suite('Session GitHub References', () => { 'https://github.com/contoso/tools/pull/7', ]); }); + + test('restricts shared-folder pull requests to their creating chat only when automatic association is disabled', () => { + const mainResource = URI.parse('custom-chat://host/main'); + const peerResource = URI.parse('custom-chat://host/peer'); + const link = (number: number) => URI.parse(`https://github.com/owner/repo/pull/${number}`); + const entries: readonly ISessionArtifact[] = [ + { id: 'main', chat: mainResource, kind: SessionArtifactKind.PullRequest, label: 'Main PR', isArtifact: true, isGitHub: true, link: link(1) }, + { id: 'peer', chat: peerResource, kind: SessionArtifactKind.PullRequest, label: 'Peer PR', isArtifact: true, isGitHub: true, link: link(2) }, + { id: 'legacy', kind: SessionArtifactKind.PullRequest, label: 'Legacy PR', isArtifact: true, isGitHub: true, link: link(3) }, + { id: 'reference', chat: peerResource, kind: SessionArtifactKind.PullRequest, label: 'Reference', isArtifact: false, isGitHub: true, link: link(4) }, + ]; + const baseSession = createSession(entries, { + owner: 'owner', repo: 'repo', + pullRequests: [1, 2, 3, 4, 5].map(number => ({ owner: 'owner', repo: 'repo', number, uri: link(number), title: `Live ${number}`, createdByThisSession: true })), + }); + const workspace = baseSession.workspace; + const main = upcastPartial({ resource: mainResource, workspace }); + const peer = upcastPartial({ resource: peerResource, workspace }); + const session = upcastPartial({ ...baseSession, mainChat: constObservable(main) }); + const snapshot = (chat: IChat | undefined, automatic: boolean) => getSessionGitHubReferences(session, undefined, chat, automatic).pullRequests.map(ref => [ref.number, ref.title]); + + assert.deepStrictEqual({ + restrictedMain: snapshot(main, false), + restrictedPeer: snapshot(peer, false), + automaticMain: snapshot(main, true), + automaticPeer: snapshot(peer, true), + session: snapshot(undefined, false), + }, { + restrictedMain: [[1, 'Live 1'], [3, 'Live 3']], + restrictedPeer: [[2, 'Live 2']], + automaticMain: [[1, 'Live 1'], [3, 'Live 3'], [2, 'Live 2'], [4, 'Live 4'], [5, 'Live 5']], + automaticPeer: [[2, 'Live 2'], [3, 'Live 3'], [1, 'Live 1'], [4, 'Live 4'], [5, 'Live 5']], + session: [[1, 'Live 1'], [2, 'Live 2'], [3, 'Live 3'], [4, 'Live 4'], [5, 'Live 5']], + }); + }); + + test('keeps a chat-owned PR without a workspace and leaves unknown ownership out of restricted peer pills', () => { + const resource = URI.parse('custom-chat://host/peer'); + const session = createSession([ + { id: 'own', chat: resource, kind: SessionArtifactKind.PullRequest, label: 'Own PR', isArtifact: true, isGitHub: true, link: URI.parse('https://github.com/owner/repo/pull/1') }, + { id: 'legacy', kind: SessionArtifactKind.PullRequest, label: 'Legacy PR', isArtifact: true, isGitHub: true, link: URI.parse('https://github.com/owner/repo/pull/2') }, + ]); + const chat = upcastPartial({ resource, workspace: constObservable(undefined) }); + assert.deepStrictEqual(getSessionGitHubReferences(session, undefined, chat, false).pullRequests.map(ref => ref.number), [1]); + }); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index e14f525231a12d..0b42285fb1422b 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -81,7 +81,7 @@ The provider cache owns adapter identity. Catalog notifications describe members Provider-specific metadata such as pull-request provenance, changesets, agent configuration, and external visibility is translated inside this provider. Shared Sessions code consumes only provider-neutral fields and capabilities. -Each chat's read state is projected from its chat summary independently of the session aggregate. In a multi-chat session, any unread normal user-visible chat forces the aggregate unread; Tool/subagent chats are excluded, and reading chats does not itself promote the aggregate to read. When supported by the negotiated protocol version, activating a chat or invoking Mark as Read or Mark as Unread on a session row dispatches the chat-level read-state action for the represented main chat and updates that chat facade optimistically; the host remains responsible for persistence and fan-out. +Each chat's read state is projected from its chat summary independently of the session aggregate. In a multi-chat session, any unread normal user-visible chat forces the aggregate unread; Tool/subagent chats are excluded. When the last unread participating chat becomes read, the host promotes the aggregate to read; repeated read actions do not clear an independently unread session. When supported by the negotiated protocol version, activating a chat or invoking Mark as Read or Mark as Unread on a session row dispatches the chat-level read-state action for the represented main chat and updates that chat facade optimistically; the host remains responsible for aggregate synchronization, persistence, and fan-out. Selectable Agent Host changesets have one catalogue owner for each scope. The default chat exists from initial draft creation and owns repository-preparation changes before provider materialization. After materialization, the session continues publishing its cumulative Session Changes entry while each chat owns its repository and turn catalogue. Peer chats also own a Session Changes entry scoped to the edits made in that chat; because Git checkpoints capture the working tree shared by every chat, it is computed from the chat's tracked edits. The client keeps a chat-owned Session Changes entry and otherwise projects the session-owned entry into the chat catalogue, so the default chat shows the cumulative summary across all chats while every chat retains chat-scoped Branch, Uncommitted, This Turn, and Compare entries. diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts index 063dc688369687..23a862a9472684 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts @@ -13,6 +13,7 @@ import { autorun, IObservable } from '../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { hasKey } from '../../../../../base/common/types.js'; import { localize } from '../../../../../nls.js'; +import { onUnexpectedError } from '../../../../../base/common/errors.js'; import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListOptions } from '../../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; @@ -22,10 +23,10 @@ import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessi import { isSessionConfigWritable } from '../../../../../platform/agentHost/common/sessionConfigProperties.js'; import { SessionConfigPropertySchema } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; -import { type IAgentHostSessionsProvider, isAgentHostProvider } from '../../../../common/agentHostSessionsProvider.js'; +import { getSessionConfigProvider } from '../../../../common/agentHostSessionsProvider.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; -import { type ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; +import { type ISessionConfigProvider, type ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { reportNewChatPickerClosed } from '../../../chat/browser/newChatPickerTelemetry.js'; import { ChatPetAchievementIds, didExplicitlyEnableChatPetAutopilot } from '../../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; import { IChatPetService } from '../../../../../workbench/contrib/chat/browser/chatPetService.js'; @@ -130,10 +131,11 @@ export abstract class AgentHostSessionEnumPicker extends Disposable { private _watchProviders(providers: readonly ISessionsProvider[]): void { for (const provider of providers) { - if (!isAgentHostProvider(provider) || this._providerListeners.has(provider.id)) { + const configProvider = getSessionConfigProvider(provider); + if (!configProvider || this._providerListeners.has(provider.id)) { continue; } - this._providerListeners.set(provider.id, provider.onDidChangeSessionConfig(() => this._updateTrigger())); + this._providerListeners.set(provider.id, configProvider.onDidChangeSessionConfig(() => this._updateTrigger())); } } @@ -182,23 +184,25 @@ export abstract class AgentHostSessionEnumPicker extends Disposable { return false; } const provider = this._sessionsProvidersService.getProvider(session.providerId); - if (!provider || !isAgentHostProvider(provider)) { + const configProvider = provider && getSessionConfigProvider(provider); + if (!configProvider) { return false; } - return provider.isSessionConfigResolving(session.sessionId).get(); + return configProvider.isSessionConfigResolving(session.sessionId).get(); } showPicker(anchor: HTMLElement, onHide?: () => void): boolean { return this._showPicker(anchor, onHide); } - protected _getActiveContext(): { provider: IAgentHostSessionsProvider; sessionId: string; currentValue: string; items: readonly IAgentHostSessionEnumPickerItem[]; tooltip: string } | undefined { + protected _getActiveContext(): { provider: ISessionConfigProvider; sessionId: string; currentValue: string; items: readonly IAgentHostSessionEnumPickerItem[]; tooltip: string } | undefined { const session = this._session.get(); if (!session) { return undefined; } - const rawProvider = this._sessionsProvidersService.getProvider(session.providerId); - if (!rawProvider || !isAgentHostProvider(rawProvider)) { + const provider = this._sessionsProvidersService.getProvider(session.providerId); + const rawProvider = provider && getSessionConfigProvider(provider); + if (!rawProvider) { return undefined; } const config = rawProvider.getSessionConfig(session.sessionId); @@ -312,7 +316,7 @@ export abstract class AgentHostSessionEnumPicker extends Disposable { }); ctx.provider.setSessionConfigValue(ctx.sessionId, this._property, item.value) .then(() => this._onDidSelectValue(ctx.currentValue, item.value)) - .catch(() => { /* best-effort */ }); + .catch(onUnexpectedError); }, onHide: () => { this._pickerVisible = false; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts index 89b301be62d92e..05393ea4868641 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts @@ -19,8 +19,8 @@ import { narrowCodexPermissionsPreset } from '../../../../../platform/agentHost/ import { SessionConfigPropertySchema } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ChatConfiguration, ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import { IPermissionLevelMeta, IPermissionPickerDelegate } from '../../copilotChatSessions/browser/permissionPicker.js'; -import { IAgentHostSessionsProvider, isAgentHostProvider, LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; -import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; +import { getSessionConfigProvider, IAgentHostSessionsProvider, isAgentHostProvider, LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; +import { ISessionConfigProvider, ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -115,7 +115,7 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe const isDevContainer = derived(this, reader => { this._configChangedSignal.read(reader); const session = this._session.read(reader); - return !!session && this._getProvider(session.providerId)?.isDevContainerRequested?.(session.sessionId) === true; + return !!session && this._getAgentHostProvider(session.providerId)?.isDevContainerRequested?.(session.sessionId) === true; }); const sandboxPolicy = derived(this, reader => { if (isDevContainer.read(reader)) { @@ -124,7 +124,7 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe } this._configChangedSignal.read(reader); const session = this._session.read(reader); - const policy = session && this._getProvider(session.providerId)?.getSessionSandboxPolicy?.(session.sessionId); + const policy = session && this._getAgentHostProvider(session.providerId)?.getSessionSandboxPolicy?.(session.sessionId); if (policy || isWeb || environmentService.remoteAuthority || session?.providerId !== LOCAL_AGENT_HOST_PROVIDER_ID || session.sessionType !== CopilotCLISessionType.id) { return policy; } @@ -161,7 +161,7 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe } this._configChangedSignal.read(reader); const session = this._session.read(reader); - return session && this._getProvider(session.providerId)?.getSessionSandboxEnabled?.(session.sessionId); + return session && this._getAgentHostProvider(session.providerId)?.getSessionSandboxEnabled?.(session.sessionId); }); this.sandboxEnabled = derived(this, reader => { this._configChangedSignal.read(reader); @@ -228,7 +228,7 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe throw new Error('Sandbox configuration is unavailable for this session'); } const operation = provider.setSessionConfigValue(session.sessionId, SessionConfigKey.SandboxEnabled, enabled ? 'on' : 'off'); - provider.trackSessionConfigOperation(session.sessionId, operation); + provider.trackSessionConfigOperation?.(session.sessionId, operation); void operation.catch(onUnexpectedError); } @@ -256,7 +256,7 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe throw new Error('Approval configuration is unavailable for this session'); } const operation = provider.setSessionConfigValue(session.sessionId, approvalProperty.key, value); - provider.trackSessionConfigOperation(session.sessionId, operation); + provider.trackSessionConfigOperation?.(session.sessionId, operation); await operation.catch(onUnexpectedError); } @@ -313,18 +313,24 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe return !!approvalProperty && isSessionConfigWritable(approvalProperty.schema, provider.getCreateSessionConfig(session.sessionId) !== undefined); } - private _getProvider(providerId: string): IAgentHostSessionsProvider | undefined { + private _getProvider(providerId: string): ISessionConfigProvider | undefined { + const provider = this._sessionsProvidersService.getProvider(providerId); + return provider && getSessionConfigProvider(provider); + } + + private _getAgentHostProvider(providerId: string): IAgentHostSessionsProvider | undefined { const provider = this._sessionsProvidersService.getProvider(providerId); return provider && isAgentHostProvider(provider) ? provider : undefined; } private _watchProviders(providers: readonly ISessionsProvider[]): void { for (const provider of providers) { - if (!isAgentHostProvider(provider) || this._providerSubscriptions.has(provider.id)) { + const configProvider = getSessionConfigProvider(provider); + if (!configProvider || this._providerSubscriptions.has(provider.id)) { continue; } const subscriptions = new DisposableStore(); - subscriptions.add(provider.onDidChangeSessionConfig(() => { + subscriptions.add(configProvider.onDidChangeSessionConfig(() => { this._configChangedSignal.trigger(undefined); })); this._providerSubscriptions.set(provider.id, subscriptions); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index c4c81afa4f8be7..6b26ff332388b8 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -1141,7 +1141,7 @@ class AdditionalChat extends Disposable { }, () => observable.get()); } - update(summary: ChatSummary): void { + update(summary: ChatSummary, supportsChatReadState: boolean = true): void { const modifiedAt = new Date(summary.modifiedAt); transaction(tx => { this._title.set(summary.title || localize('newChatTab', "New Chat"), tx); @@ -1152,7 +1152,9 @@ class AdditionalChat extends Disposable { this._lastTurnEnd.set(modifiedAt, tx); this._interactivity.set(toChatInteractivity(summary.interactivity), tx); this._isArchived.set(isSessionStatusArchived(summary.status), tx); - this._isRead.set(isSessionStatusRead(summary.status), tx); + if (supportsChatReadState) { + this._isRead.set(isSessionStatusRead(summary.status), tx); + } this._setChangesSummary(summary.changes, tx); }); } @@ -1199,8 +1201,8 @@ class AdditionalChat extends Disposable { this._title.set(title || localize('newChatTab', "New Chat"), undefined); } - setRead(isRead: boolean): void { - this._isRead.set(isRead, undefined); + setRead(isRead: boolean, tx?: ITransaction): void { + this._isRead.set(isRead, tx); } /** Present as `Untitled` until the first request is sent so the view shows the composer. */ @@ -1328,7 +1330,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { */ private readonly _defaultChatStatusOverride = observableValue('defaultChatStatusOverride', undefined); private readonly _defaultChatIsReadOverride = observableValue('defaultChatIsReadOverride', undefined); - private _defaultChatBackendUri: URI | undefined; + private readonly _defaultChatBackendUri = observableValue(this, undefined); private readonly _defaultChatUpdatedAt: ISettableObservable; private readonly _hasMultipleChats: ISettableObservable; private readonly _aggregateChatResources = new ResourceSet(); @@ -1366,6 +1368,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { private _lastCatalogState: SessionState | undefined; private readonly _chatCatalogCapabilitiesObserver = this._register(new MutableDisposable()); private readonly _supportsChatArchive: IObservable; + private readonly _supportsChatReadState: IObservable; private readonly _rawId: string; private readonly _resourceScheme: string; @@ -1522,7 +1525,18 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { }); this.artifacts = derivedOpts({ owner: this, equalsFn: structuralEquals }, reader => { const meta = this._metaObs.read(reader); - return partitionSessionArtifacts(meta, this._options.mapDiffUri).entries.map(entry => entry.artifact); + const defaultChatBackendUri = this._defaultChatBackendUri.read(reader); + const chats = this._chatsObs.read(reader); + // Artifact owners use host chat URIs; shared consumers compare UI chat resources. + return partitionSessionArtifacts(meta, this._options.mapDiffUri).entries.map(({ artifact }) => { + if (!artifact.chat) { + return artifact; + } + const chat = isEqual(artifact.chat, defaultChatBackendUri) + ? this.resource + : chats.find(chat => isEqual(this.getBackendChatResource(chat.resource), artifact.chat))?.resource; + return chat ? { ...artifact, chat } : artifact; + }); }); this.gitHubInfo = this._presentGitHubInfo(derivedOpts({ @@ -1673,6 +1687,12 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { const initializeResult = connection?.initializeResult.read(reader); return !!initializeResult && isActionKnownToVersion({ type: ActionType.ChatIsArchivedChanged, isArchived: false }, initializeResult.protocolVersion); }); + this._supportsChatReadState = derived(this, reader => { + this._options.connectionStatus?.read(reader); + const connection = this._options.getConnection(); + const initializeResult = connection?.initializeResult.read(reader); + return !!initializeResult && isActionKnownToVersion({ type: ActionType.ChatIsReadChanged, isRead: true }, initializeResult.protocolVersion); + }); this.capabilities = derivedOpts({ owner: this, equalsFn: structuralEquals }, reader => { const agentCapabilities = this._options.agentCapabilities.read(reader)?.get(this.agentProvider); this._options.connectionStatus?.read(reader); @@ -1754,7 +1774,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { const defaultChat = chats.find(chat => chat.kind === 'default'); if (defaultChat) { - this._defaultChatBackendUri = defaultChat.chat; + this._defaultChatBackendUri.set(defaultChat.chat, tx); } this._defaultChatTitleOverride.set(defaultChat?.summary || undefined, tx); this._defaultChatInteractivity.set(toChatInteractivity(defaultChat?.interactivity), tx); @@ -1767,11 +1787,14 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { .map(chat => parseChatUri(chat.chat)?.chatId) .filter(chatId => chatId !== undefined); const hasMultipleChats = peerIds.length > 0; + const supportsChatReadState = this._supportsChatReadState.get(); this._hasMultipleChats.set(hasMultipleChats, tx); if (!hasMultipleChats) { this._defaultChatIsReadOverride.set(undefined, tx); } else if (defaultChat?.isRead !== undefined) { this._defaultChatIsReadOverride.set(defaultChat.isRead, tx); + } else if (!supportsChatReadState && this._defaultChatIsReadOverride.get() === undefined) { + this._defaultChatIsReadOverride.set(false, tx); } const survivingPeers = new Set(peerIds); const chatOutputResources = new ResourceSet(chats.map(chat => chat.chat)); @@ -1802,7 +1825,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { status: withSessionStatusFlag( withSessionStatusFlag(ProtocolSessionStatus.Idle, ProtocolSessionStatus.IsArchived, chat.archived === true), ProtocolSessionStatus.IsRead, - chat.isRead ?? true, + chat.isRead ?? supportsChatReadState, ), origin: chat.origin, interactivity: chat.interactivity, @@ -1834,6 +1857,10 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { return !arrayEquals(previousChats, nextChats) || sessionReadChanged; } + supportsChatReadState(): boolean { + return this._supportsChatReadState.get(); + } + hasUnreadChat(): boolean { return this._aggregateChatResources.size > 1 && this._chatsObs.get().some(chat => this._aggregateChatResources.has(chat.resource) && !chat.isRead.get()); @@ -1915,7 +1942,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { } const defaultSummary = state.chats.find(isDefault); if (defaultSummary) { - this._defaultChatBackendUri = URI.parse(defaultSummary.resource.toString()); + this._defaultChatBackendUri.set(URI.parse(defaultSummary.resource.toString()), undefined); } this._defaultChatTitleOverride.set(defaultSummary?.title || undefined, undefined); this._defaultChatInteractivity.set(toChatInteractivity(defaultSummary?.interactivity), undefined); @@ -1976,7 +2003,12 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { // Multiple chats: the default chat must show its own status, not the // session aggregate which may have been promoted by a running peer chat. this._defaultChatStatusOverride.set(defaultSummary ? mapProtocolStatus(defaultSummary.status) : undefined, undefined); - this._defaultChatIsReadOverride.set(defaultSummary ? isSessionStatusRead(defaultSummary.status) : undefined, undefined); + const supportsChatReadState = this._supportsChatReadState.get(); + if (supportsChatReadState) { + this._defaultChatIsReadOverride.set(defaultSummary ? isSessionStatusRead(defaultSummary.status) : undefined, undefined); + } else if (this._defaultChatIsReadOverride.get() === undefined) { + this._defaultChatIsReadOverride.set(false, undefined); + } const ordered: IChat[] = []; for (const summary of state.chats) { @@ -1993,7 +2025,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { entry = this._createAdditionalChat(chatId, summary); this._additionalChats.set(chatId, entry); } else { - entry.update(summary); + entry.update(summary, supportsChatReadState); } ordered.push(entry.chat); } @@ -2012,11 +2044,11 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this._markSessionUnreadForUnreadChat(); } - setDefaultChatRead(isRead: boolean): boolean { + setDefaultChatRead(isRead: boolean, tx?: ITransaction): boolean { if (!this._hasMultipleChats.get() || this._defaultChatIsReadOverride.get() === isRead) { return false; } - this._defaultChatIsReadOverride.set(isRead, undefined); + this._defaultChatIsReadOverride.set(isRead, tx); return true; } @@ -2079,28 +2111,43 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { : this.resource; } - setChatRead(chatResource: URI, isRead: boolean): boolean { + setChatRead(chatResource: URI, isRead: boolean, tx?: ITransaction): boolean { if (isEqual(chatResource, this._defaultChat.resource)) { if (this._hasMultipleChats.get()) { - this.setDefaultChatRead(isRead); + this.setDefaultChatRead(isRead, tx); } else { - this.isRead.set(isRead, undefined); + this.isRead.set(isRead, tx); } - this._markSessionUnreadForUnreadChat(); + this._markSessionUnreadForUnreadChat(tx); return true; } const chat = chatResource.fragment ? this._additionalChats.get(chatResource.fragment) : undefined; if (!chat) { return false; } - chat.setRead(isRead); - this._markSessionUnreadForUnreadChat(); + chat.setRead(isRead, tx); + this._markSessionUnreadForUnreadChat(tx); return true; } + setLegacySessionReadState(isRead: boolean, tx?: ITransaction): boolean { + let didChange = this.isRead.get() !== isRead; + for (const chat of this._chatsObs.get()) { + if (this._aggregateChatResources.size > 0 && !this._aggregateChatResources.has(chat.resource)) { + continue; + } + if (chat.isRead.get() !== isRead) { + this.setChatRead(chat.resource, isRead, tx); + didChange = true; + } + } + this.isRead.set(isRead, tx); + return didChange; + } + getBackendChatResource(chatResource: URI): URI | undefined { if (isEqual(chatResource, this._defaultChat.resource)) { - return this._defaultChatBackendUri; + return this._defaultChatBackendUri.get(); } return chatResource.fragment ? this._additionalChats.get(chatResource.fragment)?.backendUri : undefined; } @@ -5130,7 +5177,15 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // -- Dynamic session config ---------------------------------------------- getAutomationModelConfiguration(sessionId: string): AutomationModelConfiguration | undefined { - return this._getNewSession(sessionId)?.modelConfiguration ?? this._runningModelConfigurations.get(sessionId); + const draft = this._getNewSession(sessionId); + if (draft) { + return draft.modelConfiguration; + } + const rawId = this._rawIdFromChatId(sessionId); + if (!this._runningModelConfigurations.has(sessionId) && rawId && this._sessionCache.has(rawId)) { + this._runningModelConfigurations.set(sessionId, new AutomationModelConfiguration(this._languageModelsService)); + } + return this._runningModelConfigurations.get(sessionId); } async getAutomationSessionConfiguration(sessionId: string): Promise { @@ -5335,8 +5390,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // Mirror the host default so the chip does not flash while the config resolves. const repository = newSession.session.workspace.get()?.folders[0]?.gitRepository; const defaultBranchName = normalizedValue === 'worktree' - ? repository?.upstreamBranchName ?? repository?.defaultBranchName - : undefined; + ? repository?.defaultRemoteBranchName ?? repository?.defaultBranchName + : repository?.branchName; if (workspace.baseBranch && isSessionConfigWritable(workspace.baseBranch.schema, true)) { newSession.setConfigValue(workspace.baseBranch.key, defaultBranchName); } @@ -6270,6 +6325,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement const rawId = this._rawIdFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; if (cached && rawId) { + if (!cached.supportsChatReadState()) { + this._setLegacySessionReadState(cached, isRead); + return; + } if (isRead) { for (const chat of cached.getUnreadAggregateChats()) { await this.setChatReadState(sessionId, chat.resource, true); @@ -6297,11 +6356,26 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement const connection = this.connection; const initializeResult = connection?.initializeResult.get(); const action = { type: ActionType.ChatIsReadChanged as const, isRead }; - if (!cached || !connection || !initializeResult || !isActionKnownToVersion(action, initializeResult.protocolVersion)) { + if (!cached || !connection || !initializeResult) { return false; } const backendChatResource = this.getBackendChatResource(chatResource); - if (!backendChatResource || !cached.setChatRead(chatResource, isRead)) { + if (!backendChatResource) { + throw new Error(localize('chatNotFound', "The chat could not be found.")); + } + if (!isActionKnownToVersion(action, initializeResult.protocolVersion)) { + const chatChanged = cached.chats.get().some(chat => isEqual(chat.resource, chatResource) && chat.isRead.get() !== isRead); + if (!cached.setChatRead(chatResource, isRead)) { + throw new Error(localize('chatNotFound', "The chat could not be found.")); + } + if (chatChanged) { + this._cacheDirty = true; + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); + } + this._setLegacySessionReadState(cached, isRead); + return true; + } + if (!cached.setChatRead(chatResource, isRead)) { throw new Error(localize('chatNotFound', "The chat could not be found.")); } this._cacheDirty = true; @@ -6310,6 +6384,17 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return true; } + private _setLegacySessionReadState(cached: AgentHostSessionAdapter, isRead: boolean): void { + if (cached.setLegacySessionReadState(isRead)) { + this._cacheDirty = true; + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); + } + const connection = this.connection; + if (connection) { + connection.dispatch(cached.backendUri.toString(), { type: ActionType.SessionIsReadChanged as const, isRead }); + } + } + async deleteSession(sessionId: string): Promise { await this.deleteSessions([sessionId]); } @@ -6668,6 +6753,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement location: ChatAgentLocation.Chat, userSelectedModelId: selectedModelId, userSelectedModelConfiguration: selectedModelConfiguration, + agentHostSessionConfig: options.sessionConfig, modeInfo: selectedAgentUri ? { kind: ChatModeKind.Agent, isBuiltin: false, @@ -7995,6 +8081,13 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement const rawId = AgentSession.id(session); const cached = this._sessionCache.get(rawId); if (cached) { + if (!cached.supportsChatReadState()) { + if (cached.setLegacySessionReadState(isRead)) { + this._cacheDirty = true; + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); + } + return; + } const effectiveIsRead = isRead && !cached.hasUnreadChat(); const sessionChanged = cached.isRead.get() !== effectiveIsRead; if (!sessionChanged) { @@ -8032,10 +8125,18 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement didChange = true; } - const isRead = !!(changes.status & ProtocolSessionStatus.IsRead) && !cached.hasUnreadChat(); - if (isRead !== cached.isRead.get()) { - cached.isRead.set(isRead, tx); - didChange = true; + const isRead = !!(changes.status & ProtocolSessionStatus.IsRead); + if (!cached.supportsChatReadState()) { + if (cached.setLegacySessionReadState(isRead, tx)) { + this._cacheDirty = true; + didChange = true; + } + } else { + const effectiveIsRead = isRead && !cached.hasUnreadChat(); + if (effectiveIsRead !== cached.isRead.get()) { + cached.isRead.set(effectiveIsRead, tx); + didChange = true; + } } } diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostModePicker.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostModePicker.test.ts index a39459ce10b72f..96888975095fb7 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostModePicker.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostModePicker.test.ts @@ -36,7 +36,7 @@ import { NullTelemetryService } from '../../../../../../platform/telemetry/commo import { IChatPetService } from '../../../../../../workbench/contrib/chat/browser/chatPetService.js'; import { IChatPhoneInputPresenter } from '../../../../../../workbench/contrib/chat/browser/widget/input/chatPhoneInputPresenter.js'; import { resetShownWarnings } from '../../../../../../workbench/contrib/chat/common/chatPermissionWarnings.js'; -import { ChatConfiguration } from '../../../../../../workbench/contrib/chat/common/constants.js'; +import { ChatConfiguration, ChatPermissionLevel } from '../../../../../../workbench/contrib/chat/common/constants.js'; import { TestStorageService } from '../../../../../../workbench/test/common/workbenchTestServices.js'; import { IWorkbenchEnvironmentService } from '../../../../../../workbench/services/environment/common/environmentService.js'; import { IOpenSettingsOptions, IPreferencesService } from '../../../../../../workbench/services/preferences/common/preferences.js'; @@ -44,7 +44,7 @@ import { AGENT_HOST_PERMISSIONS_SETTINGS_QUERY, MODE_PERMISSIONS_PICKER_OPEN_ATT import { IAgentHostSessionsProvider } from '../../../../../common/agentHostSessionsProvider.js'; import { ISessionsProvidersService } from '../../../../../services/sessions/browser/sessionsProvidersService.js'; import { IActiveSession } from '../../../../../services/sessions/common/sessionsManagement.js'; -import { ISessionsProvider } from '../../../../../services/sessions/common/sessionsProvider.js'; +import { ISessionConfigProvider, ISessionsProvider } from '../../../../../services/sessions/common/sessionsProvider.js'; import { AgentHostModePicker } from '../../browser/agentHostModePicker.js'; import { AgentHostPermissionPickerDelegate } from '../../browser/agentHostPermissionPickerDelegate.js'; import { PickerActionViewItem } from '../../browser/agentHostSessionConfigPicker.js'; @@ -56,7 +56,7 @@ suite('AgentHostModePicker', () => { teardown(() => resetShownWarnings()); - function setup(enabled = true, confirmPermissions = true, policyRestricted = false, publishSandboxPolicy = true, hostOS = 'linux') { + function setup(enabled = true, confirmPermissions = true, policyRestricted = false, publishSandboxPolicy = true, hostOS = 'linux', cloudDraft = false) { const config: ResolveSessionConfigResult = { schema: { type: 'object', @@ -77,10 +77,11 @@ suite('AgentHostModePicker', () => { configChanged.fire('test-session'); })); const provider = new class extends mock() { - override readonly id = 'local-agent-host'; + override readonly id = cloudDraft ? 'cloud-sandbox-creation' : 'local-agent-host'; + override readonly sessionConfig: ISessionConfigProvider | undefined = cloudDraft ? this : undefined; override readonly onDidChangeSessionConfig = configChanged.event; override getSessionConfig() { return config; } - override getCreateSessionConfig() { return undefined; } + override getCreateSessionConfig() { return cloudDraft ? config.values : undefined; } override getSessionSandboxPolicy() { return publishSandboxPolicy ? { enabled: managedSandboxEnforced.get() } : undefined; } override isDevContainerRequested() { return devContainer.get(); } override isSessionConfigResolving() { return resolving; } @@ -199,6 +200,23 @@ suite('AgentHostModePicker', () => { return { picker, trigger, config, configChanged, configuration, actionWidget, writes, session, phone, resolving, permissionDelegate, managedSandboxEnforced, settingsRequests, hoverTargets, devContainer }; } + test('reuses mode and permission controls for a cloud draft without an agent host connection', async () => { + const { config, configChanged, trigger, picker, actionWidget, writes, permissionDelegate } = setup(true, true, false, false, 'linux', true); + delete config.schema.properties.autoApprove; + delete config.schema.properties[SessionConfigKey.SandboxEnabled]; + config.schema.properties.approvalMode = { type: 'string', title: 'Approvals', enum: ['manual', 'assisted', 'allow-all'] }; + config.values = { mode: 'interactive', approvalMode: 'assisted' }; + configChanged.fire('test-session'); + picker.showPicker(trigger); + await actionWidget.select('Plan'); + await permissionDelegate.setPermissionLevel(ChatPermissionLevel.Default); + assert.deepStrictEqual({ combined: permissionDelegate.isModePickerCombined.get(), levels: permissionDelegate.availableLevels, writes, config: config.values }, { + combined: true, levels: ['default', 'assisted', 'autoApprove'], + writes: [{ session: 'test-session', property: 'mode', value: 'plan' }, { session: 'test-session', property: 'approvalMode', value: 'manual' }], + config: { mode: 'plan', approvalMode: 'manual' }, + }); + }); + test('updates the sandbox toggle, icon, and accessible label while Dev Container availability is pending', async () => { const { trigger, configuration, actionWidget, devContainer, config, writes } = setup(true, true, false, true, 'win32'); await configuration.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, 'on'); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 581357f6aa285f..c68cc7bebb8547 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -2634,6 +2634,38 @@ suite('LocalAgentHostSessionsProvider', () => { assert.deepStrictEqual(agentHost.dispatchedActions, []); }); + test('initial configuration and model preferences reach the first send of a host-listed session', async () => { + const modelId = 'agent-host-copilotcli:configured-model'; + const metadata = { + ...createTestLanguageModel('configured-model'), targetChatSessionType: 'agent-host-copilotcli', + configurationSchema: { type: 'object' as const, properties: { reasoningEffort: { type: 'string' as const, enum: ['low', 'high'] } } }, + }; + let sent: object | undefined; + const provider = createProvider(disposables, agentHost, undefined, { + openSession: true, + languageModelIds: [modelId], + lookupLanguageModel: () => metadata, + acquireOrLoadSession: async () => new ImmortalReference(new class extends mock() { + override readonly inputModel = new class extends mock() { + override readonly state = constObservable(undefined); + override setState(): void { } + override clearState(): void { } + }(); + }()), + sendRequest: async (_resource, _message, options) => { + sent = { config: options?.agentHostSessionConfig, model: options?.userSelectedModelId, modelConfiguration: options?.userSelectedModelConfiguration }; + return { kind: 'sent' as const, data: {} as IChatSendRequestData }; + }, + }); + agentHost.addSession(createSession('initial-options', { summary: 'Initial Options' })); + fireSessionAdded(agentHost, 'initial-options', { title: 'Initial Options' }); + const session = provider.getSessions().find(session => session.title.get() === 'Initial Options')!; + provider.setModel(session.sessionId, session.resource, modelId, ChatModelSource.Chosen); + await provider.getAutomationModelConfiguration(session.sessionId)!.setModelConfiguration(modelId, { reasoningEffort: 'high' }); + await provider.sendRequest(session.sessionId, session.resource, { query: 'first turn', sessionConfig: { mode: 'plan', approvalMode: 'manual' } }); + assert.deepStrictEqual(sent, { config: { mode: 'plan', approvalMode: 'manual' }, model: modelId, modelConfiguration: { reasoningEffort: 'high' } }); + }); + test('setAgent updates existing session agent and lets draft debounce persist it', () => { const provider = createProvider(disposables, agentHost); fireSessionAdded(agentHost, 'set-agent', { title: 'Set Agent Session' }); @@ -5789,7 +5821,7 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); - test('selects the current branch upstream when New Worktree is toggled on', async () => { + test('selects the default remote branch for New Worktree and the current branch for Branch', async () => { agentHost.resolveSessionConfigResult = { schema: createVSCodeSessionConfigSchema(), values: { isolation: 'folder', branch: 'feature' }, @@ -5810,7 +5842,7 @@ suite('LocalAgentHostSessionsProvider', () => { const firstToggleRequest = agentHost.resolveSessionConfigRequests.length; agentHost.resolveSessionConfigResult = { schema: createVSCodeSessionConfigSchema(), - values: { isolation: 'worktree', branch: 'origin/feature' }, + values: { isolation: 'worktree', branch: 'origin/main' }, }; await provider.setSessionConfigValue(session.sessionId, SessionConfigKey.Isolation, 'worktree'); @@ -5829,10 +5861,10 @@ suite('LocalAgentHostSessionsProvider', () => { }, { repository: 'origin/feature', requests: [ - { isolation: 'worktree', branch: 'origin/feature' }, - { isolation: 'folder' }, + { isolation: 'worktree', branch: 'origin/main' }, + { isolation: 'folder', branch: 'feature' }, ], - worktreeConfig: { isolation: 'worktree', branch: 'origin/feature' }, + worktreeConfig: { isolation: 'worktree', branch: 'origin/main' }, config: { isolation: 'folder', branch: 'feature' }, }); }); @@ -8286,6 +8318,73 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); + for (const customDefaultChat of [false, true]) { + test(`maps PR artifact owners to chat resources with ${customDefaultChat ? 'host-advertised' : 'local'} chat URIs`, () => { + const provider = createProviderWithPullRequestModels(); + const rawId = `multi-pr-owner-${customDefaultChat}`; + const directory = URI.file('/work/vscode'); + const session = setupMultiChatSession(provider, rawId, [directory]); + const sessionUri = AgentSession.uri('copilotcli', rawId).toString(); + const defaultChat = customDefaultChat ? 'custom-chat://host/conversation/main' : buildDefaultChatUri(sessionUri); + const peerChat = buildChatUri(sessionUri, 'peer-1'); + const meta = withSessionArtifacts({ + githubData: { + [directory.toString()]: { owner: 'microsoft', repo: 'vscode', pullRequestUrls: ['https://github.com/microsoft/vscode/pull/1', 'https://github.com/microsoft/vscode/pull/2'] }, + }, + }, [ + { id: 'main-pr', chat: defaultChat, type: SessionArtifactType.PullRequest, label: 'Main PR', isArtifact: true, isGitHub: true, link: 'https://github.com/microsoft/vscode/pull/1' }, + { id: 'peer-pr', chat: peerChat, type: SessionArtifactType.PullRequest, label: 'Peer PR', isArtifact: true, isGitHub: true, link: 'https://github.com/microsoft/vscode/pull/2' }, + ]); + + agentHost.setSessionState(rawId, 'copilotcli', makeState([ + makeChatSummary(defaultChat, 'Main', ProtocolSessionStatus.Idle, [directory.toString()]), + makeChatSummary(peerChat, 'Peer', ProtocolSessionStatus.Idle, [directory.toString()]), + ], { defaultChat, meta })); + const main = session.mainChat.get(); + const peer = session.chats.get().find(chat => chat.resource.fragment === 'peer-1'); + assert.ok(peer); + assert.deepStrictEqual({ + owners: session.artifacts?.get().map(artifact => [artifact.id, artifact.chat?.toString()]), + restrictedMain: getSessionGitHubReferences(session, undefined, main, false).pullRequests.map(ref => ref.number), + restrictedPeer: getSessionGitHubReferences(session, undefined, peer, false).pullRequests.map(ref => ref.number), + automaticMain: getSessionGitHubReferences(session, undefined, main).pullRequests.map(ref => ref.number), + automaticPeer: getSessionGitHubReferences(session, undefined, peer).pullRequests.map(ref => ref.number), + }, { + owners: [['peer-pr', peer.resource.toString()], ['main-pr', main.resource.toString()]], + restrictedMain: [1], + restrictedPeer: [2], + automaticMain: [1, 2], + automaticPeer: [2, 1], + }); + }); + } + + test('remaps artifact ownership when a single chat catalog hydrates without attributing unknown owners', () => { + const provider = createProviderWithPullRequestModels(); + const rawId = 'pr-owner-hydration'; + const session = setupMultiChatSession(provider, rawId); + const defaultChat = 'custom-chat://host/conversation/main'; + const unknownChat = 'custom-chat://host/conversation/unknown'; + const meta = withSessionArtifacts(undefined, [ + { id: 'main-pr', chat: defaultChat, type: SessionArtifactType.PullRequest, label: 'Main PR', isArtifact: true, isGitHub: true, link: 'https://github.com/microsoft/vscode/pull/1' }, + { id: 'unknown-pr', chat: unknownChat, type: SessionArtifactType.PullRequest, label: 'Unknown PR', isArtifact: true, isGitHub: true, link: 'https://github.com/microsoft/vscode/pull/2' }, + { id: 'legacy-pr', type: SessionArtifactType.PullRequest, label: 'Legacy PR', isArtifact: true, isGitHub: true, link: 'https://github.com/microsoft/vscode/pull/3' }, + ]); + const snapshots: (readonly (readonly (string | undefined)[])[])[] = []; + agentHost.setSessionState(rawId, 'copilotcli', makeState([], { meta })); + disposables.add(autorun(reader => { + snapshots.push(session.artifacts?.read(reader).map(artifact => [artifact.id, artifact.chat?.toString()]) ?? []); + })); + agentHost.setSessionState(rawId, 'copilotcli', makeState([ + makeChatSummary(defaultChat, 'Main'), + ], { defaultChat, meta })); + + assert.deepStrictEqual({ before: snapshots[0], after: snapshots.at(-1) }, { + before: [['legacy-pr', undefined], ['unknown-pr', unknownChat], ['main-pr', defaultChat]], + after: [['legacy-pr', undefined], ['unknown-pr', unknownChat], ['main-pr', session.mainChat.get().resource.toString()]], + }); + }); + function recordedGitHubReferences(meta: SessionState['_meta']): SessionState['_meta'] { return withSessionArtifacts(meta, [ { id: 'recorded-pr', type: SessionArtifactType.PullRequest, label: 'Recorded PR', isArtifact: true, link: 'https://github.com/microsoft/vscode/pull/2', isGitHub: true }, @@ -8742,31 +8841,71 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); - test('chat read state reports protocol rejection so callers can retry after initialization', async () => { - agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), protocolVersion: '0.8.0' }, undefined); - const provider = createProvider(disposables, agentHost); - const session = setupMultiChatSession(provider, 'chat-read-retry'); - const sessionUri = AgentSession.uri('copilotcli', 'chat-read-retry').toString(); + test('legacy hosts preserve client read state and use the session read action', async () => { + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), protocolVersion: '0.9.0' }, undefined); + const sessionUri = AgentSession.uri('copilotcli', 'legacy-chat-read').toString(); const defaultChat = buildDefaultChatUri(sessionUri); - agentHost.setSessionState('chat-read-retry', 'copilotcli', makeState([ - makeChatSummary(defaultChat, ''), - ], { defaultChat })); - const main = session.mainChat.get(); + const peerChat = buildChatUri(sessionUri, 'peer-1'); + const hiddenChat = buildChatUri(sessionUri, 'hidden'); + agentHost.addSession(createSession('legacy-chat-read', { + status: ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsRead, + chats: [ + { chat: URI.parse(defaultChat), kind: 'default', summary: '' }, + { chat: URI.parse(peerChat), kind: 'peer', summary: 'Peer' }, + { chat: URI.parse(hiddenChat), kind: 'peer', summary: 'Hidden', interactivity: ProtocolChatInteractivity.Hidden }, + ], + })); + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + const session = provider.getSessions().find(session => AgentSession.id(session.resource) === 'legacy-chat-read'); + assert.ok(session); + const initial = { + session: session.isRead.get(), + chats: session.chats.get().map(chat => chat.isRead.get()), + }; - const rejected = await provider.setChatReadState(session.sessionId, main.resource, true); - agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), protocolVersion: PROTOCOL_VERSION }, undefined); - const accepted = await provider.setChatReadState(session.sessionId, main.resource, true); + const accepted = await provider.setChatReadState(session.sessionId, session.chats.get()[2].resource, true); + const retried = await provider.setChatReadState(session.sessionId, session.chats.get()[2].resource, true); + fireSessionSummaryChanged(agentHost, 'legacy-chat-read', { + chats: [ + { resource: defaultChat, title: '' }, + { resource: peerChat, title: 'Peer' }, + { resource: hiddenChat, title: 'Hidden', interactivity: ProtocolChatInteractivity.Hidden }, + ], + defaultChat, + }); assert.deepStrictEqual({ - rejected, + initial, accepted, - isRead: main.isRead.get(), - actions: agentHost.dispatchedActions.filter(dispatch => dispatch.action.type === ActionType.ChatIsReadChanged).length, + retried, + afterRefresh: { + session: session.isRead.get(), + chats: session.chats.get().map(chat => chat.isRead.get()), + }, + actions: agentHost.dispatchedActions.map(dispatch => ({ + channel: dispatch.channel, + type: dispatch.action.type, + isRead: dispatch.action.type === ActionType.SessionIsReadChanged ? dispatch.action.isRead : undefined, + })), }, { - rejected: false, + initial: { session: false, chats: [false, false, false] }, accepted: true, - isRead: true, - actions: 1, + retried: true, + afterRefresh: { session: true, chats: [true, true, true] }, + actions: [ + { + channel: sessionUri, + type: ActionType.SessionIsReadChanged, + isRead: true, + }, + { + channel: sessionUri, + type: ActionType.SessionIsReadChanged, + isRead: true, + }, + ], }); }); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md index b7dc8e5a4b5889..1fda61276d0244 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md @@ -54,7 +54,7 @@ The provider never opens chat UI directly. Presentation and focus remain owned b Each session has a single chat; the provider does not advertise multiple chats, rename, or delete. Follow-up turns go through the committed session's existing chat resource. -Sandbox creation reuses the remote draft and optimistic replacement lifecycle. Repository selection creates only a draft. The first send provisions through `CloudSandboxAgentHostContribution`, sends the prompt once into the provisioned session's existing main chat, and transfers ownership to that environment's provider. Until that handoff, the creation provider owns an extension-independent, read-only transcript. The creation provider does not supply the regular Cloud model catalog; the connected host owns model selection. Explicit sandbox drafts fail when sandbox creation is disabled rather than falling back to the server-run Cloud agent. If the first send fails after provisioning, the environment's session is published so it remains recoverable. +Sandbox creation reuses the remote draft and optimistic replacement lifecycle. Repository selection creates only a draft. The first send provisions through `CloudSandboxAgentHostContribution`, awaits the connection's advertised repository preparation, sends the prompt once into the provisioned session's existing main chat, and transfers ownership to that environment's provider. Until that handoff, the draft exposes shared `ISession.preparationProgress`; the chat view owns the transient, extension-independent preparation transcript. Sandbox drafts obtain their account-scoped model catalog from Mission Control independently of local runtimes and the Copilot extension. Connected sandbox model providers retain service-discovered models missing from the AHP catalog, including after restoration; explicit host metadata and policy take precedence for models the host does advertise. The creation provider retains session and model configuration, resolves session options with the connected host, and applies the selected options before dispatching the first turn. Explicit sandbox drafts fail when sandbox creation is disabled rather than falling back to the server-run Cloud agent. If the first send fails after provisioning, the environment's session is published so it remains recoverable. ## Picker contributions diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/cloudSandboxSessionConfig.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/cloudSandboxSessionConfig.ts new file mode 100644 index 00000000000000..0aa054d7df9e17 --- /dev/null +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/cloudSandboxSessionConfig.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../../nls.js'; +import { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { isAutoApprovePolicyRestricted } from '../../../../../workbench/contrib/chat/common/agentHostConfigPolicy.js'; +import { ChatConfiguration, ChatDefaultPermissionLevel, IChatDefaultConfiguration } from '../../../../../workbench/contrib/chat/common/constants.js'; + +/** Cloud draft vocabulary until the provisioned host supplies its authoritative schema. */ +export function createCloudSandboxSessionConfig(configurationService: IConfigurationService): ResolveSessionConfigResult { + const restricted = isAutoApprovePolicyRestricted(configurationService); + const modes = ['interactive', 'plan', 'autopilot']; + const approvals = restricted ? ['manual'] : ['manual', 'assisted', 'allow-all']; + const defaults = configurationService.getValue(ChatConfiguration.DefaultConfiguration); + const approval = defaults?.approvals === ChatDefaultPermissionLevel.AllowAll ? 'allow-all' : defaults?.approvals; + return { + schema: { + type: 'object', + properties: { + mode: { + type: 'string', + title: localize('cloudSandbox.mode', "Agent Mode"), + enum: modes, + enumLabels: [localize('cloudSandbox.interactive', "Interactive"), localize('cloudSandbox.plan', "Plan"), localize('cloudSandbox.autopilot', "Autopilot")], + enumDescriptions: [ + localize('cloudSandbox.interactiveDescription', "Works with you and asks for input when needed."), + localize('cloudSandbox.planDescription', "Plans the task before making changes."), + localize('cloudSandbox.autopilotDescription', "Continues working until the task is done."), + ], + default: 'interactive', + }, + approvalMode: { + type: 'string', + title: localize('cloudSandbox.approvals', "Approvals"), + enum: approvals, + default: restricted ? 'manual' : 'assisted', + }, + }, + }, + values: { + mode: defaults?.mode && modes.includes(defaults.mode) ? defaults.mode : 'interactive', + approvalMode: approval && approvals.includes(approval) ? approval : restricted ? 'manual' : 'assisted', + }, + }; +} diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts index c0e3581eaa1e0f..f1431fa4332f30 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ActionViewItem, BaseActionViewItem } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { BaseActionViewItem } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { localize2 } from '../../../../../nls.js'; import { IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; @@ -11,37 +11,41 @@ import { Action2, registerAction2 } from '../../../../../platform/actions/common import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../../../workbench/common/contributions.js'; import { getNewSessionRepositoryConfigGroup, Menus } from '../../../../browser/menus.js'; -import { SessionProviderIdContext, SessionTypeContext, IsNewChatSessionContext, NewSessionCreationProviderIdContext } from '../../../../common/contextkeys.js'; +import { SessionProviderIdContext, SessionTypeContext, IsNewChatSessionContext } from '../../../../common/contextkeys.js'; import { COPILOT_PROVIDER_ID, CopilotCloudSessionType } from './copilotChatSessionsProvider.js'; import { SandboxPicker } from './sandboxPicker.js'; import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { ISessionContext } from '../../../../services/sessions/browser/sessionContext.js'; import { CLOUD_SANDBOX_CREATION_PROVIDER_ID } from '../../remoteAgentHost/browser/cloudSandboxAgentHostContribution.js'; +import { AgentHostModePicker } from '../../agentHost/browser/agentHostModePicker.js'; +import { AgentHostPermissionPickerDelegate } from '../../agentHost/browser/agentHostPermissionPickerDelegate.js'; +import { PickerActionViewItem } from '../../agentHost/browser/agentHostSessionConfigPicker.js'; +import { MobilePermissionPicker } from './mobilePermissionPicker.js'; +import { MobileAgentHostModePicker } from '../../agentHost/browser/mobile/mobileAgentHostModePicker.js'; +import { isPhoneLayout } from '../../../../browser/parts/mobile/mobileLayout.js'; +import { IWorkbenchLayoutService } from '../../../../../workbench/services/layout/browser/layoutService.js'; const IsActiveCopilotChatSessionProvider = ContextKeyExpr.equals(SessionProviderIdContext.key, COPILOT_PROVIDER_ID); const IsActiveSessionCopilotChatCloud = ContextKeyExpr.and(ContextKeyExpr.equals(SessionTypeContext.key, CopilotCloudSessionType.id), IsActiveCopilotChatSessionProvider); // -- Actions -- -registerAction2(class extends Action2 { - constructor() { - super({ - id: 'sessions.sandbox.agentDefaultModel', - title: localize2('sandbox.agentDefaultModel', "Agent Default"), - precondition: ContextKeyExpr.false(), - menu: [{ - id: Menus.NewSessionConfig, - group: 'navigation', - order: 1, - when: ContextKeyExpr.and(IsNewChatSessionContext, ChatContextKeys.enabled, ContextKeyExpr.or( - SessionProviderIdContext.isEqualTo(CLOUD_SANDBOX_CREATION_PROVIDER_ID), - NewSessionCreationProviderIdContext.isEqualTo(CLOUD_SANDBOX_CREATION_PROVIDER_ID), - )), - }], - }); - } - override run(): void { } -}); +const IsNewCloudSession = ContextKeyExpr.and(IsNewChatSessionContext, ChatContextKeys.enabled, ContextKeyExpr.or( + IsActiveCopilotChatSessionProvider, + SessionProviderIdContext.isEqualTo(CLOUD_SANDBOX_CREATION_PROVIDER_ID), +)); + +for (const [id, title, order] of [ + ['sessions.sandbox.modePicker', localize2('sandbox.modePicker', "Agent Mode"), 0], + ['sessions.sandbox.approvalPicker', localize2('sandbox.approvalPicker', "Approvals"), 1], +] as const) { + registerAction2(class extends Action2 { + constructor() { + super({ id, title, menu: [{ id: Menus.NewSessionControl, group: 'navigation', order, when: IsNewCloudSession }] }); + } + override run(): void { } + }); +} registerAction2(class extends Action2 { constructor() { @@ -66,7 +70,7 @@ registerAction2(class extends Action2 { * Wraps a standalone picker widget as a {@link BaseActionViewItem} * so it can be rendered by a {@link MenuWorkbenchToolBar}. */ -class PickerActionViewItem extends BaseActionViewItem { +class SandboxPickerActionViewItem extends BaseActionViewItem { constructor(private readonly picker: { render(container: HTMLElement): void; dispose(): void }) { super(undefined, { id: '', label: '', enabled: true, class: undefined, tooltip: '', run: () => { } }); } @@ -89,18 +93,24 @@ class CopilotPickerActionViewItemContribution extends Disposable implements IWor constructor( @IActionViewItemService actionViewItemService: IActionViewItemService, + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, ) { super(); - this._register(actionViewItemService.register( - Menus.NewSessionConfig, 'sessions.sandbox.agentDefaultModel', - action => new ActionViewItem(undefined, action, { icon: false, label: true }), - )); + this._register(actionViewItemService.register(Menus.NewSessionControl, 'sessions.sandbox.modePicker', (_action, _options, scopedInstantiationService) => { + const { session } = scopedInstantiationService.invokeFunction(accessor => accessor.get(ISessionContext)); + return new PickerActionViewItem(scopedInstantiationService.createInstance(isPhoneLayout(layoutService) ? MobileAgentHostModePicker : AgentHostModePicker, session)); + })); + this._register(actionViewItemService.register(Menus.NewSessionControl, 'sessions.sandbox.approvalPicker', (_action, _options, scopedInstantiationService) => { + const { session } = scopedInstantiationService.invokeFunction(accessor => accessor.get(ISessionContext)); + const delegate = scopedInstantiationService.createInstance(AgentHostPermissionPickerDelegate, session); + return new PickerActionViewItem(scopedInstantiationService.createInstance(MobilePermissionPicker, delegate), delegate, true); + })); this._register(actionViewItemService.register( Menus.NewSessionRepositoryConfig, 'sessions.defaultCopilot.sandboxPicker', (_action, _options, scopedInstantiationService) => { const { session } = scopedInstantiationService.invokeFunction(accessor => accessor.get(ISessionContext)); const picker = scopedInstantiationService.createInstance(SandboxPicker, session); - return new PickerActionViewItem(picker); + return new SandboxPickerActionViewItem(picker); }, )); } diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 8bfca530dfcafa..14c2bdc307db4b 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -8,11 +8,11 @@ import { raceCancellationError, raceTimeout } from '../../../../../base/common/a import { Codicon } from '../../../../../base/common/codicons.js'; import { CancellationError, isCancellationError } from '../../../../../base/common/errors.js'; import { IMarkdownString, MarkdownString, markdownStringEqual } from '../../../../../base/common/htmlContent.js'; -import { Disposable, DisposableStore, IDisposable, DisposableMap, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, DisposableMap, IReference, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; import { deepClone } from '../../../../../base/common/objects.js'; import { isWeb } from '../../../../../base/common/platform.js'; -import { constObservable, derived, IObservable, ISettableObservable, ITransaction, observableFromPromise, observableValue, observableValueOpts, transaction } from '../../../../../base/common/observable.js'; +import { autorun, constObservable, derived, IObservable, ISettableObservable, ITransaction, observableFromPromise, observableValue, observableValueOpts, transaction } from '../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; @@ -28,12 +28,12 @@ import { IChatResponseModel } from '../../../../../workbench/contrib/chat/common import { ChatSessionStatus, IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, SessionType } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { assertAutomationSessionTemplate, IAutomationSessionTemplate } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; import { AutomationModelConfiguration } from '../../../automations/browser/automationModelConfiguration.js'; -import { ChatModelSource, ISession, IChat, ISessionGitRepository, ISessionFolder, ISessionWorkspace, ISideChatSelection, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, IGitHubIssueRef, ISessionArtifact, SessionArtifactKind, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, sessionFileChangesEqual, gitHubInfoEqual, sessionWorkspaceEqual, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_GITHUB, IChatCheckpoints, ChatInteractivity, SessionTypeAuthRequirement, ISessionChangesSummary, ISessionCreationReference } from '../../../../services/sessions/common/session.js'; +import { ChatModelSource, ISession, IChat, ISessionGitRepository, ISessionFolder, ISessionWorkspace, ISideChatSelection, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, IGitHubIssueRef, ISessionArtifact, SessionArtifactKind, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, sessionFileChangesEqual, gitHubInfoEqual, sessionWorkspaceEqual, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_GITHUB, IChatCheckpoints, ChatInteractivity, SessionTypeAuthRequirement, ISessionChangesSummary, ISessionCreationReference, ISessionPreparationProgress } from '../../../../services/sessions/common/session.js'; import { linkKey } from '../../../../common/sessionLinks.js'; import { getSessionApplication } from '../../../../common/sessionApplication.js'; -import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import { basename, dirname, isEqual } from '../../../../../base/common/resources.js'; -import { IAutomationSessionConfiguration, IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionConfigurationSnapshot, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProvider, ISessionsProviderCreateSessionOptions } from '../../../../services/sessions/common/sessionsProvider.js'; +import { IAutomationSessionConfiguration, IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionConfigProvider, ISessionConfigurationSnapshot, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProvider, ISessionsProviderCreateSessionOptions } from '../../../../services/sessions/common/sessionsProvider.js'; import { ISessionOptionGroup } from '../../../chat/browser/newSession.js'; import { UNIFIED_WORKSPACE_PICKER_SETTING } from '../../../chat/common/constants.js'; import { CancellationToken, CancellationTokenSource, cancelOnDispose } from '../../../../../base/common/cancellation.js'; @@ -48,6 +48,7 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { IProgress } from '../../../../../platform/progress/common/progress.js'; import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; @@ -65,7 +66,10 @@ import { resolveGitRepositoryFromGitConfig } from '../../../../services/sessions import { IPathService } from '../../../../../workbench/services/path/common/pathService.js'; import { RepositoryPicker } from '../../../../../workbench/contrib/chat/browser/agentSessions/repositoryPicker.js'; import { ChatAIDisabledSettingId } from '../../../../../platform/chat/common/chatSettings.js'; -import { ReadOnlyChatSession } from '../../../../../workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxReadOnlySessionHandler.js'; +import { CloudSandboxModels } from '../../../../../workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxModels.js'; +import { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; +import { validateSessionConfigWrite } from '../../../../../platform/agentHost/common/sessionConfigProperties.js'; +import { createCloudSandboxSessionConfig } from './cloudSandboxSessionConfig.js'; /** Copilot Cloud session type - cloud-hosted agent. */ export const CopilotCloudSessionType: ISessionType = { @@ -133,6 +137,8 @@ export interface ICopilotChatSession { readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined>; /** Whether the session is still initializing. */ readonly loading: IObservable; + readonly preparationProgress?: IObservable; + readonly isNewSessionRequestInProgress?: IObservable; /** Whether the session is archived. */ readonly isArchived: IObservable; /** Whether the session has been read. */ @@ -294,6 +300,10 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined> = observableValue(this, undefined); readonly loading: IObservable = observableValue(this, false); + readonly preparationProgress = observableValue(this, undefined); + readonly isNewSessionRequestInProgress = this.preparationProgress.map(progress => !!progress); + private readonly _preparationCts = this._register(new MutableDisposable()); + private _preparationActivities = 0; private readonly _isArchived = observableValue(this, false); readonly isArchived: IObservable = this._isArchived; @@ -394,6 +404,13 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession this.storageService.store(STORAGE_KEY_USE_SANDBOX, useSandbox, StorageScope.PROFILE, StorageTarget.MACHINE); } + observeSandbox(onChange: () => void): void { + this._register(autorun(reader => { + this.useSandbox.read(reader); + onChange(); + })); + } + setModelId(modelId: string | undefined, source: ChatModelSource): void { this._modelId = modelId; // One update, and both halves of it: a model and where it came from are only meaningful as @@ -413,6 +430,33 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession this._status.set(status, undefined); } + startPreparation(): IReference { + if (!this._preparationCts.value) { + this._preparationCts.value = new CancellationTokenSource(this.lifetimeToken); + this.reportPreparationProgress(localize('sandbox.provisioningContainer', "Setting up cloud container")); + } + this._preparationActivities++; + const token = this._preparationCts.value.token; + const activity = toDisposable(() => { + if (--this._preparationActivities === 0) { + this.clearPreparation(); + } + }); + return { object: token, dispose: () => activity.dispose() }; + } + + reportPreparationProgress(message: string): void { + const source = this._preparationCts.value; + if (source && !source.token.isCancellationRequested && this.preparationProgress.get()?.message !== message) { + this.preparationProgress.set({ message, cancel: () => source.cancel() }, undefined); + } + } + + private clearPreparation(): void { + this.preparationProgress.set(undefined, undefined); + this._preparationCts.clear(); + } + setArchived(archived: boolean): void { this._isArchived.set(archived, undefined); } @@ -1049,7 +1093,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions * connecting and the host publishing its models, short enough not to strand a send behind a * catalog that is never coming. Exceeding it is reported to the user, not only logged. */ - private static readonly SANDBOX_MODEL_WAIT_MS = 5_000; + private static readonly SANDBOX_MODEL_WAIT_MS = 30_000; get id(): string { return this.providerMode === 'sandbox' ? CLOUD_SANDBOX_CREATION_PROVIDER_ID : COPILOT_PROVIDER_ID; } get label(): string { return this.providerMode === 'sandbox' ? localize('sandboxCreationProvider', "GitHub Sandboxes") : localize('copilotChatSessionsProvider', "Copilot Chat"); } @@ -1080,9 +1124,14 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions * cannot transiently drop them. */ private readonly _inFlightCommits = new Set(); - private readonly _sandboxSends = new Map(); - private readonly _sandboxCreationChats = this._register(new DisposableMap()); + private readonly _sandboxSends = new Set(); private readonly _repositoryPicker = this._register(new MutableDisposable()); + private readonly _sandboxCatalog = this._register(new MutableDisposable()); + private readonly _onDidChangeSandboxModels = this._register(new Emitter()); + private readonly _sandboxConfigs = new Map(); + private readonly _onDidChangeSessionConfig = this._register(new Emitter()); + readonly onDidChangeSessionConfig = this._onDidChangeSessionConfig.event; + readonly sessionConfig: ISessionConfigProvider = this; /** Cache of ISession wrappers, keyed by session ID. */ private readonly _sessionWrapperCache = new Map(); @@ -1117,35 +1166,25 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions super(); this._loadCreatedBySessions(); - if (providerMode === 'sandbox') { - this._register(this.chatSessionsService.registerChatSessionContentProvider(CopilotSandboxSessionType.id, { - provideChatSessionContent: async resource => { - const sessionId = toSessionId(this.id, resource); - const options = this._sandboxSends.get(sessionId); - if (!options) { - throw new Error(localize('sandbox.draftNotFound', "The GitHub sandbox draft is no longer available.")); - } - const chat = new ReadOnlyChatSession(resource, [{ - type: 'request', - prompt: options.query, - participant: CopilotSandboxSessionType.id, - variableData: { variables: options.attachedContext ?? [] }, - isHidden: options.hideFromTranscript, - }, { - type: 'response', - participant: CopilotSandboxSessionType.id, - parts: [{ kind: 'markdownContent', content: new MarkdownString(localize('sandbox.starting', "Starting GitHub sandbox...")) }], - }], undefined, constObservable(true)); - this._sandboxCreationChats.set(sessionId, chat); - return chat; - }, - })); - } this._register(Event.filter( this.configurationService.onDidChangeConfiguration, event => event.affectsConfiguration(UNIFIED_WORKSPACE_PICKER_SETTING), )(() => this._onDidChangeSessionTypes.fire())); + this._register(this.configurationService.onDidChangeConfiguration(event => { + if (event.affectsConfiguration(ChatConfiguration.DefaultConfiguration) || event.affectsConfiguration(ChatConfiguration.GlobalAutoApprove)) { + for (const [sessionId, config] of this._sandboxConfigs) { + const refreshed = createCloudSandboxSessionConfig(this.configurationService); + for (const [key, value] of Object.entries(config.values)) { + if (refreshed.schema.properties[key]?.enum?.some(candidate => candidate === value)) { + refreshed.values[key] = value; + } + } + this._sandboxConfigs.set(sessionId, refreshed); + this._onDidChangeSessionConfig.fire(sessionId); + } + } + })); // Forward session changes from the underlying model this._register(this.agentSessionsService.model.onDidChangeSessions(() => { @@ -1295,6 +1334,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions */ private _clearCurrentNewSessionIfMatch(session: RemoteNewSession, leak?: boolean): void { if (this._newSessions.get(session.sessionId) === session) { + this._sandboxConfigs.delete(session.sessionId); if (leak) { this._newSessions.deleteAndLeak(session.sessionId); } else { @@ -1304,6 +1344,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } deleteNewSession(sessionId: string): void { + this._sandboxConfigs.delete(sessionId); if (this._newSessions.has(sessionId)) { this._newSessions.deleteAndDispose(sessionId); this._sessionWrapperCache.delete(sessionId); @@ -1348,6 +1389,13 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions const session = this.instantiationService.createInstance(RemoteNewSession, resource, cloudWorkspace, target, this.id, automationConfiguration); session.setCreatedBySession(options?.createdBySession); this._newSessions.set(session.sessionId, session); + session.observeSandbox(() => { + if (this._isSandboxDraft(session)) { + this._getSandboxCatalog().load(); + } + this._onDidChangeSessionConfig.fire(session.sessionId); + this._onDidChangeSandboxModels.fire(); + }); try { this._applyAutomationSessionConfiguration(session, automationConfiguration); return this._chatToSession(session); @@ -1430,14 +1478,16 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions // Models can change because language models are (un)registered or because // the extension host updates a cloud session's `models` option group. return Event.signal(Event.any( + this._onDidChangeSandboxModels.event, this.languageModelsService.onDidChangeLanguageModels, this.chatSessionsService.onDidChangeOptionGroups )); } getModelsSnapshot(sessionId: string, desiredModelId?: string): ISessionModelsSnapshot { - if (this.providerMode === 'sandbox') { - return { models: [], desiredModelResolution: resolveModelIdentifier([], desiredModelId, true), modelTarget: CopilotSandboxSessionType.id }; + const draft = this._newSessions.get(sessionId); + if (draft && this._isSandboxDraft(draft)) { + return this._sandboxModelsSnapshot(desiredModelId); } const session = this.getSession(sessionId); if (session instanceof RemoteNewSession) { @@ -1466,9 +1516,11 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } getModelsSnapshotForCreation(_workspaceUri: URI, sessionTypeId: string, desiredModelId?: string): ISessionModelsSnapshot { - if (this.providerMode === 'sandbox' || sessionTypeId !== CopilotCloudSessionType.id) { - // Sandbox drafts use the connected host's catalog, and no other session types are offered. - return { models: [], desiredModelResolution: resolveModelIdentifier([], desiredModelId, true), modelTarget: this.providerMode === 'sandbox' ? CopilotSandboxSessionType.id : undefined }; + if (this.providerMode === 'sandbox') { + return this._sandboxModelsSnapshot(desiredModelId); + } + if (sessionTypeId !== CopilotCloudSessionType.id) { + return { models: [], desiredModelResolution: resolveModelIdentifier([], desiredModelId, true), modelTarget: undefined }; } const group = this.chatSessionsService.getOptionGroupsForSessionType(AgentSessionProviders.Cloud)?.find(candidate => isModelOptionGroup(candidate)); const models = group?.items.map((item): ILanguageModelChatMetadataAndIdentifier => this._toSyntheticModel(item)) ?? []; @@ -1480,9 +1532,11 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } getModelPickerOptions(sessionId: string): ISessionModelPickerOptions { - if (this.providerMode === 'sandbox') { - return { useGroupedModelPicker: false, showFeatured: false, showUnavailableFeatured: false, showManageModelsAction: false, showAutoModel: true }; + const draft = this._newSessions.get(sessionId); + if (draft && this._isSandboxDraft(draft)) { + return { useGroupedModelPicker: true, showFeatured: false, showUnavailableFeatured: false, showManageModelsAction: false, showAutoModel: false }; } + // A session type that requires an explicit model selection cannot fall // back to Auto. When it has no models, the picker shows a "No models // available" state instead. Derive this from the contribution's @@ -1498,6 +1552,56 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions }; } + private _isSandboxDraft(session: RemoteNewSession): boolean { + return this.providerMode === 'sandbox' || (session.useSandbox.get() === true && isCloudSandboxEnabled(this.configurationService)); + } + + private _getSandboxCatalog(): CloudSandboxModels { + if (!this._sandboxCatalog.value) { + const catalog = this.instantiationService.createInstance(CloudSandboxModels, CopilotSandboxSessionType.id, `${this.id}-sandbox-models`, undefined); + this._sandboxCatalog.value = catalog; + this._register(catalog.onDidChange(() => this._onDidChangeSandboxModels.fire())); + } + return this._sandboxCatalog.value; + } + + private _sandboxModelsSnapshot(desiredModelId?: string): ISessionModelsSnapshot { + const catalog = this._getSandboxCatalog(); + catalog.load(); + return { models: catalog.models, desiredModelResolution: resolveModelIdentifier(catalog.models, desiredModelId, catalog.ready), modelTarget: CopilotSandboxSessionType.id }; + } + + getSessionConfig(sessionId: string): ResolveSessionConfigResult | undefined { + const session = this._newSessions.get(sessionId); + if (!session || !this._isSandboxDraft(session)) { + return undefined; + } + let config = this._sandboxConfigs.get(sessionId); + if (!config) { + config = createCloudSandboxSessionConfig(this.configurationService); + this._sandboxConfigs.set(sessionId, config); + } + return config; + } + + getCreateSessionConfig(sessionId: string): Record | undefined { + return this.getSessionConfig(sessionId)?.values; + } + + isSessionConfigResolving(_sessionId: string): IObservable { + return constObservable(false); + } + + async setSessionConfigValue(sessionId: string, property: string, value: unknown): Promise { + const config = this.getSessionConfig(sessionId); + if (!config) { + throw new Error('Cloud sandbox draft configuration is unavailable.'); + } + validateSessionConfigWrite(config.schema, config.values, property, value, true); + this._sandboxConfigs.set(sessionId, { ...config, values: { ...config.values, [property]: value } }); + this._onDidChangeSessionConfig.fire(sessionId); + } + private _toSyntheticModel(item: IChatSessionProviderOptionItem): ILanguageModelChatMetadataAndIdentifier { const modelMetadata = item.modelMetadata; return { @@ -1659,12 +1763,24 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions throw new Error(`Session '${sessionId}' does not support side chats`); } + startNewSessionRequest(sessionId: string): IDisposable | undefined { + const session = this._newSessions.get(sessionId); + if (!session || !this._usesSandbox(session)) { + return undefined; + } + return session.startPreparation(); + } + + private _usesSandbox(session: RemoteNewSession): boolean { + return this.providerMode === 'sandbox' || !!session.useSandbox.get() && !!session.repoNwo && isCloudSandboxEnabled(this.configurationService); + } + async createNewChat(sessionId: string, _prompt?: string): Promise { const session = this._newSessions.get(sessionId); if (!session) { throw new Error(`[CopilotChatSessionsProvider] Session '${sessionId}' does not support multiple chats`); } - if (this.providerMode !== 'sandbox') { + if (!this._usesSandbox(session)) { (await this._createChatSession(session.resource, session)).dispose(); } const newChat = this._withChangesets(buildChatFromSession(session), session.workspace); @@ -1673,7 +1789,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } /** Test seam: the contribution registry is global, so tests override this with a stub. */ - protected _getCloudSandboxContribution(): Pick { + protected _getCloudSandboxContribution(): Pick { return getWorkbenchContribution(CloudSandboxAgentHostContribution.ID); } @@ -1693,7 +1809,10 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions * first turn has to be dispatched here rather than being picked up server-side. */ private async _sendFirstChatToSandbox(session: RemoteNewSession, repoNwo: string, options: ISendRequestOptions): Promise { - session.setTitle((options.title || options.query.split('\n')[0]).substring(0, 100) || localize('new session', "New Session")); + this._sandboxSends.add(session.sessionId); + const preparation = session.startPreparation(); + const token = preparation.object; + session.setTitle((options.title || options.query.split('\n')[0]).trim().substring(0, 100) || localize('new session', "New Session")); session.setStatus(SessionStatus.InProgress); this._sessionCache.set(session.resource.toString(), session); const placeholder = this._chatToSession(session); @@ -1702,23 +1821,40 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions let provisioned: ICloudSandboxProvisionedSession | undefined; // Read before provisioning: the composer session is retired below, and its selection is the // only record of what the user picked for this turn. - const selectedModel = this.providerMode === 'sandbox' ? undefined : this._selectedCloudModel(session); - const token = this.providerMode === 'sandbox' ? session.lifetimeToken : CancellationToken.None; + const selectedModel = this._selectedCloudModel(session); + const config = this.getCreateSessionConfig(session.sessionId); + const store = new DisposableStore(); + store.add(preparation); + store.add(token.onCancellationRequested(() => store.dispose())); + const progress: IProgress = { + report: message => { + if (!store.isDisposed) { + session.reportPreparationProgress(message); + } + }, + }; try { - provisioned = await this._getCloudSandboxContribution().provisionSession({ + const contribution = this._getCloudSandboxContribution(); + provisioned = await raceCancellationError(contribution.provisionSession({ repoNwo, // No `baseRef`: cloud sessions have no branch picker; Mission Control chooses. prompt: options.query, - }, token); + }, token, progress), token); if (token.isCancellationRequested) { throw new CancellationError(); } + store.add(contribution.trackSessionCreationProgress(provisioned.environmentId, repoNwo, progress)); + await raceCancellationError(contribution.prepareSession(provisioned.environmentId, repoNwo, token), token); + progress.report(localize('sandbox.startingAgent', "Starting Copilot agent")); // Send into the session's main chat rather than `createNewChat`, which would mint an // *additional* peer chat inside a session that already has one. const chat = provisioned.session.mainChat.get(); - await this._carryModelToSandbox(provisioned, chat.resource, selectedModel); - const committed = await provisioned.provider.sendRequest(provisioned.session.sessionId, chat.resource, options); + const sessionConfig = config ? await provisioned.provider.resolveInitialSessionConfig(provisioned.session.sessionId, config, token) : undefined; + await raceCancellationError(this._carryModelToSandbox(provisioned, chat.resource, selectedModel), token); + // Preserve the prompt-derived title rather than the host's branch-name default. + await raceCancellationError(provisioned.provider.renameSession(provisioned.session.sessionId, session.title.get()), token); + const committed = await provisioned.provider.sendRequest(provisioned.session.sessionId, chat.resource, { ...options, sessionConfig }); // Retire only once the turn is dispatched; swapping earlier bounces the view home. this._publishSandboxSession(provisioned, { announce: false }); @@ -1736,6 +1872,9 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions this._onDidChangeSessions.fire({ added: [], removed: [placeholder], changed: [] }); session.dispose(); throw error; + } finally { + store.dispose(); + this._sandboxSends.delete(session.sessionId); } } @@ -1743,23 +1882,21 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions private _publishSandboxSession(provisioned: ICloudSandboxProvisionedSession, options?: { announce?: boolean }): void { provisioned.provider.publishWithheldSession(AgentSession.id(provisioned.session.resource), options); } - - /** - * The composer's model selection as the sandbox knows it, plus the label to name it by. - * - * Cloud sessions pick from the extension host's `models` option group, whose ids are the - * group's own item ids, while a sandbox registers its models from what the agent host - * advertises. Different id spaces, so only the underlying model id crosses over. - * - * Only the model, because only the model exists: an option item's `modelMetadata` is hover and - * pricing detail with no configuration schema, so a cloud composer never offers a thinking - * level or context tier to carry alongside it. - */ - private _selectedCloudModel(session: RemoteNewSession): { readonly rawModelId: string; readonly label: string } | undefined { + /** Carries the raw model ID and its preferences across the draft and connected host's identifier spaces. */ + private _selectedCloudModel(session: RemoteNewSession): { readonly rawModelId: string; readonly label: string; readonly config?: IAutomationSessionTemplate['modelConfiguration']; readonly fromCloudCatalog?: boolean } | undefined { const selectedModelId = session.selectedModelId; if (!selectedModelId) { return undefined; } + const sandboxModel = this._sandboxCatalog.value?.models.find(model => model.identifier === selectedModelId); + if (sandboxModel) { + return { + rawModelId: sandboxModel.metadata.id, + label: sandboxModel.metadata.name, + config: session.modelConfiguration.captureModelConfiguration(selectedModelId), + fromCloudCatalog: true, + }; + } const { modelOption } = session.getModelOptionsSnapshot(); const item = modelOption?.group.items.find(i => i.id === selectedModelId); const rawModelId = item?.modelMetadata?.id ?? item?.id ?? selectedModelId; @@ -1780,7 +1917,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions * Every path that gives up tells the user: an absent `Message.model` means "host decides", so * nothing downstream would report running at a capability and price they did not choose. */ - private async _carryModelToSandbox(provisioned: ICloudSandboxProvisionedSession, chatResource: URI, selected: { readonly rawModelId: string; readonly label: string } | undefined): Promise { + private async _carryModelToSandbox(provisioned: ICloudSandboxProvisionedSession, chatResource: URI, selected: { readonly rawModelId: string; readonly label: string; readonly config?: IAutomationSessionTemplate['modelConfiguration']; readonly fromCloudCatalog?: boolean } | undefined): Promise { if (!selected) { return; } @@ -1804,10 +1941,24 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions for (; ;) { const resolution = provider.getModelsSnapshot(sessionId, desiredModelId).desiredModelResolution; if (resolution.kind === 'available') { + if (selected.config && Object.keys(selected.config).length) { + const schema = resolution.model.metadata.configurationSchema; + for (const [key, value] of Object.entries(selected.config)) { + const property = schema?.properties?.[key]; + if (!property || property.readOnly || (property.enum && !property.enum.includes(value))) { + throw new Error(localize('sandbox.modelConfigurationUnavailable', "The sandbox does not support the selected {0} for {1}.", key, label)); + } + } + const configuration = provider.getAutomationModelConfiguration(sessionId); + if (!configuration) { + throw new Error('The sandbox model configuration is unavailable.'); + } + await configuration.setModelConfiguration(resolution.model.identifier, selected.config); + } provider.setModel(sessionId, chatResource, resolution.model.identifier, ChatModelSource.CarriedOver); return; } - if (resolution.kind !== 'pending') { + if (resolution.kind !== 'pending' && !selected.fromCloudCatalog) { this.logService.info(`[CopilotChatSessionsProvider] Sandbox session ${sessionId} does not advertise model '${rawModelId}'; letting the agent host choose.`); this._notifySandboxModelNotApplied(label); return; @@ -1855,13 +2006,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions if (!newSession.repoNwo || !isCloudSandboxEnabled(this.configurationService) || this.configurationService.getValue(ChatAIDisabledSettingId)) { throw new Error(localize('sandbox.unavailable', "GitHub sandbox creation is no longer available. Enable the feature and choose a repository to try again.")); } - this._sandboxSends.set(sessionId, options); - try { - return await this._sendFirstChatToSandbox(newSession, newSession.repoNwo, options); - } finally { - this._sandboxSends.delete(sessionId); - this._sandboxCreationChats.deleteAndDispose(sessionId); - } + return this._sendFirstChatToSandbox(newSession, newSession.repoNwo, options); } // `useSandbox` is persisted, so it can outlive the setting being turned off. Re-check // rather than trust it: falling back to the cloud agent beats a send that must fail. @@ -2528,6 +2673,8 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions modelId: chat.modelId, mode: chat.mode, loading: chat.loading, + preparationProgress: chat.preparationProgress, + isNewSessionRequestInProgress: chat.isNewSessionRequestInProgress, isArchived: chat.isArchived, isRead: chat.isRead, description: chat.description, diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts index 720306e85645aa..ef31d61ba5e3a5 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts @@ -266,6 +266,16 @@ export class PermissionPicker extends Disposable { return slot; } + focus(): void { + this._triggerElement?.focus(); + } + + setFocusable(focusable: boolean): void { + if (this._triggerElement) { + this._triggerElement.tabIndex = focusable ? 0 : -1; + } + } + showPicker(): void { this._showPicker(); } diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index 9108204c9e9bed..022af065b2b072 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -43,12 +43,13 @@ import { CloudSandboxEnabledSettingId, type ICloudSandboxCreateSessionRequest } import { RemoteAgentHostsEnabledSettingId } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { CLOUD_SANDBOX_CREATION_PROVIDER_ID, CloudSandboxAgentHostContribution, type ICloudSandboxProvisionedSession } from '../../../remoteAgentHost/browser/cloudSandboxAgentHostContribution.js'; import { CloudSandboxSessionsProvider } from '../../../remoteAgentHost/browser/cloudSandboxSessionsProvider.js'; -import { ChatModeKind, ChatPermissionLevel } from '../../../../../../workbench/contrib/chat/common/constants.js'; +import { ChatConfiguration, ChatDefaultPermissionLevel, ChatModeKind, ChatPermissionLevel } from '../../../../../../workbench/contrib/chat/common/constants.js'; import { UNIFIED_WORKSPACE_PICKER_SETTING } from '../../../../chat/common/constants.js'; import { CopilotChatSessionsProvider, COPILOT_PROVIDER_ID, CopilotCloudSessionType, CopilotSandboxSessionType, RemoteNewSession } from '../../browser/copilotChatSessionsProvider.js'; import { ChatAIDisabledSettingId } from '../../../../../../platform/chat/common/chatSettings.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; +import { IProgress } from '../../../../../../platform/progress/common/progress.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { IPathService } from '../../../../../../workbench/services/path/common/pathService.js'; import { MockLabelService } from '../../../../../../workbench/services/label/test/common/mockLabelService.js'; @@ -61,6 +62,10 @@ import { RepositoryPicker } from '../../../../../../workbench/contrib/chat/brows import { GitHubPullRequestModel } from '../../../../github/browser/models/githubPullRequestModel.js'; import { IPullRequestIconCache } from '../../../../github/browser/pullRequestIconCache.js'; import { computePullRequestIcon, GitHubPullRequestState, IGitHubPullRequest, IGitHubRepository } from '../../../../github/common/types.js'; +import { CloudSandboxModels } from '../../../../../../workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxModels.js'; +import { createCloudSandboxSessionConfig } from '../../browser/cloudSandboxSessionConfig.js'; +import { AutomationModelConfiguration } from '../../../../automations/browser/automationModelConfiguration.js'; +import { validateSessionConfigWrite } from '../../../../../../platform/agentHost/common/sessionConfigProperties.js'; // ---- Helpers ---------------------------------------------------------------- @@ -313,6 +318,7 @@ function createProviderWithConfig( opts?: ICreateProviderOptions, ): { provider: CopilotChatSessionsProvider; configService: TestConfigurationService; labelService: MockLabelService } { const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stubInstance(CloudSandboxModels, upcastPartial({ models: [], ready: true, onDidChange: Event.None, load: () => { }, dispose: () => { } })); const configService = new TestConfigurationService(); configService.setUserConfiguration(UNIFIED_WORKSPACE_PICKER_SETTING, opts?.consolidatedRemoteWorkspaces ?? false); @@ -376,12 +382,12 @@ function createProviderWithConfig( * workbench contribution registry. */ class TestSandboxCopilotProvider extends CopilotChatSessionsProvider { - sandboxContribution: Pick | undefined; + sandboxContribution: Pick | undefined; /** Only the timeout test lowers this; the rest keep the real budget so they cannot race it. */ sandboxModelWaitMs: number | undefined; - protected override _getCloudSandboxContribution(): Pick { + protected override _getCloudSandboxContribution(): Pick { if (!this.sandboxContribution) { throw new Error('No cloud sandbox contribution was registered'); } @@ -401,9 +407,10 @@ function createProviderForSendTests( disposables: DisposableStore, model: MockAgentSessionsModel, sendRequest: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise, - opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; configurationService?: TestConfigurationService; getOptionGroups?: () => IChatSessionProviderOptionGroup[] | undefined; notifications?: string[]; languageModelsService?: Partial; providerMode?: 'default' | 'sandbox'; onGetChatSession?: () => void; chatContentProviders?: IChatSessionContentProvider[]; storageService?: IStorageService }, + opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; configurationService?: TestConfigurationService; getOptionGroups?: () => IChatSessionProviderOptionGroup[] | undefined; notifications?: string[]; languageModelsService?: Partial; providerMode?: 'default' | 'sandbox'; onGetChatSession?: () => void; chatContentProviders?: IChatSessionContentProvider[]; storageService?: IStorageService; sandboxModels?: readonly ILanguageModelChatMetadataAndIdentifier[] }, ): TestSandboxCopilotProvider { const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stubInstance(CloudSandboxModels, upcastPartial({ models: opts?.sandboxModels ?? [], ready: true, onDidChange: Event.None, load: () => { }, dispose: () => { } })); const configService = opts?.configurationService ?? new TestConfigurationService(); @@ -440,7 +447,7 @@ function createProviderForSendTests( removeHistoryEntry: async (resource: URI) => { model.removeSession(resource); }, setChatSessionTitle: () => { }, }); - instantiationService.stub(ILanguageModelsService, { lookupLanguageModel: () => undefined, getModelConfiguration: () => undefined, setModelConfiguration: async () => { }, ...opts?.languageModelsService }); + instantiationService.stub(ILanguageModelsService, { lookupLanguageModel: id => opts?.sandboxModels?.find(model => model.identifier === id)?.metadata, getModelConfiguration: () => undefined, setModelConfiguration: async () => { }, ...opts?.languageModelsService }); instantiationService.stub(INotificationService, new class extends mock() { override warn(message: unknown): void { opts?.notifications?.push(String(message)); } }()); @@ -634,6 +641,7 @@ suite('CopilotChatSessionsProvider', () => { })), }); }); + } test('cancelling repository selection creates no workspace or session in either mode', async () => { @@ -2527,7 +2535,33 @@ suite('CopilotChatSessionsProvider', () => { // `repoNwo` has to strip back down to `owner/repo`. const repoWorkspace = URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: '/osortega/simple-server/HEAD' }); - function createSandboxProvider(opts: { enabled?: boolean; provision?: () => Promise; getOptionGroups?: () => IChatSessionProviderOptionGroup[] | undefined; providerMode?: 'default' | 'sandbox'; onGetChatSession?: () => void } = {}) { + test('seeds cloud approvals from each default configuration value', () => { + const values = [ChatDefaultPermissionLevel.Manual, ChatDefaultPermissionLevel.Assisted, ChatDefaultPermissionLevel.AllowAll, undefined].map(approvals => { + const configuration = new TestConfigurationService({ [ChatConfiguration.DefaultConfiguration]: { approvals } }); + disposables.add(configuration.onDidChangeConfigurationEmitter); + return createCloudSandboxSessionConfig(configuration).values.approvalMode; + }); + assert.deepStrictEqual(values, ['manual', 'assisted', 'allow-all', 'assisted']); + }); + + test('enterprise policy clamps an Allow All default to Manual', () => { + const configuration = new class extends TestConfigurationService { + override inspect(key: string) { + const inspected = super.inspect(key); + return { ...inspected, policyValue: key === ChatConfiguration.GlobalAutoApprove ? inspected.value : undefined }; + } + }({ + [ChatConfiguration.DefaultConfiguration]: { approvals: ChatDefaultPermissionLevel.AllowAll }, + [ChatConfiguration.GlobalAutoApprove]: false, + }); + disposables.add(configuration.onDidChangeConfigurationEmitter); + const config = createCloudSandboxSessionConfig(configuration); + assert.deepStrictEqual({ values: config.values, approvals: config.schema.properties.approvalMode.enum }, { + values: { mode: 'interactive', approvalMode: 'manual' }, approvals: ['manual'], + }); + }); + + function createSandboxProvider(opts: { enabled?: boolean; provision?: CloudSandboxAgentHostContribution['provisionSession']; prepare?: CloudSandboxAgentHostContribution['prepareSession']; trackProgress?: CloudSandboxAgentHostContribution['trackSessionCreationProgress']; getOptionGroups?: () => IChatSessionProviderOptionGroup[] | undefined; providerMode?: 'default' | 'sandbox'; onGetChatSession?: () => void; sandboxModels?: readonly ILanguageModelChatMetadataAndIdentifier[] } = {}) { const configurationService = new TestConfigurationService(); configurationService.setUserConfiguration(CloudSandboxEnabledSettingId, opts.enabled ?? true); configurationService.setUserConfiguration(RemoteAgentHostsEnabledSettingId, true); @@ -2539,17 +2573,19 @@ suite('CopilotChatSessionsProvider', () => { cloudSends.push(message); // Never settles: these tests only assert which path the send took. return new Promise(() => { }); - }, { configurationService, getOptionGroups: opts.getOptionGroups, notifications, providerMode: opts.providerMode, onGetChatSession: opts.onGetChatSession, chatContentProviders }); + }, { configurationService, getOptionGroups: opts.getOptionGroups, notifications, providerMode: opts.providerMode, onGetChatSession: opts.onGetChatSession, chatContentProviders, sandboxModels: opts.sandboxModels }); const provisionRequests: ICloudSandboxCreateSessionRequest[] = []; provider.sandboxContribution = { - provisionSession: async request => { + provisionSession: async (request, token, progress) => { provisionRequests.push(request); if (opts.provision) { - return opts.provision(); + return opts.provision(request, token, progress); } throw new Error('provisioning failed'); }, + prepareSession: opts.prepare ?? (async () => { }), + trackSessionCreationProgress: opts.trackProgress ?? (() => toDisposable(() => { })), }; return { provider, provisionRequests, cloudSends, notifications, configurationService, chatContentProviders }; } @@ -2561,31 +2597,60 @@ suite('CopilotChatSessionsProvider', () => { * resolution reports `pending` until it yields the model, mirroring an agent host that has * connected but not yet published. */ - function provisionedSession(sendRequest?: CloudSandboxSessionsProvider['sendRequest'], sandboxModels: () => readonly ILanguageModelChatMetadataAndIdentifier[] = () => []): ICloudSandboxProvisionedSession & { published: string[]; modelSelections: { modelId: string; source: ChatModelSource }[]; modelsChanged: Emitter } { + function provisionedSession(sendRequest?: CloudSandboxSessionsProvider['sendRequest'], sandboxModels: () => readonly ILanguageModelChatMetadataAndIdentifier[] = () => []): ICloudSandboxProvisionedSession & { published: string[]; renames: { sessionId: string; title: string }[]; modelSelections: { modelId: string; source: ChatModelSource }[]; modelsChanged: Emitter; configurations: Record[]; modelConfigurations: Record[] } { + const title = observableValue('title', 'main'); const committed = upcastPartial({ sessionId: 'agenthost:sess-new', resource: URI.parse('agent-host-copilot:/sess-new'), + title, }); const sandboxSession = upcastPartial({ sessionId: 'agenthost:sess-new', resource: URI.parse('agent-host-copilot:/sess-new'), + title, mainChat: constObservable(upcastPartial({ resource: URI.parse('agent-host-copilot:/sess-new') })), }); const published: string[] = []; + const renames: { sessionId: string; title: string }[] = []; const modelSelections: { modelId: string; source: ChatModelSource }[] = []; const modelsChanged = disposables.add(new Emitter()); + const configurations: Record[] = []; + const modelConfigurations: Record[] = []; + const config = createCloudSandboxSessionConfig(new TestConfigurationService()); + for (const property of Object.values(config.schema.properties)) { + property.sessionMutable = true; + } return { taskId: 'task-new', sessionId: 'sess-new', environmentId: 'env-new', session: sandboxSession, published, + renames, modelSelections, modelsChanged, + configurations, + modelConfigurations, provider: upcastPartial({ sendRequest: sendRequest ?? (async () => committed), + renameSession: async (sessionId, newTitle) => { + renames.push({ sessionId, title: newTitle }); + title.set(newTitle, undefined); + }, publishWithheldSession: (rawId: string) => { published.push(rawId); }, onDidChangeModels: modelsChanged.event, + onDidChangeSessionConfig: Event.None, + getSessionConfig: () => config, + resolveInitialSessionConfig: async (_sessionId, values) => { + for (const [key, value] of Object.entries(values)) { + validateSessionConfigWrite(config.schema, config.values, key, value, true); + } + configurations.push(values); + return values; + }, + getAutomationModelConfiguration: () => upcastPartial({ + setModelConfiguration: async (_id, values) => { modelConfigurations.push(values); }, + }), getModelsSnapshot: (_sessionId: string, desiredModelId?: string) => { const models = sandboxModels(); const model = models.find(m => m.identifier === desiredModelId); @@ -2625,7 +2690,268 @@ suite('CopilotChatSessionsProvider', () => { }]; } + for (const providerMode of ['default', 'sandbox'] as const) { + for (const { name, options, expected } of [ + { name: 'first prompt', options: { query: 'Fix the login bug' }, expected: 'Fix the login bug' }, + { name: 'multiline prompt', options: { query: 'Fix the login bug\nHere are the details' }, expected: 'Fix the login bug' }, + { name: 'surrounding whitespace', options: { query: ' Fix the login bug \nHere are the details' }, expected: 'Fix the login bug' }, + { name: 'long prompt', options: { query: 'x'.repeat(101) }, expected: 'x'.repeat(100) }, + { name: 'explicit title', options: { query: 'Fix the login bug', title: 'Login fix' }, expected: 'Login fix' }, + { name: 'empty first line', options: { query: '\nFix the login bug' }, expected: 'New Session' }, + { name: 'whitespace first line', options: { query: ' \nFix the login bug' }, expected: 'New Session' }, + ]) { + test(`${providerMode} creation pushes the ${name} title to the sandbox before sending`, async () => { + let titleAtSend: string | undefined; + const provisioned = provisionedSession(async () => { + titleAtSend = provisioned.session.title.get(); + return provisioned.session; + }); + const { provider } = createSandboxProvider({ providerMode, provision: async () => provisioned }); + const sessionType = providerMode === 'sandbox' ? CopilotSandboxSessionType.id : CopilotCloudSessionType.id; + const draft = provider.createNewSession(repoWorkspace, sessionType); + const session = provider.getSession(draft.sessionId)!; + session.setUseSandbox(true); + + const committed = await provider.sendRequest(draft.sessionId, draft.mainChat.get().resource, options); + + assert.deepStrictEqual({ + renames: provisioned.renames, + titleAtSend, + committedTitle: committed.title.get(), + }, { + renames: [{ sessionId: 'agenthost:sess-new', title: expected }], + titleAtSend: expected, + committedTitle: expected, + }); + }); + } + test(`${providerMode} sandbox startup uses draft preparation progress without registering a chat provider or changing its resource`, async () => { + const pending = new DeferredPromise(); + const preparing = new DeferredPromise(); + const tracking = new DeferredPromise(); + const sending = new DeferredPromise(); + const dispatched = new DeferredPromise(); + let progress: IProgress | undefined; + let released = false; + const provisioned = provisionedSession(async () => { + await sending.complete(); + return dispatched.p; + }); + const { provider, chatContentProviders } = createSandboxProvider({ + providerMode, + provision: (_request, _token, progress) => { + progress?.report('Connecting to cloud container'); + return pending.p; + }, + prepare: () => preparing.p, + trackProgress: (_environmentId, _repoNwo, reporter) => { + progress = reporter; + progress.report('Cloning repository (0%)'); + void tracking.complete(); + return toDisposable(() => { released = true; }); + }, + }); + const draft = provider.createNewSession(repoWorkspace, providerMode === 'sandbox' ? CopilotSandboxSessionType.id : CopilotCloudSessionType.id); + provider.getSession(draft.sessionId)!.setUseSandbox(true); + const activity = disposables.add(provider.startNewSessionRequest(draft.sessionId)!); + const initial = draft.preparationProgress?.get()?.message; + const before = draft.mainChat.get().resource; + await provider.createNewChat(draft.sessionId); + const updates: (string | undefined)[] = []; + disposables.add(autorun(reader => updates.push(draft.preparationProgress?.read(reader)?.message))); + const request = provider.sendRequest(draft.sessionId, draft.mainChat.get().resource, { query: 'fix it' }); + const resource = draft.mainChat.get().resource; + await pending.complete(provisioned); + await tracking.p; + progress!.report('Cloning repository (58%)'); + const current = provider.getSessions()[0].preparationProgress?.get()?.message; + await preparing.complete(); + await sending.p; + await dispatched.complete(upcastPartial({ sessionId: 'committed' })); + await request; + activity.dispose(); + progress!.report('Late progress'); + + assert.deepStrictEqual({ + initial, + updates, + current, + sameResource: extUri.isEqual(resource, before), + contentProviders: chatContentProviders.length, + released, + final: draft.preparationProgress?.get(), + active: draft.isNewSessionRequestInProgress?.get(), + }, { + initial: 'Setting up cloud container', + updates: ['Setting up cloud container', 'Connecting to cloud container', 'Cloning repository (0%)', 'Cloning repository (58%)', 'Starting Copilot agent', undefined], + current: 'Cloning repository (58%)', + sameResource: true, + contentProviders: 0, + released: true, + final: undefined, + active: false, + }); + }); + + test(`${providerMode} sandbox preparation can be canceled from the existing progress surface`, async () => { + const pending = new DeferredPromise(); + const { provider } = createSandboxProvider({ providerMode, provision: () => pending.p }); + const draft = provider.createNewSession(repoWorkspace, providerMode === 'sandbox' ? CopilotSandboxSessionType.id : CopilotCloudSessionType.id); + provider.getSession(draft.sessionId)!.setUseSandbox(true); + const activity = disposables.add(provider.startNewSessionRequest(draft.sessionId)!); + const request = provider.sendRequest(draft.sessionId, draft.mainChat.get().resource, { query: 'fix it' }); + const rejected = assert.rejects(request, /Canceled/); + draft.preparationProgress?.get()?.cancel(); + await rejected; + await pending.complete(provisionedSession()); + activity.dispose(); + assert.deepStrictEqual({ + progress: draft.preparationProgress?.get(), + active: draft.isNewSessionRequestInProgress?.get(), + listed: provider.getSessions(), + }, { progress: undefined, active: false, listed: [] }); + }); + + test(`${providerMode} rejected overlapping request does not clear the active sandbox preparation`, async () => { + const pending = new DeferredPromise(); + let token: CancellationToken | undefined; + let progress: IProgress | undefined; + const { provider } = createSandboxProvider({ + providerMode, + provision: (_request, requestToken, reporter) => { + token = requestToken; + progress = reporter; + return pending.p; + }, + }); + const draft = provider.createNewSession(repoWorkspace, providerMode === 'sandbox' ? CopilotSandboxSessionType.id : CopilotCloudSessionType.id); + provider.getSession(draft.sessionId)!.setUseSandbox(true); + const firstActivity = disposables.add(provider.startNewSessionRequest(draft.sessionId)!); + const request = provider.sendRequest(draft.sessionId, draft.mainChat.get().resource, { query: 'fix it' }); + const secondActivity = disposables.add(provider.startNewSessionRequest(draft.sessionId)!); + await assert.rejects(provider.sendRequest(draft.sessionId, draft.mainChat.get().resource, { query: 'duplicate' }), /already being started/); + secondActivity.dispose(); + secondActivity.dispose(); + progress?.report('Connecting to cloud container'); + const afterRejectedRequest = { + message: draft.preparationProgress?.get()?.message, + active: draft.isNewSessionRequestInProgress?.get(), + cancelled: token?.isCancellationRequested, + }; + firstActivity.dispose(); + const stillSending = draft.isNewSessionRequestInProgress?.get(); + await pending.complete(provisionedSession()); + await request; + assert.deepStrictEqual({ + afterRejectedRequest, + stillSending, + progress: draft.preparationProgress?.get(), + active: draft.isNewSessionRequestInProgress?.get(), + }, { + afterRejectedRequest: { message: 'Connecting to cloud container', active: true, cancelled: false }, + stillSending: true, + progress: undefined, + active: false, + }); + }); + } + + test('does not start sandbox preparation for a normal Cloud session or a disabled sandbox preference', () => { + const results = []; + for (const enabled of [true, false]) { + const { provider } = createSandboxProvider({ enabled }); + const draft = provider.createNewSession(repoWorkspace, CopilotCloudSessionType.id); + provider.getSession(draft.sessionId)!.setUseSandbox(!enabled); + results.push({ + activity: provider.startNewSessionRequest(draft.sessionId), + progress: draft.preparationProgress?.get(), + active: draft.isNewSessionRequestInProgress?.get(), + }); + } + assert.deepStrictEqual(results, [{ activity: undefined, progress: undefined, active: false }, { activity: undefined, progress: undefined, active: false }]); + }); + suite('explicit browser sandbox creation', () => { + test('waits for cloud-service models even when the host already published a smaller catalog', async () => { + const cloudModel = { ...sandboxModel('claude-sonnet-4.6'), identifier: 'cloud-catalog:claude-sonnet-4.6' }; + const models = [sandboxModel('auto')]; + const provisioned = provisionedSession(undefined, () => models); + const { provider, notifications } = createSandboxProvider({ providerMode: 'sandbox', sandboxModels: [cloudModel], provision: async () => provisioned }); + const waiting = new DeferredPromise(); + const originalGetModels = provisioned.provider.getModelsSnapshot; + provisioned.provider.getModelsSnapshot = (sessionId, desired) => { + const result = originalGetModels(sessionId, desired); + if (desired && result.desiredModelResolution.kind === 'unavailable') { + waiting.complete(); + } + return result; + }; + const draft = provider.createNewSession(repoWorkspace, CopilotSandboxSessionType.id); + provider.setModel(draft.sessionId, draft.resource, cloudModel.identifier, ChatModelSource.Chosen); + const sent = provider.sendRequest(draft.sessionId, draft.resource, { query: 'hello' }); + await waiting.p; + models.push(sandboxModel('claude-sonnet-4.6')); + provisioned.modelsChanged.fire(); + await sent; + assert.deepStrictEqual({ models: provisioned.modelSelections, notifications }, { + models: [{ modelId: 'agent-host-copilot:claude-sonnet-4.6', source: ChatModelSource.CarriedOver }], notifications: [], + }); + }); + test('uses cloud models and sends mode, approvals, and reasoning before the first turn', async () => { + const schema = { type: 'object' as const, properties: { reasoningEffort: { type: 'string' as const, enum: ['low', 'high'] } } }; + const cloudModel = { ...sandboxModel('new-model'), identifier: 'cloud-catalog:new-model', metadata: { ...sandboxModel('new-model').metadata, configurationSchema: schema } }; + const hostModel = { ...sandboxModel('new-model'), metadata: { ...sandboxModel('new-model').metadata, configurationSchema: schema } }; + let sentWith: object | undefined; + let prepared = false; + const provisioned = provisionedSession(async (_sessionId, _chatResource, options) => { + sentWith = { prepared, title: provisioned.session.title.get(), sessionConfig: options.sessionConfig, configurations: [...provisioned.configurations], models: [...provisioned.modelSelections], modelConfigurations: [...provisioned.modelConfigurations] }; + return provisioned.session; + }, () => [hostModel]); + const { provider } = createSandboxProvider({ providerMode: 'sandbox', sandboxModels: [cloudModel], provision: async () => provisioned, prepare: async () => { prepared = true; } }); + const draft = provider.createNewSession(repoWorkspace, CopilotSandboxSessionType.id); + provider.setModel(draft.sessionId, draft.resource, cloudModel.identifier, ChatModelSource.Chosen); + await provider.getAutomationModelConfiguration(draft.sessionId)!.setModelConfiguration(cloudModel.identifier, { reasoningEffort: 'high' }); + await provider.setSessionConfigValue(draft.sessionId, 'mode', 'plan'); + await provider.setSessionConfigValue(draft.sessionId, 'approvalMode', 'manual'); + await provider.sendRequest(draft.sessionId, draft.resource, { query: 'make a plan' }); + assert.deepStrictEqual(sentWith, { + prepared: true, + title: 'make a plan', + sessionConfig: { mode: 'plan', approvalMode: 'manual' }, + configurations: [{ mode: 'plan', approvalMode: 'manual' }], + models: [{ modelId: hostModel.identifier, source: ChatModelSource.CarriedOver }], + modelConfigurations: [{ reasoningEffort: 'high' }], + }); + }); + + test('keeps sandbox controls and catalog out of ordinary cloud drafts', () => { + const cloudModel = sandboxModel('sandbox-only'); + const { provider } = createSandboxProvider({ sandboxModels: [cloudModel], getOptionGroups: () => cloudModelOptionGroup('ordinary-cloud', 'ordinary-cloud') }); + const draft = provider.createNewSession(repoWorkspace, CopilotCloudSessionType.id); + const session = provider.getSession(draft.sessionId)!; + session.setUseSandbox(false); + const ordinary = { models: provider.getModelsSnapshot(draft.sessionId).models.map(model => model.metadata.id), config: provider.getSessionConfig(draft.sessionId)?.values }; + session.setUseSandbox(true); + const sandbox = { models: provider.getModelsSnapshot(draft.sessionId).models.map(model => model.metadata.id), config: provider.getSessionConfig(draft.sessionId)?.values }; + session.setUseSandbox(false); + assert.deepStrictEqual({ ordinary, sandbox, restored: provider.getSessionConfig(draft.sessionId) }, { + ordinary: { models: ['ordinary-cloud'], config: undefined }, + sandbox: { models: ['sandbox-only'], config: { mode: 'interactive', approvalMode: 'assisted' } }, + restored: undefined, + }); + }); + + test('does not send when the host rejects the requested configuration', async () => { + let sent = false; + const provisioned = provisionedSession(async () => { sent = true; return provisioned.session; }); + const hostConfig = provisioned.provider.getSessionConfig('agenthost:sess-new')!; + hostConfig.schema.properties.mode.enum = ['interactive']; + const { provider } = createSandboxProvider({ providerMode: 'sandbox', provision: async () => provisioned }); + const draft = provider.createNewSession(repoWorkspace, CopilotSandboxSessionType.id); + await provider.setSessionConfigValue(draft.sessionId, 'mode', 'autopilot'); + await assert.rejects(provider.sendRequest(draft.sessionId, draft.resource, { query: 'work' }), /does not offer/); + assert.deepStrictEqual({ sent, published: provisioned.published }, { sent: false, published: ['sess-new'] }); + }); test('creates a repository-only draft without allocating or loading an extension chat', async () => { model.addSession(createMockAgentSession(URI.parse('copilot-cloud-agent:/existing-cloud'), { providerType: AgentSessionProviders.Cloud })); model.addSession(createMockAgentSession(URI.parse('copilotcli:/existing-cli'), { providerType: AgentSessionProviders.Background })); @@ -2673,7 +2999,7 @@ suite('CopilotChatSessionsProvider', () => { sent.push({ sessionId, resource: resource.toString(), query: options.query }); return upcastPartial({ sessionId, resource }); }); - const { provider, provisionRequests, cloudSends, chatContentProviders } = createSandboxProvider({ + const { provider, provisionRequests, cloudSends } = createSandboxProvider({ providerMode: 'sandbox', provision: () => pending.p, getOptionGroups: () => cloudModelOptionGroup('cloud-only-model', 'cloud-only-model'), @@ -2681,20 +3007,11 @@ suite('CopilotChatSessionsProvider', () => { const draft = provider.createNewSession(repoWorkspace, CopilotSandboxSessionType.id); const session = provider.getSession(draft.sessionId)!; session.setUseSandbox(false); - provider.setModel(draft.sessionId, draft.resource, 'cloud-only-model', ChatModelSource.Chosen); const replacements: string[] = []; disposables.add(provider.onDidReplaceSession(event => replacements.push(event.to.sessionId))); const request = provider.sendRequest(draft.sessionId, draft.resource, { query: 'fix it' }); const repeatedRequest = assert.rejects(provider.sendRequest(draft.sessionId, draft.resource, { query: 'another prompt' }), /already being started/); - const content = await chatContentProviders[0].provideChatSessionContent(draft.resource, CancellationToken.None); - const startup = { - resource: extUri.isEqual(content.sessionResource, draft.resource), - prompt: content.history[0].type === 'request' ? content.history[0].prompt : undefined, - progress: content.history[1].type === 'response' ? content.history[1].parts.map(part => part.kind === 'markdownContent' ? part.content.value : part.kind) : [], - readOnly: content.isReadOnly?.get(), - }; - let contentDisposed = false; - disposables.add(Event.once(content.onWillDispose)(() => contentDisposed = true)); + const startup = draft.preparationProgress?.get()?.message; await pending.complete(provisioned); const committed = await request; await repeatedRequest; @@ -2709,7 +3026,7 @@ suite('CopilotChatSessionsProvider', () => { committed: committed.sessionId, placeholders: provider.getSessions(), startup, - contentDisposed, + progressCleared: draft.preparationProgress?.get(), }, { provisionRequests: [{ repoNwo: 'osortega/simple-server', prompt: 'fix it' }], sent: [{ sessionId: 'agenthost:sess-new', resource: 'agent-host-copilot:/sess-new', query: 'fix it' }], @@ -2719,28 +3036,27 @@ suite('CopilotChatSessionsProvider', () => { replacements: ['agenthost:sess-new'], committed: 'agenthost:sess-new', placeholders: [], - startup: { resource: true, prompt: 'fix it', progress: ['Starting GitHub sandbox...'], readOnly: true }, - contentDisposed: true, + startup: 'Setting up cloud container', + progressCleared: undefined, }); }); - test('reopens the provisional transcript and retires it when provisioning fails', async () => { + test('clears draft preparation progress when provisioning fails', async () => { const pending = new DeferredPromise(); const { provider, chatContentProviders } = createSandboxProvider({ providerMode: 'sandbox', provision: () => pending.p }); const draft = provider.createNewSession(repoWorkspace, CopilotSandboxSessionType.id); const request = provider.sendRequest(draft.sessionId, draft.resource, { query: 'fix it' }); const rejected = assert.rejects(request, /provisioning failed/); - const contentProvider = chatContentProviders[0]; - const first = await contentProvider.provideChatSessionContent(draft.resource, CancellationToken.None); - first.dispose(); - const reopened = await contentProvider.provideChatSessionContent(draft.resource, CancellationToken.None); - let disposed = false; - disposables.add(Event.once(reopened.onWillDispose)(() => disposed = true)); + const initial = draft.preparationProgress?.get()?.message; await pending.error(new Error('provisioning failed')); await rejected; - assert.deepStrictEqual({ recreated: first !== reopened, history: reopened.history, disposed }, { recreated: true, history: first.history, disposed: true }); - await assert.rejects(contentProvider.provideChatSessionContent(draft.resource, CancellationToken.None), /no longer available/); + assert.deepStrictEqual({ + initial, + progress: draft.preparationProgress?.get(), + active: draft.isNewSessionRequestInProgress?.get(), + contentProviders: chatContentProviders.length, + }, { initial: 'Setting up cloud container', progress: undefined, active: false, contentProviders: 0 }); }); for (const [setting, disabledValue] of [[CloudSandboxEnabledSettingId, false], [RemoteAgentHostsEnabledSettingId, false], [ChatAIDisabledSettingId, true]] as const) { @@ -2761,10 +3077,15 @@ suite('CopilotChatSessionsProvider', () => { test('preserves a provisioned session after the first send fails', async () => { const provisioned = provisionedSession(async () => { throw new Error('send failed'); }); - const { provider, cloudSends } = createSandboxProvider({ providerMode: 'sandbox', provision: async () => provisioned }); + let released = false; + const { provider, cloudSends } = createSandboxProvider({ + providerMode: 'sandbox', + provision: async () => provisioned, + trackProgress: () => toDisposable(() => { released = true; }), + }); const draft = provider.createNewSession(repoWorkspace, CopilotSandboxSessionType.id); await assert.rejects(provider.sendRequest(draft.sessionId, draft.resource, { query: 'fix it' }), /send failed/); - assert.deepStrictEqual({ published: provisioned.published, placeholders: provider.getSessions(), cloudSends }, { published: ['sess-new'], placeholders: [], cloudSends: [] }); + assert.deepStrictEqual({ published: provisioned.published, placeholders: provider.getSessions(), cloudSends, released }, { published: ['sess-new'], placeholders: [], cloudSends: [], released: true }); }); test('cleans up the placeholder after provisioning fails', async () => { @@ -2788,7 +3109,7 @@ suite('CopilotChatSessionsProvider', () => { const rejected = assert.rejects(request, /Canceled/); await pending.complete(provisioned); await rejected; - assert.deepStrictEqual({ sends, published: provisioned.published, placeholders: provider.getSessions() }, { sends: 0, published: ['sess-new'], placeholders: [] }); + assert.deepStrictEqual({ sends, placeholders: provider.getSessions(), progress: draft.preparationProgress?.get() }, { sends: 0, placeholders: [], progress: undefined }); }); }); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts index d52d1bd9aaf7c2..d03b9895853735 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts @@ -30,6 +30,7 @@ import { TestStorageService } from '../../../../../../workbench/test/common/work import { IWorkbenchLayoutService } from '../../../../../../workbench/services/layout/browser/layoutService.js'; import { DEFAULT_PERMISSION_LEVELS, getPermissionLevelMeta, IPermissionPickerDelegate, PermissionPicker } from '../../browser/permissionPicker.js'; import { MobilePermissionPicker } from '../../browser/mobilePermissionPicker.js'; +import { PickerActionViewItem } from '../../../agentHost/browser/agentHostSessionConfigPicker.js'; suite('Copilot PermissionPicker', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -41,6 +42,34 @@ suite('Copilot PermissionPicker', () => { managedSandboxAllowsBypass: constObservable(false), }; + test('toolbar focus and focusability target the permission trigger rather than its slot', () => { + const container = dom.append(document.body, dom.$('div')); + store.add(toDisposable(() => container.remove())); + const configuration = new TestConfigurationService(); + store.add(configuration.onDidChangeConfigurationEmitter); + const picker = new PermissionPicker( + { getPermissionLevelMeta: (_level, meta) => meta, setPermissionLevel: () => { } }, + new class extends mock() { }(), + configuration, + new TestDialogService(), + new class extends mock() { }(), + store.add(new TestStorageService()), + NullTelemetryService, + new class extends mock() { }(), + unmanagedEnablementService, + ); + const item = store.add(new PickerActionViewItem(picker, undefined, true)); + item.render(container); + const trigger = container.querySelector('.action-label')!; + item.setFocusable(false); + const disabledTabIndex = trigger.tabIndex; + item.setFocusable(true); + item.focus(); + assert.deepStrictEqual({ disabledTabIndex, enabledTabIndex: trigger.tabIndex, focused: document.activeElement === trigger, itemFocused: item.isFocused() }, { + disabledTabIndex: -1, enabledTabIndex: 0, focused: true, itemFocused: true, + }); + }); + for (const policyRestricted of [false, true]) { test(`labels Assisted permissions as experimental on phones${policyRestricted ? ' while honoring enterprise policy' : ''}`, async () => { const container = dom.append(document.body, dom.$('.phone-layout')); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md index 333694b6f52234..02e78369e41936 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md @@ -22,6 +22,8 @@ Cloud sandbox environments are automatically trusted like Codespaces. The sandbo Remote providers may delegate deletion to their inventory owner instead of AHP. In both windows, sandbox deletion addresses the owning Mission Control task without connecting to the environment; successful deletion removes the cached session, persisted discovery inventory, and environment provider. Failed deletion retains those entries, and stale discovery responses cannot restore a successfully deleted task. +In both windows, archiving and unarchiving an inventory-owned sandbox session updates its Mission Control task without waking the environment. Local archive state changes only after the request succeeds. Discovery includes archived tasks and reconciles their archive flags even while connected; archived sessions retain their provider and history so they remain available through the archive filter. Mission Control, rather than AHP, owns these task archive flags. Additional sessions sharing the environment continue to use AHP. + In the Agents Window, renaming a discovered sandbox session updates its owning Mission Control task without waking the environment. After success, the provider updates the local title and sends an AHP rename only if already connected; offline renames are not queued for replay. Host-reported titles remain authoritative on connection. Sessions sharing the environment but not owned by the discovered task continue to use AHP. The sandbox contribution saves a minimal discovery inventory in machine-local profile storage, separately for each authentication provider and account. Once the current account is known, it restores providers and cached rows before awaiting network discovery, without waking environments. Failed or partial discovery retains unconfirmed entries. Account changes remove the previous account's providers; credential refreshes for the same account preserve them. No credentials are stored in the inventory. @@ -45,9 +47,9 @@ Provider-owned session resolution policies retain the remote address for the pro The remote Agent Host service owns client-local display-name overrides in machine-local application storage, keyed by normalized connection address. Overrides take precedence over configured or discovered names in provider and resource labels without changing connection details or routing identities; clearing an override restores the current default name. -In the Editor Window, a chat session contribution's `sessionListGroup` selects its provider filter without changing its controller, resource scheme, or content-provider routing. Disconnected discovery supplies activity, not authoritative read/archive flags or proof that the host is available. +In the Editor Window, a chat session contribution's `sessionListGroup` selects its provider filter without changing its controller, resource scheme, or content-provider routing. Disconnected discovery supplies activity and task archive flags, not authoritative read flags or proof that the host is available. -Both sandbox adapters let fresh discovery update disk-cached activity while preserving host-owned workspace information and user flags. Host-reported activity takes precedence over discovery for the rest of that adapter's lifetime, including after disconnection; older discovery responses cannot replace a newer discovery result. The Agents Window's persisted discovery baselines let title, timestamp, and project fields continue to refresh until the host changes them. Missing activity does not clear a previously reported status. Sandbox connection availability and read-only interactivity remain separate from conversation activity, so disconnection does not turn a reported input request into a conversation error. +Both sandbox adapters let fresh discovery update disk-cached activity while preserving host-owned workspace information and read flags. Host-reported activity takes precedence over discovery for the rest of that adapter's lifetime, including after disconnection; older discovery responses cannot replace a newer discovery result. The Agents Window's persisted discovery baselines let title, timestamp, and project fields continue to refresh until the host changes them. Missing activity does not clear a previously reported status. Sandbox connection availability and read-only interactivity remain separate from conversation activity, so disconnection does not turn a reported input request into a conversation error. In the Agents Window, all sandbox sessions discovered through Mission Control are non-external, regardless of which client created them or the host's external-session metadata. This classification applies to discovery, connected sessions, and cached summaries restored in any VS Code profile. diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts index 4867a703b4f349..27ce48ea0a5701 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts @@ -4,19 +4,25 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { raceCancellationError } from '../../../../../base/common/async.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { CancellationError } from '../../../../../base/common/errors.js'; -import { DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; import { isWeb } from '../../../../../base/common/platform.js'; +import { equalsIgnoreCase } from '../../../../../base/common/strings.js'; +import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { AgentSession } from '../../../../../platform/agentHost/common/agent.js'; import { ChangesetKind } from '../../../../../platform/agentHost/common/changesetUri.js'; import { CLOUD_SANDBOX_AGENT_PROVIDER, CLOUD_SANDBOX_SESSION_SCHEME, CloudSandboxAuthenticationRequiredError, cloudSandboxAddress, ICloudSandboxAgentHostService, ICloudSandboxApiService, ICloudSandboxCreatedSession, ICloudSandboxCreateSessionRequest } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; +import { readCloudSandboxProjects } from '../../../../../platform/agentHost/common/meta/cloudSandboxProjectMeta.js'; import { IRemoteAgentHostService } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { IProgress } from '../../../../../platform/progress/common/progress.js'; import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; @@ -24,6 +30,7 @@ import { CloudSandboxSessionContribution, discoveredSessionProject, ICloudSandbo import { IRemoteAgentHostConnectionCustomizationService } from '../../../../../workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostConnectionCustomization.js'; import { IChatEntitlementService } from '../../../../../workbench/services/chat/common/chatEntitlementService.js'; import { IHostService } from '../../../../../workbench/services/host/browser/host.js'; +import { getGitHubRepositoryFromRemoteUrl } from '../../../../../workbench/contrib/git/common/utils.js'; import { IAgentHostConnectionLabels, IAgentHostGroup } from '../../../../common/agentHostSessionsProvider.js'; import { IAgentHostFilterService } from '../../../../services/agentHostFilter/common/agentHostFilter.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; @@ -66,8 +73,8 @@ export class CloudSandboxAgentHostContribution extends CloudSandboxSessionContri constructor( @ICloudSandboxAgentHostService cloudSandboxService: ICloudSandboxAgentHostService, @ICloudSandboxApiService apiService: ICloudSandboxApiService, - @IRemoteAgentHostService remoteAgentHostService: IRemoteAgentHostService, - @IRemoteAgentHostConnectionCustomizationService connectionCustomizations: IRemoteAgentHostConnectionCustomizationService, + @IRemoteAgentHostService private readonly _remoteService: IRemoteAgentHostService, + @IRemoteAgentHostConnectionCustomizationService private readonly _sandboxConnectionCustomizations: IRemoteAgentHostConnectionCustomizationService, @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, @IAgentHostFilterService private readonly _agentHostFilterService: IAgentHostFilterService, @IConfigurationService configurationService: IConfigurationService, @@ -80,7 +87,7 @@ export class CloudSandboxAgentHostContribution extends CloudSandboxSessionContri @IStorageService storageService: IStorageService, @IWorkspaceTrustManagementService workspaceTrustManagementService: IWorkspaceTrustManagementService, ) { - super(cloudSandboxService, apiService, remoteAgentHostService, connectionCustomizations, configurationService, instantiationService, chatSessionsService, logService, chatEntitlementService, hostService, storageService, workspaceTrustManagementService); + super(cloudSandboxService, apiService, _remoteService, _sandboxConnectionCustomizations, configurationService, instantiationService, chatSessionsService, logService, chatEntitlementService, hostService, storageService, workspaceTrustManagementService); this._updateRegistration(); this._register(this._agentHostFilterService.registerDiscoveryHandler(() => this._discoverAndSeed())); this._register(this._agentHostFilterService.onDidChange(() => { @@ -90,6 +97,24 @@ export class CloudSandboxAgentHostContribution extends CloudSandboxSessionContri })); } + async prepareSession(environmentId: string, repoNwo: string, token: CancellationToken): Promise { + const address = cloudSandboxAddress(environmentId); + const connection = this._remoteService.getConnection(address); + if (!connection) { + throw new Error(localize('sandbox.connectionUnavailable', "The cloud sandbox connection is no longer available.")); + } + const store = new DisposableStore(); + try { + const prepare = this._sandboxConnectionCustomizations.get(address)?.createSessionPreparation?.(connection, store); + if (!prepare) { + throw new Error(localize('sandbox.preparationUnavailable', "Cloud sandbox repository preparation is not registered.")); + } + await prepare(URI.from({ scheme: Schemas.https, authority: 'github.com', path: `/${repoNwo}` }), token); + } finally { + store.dispose(); + } + } + protected override _updateRegistration(): void { if (!this._isEnabled()) { this._hostGroupRegistration.clear(); @@ -107,6 +132,7 @@ export class CloudSandboxAgentHostContribution extends CloudSandboxSessionContri const rawId = env.sessionId; const address = cloudSandboxAddress(env.environmentId); this._providerInstances.get(address)?.setTaskRenameHandler(rawId, title => this._renameSandboxSession(address, rawId, title)); + this._providerInstances.get(address)?.setTaskArchiveHandler(rawId, archived => this._setSandboxSessionArchived(address, rawId, archived)); } } @@ -137,7 +163,7 @@ export class CloudSandboxAgentHostContribution extends CloudSandboxSessionContri return this._instantiationService.createInstance(CloudSandboxSessionsProvider, config); } - async provisionSession(request: ICloudSandboxCreateSessionRequest, token: CancellationToken): Promise { + async provisionSession(request: ICloudSandboxCreateSessionRequest, token: CancellationToken, progress?: IProgress): Promise { if (!this._isEnabled()) { throw new Error('Copilot cloud sandbox connections are not enabled.'); } @@ -150,6 +176,7 @@ export class CloudSandboxAgentHostContribution extends CloudSandboxSessionContri } this._restoreAccount(accountKey); const enabledToken = this._enabledCts.token; + progress?.report(localize('sandbox.provisioningContainer', "Setting up cloud container")); const created = await this._apiService.createSession(request, token); const name = request.repoNwo ?? created.taskId; const address = cloudSandboxAddress(created.environmentId); @@ -158,6 +185,7 @@ export class CloudSandboxAgentHostContribution extends CloudSandboxSessionContri } this._provisioning.add(address); let seededProvider: CloudSandboxSessionsProvider | undefined; + let connectionAttempt: Promise | undefined; try { const now = Date.now(); this._ensureProvider({ ...created, name, repoName: request.repoNwo, updatedAt: new Date(now).toISOString() }); @@ -175,8 +203,10 @@ export class CloudSandboxAgentHostContribution extends CloudSandboxSessionContri }); seededProvider = provider; this._persistInventory(); - await this.connect({ environmentId: created.environmentId, sessionId: created.sessionId, name, connectionSource: 'created' }); - if (!this._isEnabled() || this._providerInstances.get(address) !== provider) { + progress?.report(localize('sandbox.connectingContainer', "Connecting to cloud container")); + connectionAttempt = this.connect({ environmentId: created.environmentId, sessionId: created.sessionId, name, connectionSource: 'created' }); + await raceCancellationError(connectionAttempt, token); + if (token.isCancellationRequested || !this._isEnabled() || this._providerInstances.get(address) !== provider) { throw new CancellationError(); } const session = provider.getCachedSession(created.sessionId); @@ -191,7 +221,49 @@ export class CloudSandboxAgentHostContribution extends CloudSandboxSessionContri } throw error; } finally { - this._provisioning.delete(address); + const releaseProvisioning = () => { this._provisioning.delete(address); }; + if (connectionAttempt) { + // A canceled caller must not let discovery tear down a connection that is still waking. + void connectionAttempt.then(releaseProvisioning, releaseProvisioning); + } else { + releaseProvisioning(); + } + } + } + + /** Observe repository setup until the caller finishes dispatching the first turn. */ + trackSessionCreationProgress(environmentId: string, repoNwo: string, progress: IProgress): IDisposable { + const store = new DisposableStore(); + const connection = this._remoteService.getConnection(cloudSandboxAddress(environmentId)); + if (!connection) { + return store; } + let cloningProjectId: string | undefined; + const update = () => { + const state = connection.rootState.value; + if (!state || state instanceof Error) { + return; + } + const projects = readCloudSandboxProjects(state)?.filter(project => { + const remote = project.remoteUrl && getGitHubRepositoryFromRemoteUrl(project.remoteUrl, ['github.com']); + return remote && equalsIgnoreCase(`${remote.owner}/${remote.repo}`, repoNwo); + }); + const project = projects?.find(project => project.status === 'ready') ?? projects?.find(project => project.status === 'cloning') ?? projects?.[0]; + if (project?.status === 'cloning') { + cloningProjectId = project.id; + progress.report(project.progress === undefined + ? localize('sandbox.cloningRepository', "Cloning repository") + : localize('sandbox.cloningRepositoryProgress', "Cloning repository ({0}%)", Math.round(project.progress))); + } else if (project?.status === 'ready') { + progress.report(localize('sandbox.startingAgent', "Starting Copilot agent")); + store.dispose(); + } else if (project?.status === 'failed' && project.id === cloningProjectId) { + progress.report(localize('sandbox.cloningFailed', "Repository cloning failed")); + store.dispose(); + } + }; + store.add(connection.rootState.onDidChange(update)); + update(); + return store; } } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxSessionsProvider.ts index b7915f499078d9..17bddcaf4b3892 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxSessionsProvider.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { raceCancellationError, Sequencer } from '../../../../../base/common/async.js'; import { CancellationError } from '../../../../../base/common/errors.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { constObservable } from '../../../../../base/common/observable.js'; @@ -12,7 +13,11 @@ import { localize } from '../../../../../nls.js'; import { AgentSession, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agent.js'; import { StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import type { ISession } from '../../../../services/sessions/common/session.js'; +import type { AgentHostSessionAdapter } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { CLOUD_SANDBOX_AGENT_PROVIDER } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; +import { validateSessionConfigWrite } from '../../../../../platform/agentHost/common/sessionConfigProperties.js'; /** * Sessions provider for a Copilot cloud sandbox. @@ -32,6 +37,8 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide override get automations(): undefined { return undefined; } private _taskRenameHandler: { readonly rawId: string; readonly rename: (title: string) => Promise } | undefined; + private _taskArchiveHandler: { readonly rawId: string; readonly setArchived: (archived: boolean) => Promise } | undefined; + private readonly _archiveSequencer = new Sequencer(); /** * Provisional sessions kept out of {@link getSessions} because the caller is still showing a @@ -45,10 +52,28 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide * waking a sandbox can take minutes. */ private readonly _provisionalSessions = new Map(); + private readonly _pendingSessionTitles = new Map(); /** How long a provisional session resists eviction after the host first omits it. */ static readonly PROVISIONAL_GRACE_MS = 2 * 60_000; + /** Resolves creation options without requiring the host to know the preallocated session yet. */ + async resolveInitialSessionConfig(sessionId: string, values: Record, token: CancellationToken): Promise> { + const rawId = this._rawIdFromChatId(sessionId); + const connection = this.connection; + if (!connection || !rawId || !this._sessionCache.has(rawId)) { + throw new Error(localize('cloudSandbox.configUnavailable', "The sandbox connection is unavailable. Your prompt was not sent.")); + } + const config = await raceCancellationError(connection.resolveSessionConfig({ provider: CLOUD_SANDBOX_AGENT_PROVIDER, config: values }), token); + for (const [key, value] of Object.entries(values)) { + validateSessionConfigWrite(config.schema, config.values, key, value, true); + if (config.values[key] !== value) { + throw new Error(localize('cloudSandbox.configNotApplied', "The sandbox could not apply the selected {0}. Your prompt was not sent.", key)); + } + } + return { ...values }; + } + protected override _adapterOptions() { return { ...super._adapterOptions(), @@ -71,8 +96,36 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide return `sessions.cloudSandbox.localSession.${this.id}.${rawId}`; } + protected override updateAdapter(adapter: AgentHostSessionAdapter, meta: IAgentSessionMetadata): boolean { + const changed = super.updateAdapter(adapter, meta); + const rawId = AgentSession.id(meta.session); + // Unlike discovery seeds, this metadata comes from the host's listing or session-added notification. + this._provisionalSessions.delete(rawId); + const title = this._pendingSessionTitles.get(rawId); + if (title !== undefined && this.connection) { + this._pendingSessionTitles.delete(rawId); + void super.renameSession(adapter.sessionId, title).catch(error => { + this._logService.error(`[CloudSandboxSessionsProvider] Failed to apply initial title for ${rawId}`, error); + }); + return true; + } + return changed; + } + + protected override _onBackendSessionRemoved(rawId: string): void { + super._onBackendSessionRemoved(rawId); + this._pendingSessionTitles.delete(rawId); + } + protected override _resolveArchivedState(rawId: string, isArchived: boolean): boolean { - return this._sessionCache.get(rawId)?.isArchived.get() ?? isArchived; + return this._taskArchiveHandler?.rawId === rawId + ? this._sessionCache.get(rawId)?.isArchived.get() ?? isArchived + : super._resolveArchivedState(rawId, isArchived); + } + + /** Bind the discovered session's Mission Control archive operation. */ + setTaskArchiveHandler(rawId: string, setArchived: (archived: boolean) => Promise): void { + this._taskArchiveHandler = { rawId, setArchived }; } /** Bind the discovered session's Mission Control rename operation. */ @@ -88,16 +141,18 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide } const handler = this._taskRenameHandler; if (handler?.rawId !== rawId) { - if (!this.connection) { + if (!this.connection && !this._provisionalSessions.has(rawId)) { throw new Error(localize('cloudSandbox.renameUnavailable', "Connect to the environment to rename this session.")); } - return super.renameSession(sessionId, title); - } - await handler.rename(title); - if (this._store.isDisposed || this._sessionCache.get(rawId) !== session) { - throw new CancellationError(); + } else { + await handler.rename(title); + if (this._store.isDisposed || this._sessionCache.get(rawId) !== session) { + throw new CancellationError(); + } } - if (this.connection) { + if (this._provisionalSessions.has(rawId)) { + this._pendingSessionTitles.set(rawId, title); + } else if (this.connection) { return super.renameSession(sessionId, title); } session.title.set(title, undefined); @@ -105,22 +160,45 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide } override async archiveSession(sessionId: string): Promise { - this._setLocalArchived(sessionId, true); + await this._archiveSequencer.queue(() => this._setArchived(sessionId, true)); } override async unarchiveSession(sessionId: string): Promise { - this._setLocalArchived(sessionId, false); + await this._archiveSequencer.queue(() => this._setArchived(sessionId, false)); } - private _setLocalArchived(sessionId: string, isArchived: boolean): void { + private async _setArchived(sessionId: string, isArchived: boolean): Promise { + if (this._store.isDisposed) { + throw new CancellationError(); + } const rawId = this._rawIdFromChatId(sessionId); const session = rawId ? this._sessionCache.get(rawId) : undefined; - if (!session) { + if (!session || !rawId) { throw new Error(localize('cloudSandbox.sessionNotFound', "Sandbox session not found.")); } + const handler = this._taskArchiveHandler; + if (handler?.rawId !== rawId) { + if (!this.connection) { + throw new Error(localize('cloudSandbox.archiveUnavailable', "Connect to the environment to change this session's archive state.")); + } + return isArchived ? super.archiveSession(sessionId) : super.unarchiveSession(sessionId); + } + await handler.setArchived(isArchived); + if (this._store.isDisposed || this._sessionCache.get(rawId) !== session) { + throw new CancellationError(); + } + this.setSessionArchived(rawId, isArchived); + } - // TODO: Reconcile local archive state with Mission Control so sessions are archived across clients. - session.isArchived.set(isArchived, undefined); + setSessionArchived(rawId: string, archived: boolean): void { + const session = this._sessionCache.get(rawId); + if (!session) { + throw new Error(localize('cloudSandbox.sessionNotFound', "Sandbox session not found.")); + } + if (session.isArchived.get() === archived) { + return; + } + session.isArchived.set(archived, undefined); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [session] }); } @@ -177,6 +255,7 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide const session = this._removeCachedSession(rawId); this._withheldSessions.delete(rawId); this._provisionalSessions.delete(rawId); + this._pendingSessionTitles.delete(rawId); if (session) { this._onDidChangeSessions.fire({ added: [], removed: [session], changed: [] }); session.dispose(); @@ -199,6 +278,7 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide return false; } this._provisionalSessions.delete(rawId); + this._pendingSessionTitles.delete(rawId); return true; } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts index 846d195c6d4cf3..be36fe84a1faa4 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts @@ -5,8 +5,8 @@ import assert from 'assert'; import { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; -import { CancellationToken } from '../../../../../../base/common/cancellation.js'; -import { CancellationError } from '../../../../../../base/common/errors.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; +import { CancellationError, isCancellationError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { isWeb } from '../../../../../../base/common/platform.js'; @@ -16,7 +16,7 @@ import { runWithFakedTimers } from '../../../../../../base/test/common/virtualSc import { URI } from '../../../../../../base/common/uri.js'; import { StorageValue } from '../../../../../../base/parts/storage/common/storage.js'; import { AgentSession } from '../../../../../../platform/agentHost/common/agent.js'; -import { IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IAgentConnection, IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agentService.js'; import { agentHostAuthority, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { remoteAgentHostSessionTypeId } from '../../../../../../platform/agentHost/common/agentHostSessionType.js'; import { IReplayedTaskHistory } from '../../../../../../platform/agentHost/common/taskEventReplay.js'; @@ -36,6 +36,8 @@ import { } from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { SessionStatus } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { RootStateSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; +import { RootState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { constObservable, IObservable, observableValue } from '../../../../../../base/common/observable.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { ChatAIDisabledSettingId } from '../../../../../../platform/chat/common/chatSettings.js'; @@ -56,6 +58,7 @@ import { ISessionsProvider } from '../../../../../services/sessions/common/sessi import { ISessionsProvidersService } from '../../../../../services/sessions/browser/sessionsProvidersService.js'; import { CLOUD_SANDBOX_CREATION_PROVIDER_ID, CloudSandboxAgentHostContribution } from '../../browser/cloudSandboxAgentHostContribution.js'; import { IRemoteAgentHostConnectionCustomizationService } from '../../../../../../workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostConnectionCustomization.js'; +import { createCloudSandboxSessionPreparation } from '../../../../../../workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxLegacySessionPreparation.js'; import { ICloudSandboxSessionEnvironment } from '../../../../../../workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionContribution.js'; import { IRemoteAgentHostSessionsProviderConfig } from '../../browser/remoteAgentHostSessionsProvider.js'; import { CloudSandboxSessionsProvider } from '../../browser/cloudSandboxSessionsProvider.js'; @@ -70,6 +73,22 @@ class StubProvider extends mock() { override readonly connectionStatus: IObservable = this._status; disposed = false; taskRenameHandler: { readonly rawId: string; readonly rename: (title: string) => Promise } | undefined; + taskArchiveHandler: { readonly rawId: string; readonly setArchived: (archived: boolean) => Promise } | undefined; + readonly archiveUpdates: { rawId: string; archived: boolean }[] = []; + + override setTaskArchiveHandler(rawId: string, setArchived: (archived: boolean) => Promise): void { + this.taskArchiveHandler = { rawId, setArchived }; + } + + override setSessionArchived(rawId: string, archived: boolean): void { + this.archiveUpdates.push({ rawId, archived }); + const index = this.seeded.findIndex(meta => AgentSession.id(meta.session) === rawId); + if (index !== -1) { + const meta = this.seeded[index]; + const status = meta.status ?? SessionStatus.Idle; + this.seeded[index] = { ...meta, status: archived ? status | SessionStatus.IsArchived : status & ~SessionStatus.IsArchived }; + } + } override setTaskRenameHandler(rawId: string, rename: (title: string) => Promise): void { this.taskRenameHandler = { rawId, rename }; @@ -231,6 +250,7 @@ interface ITestHarness { readonly historyRequests: string[]; readonly deletedTasks: string[]; readonly renamedTasks: { taskId: string; title: string }[]; + readonly archivedTasks: { taskId: string; archived: boolean }[]; /** Host groups currently declared to the filter service. */ readonly hostGroups: IAgentHostGroup[]; readonly discoveryModes: boolean[]; @@ -251,6 +271,7 @@ async function createContribution(store: Pick, sessions: readonly getEnvironment?: (id: string, token: CancellationToken) => Promise; readonly deleteTask?: (taskId: string, token: CancellationToken) => Promise; readonly renameTask?: (taskId: string, title: string, token: CancellationToken) => Promise; + readonly setTaskArchived?: (taskId: string, archived: boolean, token: CancellationToken) => Promise; /** Whether the sandbox feature settings start on. Defaults to `true`. */ readonly enabled?: boolean; readonly aiDisabled?: boolean; @@ -259,6 +280,7 @@ async function createContribution(store: Pick, sessions: readonly storageService?: IStorageService; readonly accountKey?: string | null; readonly waitForDiscovery?: boolean; + readonly connection?: IAgentConnection; }): Promise { const discoveryHandlers: (() => Promise)[] = []; const hostGroups: IAgentHostGroup[] = []; @@ -270,6 +292,7 @@ async function createContribution(store: Pick, sessions: const historyRequests: string[] = []; const deletedTasks: string[] = []; const renamedTasks: { taskId: string; title: string }[] = []; + const archivedTasks: { taskId: string; archived: boolean }[] = []; const onDidChangeSentiment = store.add(new Emitter()); let chatHidden = options?.chatHidden ?? false; const discoveryModes: boolean[] = []; @@ -292,6 +315,7 @@ async function createContribution(store: Pick, sessions: historyRequests, deletedTasks, renamedTasks, + archivedTasks, hostGroups, discoveryModes, changeAccount: value => { @@ -360,6 +384,10 @@ async function createContribution(store: Pick, sessions: renamedTasks.push({ taskId, title }); await options?.renameTask?.(taskId, title, token); } + override async setTaskArchived(taskId: string, archived: boolean, token: CancellationToken): Promise { + archivedTasks.push({ taskId, archived }); + await options?.setTaskArchived?.(taskId, archived, token); + } }()); instantiationService.stub(ICloudSandboxAgentHostService, new class extends mock() { override async connect(connectOptions: ICloudSandboxConnectOptions, token: CancellationToken): Promise { @@ -377,11 +405,18 @@ async function createContribution(store: Pick, sessions: override readonly connections = []; // No live protocol client is modelled, so activation stops once the connect has been made // rather than going on to wait for the host to advertise its agents. - override getConnection() { return undefined; } + override getConnection(address: string) { return address === cloudSandboxAddress('env-new') ? options?.connection : undefined; } override async removeRemoteAgentHost(): Promise { } }()); instantiationService.stub(IRemoteAgentHostConnectionCustomizationService, new class extends mock() { override register(): IDisposable { return toDisposable(() => { }); } + override get() { + return { + createSessionPreparation: (connection: IAgentConnection, owner: DisposableStore) => createCloudSandboxSessionPreparation(connection.rootState, async () => { + throw new Error('Unexpected clone request'); + }, owner), + }; + } }()); instantiationService.stub(ISessionsProvidersService, store.add(new StubSessionsProvidersService()) as unknown as ISessionsProvidersService); instantiationService.stub(IAgentHostFilterService, new class extends mock() { @@ -461,6 +496,88 @@ suite('CloudSandboxAgentHostContribution', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + test('archives the owning task without connecting and persists the flag for offline restoration', async () => { + const storage = store.add(new InMemoryStorageService()); + const harness = await createContribution(store, [discoveredSession()], { storageService: storage }); + const provider = harness.contribution.stubProviders.get(cloudSandboxAddress('env-1'))!; + await provider.taskArchiveHandler!.setArchived(true); + harness.contribution.dispose(); + const restored = await createContribution(store, [], { + storageService: storage, listSessions: async () => ({ kind: 'failed', reason: 'offline' }), + }); + assert.deepStrictEqual({ + rawId: provider.taskArchiveHandler!.rawId, + archivedTasks: harness.archivedTasks, connectedTo: harness.connectedTo, + restoredArchived: !!(restored.contribution.stubProviders.get(cloudSandboxAddress('env-1'))!.seeded[0].status! & SessionStatus.IsArchived), + }, { + rawId: 'sess-1', archivedTasks: [{ taskId: 'task-1', archived: true }], connectedTo: [], restoredArchived: true, + }); + }); + + test('reconciles task archive and unarchive updates from another client without removing the provider', async () => { + const harness = await createContribution(store, [discoveredSession()]); + const address = cloudSandboxAddress('env-1'); + const provider = harness.contribution.stubProviders.get(address)!; + harness.discovered = [discoveredSession({ isArchived: true })]; + await harness.runDiscovery(); + const archived = !!(provider.seeded[0].status! & SessionStatus.IsArchived); + harness.discovered = [discoveredSession({ isArchived: false })]; + await harness.runDiscovery(); + assert.deepStrictEqual({ + archived, unarchived: !(provider.seeded[0].status! & SessionStatus.IsArchived), + sameProvider: harness.contribution.stubProviders.get(address) === provider, disposed: provider.disposed, + }, { archived: true, unarchived: true, sameProvider: true, disposed: false }); + }); + + test('restores explicit unarchived inventory state without network discovery', async () => { + const storage: IStorageService = store.add(new InMemoryStorageService()); + const session = discoveredSession({ isArchived: true }); + const harness = await createContribution(store, [session], { storageService: storage }); + harness.discovered = [{ ...session, isArchived: false }]; + await harness.runDiscovery(); + harness.contribution.dispose(); + const restored = await createContribution(store, [], { + storageService: storage, listSessions: async () => ({ kind: 'failed', reason: 'offline' }), + }); + const key = `sessions.cloudSandbox.inventory.["github","account-1"].${JSON.stringify([session.environmentId, session.sessionId])}`; + const inventory = storage.getObject<{ sessions: ICloudSandboxDiscoveredSession[] }>(key, StorageScope.PROFILE); + assert.deepStrictEqual({ + persisted: inventory?.sessions[0].isArchived, + restoredUpdates: restored.contribution.stubProviders.get(cloudSandboxAddress(session.environmentId))!.archiveUpdates, + }, { persisted: false, restoredUpdates: [{ rawId: session.sessionId, archived: false }] }); + }); + + test('does not persist rejected task archives', async () => { + const storage = store.add(new InMemoryStorageService()); + const harness = await createContribution(store, [discoveredSession()], { + storageService: storage, setTaskArchived: async () => { throw new Error('archive rejected'); }, + }); + const provider = harness.contribution.stubProviders.get(cloudSandboxAddress('env-1'))!; + await assert.rejects(provider.taskArchiveHandler!.setArchived(true), /archive rejected/); + harness.contribution.dispose(); + const restored = await createContribution(store, [], { + storageService: storage, listSessions: async () => ({ kind: 'failed', reason: 'offline' }), + }); + assert.strictEqual(!!(restored.contribution.stubProviders.get(cloudSandboxAddress('env-1'))!.seeded[0].status! & SessionStatus.IsArchived), false); + }); + + test('cancels task archive updates when the account changes', async () => { + const pending = new DeferredPromise(); + const entered = new DeferredPromise(); + const harness = await createContribution(store, [discoveredSession()], { + setTaskArchived: async () => { + await entered.complete(); + await pending.p; + }, + }); + const provider = harness.contribution.stubProviders.get(cloudSandboxAddress('env-1'))!; + const archive = provider.taskArchiveHandler!.setArchived(true); + await entered.p; + harness.changeAccount(undefined); + await pending.complete(); + await assert.rejects(archive, CancellationError); + }); + test('renames the discovered task without connecting and persists its new inventory name', async () => { const storage = store.add(new InMemoryStorageService()); const harness = await createContribution(store, [discoveredSession()], { storageService: storage }); @@ -786,6 +903,76 @@ suite('CloudSandboxAgentHostContribution', () => { }); }); + for (const fails of [false, true]) { + test(`waits for an explicit sandbox wake before ${fails ? 'falling back to history' : 'completing activation'}`, async () => { + const harness = await createContribution(store, [discoveredSession()]); + const wake = new DeferredPromise(); + harness.onConnect = () => wake.p; + const connection = harness.contribution.connect({ environmentId: 'env-1', sessionId: 'sess-1', name: 'Sandbox' }); + const connectionResult = connection.then(() => 'connected', () => 'failed'); + let opened: boolean | undefined; + const activation = harness.activate('env-1').then(result => { opened = result; }); + await timeout(0); + + const duringWake = { opened, historyRequests: [...harness.historyRequests], servedFromHistory: harness.readOnlySessionTypes.length }; + if (fails) { + await wake.error(new Error('Expected wake failure')); + } else { + await wake.complete(); + } + await activation; + + assert.deepStrictEqual({ + duringWake, + connection: await connectionResult, + connectedTo: harness.connectedTo, + historyRequests: harness.historyRequests, + servedFromHistory: harness.readOnlySessionTypes.length, + }, { + duringWake: { opened: undefined, historyRequests: [], servedFromHistory: 0 }, + connection: fails ? 'failed' : 'connected', + connectedTo: ['env-1'], + historyRequests: fails ? ['task-1'] : [], + servedFromHistory: fails ? 1 : 0, + }); + }); + } + + test('joins a sandbox wake that starts during the environment lookup', async () => { + const environment = new DeferredPromise(); + const requested = new DeferredPromise(); + const wake = new DeferredPromise(); + const harness = await createContribution(store, [discoveredSession()], { + getEnvironment: () => { + requested.complete(); + return environment.p; + }, + }); + let opened: boolean | undefined; + const activation = harness.activate('env-1').then(result => { opened = result; }); + await requested.p; + harness.onConnect = () => wake.p; + const connection = harness.contribution.connect({ environmentId: 'env-1', sessionId: 'sess-1', name: 'Sandbox' }); + await environment.complete({ id: 'env-1', status: 'offline' }); + await timeout(0); + + const duringWake = { opened, historyRequests: [...harness.historyRequests], servedFromHistory: harness.readOnlySessionTypes.length }; + await wake.complete(); + await Promise.all([connection, activation]); + + assert.deepStrictEqual({ + duringWake, + connectedTo: harness.connectedTo, + historyRequests: harness.historyRequests, + servedFromHistory: harness.readOnlySessionTypes.length, + }, { + duringWake: { opened: undefined, historyRequests: [], servedFromHistory: 0 }, + connectedTo: ['env-1'], + historyRequests: [], + servedFromHistory: 0, + }); + }); + test('does not wake an environment whose state could not be read', async () => { const harness = await createContribution(store, [discoveredSession()], { getEnvironment: async () => { throw new Error('Expected environment lookup failure'); }, @@ -1092,7 +1279,7 @@ suite('CloudSandboxAgentHostContribution startup inventory', () => { }, { offlineTrusted: true, previousRegistrationReleased: false, - cached: [session], + cached: [{ ...session, isArchived: false }], machineKeys: [entryKey(session)], seeded: [{ id: session.sessionId, title: session.name, modifiedTime: Date.parse(session.updatedAt!), repository: session.repoName }], connected: [], history: [], @@ -1139,8 +1326,8 @@ suite('CloudSandboxAgentHostContribution startup inventory', () => { assert.deepStrictEqual({ merged, afterRemoval: readInventory(storageService), }, { - merged: [discoveredSession(), other], - afterRemoval: [other], + merged: [discoveredSession({ isArchived: false }), { ...other, isArchived: false }], + afterRemoval: [{ ...other, isArchived: false }], }); }); @@ -1229,7 +1416,7 @@ suite('CloudSandboxAgentHostContribution startup inventory', () => { }, { otherAccountRows: 0, restoredImmediately: true, hiddenOnSignOut: true, signedOutRows: 0, signedOutRequests: [], - saved: [discoveredSession()], + saved: [discoveredSession({ isArchived: false })], }); }); @@ -1297,8 +1484,8 @@ suite('CloudSandboxAgentHostContribution startup inventory', () => { currentAccount: readInventory(storageService, otherAccount), }, { cancelled: true, visible: [cloudSandboxAddress('env-2')], - previousAccount: [discoveredSession()], - currentAccount: sessions, + previousAccount: [discoveredSession({ isArchived: false })], + currentAccount: sessions.map(session => ({ ...session, isArchived: false })), }); }); @@ -1365,7 +1552,7 @@ suite('CloudSandboxAgentHostContribution startup inventory', () => { await second.runDiscovery(); assert.deepStrictEqual({ afterUpdate, afterRemoval: readInventory(shared) }, { - afterUpdate: [updated], + afterUpdate: [{ ...updated, isArchived: false }], afterRemoval: [], }); }); @@ -1656,8 +1843,9 @@ suite('CloudSandboxAgentHostContribution provisioning', () => { const harness = await createContribution(store, []); let connectionSource: ICloudSandboxConnectOptions['connectionSource']; harness.onConnect = async options => { connectionSource = options.connectionSource; }; + const progress: string[] = []; - const provisioned = await harness.contribution.provisionSession({ repoNwo: 'osortega/simple-server', prompt: 'fix it' }, CancellationToken.None); + const provisioned = await harness.contribution.provisionSession({ repoNwo: 'osortega/simple-server', prompt: 'fix it' }, CancellationToken.None, { report: message => progress.push(message) }); const provider = harness.contribution.stubProviders.get(cloudSandboxAddress('env-new')); assert.deepStrictEqual({ @@ -1668,15 +1856,138 @@ suite('CloudSandboxAgentHostContribution provisioning', () => { connectedTo: harness.connectedTo, connectionSource, resolvedSession: provisioned.session.resource.path, + progress, }, { ids: { taskId: 'task-new', sessionId: 'sess-new', environmentId: 'env-new' }, seeded: [{ session: 'copilot:/sess-new', summary: 'osortega/simple-server', project: 'osortega/simple-server' }], connectedTo: ['env-new'], connectionSource: 'created', resolvedSession: '/sess-new', + progress: ['Setting up cloud container', 'Connecting to cloud container'], }); }); + test('tracks initial and live clone percentages only for the selected repository', async () => { + const root = store.add(new RootStateSubscription('test', () => { })); + let sequence = 0; + const update = (status: string, progress?: number) => root.handleSnapshot({ + agents: [], + _meta: { 'copilot.projectManagement': { available: true } }, + config: { + schema: { type: 'object', properties: {} }, + values: { + copilot: { + projects: [ + { id: 'other', path: '/other', git: true, status: 'ready', remoteUrl: 'https://github.com/other/repo' }, + { id: 'checkout', path: '/checkout', git: true, status, progress, remoteUrl: 'git@github.com:Microsoft/VSCode.git' }, + ] + } + }, + }, + }, sequence++); + update('cloning', 0); + const harness = await createContribution(store, [], { connection: upcastPartial({ rootState: root }) }); + const messages: string[] = []; + store.add(harness.contribution.trackSessionCreationProgress('env-new', 'microsoft/vscode', { report: message => messages.push(message) })); + update('cloning', 58); + update('ready'); + update('cloning', 1); + + assert.deepStrictEqual(messages, ['Cloning repository (0%)', 'Cloning repository (58%)', 'Starting Copilot agent']); + }); + + test('reuses repository preparation and waits for the host to report the clone ready', async () => { + const root = store.add(new RootStateSubscription('test', () => { })); + const state = (status: 'cloning' | 'ready'): RootState => ({ + agents: [], + _meta: { 'copilot.projectManagement': { available: true } }, + config: { + schema: { type: 'object', properties: {} }, + values: { copilot: { projects: [{ id: 'checkout', path: '/checkout', git: true, status, remoteUrl: 'https://github.com/microsoft/vscode' }] } }, + }, + }); + root.handleSnapshot(state('cloning'), 0); + const harness = await createContribution(store, [], { connection: upcastPartial({ rootState: root }) }); + let ready = false; + const preparation = harness.contribution.prepareSession('env-new', 'microsoft/vscode', CancellationToken.None).then(() => ready = true); + await timeout(0); + const beforeReady = ready; + root.handleSnapshot(state('ready'), 1); + await preparation; + assert.deepStrictEqual({ beforeReady, afterReady: ready }, { beforeReady: false, afterReady: true }); + }); + + test('continues tracking a retry after an initially failed checkout', async () => { + const results: string[][] = []; + for (const outcome of ['ready', 'failed'] as const) { + const root = store.add(new RootStateSubscription('test', () => { })); + const state = (status: string, progress?: number): RootState => ({ + agents: [], + _meta: { 'copilot.projectManagement': { available: true } }, + config: { + schema: { type: 'object', properties: {} }, + values: { copilot: { projects: [{ id: 'checkout', path: '/checkout', git: true, status, progress, remoteUrl: 'https://github.com/microsoft/vscode' }] } }, + }, + }); + root.handleSnapshot(state('failed'), 0); + const harness = await createContribution(store, [], { connection: upcastPartial({ rootState: root }) }); + const messages: string[] = []; + store.add(harness.contribution.trackSessionCreationProgress('env-new', 'microsoft/vscode', { report: message => messages.push(message) })); + root.handleSnapshot(state('failed'), 1); + root.handleSnapshot(state('cloning', 0), 2); + root.handleSnapshot(state('cloning', 58), 3); + root.handleSnapshot(state(outcome), 4); + root.handleSnapshot(state('cloning', 1), 5); + results.push(messages); + } + assert.deepStrictEqual(results, [ + ['Cloning repository (0%)', 'Cloning repository (58%)', 'Starting Copilot agent'], + ['Cloning repository (0%)', 'Cloning repository (58%)', 'Repository cloning failed'], + ]); + }); + + test('keeps unknown clone percentages indeterminate and releases the observer on failure or disposal', async () => { + const results: string[][] = []; + for (const failed of [false, true]) { + const root = store.add(new RootStateSubscription('test', () => { })); + const state = (status: string): RootState => ({ + agents: [], + _meta: { 'copilot.projectManagement': { available: true } }, + config: { + schema: { type: 'object', properties: {} }, + values: { copilot: { projects: [{ id: 'checkout', path: '/checkout', git: true, status, remoteUrl: 'https://github.com/microsoft/vscode' }] } }, + }, + }); + root.handleSnapshot(state('cloning'), 0); + const harness = await createContribution(store, [], { connection: upcastPartial({ rootState: root }) }); + const messages: string[] = []; + const tracker = store.add(harness.contribution.trackSessionCreationProgress('env-new', 'microsoft/vscode', { report: message => messages.push(message) })); + if (failed) { + root.handleSnapshot(state('failed'), 1); + } else { + tracker.dispose(); + } + root.handleSnapshot(state('ready'), 2); + results.push(messages); + } + assert.deepStrictEqual(results, [['Cloning repository'], ['Cloning repository', 'Repository cloning failed']]); + }); + + test('does not invent clone progress for missing capabilities, projects, or connections', async () => { + const messages: string[] = []; + const root = store.add(new RootStateSubscription('test', () => { })); + root.handleSnapshot({ agents: [] }, 0); + const harness = await createContribution(store, [], { connection: upcastPartial({ rootState: root }) }); + store.add(harness.contribution.trackSessionCreationProgress('env-new', 'microsoft/vscode', { report: message => messages.push(message) })); + store.add(harness.contribution.trackSessionCreationProgress('missing', 'microsoft/vscode', { report: message => messages.push(message) })); + root.handleSnapshot({ + agents: [], + _meta: { 'copilot.projectManagement': { available: true } }, + config: { schema: { type: 'object', properties: {} }, values: { copilot: { projects: [] } } }, + }, 1); + assert.deepStrictEqual(messages, []); + }); + test('a discovery pass that cannot see the new task yet does not tear it down mid-provision', async () => { // The scan was issued before the task existed, so it reports the environment as absent. // Without the in-flight guard, reconciliation disposes the provider we are connecting to. @@ -1715,6 +2026,50 @@ suite('CloudSandboxAgentHostContribution provisioning', () => { }); }); + test('keeps cancelled provisioning protected from discovery until connection success or failure', async () => { + const results = []; + for (const success of [true, false]) { + const harness = await createContribution(store, []); + const connecting = new DeferredPromise(); + const connected = new DeferredPromise(); + let connectToken: CancellationToken | undefined; + harness.onConnect = async (_options, token) => { + connectToken = token; + await connecting.complete(); + await connected.p; + }; + const source = store.add(new CancellationTokenSource()); + const request = harness.contribution.provisionSession({ prompt: 'fix it' }, source.token); + const rejected = assert.rejects(request, isCancellationError); + await connecting.p; + source.cancel(); + await rejected; + await harness.runDiscovery(); + const provider = harness.contribution.stubProviders.get(cloudSandboxAddress('env-new')); + const beforeSettled = { + disposed: provider?.disposed, + connectCancelled: connectToken?.isCancellationRequested, + withheld: [...(provider?.withheld ?? [])], + listed: provider?.getSessions().map(session => AgentSession.id(session.resource)), + }; + const completion = harness.contribution.connect({ environmentId: 'env-new', name: 'Sandbox' }); + if (success) { + await connected.complete(); + await completion; + } else { + const failed = assert.rejects(completion, /connection failed/); + await connected.error(new Error('connection failed')); + await failed; + } + await harness.runDiscovery(); + results.push({ beforeSettled, afterSettled: provider?.disposed }); + } + assert.deepStrictEqual(results, [true, false].map(() => ({ + beforeSettled: { disposed: false, connectCancelled: false, withheld: [], listed: ['sess-new'] }, + afterSettled: true, + }))); + }); + test('rejects when the feature is disabled while the sandbox is waking', async () => { // Connecting waits out the VM boot, which is long enough for the setting to change. // Returning a provider that teardown has already disposed would send into nothing. diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHost.contribution.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHost.contribution.test.ts index 851873e25ab171..094552b53408c0 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHost.contribution.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHost.contribution.test.ts @@ -35,6 +35,7 @@ import { IRemoteAgentHostAuthenticationService, RemoteAgentHostAuthenticationSer import { RemoteAgentHostLogForwarder } from '../../../../../../workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostLogForwarder.js'; import { CloudSandboxApiService } from '../../../../../../workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.js'; import { CloudSandboxAgentHostService } from '../../../../../../workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxAgentHostService.js'; +import { createCloudSandboxConnectionCustomization } from '../../../../../../workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxConnectionCustomization.js'; import { SSHAgentHostContribution } from '../../browser/sshAgentHost.contribution.js'; import { WebSocketAgentHostContribution } from '../../browser/webSocketAgentHost.contribution.js'; import '../../browser/remoteAgentHost.contribution.js'; @@ -180,6 +181,192 @@ suite('RemoteAgentHost connection authentication readiness', () => { suite('RemoteAgentHost auth notifications', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + test('renews repeated sandbox expiry without prompting for an unchanged GitHub session', async () => { + const h = createAuthenticationHarness(store); + const forwarded: string[] = []; + const connection = h.connect(async request => { + forwarded.push(request.token); + return { authenticated: true }; + }); + let prompts = 0; + h.contribution._instantiationService.stub(ICommandService, { + executeCommand: async () => { + prompts++; + return { success: undefined } as R; + }, + }); + let renewals = 0; + const sandboxService = new class extends mock() { + override getSealedGitHubToken(): string { + return 'copilot-sealed.v1.key.cached'; + } + override async refreshSealedGitHubToken(): Promise { + return `copilot-sealed.v1.key.renewed-${++renewals}`; + } + }(); + h.contribution._connectionCustomizations = { + get: address => createCloudSandboxConnectionCustomization(address, sandboxService), + }; + const notification: INotification = { + type: NotificationType.AuthRequired, + channel: 'ahp-root://', + resource: { ...h.resource, resource: 'https://api.github.com' }, + reason: AuthRequiredReason.Expired, + }; + + await h.contribution._authenticateWithConnection(h.address, connection, [{ ...h.agents[0], protectedResources: [notification.resource] }]); + for (let i = 0; i < 3; i++) { + h.contribution._handleAuthenticationRequiredNotification(h.address, connection, notification); + await timeout(0); + } + + assert.deepStrictEqual({ forwarded, prompts, renewals }, { + forwarded: ['copilot-sealed.v1.key.cached', 'copilot-sealed.v1.key.renewed-1', 'copilot-sealed.v1.key.renewed-2', 'copilot-sealed.v1.key.renewed-3'], + prompts: 0, + renewals: 3, + }); + }); + + test('shares sandbox renewal through authentication completion without resolving GitHub sessions', async () => { + const h = createAuthenticationHarness(store); + let sessionLookups = 0; + h.contribution._instantiationService.stub(IAuthenticationService, { + getOrActivateProviderIdForServer: async () => { + sessionLookups++; + throw new Error('Sandbox renewal must not resolve a user session'); + }, + }); + const renewed = new DeferredPromise(); + const authenticated = new DeferredPromise<{ authenticated: true }>(); + const forwarded: string[] = []; + const connection = h.connect(async request => { + forwarded.push(request.token); + return authenticated.p; + }); + let renewals = 0; + const sandboxService = new class extends mock() { + override refreshSealedGitHubToken(): Promise { + renewals++; + return renewed.p; + } + }(); + h.contribution._connectionCustomizations = { + get: address => createCloudSandboxConnectionCustomization(address, sandboxService), + }; + const notification: INotification = { + type: NotificationType.AuthRequired, + channel: 'ahp-root://', + resource: { ...h.resource, resource: 'https://api.github.com' }, + reason: AuthRequiredReason.Expired, + }; + + h.contribution._handleAuthenticationRequiredNotification(h.address, connection, notification); + h.contribution._handleAuthenticationRequiredNotification(h.address, connection, notification); + await renewed.complete('copilot-sealed.v1.key.renewed'); + await timeout(0); + h.contribution._handleAuthenticationRequiredNotification(h.address, connection, notification); + await authenticated.complete({ authenticated: true }); + await timeout(0); + + assert.deepStrictEqual({ forwarded, renewals, sessionLookups }, { + forwarded: ['copilot-sealed.v1.key.renewed'], + renewals: 1, + sessionLookups: 0, + }); + }); + + test('logs failed sandbox renewal without forwarding stale credentials or prompting and allows a later retry', async () => { + const h = createAuthenticationHarness(store); + const forwarded: string[] = []; + const connection = h.connect(async request => { + forwarded.push(request.token); + return { authenticated: true }; + }); + const errors: string[] = []; + h.contribution._logService = new class extends NullLogService { + override error(message: string): void { + errors.push(message); + } + }(); + let prompts = 0; + h.contribution._instantiationService.stub(ICommandService, { + executeCommand: async () => { + prompts++; + return { success: undefined } as R; + }, + }); + let renewals = 0; + const sandboxService = new class extends mock() { + override getSealedGitHubToken(): string { + throw new Error('Must not fall back to cached credentials'); + } + override async refreshSealedGitHubToken(): Promise { + if (++renewals === 1) { + throw new Error('renewal unavailable'); + } + return 'copilot-sealed.v1.key.renewed'; + } + }(); + h.contribution._connectionCustomizations = { + get: address => createCloudSandboxConnectionCustomization(address, sandboxService), + }; + const notification: INotification = { + type: NotificationType.AuthRequired, + channel: 'ahp-root://', + resource: { ...h.resource, resource: 'https://api.github.com' }, + reason: AuthRequiredReason.Expired, + }; + + h.contribution._handleAuthenticationRequiredNotification(h.address, connection, notification); + await timeout(0); + const forwardedAfterFailure = [...forwarded]; + h.contribution._handleAuthenticationRequiredNotification(h.address, connection, notification); + await timeout(0); + + assert.deepStrictEqual({ forwardedAfterFailure, forwarded, prompts, renewals, errors }, { + forwardedAfterFailure: [], + forwarded: ['copilot-sealed.v1.key.renewed'], + prompts: 0, + renewals: 2, + errors: ['[RemoteAgentHost] Failed to authenticate notified resource https://api.github.com'], + }); + }); + + test('does not forward renewed sandbox credentials after the connection is replaced', async () => { + const h = createAuthenticationHarness(store); + const forwarded: string[] = []; + const connection = h.connect(async request => { + forwarded.push(request.token); + return { authenticated: true }; + }); + const renewed = new DeferredPromise(); + const sandboxService = new class extends mock() { + override refreshSealedGitHubToken(): Promise { + return renewed.p; + } + }(); + h.contribution._connectionCustomizations = { + get: address => ({ ...createCloudSandboxConnectionCustomization(address, sandboxService), createSessionPreparation: undefined }), + }; + const notification: INotification = { + type: NotificationType.AuthRequired, + channel: 'ahp-root://', + resource: { ...h.resource, resource: 'https://api.github.com' }, + reason: AuthRequiredReason.Expired, + }; + + h.contribution._handleAuthenticationRequiredNotification(h.address, connection, notification); + await timeout(0); + h.connect(async request => { + forwarded.push(request.token); + return { authenticated: true }; + }); + await renewed.complete('copilot-sealed.v1.key.renewed'); + await timeout(0); + + assert.deepStrictEqual(forwarded, []); + }); + test('resends the current token for an expired notification resource that is not advertised by root agents', async () => { const instantiationService = createAuthenticationInstantiationService(store); instantiationService.stub(IAuthenticationService, { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 6c309302ed536e..dbc734705813f4 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -28,6 +28,7 @@ import { AgentHostTransportFailureReason } from '../../../../../../platform/agen import { SessionArtifactType, withSessionArtifacts } from '../../../../../../platform/agentHost/common/sessionArtifacts.js'; import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ChangesetStatus, CustomizationType, MessageKind, ResponsePartKind, SessionLifecycle, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, type AgentCustomization, type AgentInfo, type AutomationState, type ChangesetFile, type ChangesetState, type ChatState, type RootState, type SessionConfigState, type SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { PROTOCOL_VERSION } from '../../../../../../platform/agentHost/common/state/protocol/version/registry.js'; import { ActionType, NotificationType, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { buildChatUri, buildDefaultChatUri, createChatState, isAhpAutomationCatalogChannel, SessionStatus as ProtocolSessionStatus, StateComponents, withSessionExternal } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; @@ -84,7 +85,7 @@ class MockAgentConnection extends mock() { private _rootStateValue: RootState = { agents: [{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as AgentInfo] }; override readonly rootState: IAgentSubscription; override readonly initializeResult = constObservable({ - protocolVersion: '1', + protocolVersion: PROTOCOL_VERSION, serverSeq: 0, snapshots: [], automations: { create: {}, schedules: {}, runCancellation: {} }, @@ -1301,7 +1302,7 @@ suite('RemoteAgentHostSessionsProvider', () => { const handle = '00000000-0000-4000-8000-000000000001'; const hostConnection = new class extends MockAgentConnection { override readonly initializeResult = constObservable({ - protocolVersion: '1', serverSeq: 0, snapshots: [], + protocolVersion: PROTOCOL_VERSION, serverSeq: 0, snapshots: [], automations: { create: {}, schedules: {}, runCancellation: {} }, _meta: getAgentHostExtensionInitializeResultMeta(), }); @@ -3462,7 +3463,7 @@ suite('CloudSandboxSessionsProvider external sessions', () => { const importedSessions: URI[] = []; const connection = disposables.add(new class extends MockAgentConnection { override readonly initializeResult = constObservable({ - protocolVersion: '1', serverSeq: 0, snapshots: [], + protocolVersion: PROTOCOL_VERSION, serverSeq: 0, snapshots: [], automations: { create: {}, schedules: {}, runCancellation: {} }, _meta: getAgentHostExtensionInitializeResultMeta(true, false, false, true), }); @@ -3540,6 +3541,37 @@ suite('CloudSandboxSessionsProvider discovery metadata', () => { }) as CloudSandboxSessionsProvider; } + test('resolves initial configuration before the host knows the provisional session', async () => { + const provider = createSandboxProvider(); + provider.seedProvisionalSession(metadata); + provider.setConnection(connection); + connection.resolveSessionConfigResult = { + schema: { + type: 'object', properties: { + mode: { type: 'string', title: 'Mode', enum: ['interactive', 'plan', 'autopilot'] }, + approvalMode: { type: 'string', title: 'Approvals', enum: ['manual', 'assisted', 'allow-all'] }, + } + }, + values: { mode: 'plan', approvalMode: 'manual' }, + }; + const session = provider.getCachedSession('discovered-session')!; + const values = await provider.resolveInitialSessionConfig(session.sessionId, { mode: 'plan', approvalMode: 'manual' }, CancellationToken.None); + assert.deepStrictEqual({ values, created: connection.createdSessionUris, dispatched: connection.dispatchedActions }, { + values: { mode: 'plan', approvalMode: 'manual' }, created: [], dispatched: [], + }); + }); + + test('rejects a host that cannot apply initial configuration', async () => { + const provider = createSandboxProvider(); + provider.seedProvisionalSession(metadata); + provider.setConnection(connection); + connection.resolveSessionConfigResult = { + schema: { type: 'object', properties: { mode: { type: 'string', title: 'Mode', enum: ['interactive', 'plan'] } } }, + values: { mode: 'interactive' }, + }; + await assert.rejects(provider.resolveInitialSessionConfig(provider.getCachedSession('discovered-session')!.sessionId, { mode: 'plan' }, CancellationToken.None), /could not apply/); + }); + test('opts out of workspace selection while retaining workspace resolution', () => { const provider = createSandboxProvider(); const uri = toAgentHostUri(URI.file('/workspace'), agentHostAuthority(provider.remoteAddress)); @@ -3971,6 +4003,8 @@ suite('CloudSandboxSessionsProvider discovery status', () => { workingDirectory: URI.file('/remote/project'), }); const first = sandbox(storage); + assert.ok(first instanceof CloudSandboxSessionsProvider); + first.setTaskArchiveHandler('discovered', async () => { }); first.seedSessions([metadata]); await first.archiveSession(first.getSessions()[0].sessionId); await storage.flush(); @@ -4072,7 +4106,7 @@ suite('CloudSandboxSessionsProvider renaming', () => { refresh(): Promise { return this._refreshSessions(); } } - function createSandbox(): { provider: TestSandboxProvider; connection: MockAgentConnection; renamed: string[] } { + function createSandbox(provisional = false): { provider: TestSandboxProvider; connection: MockAgentConnection; renamed: string[] } { const connection = store.add(new MockAgentConnection()); connection.addSession({ ...metadata, session: backendUri }); const provider = createProvider(store.add(new DisposableStore()), connection, { @@ -4081,7 +4115,11 @@ suite('CloudSandboxSessionsProvider renaming', () => { }) as TestSandboxProvider; const renamed: string[] = []; provider.setTaskRenameHandler('sandbox-session', async title => { renamed.push(title); }); - provider.seedSessions([metadata], { updateExisting: true }); + if (provisional) { + provider.seedProvisionalSession(metadata); + } else { + provider.seedSessions([metadata], { updateExisting: true }); + } return { provider, connection, renamed }; } @@ -4142,6 +4180,23 @@ suite('CloudSandboxSessionsProvider renaming', () => { assert.deepStrictEqual({ title: session.title.get(), dispatched: connection.dispatchedActions }, { title: 'Old title', dispatched: [] }); }); + test('does not queue a provisional rename rejected by Mission Control', async () => { + const { provider, connection } = createSandbox(true); + const session = provider.getCachedSession('sandbox-session')!; + provider.setTaskRenameHandler('sandbox-session', async () => { throw new Error('task rename rejected'); }); + await assert.rejects(provider.renameSession(session.sessionId, 'New title'), /task rename rejected/); + provider.setConnection(connection); + await provider.refresh(); + + assert.deepStrictEqual({ + title: session.title.get(), + dispatched: connection.dispatchedActions, + }, { + title: 'Old title', + dispatched: [], + }); + }); + test('does not update a session deleted while task rename is in flight', async () => { const { provider, connection } = createSandbox(); const pending = new DeferredPromise(); @@ -4325,19 +4380,26 @@ suite('CloudSandboxSessionsProvider archiving', () => { ensureNoDisposablesAreLeakedInTestSuite(); - function createSandboxProvider(overrides?: { storageService?: IStorageService; connectOnDemand?: () => Promise }): RemoteAgentHostSessionsProvider { - return createProvider(disposables, connection, { + function createSandboxProvider(overrides?: { storageService?: IStorageService; connectOnDemand?: () => Promise; setArchived?: (archived: boolean) => Promise }): CloudSandboxSessionsProvider { + const provider = createProvider(disposables, connection, { address: 'cloudsandbox:archive-test', sessionSchemeAlias: { ui: 'copilot', backend: 'ahp-session' }, ctor: CloudSandboxSessionsProvider, noConnection: true, ...overrides, }); + assert.ok(provider instanceof CloudSandboxSessionsProvider); + provider.setTaskArchiveHandler('sandbox-session', overrides?.setArchived ?? (async () => { })); + return provider; } - test('archives and unarchives without connecting or dispatching host actions', async () => { + test('archives and unarchives the owning task without connecting or dispatching host actions', async () => { let connectCalls = 0; - const provider = createSandboxProvider({ connectOnDemand: async () => { connectCalls++; } }); + const taskArchives: boolean[] = []; + const provider = createSandboxProvider({ + connectOnDemand: async () => { connectCalls++; }, + setArchived: async archived => { taskArchives.push(archived); }, + }); provider.seedSessions([metadata]); const session = provider.getSessions()[0]; const archivedStates: boolean[] = []; @@ -4350,13 +4412,126 @@ suite('CloudSandboxSessionsProvider archiving', () => { archivedStates, connectCalls, hostActions: connection.dispatchedActions, + taskArchives, }, { archivedStates: [false, true, false], connectCalls: 0, hostActions: [], + taskArchives: [true, false], }); }); + test('retains archive state when Mission Control rejects a mutation', async () => { + const provider = createSandboxProvider({ setArchived: async () => { throw new Error('archive rejected'); } }); + provider.seedSessions([metadata]); + const session = provider.getSessions()[0]; + await assert.rejects(provider.archiveSession(session.sessionId), /archive rejected/); + const afterArchive = session.isArchived.get(); + provider.setSessionArchived('sandbox-session', true); + await assert.rejects(provider.unarchiveSession(session.sessionId), /archive rejected/); + assert.deepStrictEqual({ afterArchive, afterUnarchive: session.isArchived.get() }, { afterArchive: false, afterUnarchive: true }); + }); + + test('serializes task mutations and changes local state only after success', async () => { + const pending = new DeferredPromise(); + const entered = new DeferredPromise(); + const mutations: boolean[] = []; + const provider = createSandboxProvider({ + setArchived: async archived => { + mutations.push(archived); + if (archived) { + await entered.complete(); + await pending.p; + } + } + }); + provider.seedSessions([metadata]); + const session = provider.getSessions()[0]; + const archive = provider.archiveSession(session.sessionId); + await entered.p; + const unarchive = provider.unarchiveSession(session.sessionId); + const before = { archived: session.isArchived.get(), mutations: [...mutations] }; + await pending.complete(); + await Promise.all([archive, unarchive]); + assert.deepStrictEqual({ before, after: { archived: session.isArchived.get(), mutations } }, { + before: { archived: false, mutations: [true] }, + after: { archived: false, mutations: [true, false] }, + }); + }); + + test('cancels queued task mutations when the provider is disposed', async () => { + const pending = new DeferredPromise(); + const entered = new DeferredPromise(); + const mutations: boolean[] = []; + const provider = createSandboxProvider({ + setArchived: async archived => { + mutations.push(archived); + await entered.complete(); + await pending.p; + }, + }); + provider.seedSessions([metadata]); + const session = provider.getSessions()[0]; + const archive = assert.rejects(provider.archiveSession(session.sessionId), CancellationError); + await entered.p; + const unarchive = assert.rejects(provider.unarchiveSession(session.sessionId), CancellationError); + provider.dispose(); + await pending.complete(); + await Promise.all([archive, unarchive]); + assert.deepStrictEqual(mutations, [true]); + }); + + test('applies archive and unarchive discoveries from another client despite conflicting host state', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const provider = createSandboxProvider(); + provider.seedSessions([metadata]); + connection.addSession({ ...metadata, session: backendResource, status: ProtocolSessionStatus.Idle }); + provider.setConnection(connection); + await timeout(0); + const session = provider.getSessions()[0]; + provider.setSessionArchived('sandbox-session', true); + connection.fireNotification({ + channel: 'ahp-root://', type: NotificationType.SessionSummaryChanged, + session: backendResource.toString(), changes: { status: ProtocolSessionStatus.Idle }, + }); + const archived = session.isArchived.get(); + provider.setSessionArchived('sandbox-session', false); + connection.fireAction({ + channel: backendResource.toString(), + action: { type: ActionType.SessionIsArchivedChanged, isArchived: true }, + serverSeq: 1, origin: undefined, + }); + assert.deepStrictEqual({ archived, unarchived: session.isArchived.get() }, { archived: true, unarchived: false }); + })); + + test('rejects offline mutations for sessions without an owning task', async () => { + const provider = createSandboxProvider(); + provider.seedSessions([createSession('additional-session')]); + const session = provider.getSessions()[0]; + await assert.rejects(provider.archiveSession(session.sessionId), /Connect to the environment/); + assert.strictEqual(session.isArchived.get(), false); + }); + + test('uses AHP for additional sessions sharing the sandbox', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const provider = createSandboxProvider(); + connection.addSession({ ...createSession('additional-session', { provider: 'copilot' }), session: AgentSession.uri('ahp-session', 'additional-session') }); + provider.setConnection(connection); + await timeout(0); + const session = provider.getSessions()[0]; + await provider.archiveSession(session.sessionId); + connection.fireAction({ + channel: 'ahp-session:/additional-session', + action: { type: ActionType.SessionIsArchivedChanged, isArchived: false }, + serverSeq: 1, origin: undefined, + }); + assert.deepStrictEqual({ + archived: session.isArchived.get(), + actions: connection.dispatchedActions.map(({ channel, action }) => ({ channel, action })), + }, { + archived: false, + actions: [{ channel: 'ahp-session:/additional-session', action: { type: ActionType.SessionIsArchivedChanged, isArchived: true } }], + }); + })); + test('restores local archive and unarchive choices through rediscovery and reconnect', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const storageService = disposables.add(new InMemoryStorageService()); const provider = createSandboxProvider({ storageService }); @@ -4539,6 +4714,120 @@ suite('CloudSandboxSessionsProvider provisional sessions', () => { await timeout(0); } + for (const source of ['session-added notification', 'host listing'] as const) { + test(`a provisional rename reaches the host after delayed creation via ${source}`, async () => { + const rawId = 'delayed-title'; + let hostTitle: string | undefined; + const rejectedTitles: string[] = []; + connection = new class extends MockAgentConnection { + override dispatch(channel: string, action: SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction): void { + if (action.type === ActionType.SessionTitleChanged) { + if (hostTitle === undefined) { + rejectedTitles.push(action.title); + } else { + hostTitle = action.title; + this.addSession(createSession(rawId, { provider: 'ahp-session', summary: hostTitle })); + } + } + super.dispatch(channel, action); + } + }(); + const provider = createProvider(disposables, connection, { + ctor: CloudSandboxSessionsProvider, + sessionSchemeAlias: { ui: 'copilot', backend: 'ahp-session' }, + noConnection: true, + }) as CloudSandboxSessionsProvider; + const metadata = createSession(rawId, { provider: 'copilot', summary: 'owner/repository' }); + provider.seedProvisionalSession(metadata); + const taskRenames: string[] = []; + provider.setTaskRenameHandler(rawId, async title => { taskRenames.push(title); }); + provider.setConnection(connection); + await timeout(0); + const session = provider.getCachedSession(rawId)!; + + await provider.renameSession(session.sessionId, 'Fix the login bug'); + provider.seedSessions([{ ...metadata, summary: 'Discovery title', modifiedTime: metadata.modifiedTime + 1 }], { updateExisting: true }); + const beforeHostCreation = { + localTitle: session.title.get(), + actions: [...connection.dispatchedActions], + taskRenames: [...taskRenames], + }; + + hostTitle = 'main'; + connection.addSession(createSession(rawId, { provider: 'ahp-session', summary: hostTitle })); + if (source === 'session-added notification') { + fireSessionAdded(connection, rawId, { provider: 'ahp-session', title: hostTitle }); + } else { + await refreshViaTurnComplete(connection, rawId); + } + await refreshViaTurnComplete(connection, rawId); + + assert.deepStrictEqual({ + beforeHostCreation, + localTitle: session.title.get(), + hostTitle, + rejectedTitles, + taskRenames, + actions: connection.dispatchedActions.map(({ channel, action }) => ({ channel, action })), + }, { + beforeHostCreation: { localTitle: 'Fix the login bug', actions: [], taskRenames: ['Fix the login bug'] }, + localTitle: 'Fix the login bug', + hostTitle: 'Fix the login bug', + rejectedTitles: [], + taskRenames: ['Fix the login bug'], + actions: [{ channel: `ahp-session:/${rawId}`, action: { type: ActionType.SessionTitleChanged, title: 'Fix the login bug' } }], + }); + }); + } + + test('a newer rename replaces the queued initial title before host creation', async () => { + const provider = createSandboxProvider(disposables, connection, { noConnection: true }); + provider.seedProvisionalSession(createSession('renamed-before-create')); + const session = provider.getCachedSession('renamed-before-create')!; + + await provider.renameSession(session.sessionId, 'First prompt'); + await provider.renameSession(session.sessionId, 'My chosen title'); + provider.setConnection(connection); + await timeout(0); + fireSessionAdded(connection, 'renamed-before-create', { title: 'main' }); + + assert.deepStrictEqual({ + title: session.title.get(), + actions: connection.dispatchedActions.map(({ action }) => action), + }, { + title: 'My chosen title', + actions: [{ type: ActionType.SessionTitleChanged, title: 'My chosen title' }], + }); + }); + + for (const removal of ['local deletion', 'host removal'] as const) { + test(`a queued title is discarded on ${removal}`, async () => { + const provider = createSandboxProvider(disposables, connection, { noConnection: true }); + const metadata = createSession('removed-before-create'); + provider.seedProvisionalSession(metadata); + const session = provider.getCachedSession('removed-before-create')!; + await provider.renameSession(session.sessionId, 'First prompt'); + provider.setConnection(connection); + await timeout(0); + + if (removal === 'local deletion') { + provider.removeDeletedSession('removed-before-create'); + } else { + fireSessionRemoved(connection, 'removed-before-create'); + } + provider.seedSessions([metadata]); + fireSessionAdded(connection, 'removed-before-create', { title: 'main' }); + + assert.deepStrictEqual({ + title: provider.getCachedSession('removed-before-create')?.title.get(), + actions: connection.dispatchedActions, + }, { + title: 'main', + actions: [], + }); + }); + } + test('a provisional session survives a host listing that does not know it yet', () => runWithFakedTimers({ useFakeTimers: true }, async () => { // The first listing after connecting can legitimately omit a just-minted session. connection.addSession(createSession('other-1', { summary: 'Someone else' })); diff --git a/src/vs/sessions/services/sessions/common/sessionsProvider.ts b/src/vs/sessions/services/sessions/common/sessionsProvider.ts index 288f3c15060791..1c734dee3aece5 100644 --- a/src/vs/sessions/services/sessions/common/sessionsProvider.ts +++ b/src/vs/sessions/services/sessions/common/sessionsProvider.ts @@ -9,6 +9,7 @@ import { IDisposable } from '../../../../base/common/lifecycle.js'; import { IObservable } from '../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; +import { ResolveSessionConfigResult } from '../../../../platform/agentHost/common/state/protocol/commands.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { ILanguageModelChatMetadataAndIdentifier, type IModelConfigurationAccess } from '../../../../workbench/contrib/chat/common/languageModels.js'; @@ -29,6 +30,16 @@ export interface ISessionChangeEvent { /** Why a session resource is being resolved, so a provider can pick a latency budget. */ export type SessionResourceResolveReason = 'open' | 'restore'; +/** Schema-backed configuration consumed by mode and approval pickers, including pre-provisioning drafts. */ +export interface ISessionConfigProvider { + readonly onDidChangeSessionConfig: Event; + getSessionConfig(sessionId: string): ResolveSessionConfigResult | undefined; + getCreateSessionConfig(sessionId: string): Record | undefined; + isSessionConfigResolving(sessionId: string): IObservable; + setSessionConfigValue(sessionId: string, property: string, value: unknown): Promise; + trackSessionConfigOperation?(sessionId: string, operation: Promise): void; +} + /** Provider-owned permission choice exposed while configuring a new session. */ export interface ISessionPermissionOption { readonly id: string; @@ -52,6 +63,8 @@ export interface IPreparedNewSession { * Options for sending a request to a session. */ export interface ISendRequestOptions { + /** Initial configuration resolved by the target provider before its first turn. */ + readonly sessionConfig?: Readonly>; /** UI-only response observation, forwarded to the chat service rather than the backend. */ readonly onDidCreateResponse?: IChatSendRequestOptions['onDidCreateResponse']; /** The query text to send. */ @@ -174,6 +187,7 @@ export interface IDeleteChatOptions { * serve the same session type (e.g., one per remote agent host). */ export interface ISessionsProvider { + readonly sessionConfig?: ISessionConfigProvider; /** * Unique identifier for the provider. */ diff --git a/src/vs/workbench/api/browser/mainThreadDebugService.ts b/src/vs/workbench/api/browser/mainThreadDebugService.ts index 53f4fc1185c7e8..205df9031d607c 100644 --- a/src/vs/workbench/api/browser/mainThreadDebugService.ts +++ b/src/vs/workbench/api/browser/mainThreadDebugService.ts @@ -28,8 +28,8 @@ export class MainThreadDebugService implements MainThreadDebugServiceShape, IDeb private readonly _toDispose = new DisposableStore(); private readonly _debugAdapters: Map; private _debugAdaptersHandleCounter = 1; - private readonly _debugConfigurationProviders: Map; - private readonly _debugAdapterDescriptorFactories: Map; + private readonly _debugConfigurationProviders = this._toDispose.add(new DisposableMap()); + private readonly _debugAdapterDescriptorFactories = this._toDispose.add(new DisposableMap()); private readonly _extHostKnownSessions: Set; private readonly _visualizerHandles = new Map(); private readonly _visualizerTreeHandles = new Map(); @@ -86,8 +86,6 @@ export class MainThreadDebugService implements MainThreadDebugServiceShape, IDeb })); this._debugAdapters = new Map(); - this._debugConfigurationProviders = new Map(); - this._debugAdapterDescriptorFactories = new Map(); this._extHostKnownSessions = new Set(); const viewModel = this.debugService.getViewModel(); @@ -273,18 +271,13 @@ export class MainThreadDebugService implements MainThreadDebugServiceShape, IDeb return this._proxy.$resolveDebugConfigurationWithSubstitutedVariables(handle, folder, config, token); }; } - this._debugConfigurationProviders.set(handle, provider); - this._toDispose.add(this.debugService.getConfigurationManager().registerDebugConfigurationProvider(provider)); + this._debugConfigurationProviders.set(handle, this.debugService.getConfigurationManager().registerDebugConfigurationProvider(provider)); return Promise.resolve(undefined); } public $unregisterDebugConfigurationProvider(handle: number): void { - const provider = this._debugConfigurationProviders.get(handle); - if (provider) { - this._debugConfigurationProviders.delete(handle); - this.debugService.getConfigurationManager().unregisterDebugConfigurationProvider(provider); - } + this._debugConfigurationProviders.deleteAndDispose(handle); } public $registerDebugAdapterDescriptorFactory(debugType: string, handle: number): Promise { @@ -295,18 +288,13 @@ export class MainThreadDebugService implements MainThreadDebugServiceShape, IDeb return Promise.resolve(this._proxy.$provideDebugAdapter(handle, this.getSessionDto(session))); } }; - this._debugAdapterDescriptorFactories.set(handle, provider); - this._toDispose.add(this.debugService.getAdapterManager().registerDebugAdapterDescriptorFactory(provider)); + this._debugAdapterDescriptorFactories.set(handle, this.debugService.getAdapterManager().registerDebugAdapterDescriptorFactory(provider)); return Promise.resolve(undefined); } public $unregisterDebugAdapterDescriptorFactory(handle: number): void { - const provider = this._debugAdapterDescriptorFactories.get(handle); - if (provider) { - this._debugAdapterDescriptorFactories.delete(handle); - this.debugService.getAdapterManager().unregisterDebugAdapterDescriptorFactory(provider); - } + this._debugAdapterDescriptorFactories.deleteAndDispose(handle); } private getSession(sessionId: DebugSessionUUID | undefined): IDebugSession | undefined { diff --git a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts index d24f1ebaf71c20..6b724dec027eae 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts @@ -1131,7 +1131,10 @@ class MainThreadPasteEditProvider implements languages.DocumentPasteEditProvider }; }), dispose: () => { - this._proxy.$releasePasteEdits(this._handle, request.id); + const cacheId = edits[0]?._cacheId?.[0]; + if (typeof cacheId === 'number') { + this._proxy.$releasePasteEdits(this._handle, cacheId); + } }, }; } finally { diff --git a/src/vs/workbench/api/common/extHostLanguageFeatures.ts b/src/vs/workbench/api/common/extHostLanguageFeatures.ts index fffb8d9c7f4570..bdf8bc6bcc9ee5 100644 --- a/src/vs/workbench/api/common/extHostLanguageFeatures.ts +++ b/src/vs/workbench/api/common/extHostLanguageFeatures.ts @@ -665,7 +665,7 @@ class DocumentPasteEditProvider { only: context.only ? new DocumentDropOrPasteEditKind(context.only) : undefined, triggerKind: context.triggerKind, }, token); - if (!edits || token.isCancellationRequested) { + if (!edits?.length || token.isCancellationRequested) { return []; } diff --git a/src/vs/workbench/api/test/browser/extHostLanguageFeatures.test.ts b/src/vs/workbench/api/test/browser/extHostLanguageFeatures.test.ts index 661cff2bd7948e..5f1836132a3775 100644 --- a/src/vs/workbench/api/test/browser/extHostLanguageFeatures.test.ts +++ b/src/vs/workbench/api/test/browser/extHostLanguageFeatures.test.ts @@ -13,6 +13,8 @@ import * as types from '../../common/extHostTypes.js'; import { createTextModel } from '../../../../editor/test/common/testTextModel.js'; import { Position as EditorPosition, Position } from '../../../../editor/common/core/position.js'; import { Range as EditorRange } from '../../../../editor/common/core/range.js'; +import { Selection } from '../../../../editor/common/core/selection.js'; +import { Cache } from '../../common/cache.js'; import { TestRPCProtocol } from '../common/testRPCProtocol.js'; import { IMarkerService } from '../../../../platform/markers/common/markers.js'; import { MarkerService } from '../../../../platform/markers/common/markerService.js'; @@ -192,6 +194,58 @@ suite('ExtHostLanguageFeatures', function () { }); } + // --- paste edits + + for (const declinedRequests of [0, 2]) { + test(`DocumentPasteEdits releases cache IDs after ${declinedRequests} declined requests`, async () => { + let requests = 0; + disposables.add(extHost.registerDocumentPasteEditProvider(defaultExtension, defaultSelector, { + provideDocumentPasteEdits: () => ++requests <= declinedRequests ? undefined : [new types.DocumentPasteEdit('pasted text', 'Test paste', types.DocumentDropOrPasteEditKind.Text)] + }, { pasteMimeTypes: ['text/plain'], providedPasteEditKinds: [types.DocumentDropOrPasteEditKind.Text] })); + await rpcProtocol.sync(); + const provider = languageFeaturesService.documentPasteEditProvider.all(model)[0]; + const release = sinon.spy(extHost, '$releasePasteEdits'); + const provide = () => provider.provideDocumentPasteEdits!(model, [new Selection(1, 1, 1, 1)], new VSDataTransfer(), { triggerKind: languages.DocumentPasteTriggerKind.Automatic }, CancellationToken.None); + try { + for (let i = 0; i < declinedRequests; i++) { + const declined = await provide(); + declined?.dispose(); + } + await rpcProtocol.sync(); + release.resetHistory(); + const first = await provide(); + assert.ok(first); + disposables.add(first); + const second = await provide(); + assert.ok(second); + disposables.add(second); + second.dispose(); + first.dispose(); + await rpcProtocol.sync(); + assert.deepStrictEqual(release.args.map(([, cacheId]) => cacheId), [2, 1]); + } finally { + release.restore(); + } + }); + } + + test('DocumentPasteEdits does not cache empty results without release IDs', async () => { + disposables.add(extHost.registerDocumentPasteEditProvider(defaultExtension, defaultSelector, { + provideDocumentPasteEdits: () => [] + }, { pasteMimeTypes: ['text/plain'], providedPasteEditKinds: [types.DocumentDropOrPasteEditKind.Text] })); + await rpcProtocol.sync(); + const provider = languageFeaturesService.documentPasteEditProvider.all(model)[0]; + const cache = sinon.spy(Cache.prototype, 'add'); + try { + const result = await provider.provideDocumentPasteEdits!(model, [new Selection(1, 1, 1, 1)], new VSDataTransfer(), { triggerKind: languages.DocumentPasteTriggerKind.Automatic }, CancellationToken.None); + result?.dispose(); + await rpcProtocol.sync(); + assert.strictEqual(cache.callCount, 0); + } finally { + cache.restore(); + } + }); + // --- outline test('DocumentSymbols, register/deregister', async () => { diff --git a/src/vs/workbench/api/test/browser/mainThreadDebugService.test.ts b/src/vs/workbench/api/test/browser/mainThreadDebugService.test.ts new file mode 100644 index 00000000000000..209831a107f2f1 --- /dev/null +++ b/src/vs/workbench/api/test/browser/mainThreadDebugService.test.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Event } from '../../../../base/common/event.js'; +import { toDisposable } from '../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { DebugConfigurationProviderTriggerKind, IAdapterManager, IConfigurationManager, IDebugAdapterDescriptorFactory, IDebugConfigurationProvider, IDebugModel, IDebugService, IViewModel } from '../../../contrib/debug/common/debug.js'; +import { IDebugVisualizerService } from '../../../contrib/debug/common/debugVisualizers.js'; +import { IExtHostContext } from '../../../services/extensions/common/extHostCustomers.js'; +import { ExtensionHostKind } from '../../../services/extensions/common/extensionHostKind.js'; +import { mock } from '../../../test/common/workbenchTestServices.js'; +import { MainThreadDebugService } from '../../browser/mainThreadDebugService.js'; + +suite('MainThreadDebugService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('disposes debug configuration provider registrations when they are unregistered', async () => { + let registrationDisposed = false; + const configurationManager = new class extends mock() { + override registerDebugConfigurationProvider(_provider: IDebugConfigurationProvider) { + return toDisposable(() => registrationDisposed = true); + } + override unregisterDebugConfigurationProvider(_provider: IDebugConfigurationProvider): void { } + }; + const model = new class extends mock() { + override readonly onDidChangeBreakpoints = Event.None; + override getBreakpoints() { return []; } + override getFunctionBreakpoints() { return []; } + override getDataBreakpoints() { return []; } + }; + const viewModel = new class extends mock() { + override readonly onDidFocusSession = Event.None; + override readonly onDidFocusThread = Event.None; + override readonly onDidFocusStackFrame = Event.None; + }; + const debugService = new class extends mock() { + override readonly onDidNewSession = Event.None; + override readonly onWillNewSession = Event.None; + override readonly onDidEndSession = Event.None; + override getConfigurationManager() { return configurationManager; } + override getModel() { return model; } + override getViewModel() { return viewModel; } + }; + const extHostContext = new class extends mock() { + override readonly remoteAuthority = ''; + override readonly extensionHostKind = ExtensionHostKind.LocalProcess; + override getProxy(): any { return {}; } + }; + const service = store.add(new MainThreadDebugService( + extHostContext, + debugService, + new class extends mock() { } + )); + + await service.$registerDebugConfigurationProvider('test', DebugConfigurationProviderTriggerKind.Initial, true, false, false, 1); + service.$unregisterDebugConfigurationProvider(1); + + assert.strictEqual(registrationDisposed, true); + }); + + test('disposes debug adapter descriptor factory registrations when they are unregistered', async () => { + let registrationDisposed = false; + const adapterManager = new class extends mock() { + override registerDebugAdapterDescriptorFactory(_factory: IDebugAdapterDescriptorFactory) { + return toDisposable(() => registrationDisposed = true); + } + override unregisterDebugAdapterDescriptorFactory(_factory: IDebugAdapterDescriptorFactory): void { } + }; + const model = new class extends mock() { + override readonly onDidChangeBreakpoints = Event.None; + override getBreakpoints() { return []; } + override getFunctionBreakpoints() { return []; } + override getDataBreakpoints() { return []; } + }; + const viewModel = new class extends mock() { + override readonly onDidFocusSession = Event.None; + override readonly onDidFocusThread = Event.None; + override readonly onDidFocusStackFrame = Event.None; + }; + const debugService = new class extends mock() { + override readonly onDidNewSession = Event.None; + override readonly onWillNewSession = Event.None; + override readonly onDidEndSession = Event.None; + override getAdapterManager() { return adapterManager; } + override getModel() { return model; } + override getViewModel() { return viewModel; } + }; + const extHostContext = new class extends mock() { + override readonly remoteAuthority = ''; + override readonly extensionHostKind = ExtensionHostKind.LocalProcess; + override getProxy(): any { return {}; } + }; + const service = store.add(new MainThreadDebugService( + extHostContext, + debugService, + new class extends mock() { } + )); + + await service.$registerDebugAdapterDescriptorFactory('test', 1); + service.$unregisterDebugAdapterDescriptorFactory(1); + + assert.strictEqual(registrationDisposed, true); + }); +}); diff --git a/src/vs/workbench/browser/chatDropdownPill.ts b/src/vs/workbench/browser/chatDropdownPill.ts index d48948a6a3be25..3f68698c80c779 100644 --- a/src/vs/workbench/browser/chatDropdownPill.ts +++ b/src/vs/workbench/browser/chatDropdownPill.ts @@ -117,8 +117,7 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { for (const [id, cached] of this._resourceDropdownHovers) { const entry = this._entries.find(entry => entry.id === id); if (!entry || !sameResourceHover(cached.entry, entry)) { - cached.hover.disposable?.dispose(); - this._resourceDropdownHovers.delete(id); + this._evictResourceHover(id, entry); } } this._summaryIcon = ThemeIcon.isThemeIcon(this._pillOptions.icon) ? this._pillOptions.icon : this._pillOptions.icon.read(reader); @@ -407,8 +406,7 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { return hover; } if (cached) { - cached.hover.disposable?.dispose(); - this._resourceDropdownHovers.delete(entry.id); + this._evictResourceHover(entry.id, entry); } if (!entry.imagePreview) { if (cacheable && baseHover) { @@ -444,6 +442,25 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { return hover; } + /** + * Releases the hover cached for an entry that changed or was removed. An entry can + * hand out the same hover disposable on every update, for content its source keeps + * alive. That hover stays cached until the entry is removed or stops carrying the + * disposable, so it is still released when the entry updates while the dropdown is closed. + */ + private _evictResourceHover(id: string, replacement: IChatPillEntry | undefined): void { + const cached = this._resourceDropdownHovers.get(id); + if (!cached) { + return; + } + const disposable = cached.hover.disposable; + if (disposable && disposable === replacement?.hover?.disposable) { + return; + } + disposable?.dispose(); + this._resourceDropdownHovers.delete(id); + } + private _prefetchEntry(entry: IChatPillEntry): void { if (!this._prefetchedEntries.has(entry.id)) { this._prefetchedEntries.add(entry.id); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 84f46b29ddea50..68b81e06066f5e 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -123,7 +123,8 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('chat.inputBlocked', 'When another Codex app is using a conversation, a banner above the input explains how to release it. You can keep editing your draft, but sending is disabled. Quit the app holding the conversation, such as ChatGPT, or exit the Codex CLI session. Use Tab to focus Retry in the banner, then press Enter or Space to check again. Retry does not send your draft.')); content.push(localize('chat.subagentPills', "Subagents appear as pills in the response. Use Tab to focus a subagent pill, then Enter or Space to open its chat to the side. If the subagent has no separate chat, Enter or Space expands or collapses its inline details instead. The pill announces its running state, model settings, and any pending confirmations. File and markdown links in its activity row can be focused with Tab and opened independently with Enter.")); content.push(localize('chat.nestedSubagentActivity', "When a subagent starts another subagent, its activity row identifies the nested subagent by name. Focus that link with Tab and press Enter or Space to open the nested subagent's chat to the side. The enclosing pill still opens the parent subagent.")); - content.push(localize('chat.vscodePet', 'Type /vscode-pet to show or hide the VS Code pet above the input. One pet appears in whichever editor or Agents window is active. Drag it around the chat with the mouse and release it to drop it, or flick it in any direction to throw it along the gesture before gravity pulls it down. Pointer collisions are ignored for half a second after a drag release. After that, while the pet is falling, move the pointer into it to bounce it upward; pointer movement and where it catches the pet affect the bounce. Sideways and upward travel do not start the bounce counter. A counter beside the pet tracks consecutive bounces and remains for up to five seconds after landing, or until the pet next reacts or interacts. Landing with at least twenty bounces triggers confetti unless reduced motion is enabled. If it falls past the input, a despawn effect appears at the bottom and a respawn effect appears at the top before it automatically returns to the input. Moving the pointer rapidly between the pet\u2019s left and right sides makes it dizzy. With the keyboard, use Tab to focus the pet, then the left and right arrows to make it hop along the input until it reaches an edge. Hold Shift with the left or right arrow to throw it toward a wall; while it is airborne, press Enter or Space to bounce it upward. Rapidly alternate the unmodified arrows to make it dizzy. Press Enter or Space while it is resting to interact with it. When an achievement unlocks, the pet shows a gold star for ten seconds; activate the pet during that time to open Achievements. Open its context menu{0} (for example Shift+F10), use the up and down arrow keys to choose Achievements, Go on the Run, Come Back, Grow, Shrink, Reset Size, Stable Colors, or Insiders Colors, and press Enter to activate the choice. Grow and Shrink change its size in twenty-percent steps, while Reset Size restores its default size. The pet position and selected size are shared across chats and windows and remembered after you restart.', '')); + content.push(localize('chat.vscodePet', 'Type /vscode-pet to show or hide Blobby, the VS Code pet, above the input. One pet appears in whichever editor or Agents window is active. Drag it around the chat with the mouse and release it to drop it, or flick it in any direction to throw it along the gesture before gravity pulls it down. Pointer collisions are ignored for half a second after a drag release. After that, while the pet is falling, move the pointer into it to bounce it upward; pointer movement and where it catches the pet affect the bounce. Sideways and upward travel do not start the bounce counter. A counter beside the pet tracks consecutive bounces and remains for up to five seconds after landing, or until the pet next reacts or interacts. Landing with at least twenty bounces triggers confetti unless reduced motion is enabled. If it falls past the input, a despawn effect appears at the bottom and a respawn effect appears at the top before it automatically returns to the input. Moving the pointer rapidly between the pet\u2019s left and right sides makes it dizzy. With the keyboard, use Tab to focus the pet, then the left and right arrows to make it hop along the input until it reaches an edge. Hold Shift with the left or right arrow to throw it toward a wall; while it is airborne, press Enter or Space to bounce it upward. Rapidly alternate the unmodified arrows to make it dizzy. Press Enter or Space while it is resting to interact with it. When an achievement unlocks, the pet shows a gold star for ten seconds; activate the pet during that time to open Achievements. Open its context menu{0} (for example Shift+F10), use the up and down arrow keys to choose Achievements, Go on the Run, Come Back, Grow, Shrink, Reset Size, or Change Color, and press Enter to activate the choice. Grow and Shrink change its size in twenty-percent steps, while Reset Size restores its default size. The pet position, size, and color are shared across chats and windows and remembered after you restart.', '')); + content.push(localize('chat.blobby', "Type /blobby to unlock the True Name achievement without sending a chat request. If Blobby is off, the command turns it on; if it is already on, it opens the Color tab in Customize Blobby. Change Color in the pet's context menu always opens Color, where Stable and Insiders are free choices. True Name unlocks solid presets and custom colors; activating that achievement also opens Color. Use the color picker or enter an opaque hex color such as #ff8800, then activate Apply Color. Unapplied custom colors are discarded when you leave the tab or close the modal.")); if (supportsFileReferences) { content.push(localize('chat.attachments.inlineReferences', 'To mention an attached context item at a specific position without removing it from the attached context, type # or @ and select the attachment from the suggestions.')); content.push(localize('chat.attachments.inlineReferenceHover', 'To inspect an inline attachment reference, place the cursor on it and invoke Show or Focus Hover{0}. Image references include a preview, while file and folder references include their path.', '')); diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts index 05ee58d5fb78b7..9a73d7fb5db10d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts @@ -24,6 +24,7 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { IUserDataProfileService } from '../../../services/userDataProfile/common/userDataProfile.js'; import type { Dto } from '../../../services/extensions/common/proxyIdentifier.js'; import { IAgentPluginRepositoryService, IEnsureRepositoryOptions, IPullRepositoryOptions } from '../common/plugins/agentPluginRepositoryService.js'; +import { getPluginCacheUri, validatePluginCacheUri } from '../common/plugins/marketplaceReference.js'; import { IMarketplacePlugin, IMarketplaceReference, IPluginSourceDescriptor, MarketplaceReferenceKind, MarketplaceType, PluginSourceKind } from '../common/plugins/pluginMarketplaceService.js'; import { IPluginSource } from '../common/plugins/pluginSource.js'; import { IPluginGitService } from '../common/plugins/pluginGitService.js'; @@ -109,9 +110,9 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi return marketplace.localRepositoryUri; } - const indexed = this._marketplaceIndex.value.get(marketplace.canonicalId); - if (indexed?.repositoryUri && this._isSupportedIndexedRepositoryUri(indexed.repositoryUri)) { - return indexed.repositoryUri; + const indexed = this._getIndexedRepositoryUri(marketplace); + if (indexed) { + return indexed; } return this._getRepoCacheDirForReference(marketplace); @@ -146,11 +147,7 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi } private async _ensureRemoteRepository(marketplace: IMarketplaceReference, options: IEnsureRepositoryOptions | undefined): Promise { - const storedIndexedRepoDir = this._marketplaceIndex.value.get(marketplace.canonicalId)?.repositoryUri; - const indexedRepoDir = storedIndexedRepoDir && this._isSupportedIndexedRepositoryUri(storedIndexedRepoDir) ? storedIndexedRepoDir : undefined; - if (storedIndexedRepoDir && !indexedRepoDir) { - this._removeMarketplaceIndex(marketplace); - } + const indexedRepoDir = this._getIndexedRepositoryUri(marketplace); const primaryRepoDir = this._getRepoCacheDirForReference(marketplace); const fallbackRepoDir = marketplace.ref ? undefined : this._getMarketplaceVariantCacheDir(marketplace, 'default'); const candidates: URI[] = []; @@ -264,8 +261,22 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi } } - private _isSupportedIndexedRepositoryUri(repositoryUri: URI): boolean { - return isEqualOrParent(repositoryUri, this._cacheRoot) || isEqualOrParent(repositoryUri, this._legacyCacheRoot); + private _getIndexedRepositoryUri(marketplace: IMarketplaceReference): URI | undefined { + const indexed = this._marketplaceIndex.value.get(marketplace.canonicalId); + if (!indexed?.repositoryUri) { + return undefined; + } + let validationError: unknown; + for (const root of [this._cacheRoot, this._legacyCacheRoot]) { + try { + return validatePluginCacheUri(root, indexed.repositoryUri); + } catch (error) { + validationError = error; + } + } + this._logService.warn(`[AgentPluginRepositoryService] Discarding invalid cached marketplace location for ${marketplace.displayLabel}`, validationError); + this._removeMarketplaceIndex(marketplace); + return undefined; } /** @@ -417,12 +428,12 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi } return this._getMarketplaceVariantCacheDir(reference, refSegment); } - return joinPath(this._cacheRoot, ...reference.cacheSegments); + return getPluginCacheUri(this._cacheRoot, reference.cacheSegments); } private _getMarketplaceVariantCacheDir(reference: IMarketplaceReference, variant: string): URI { const baseSegments = reference.ref ? reference.cacheSegments.slice(0, -1) : reference.cacheSegments; - return joinPath(this._cacheRoot, MARKETPLACE_VARIANT_CACHE_SEGMENT, ...baseSegments, variant); + return getPluginCacheUri(this._cacheRoot, [MARKETPLACE_VARIANT_CACHE_SEGMENT, ...baseSegments, variant]); } private _loadMarketplaceIndex(): Map { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts index 172ff311b4d8f7..4ed879c4ca7611 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { raceCancellation, raceTimeout } from '../../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; @@ -33,6 +34,7 @@ import { getAgentHostPluginEnablementActions } from '../../agentPluginActions.js const REMOTE_HOST_GROUP = 'remote-host'; const REMOTE_CLIENT_GROUP = 'remote-client'; +const SOURCE_FOLDER_CUSTOMIZATION_READINESS_TIMEOUT_MS = 2000; type PluginMeta = { item: ICustomizationItem; nonce: string | undefined; status: ReturnType; statusMessage: string | undefined; enabled: boolean | undefined; disabledReason: CustomizationDisabledReason | undefined; childGroupKey: string; isBundleItem: boolean; pluginLabel: string | undefined }; @@ -44,7 +46,9 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto /** Cache: pluginUri → last expansion (keyed by nonce and label so we re-fetch on content or display-name changes). */ private readonly _expansionCache = new ResourceMap<{ nonce: string | undefined; pluginLabel: string | undefined; children: readonly ICustomizationItem[] }>(); + private readonly _sourceFolderReadiness = new ResourceMap>(); private readonly _contentExpander: AgentCustomizationContentExpander; + protected readonly _sourceFolderReadinessTimeoutMs: number = SOURCE_FOLDER_CUSTOMIZATION_READINESS_TIMEOUT_MS; private _draftCustomAgents: IObservable | undefined; private _draftCustomizations: IObservable | undefined; @@ -196,7 +200,15 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto // One-shot callers (the migration hint) must not read the empty // placeholder a still-loading session reports, or they conclude there is // nothing to migrate. - await this._customAgentsService.whenCustomizationsReady(sessionResource, token); + let readiness = this._sourceFolderReadiness.get(sessionResource); + if (!readiness) { + readiness = raceTimeout( + this._customAgentsService.whenCustomizationsReady(sessionResource), + this._sourceFolderReadinessTimeoutMs, + ); + this._sourceFolderReadiness.set(sessionResource, readiness); + } + await raceCancellation(readiness, token); const workingDirectories = this._customAgentsService.getWorkingDirectories(sessionResource); const clientWorkingDirectories = this._customAgentsService.getClientWorkingDirectoryUris(sessionResource); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts index 3619d84e3d65fb..e6e9ce60b2bc63 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts @@ -14,7 +14,7 @@ import { equals } from '../../../../../../base/common/objects.js'; import { autorun, derived, IObservable, observableValue, transaction } from '../../../../../../base/common/observable.js'; import { type IExtUri } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { isRemoteAgentHostSessionType } from '../../../../../../platform/agentHost/common/agentHostSessionType.js'; +import { isCopilotAgentHostSessionType, isRemoteAgentHostSessionType } from '../../../../../../platform/agentHost/common/agentHostSessionType.js'; import type { AgentCustomization, SessionActiveClient, ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import type { ClientPluginCustomization } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { CLIENT_SEMANTIC_SEARCH_REFERENCE_NAME, CLIENT_SEMANTIC_SEARCH_TOOL_ID, CopilotSemanticSearchEnabledSettingId, SEMANTIC_SEARCH_TOOL_NAME } from '../../../../../../platform/agentHost/common/semanticSearchConstants.js'; @@ -380,7 +380,7 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo const tools = this._allToolsObs.read(reader); const toolSets = this._allToolSetsObs.read(reader); const enablement = this._toolSetEnablementService.observe(sessionType).read(reader); - const isCopilotSession = isCopilotCliSessionType(sessionType); + const isCopilotSession = isCopilotAgentHostSessionType(sessionType); const semanticSearchEnabled = isCopilotSession && this._semanticSearchEnabled.read(reader); const semanticSearchTool = isCopilotSession ? tools.find(tool => tool.id === CLIENT_SEMANTIC_SEARCH_TOOL_ID) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts index edf88fd0ca34bc..357df259061861 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts @@ -255,9 +255,15 @@ export class AgentHostAuthenticationRecovery { private async _recover(accessor: ServicesAccessor, key: string, resource: ProtectedResourceMetadata, options: IResolvedAgentHostAuthenticationOptions): Promise { throwIfAuthenticationStale(options); + const logService = accessor.get(ILogService); + if (options.renewAuthentication) { + await options.renewAuthentication(resource); + throwIfAuthenticationStale(options); + logService.info(`${options.logPrefix} Renewed authentication for resource: ${resource.resource}`); + return; + } const authenticationService = accessor.get(IAuthenticationService); const commandService = accessor.get(ICommandService); - const logService = accessor.get(ILogService); const scopes = resource.scopes_supported ?? []; const quarantinePresent = options.authTokenCache?.getRejectedSession(resource.resource, scopes) !== undefined; const resolution = await resolveSessionForProtectedResource(authenticationService, logService, resource, options, null); @@ -473,6 +479,8 @@ export interface IAgentHostAuthenticationOptions { readonly authTokenCache?: AgentHostAuthTokenCache; readonly logPrefix: string; readonly isCurrent?: () => boolean; + /** Renews a connection-owned credential instead of retrying or replacing the user's authentication session. */ + readonly renewAuthentication?: (resource: ProtectedResourceMetadata) => Promise; readonly authenticate: (request: IAgentHostAuthenticateRequest) => Promise; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts index 8d84ec8aebf0c8..c413c19d10fded 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../../../base/common/uri.js'; -import { raceCancellation, raceTimeout } from '../../../../../../base/common/async.js'; +import { raceCancellation } from '../../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { StringSHA1 } from '../../../../../../base/common/hash.js'; @@ -16,7 +16,7 @@ import { IAgentConnection } from '../../../../../../platform/agentHost/common/ag import { IAgentHostResourceUriMapper } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { AMBIENT_AGENT_HOST_AUTHORITY, IAgentHostConnectionsService, IAgentHostSessionResolution } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { getEffectiveAgents } from '../../../../../../platform/agentHost/common/customAgents.js'; -import { readMcpServerSource } from '../../../../../../platform/agentHost/common/meta/mcpCustomizationMeta.js'; +import { readMcpServerDisplayName, readMcpServerSource } from '../../../../../../platform/agentHost/common/meta/mcpCustomizationMeta.js'; import { getCustomizationDisabledReason, isCustomizationEnabled, withCustomizationEnablement } from '../../../../../../platform/agentHost/common/customizationEnablement.js'; import { type IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; @@ -39,6 +39,12 @@ import { ContributionEnablementState } from '../../../common/enablement.js'; export const IAgentHostCustomizationService = createDecorator('agentHostCustomizationService'); +export function getMcpServerDisplayLabel(server: Pick): string { + return server.displayName + ? localize('agentHost.mcpServer.connectorDisplayName', "{0} (Connector)", server.displayName) + : server.name; +} + export interface IAgentHostCustomizationService { readonly _serviceBrand: undefined; readonly onDidChangeCustomAgents: Event; @@ -49,11 +55,11 @@ export interface IAgentHostCustomizationService { getCustomizations(sessionResource: URI): readonly Customization[]; /** - * Waits up to two seconds for {@link getCustomizations} to reflect the session's first state snapshot; it may resolve earlier on cancellation, failure, or when no agent-host session exists. - * The wait is shared per session, so repeated calls observe one deadline rather than restarting it, and resolve immediately once it has elapsed. - * Intended for one-shot reads; reactive callers should continue listening to {@link onDidChangeCustomizations}. + * Waits for {@link getCustomizations} to reflect the session's first state snapshot, or until the caller cancels or the subscription fails. + * The readiness wait is shared per session; callers choose their own cancellation or timeout policy. + * Returns whether a session-state snapshot is available. */ - whenCustomizationsReady(sessionResource: URI, token?: CancellationToken): Promise; + whenCustomizationsReady(sessionResource: URI, token?: CancellationToken): Promise; /** * The harness-owned decision about the multi-root Folder picker for a @@ -121,8 +127,8 @@ export class NullAgentHostCustomizationService implements IAgentHostCustomizatio getCustomizations(_sessionResource: URI): readonly Customization[] { return []; } - whenCustomizationsReady(_sessionResource: URI, _token?: CancellationToken): Promise { - return Promise.resolve(); + whenCustomizationsReady(_sessionResource: URI, _token?: CancellationToken): Promise { + return Promise.resolve(true); } getFolderPickerDecision(_sessionResource: URI): ISessionFolderPickerDecision | undefined { return undefined; @@ -212,8 +218,8 @@ export abstract class AbstractAgentHostCustomizationService extends Disposable i * Targets resolved by this base are backed by already-materialized provider * state, so a snapshot is available as soon as the target resolves. */ - whenCustomizationsReady(_sessionResource: URI, _token?: CancellationToken): Promise { - return Promise.resolve(); + whenCustomizationsReady(_sessionResource: URI, _token?: CancellationToken): Promise { + return Promise.resolve(false); } getFolderPickerDecision(sessionResource: URI): ISessionFolderPickerDecision | undefined { @@ -250,6 +256,7 @@ export abstract class AbstractAgentHostCustomizationService extends Disposable i return { id: this._scopedMcpServerId(sessionResource, server.id), name: server.name, + displayName: readMcpServerDisplayName(server), source: readMcpServerSource(server), enabled: isCustomizationEnabled(server) && (!plugin || isCustomizationEnabled(plugin)), enablement: server.enablement, @@ -341,13 +348,14 @@ export abstract class AbstractAgentHostCustomizationService extends Disposable i if (!server || server.state.kind !== McpServerStatus.AuthRequired) { return false; } + const displayName = readMcpServerDisplayName(server); try { await target.startMcpServer(server.id); return await this._instantiationService.invokeFunction(resolveMcpServerAuthentication, server.state.resource, { allowInteraction: true, logPrefix: '[AgentHost]', mcpServerId: agentHostMcpServerId(sessionResource.authority, server.name, server.state.resource.resource), - mcpServerName: server.name, + mcpServerName: getMcpServerDisplayLabel({ name: server.name, displayName }), mcpServerUrl: server.state.resource.resource, oauthClient: server.state.oauthClient, scopes: server.state.requiredScopes ?? [], @@ -478,11 +486,9 @@ export function getPresentableMcpServerCustomizations(customizations: readonly C return entries.filter(entry => entry.isTopLevel || !topLevelNames.has(entry.server.name)); } -/** - * Upper bound on how long {@link WorkbenchAgentHostCustomizationService.whenCustomizationsReady} - * waits for a session's first state snapshot. - */ -const SESSION_STATE_SNAPSHOT_TIMEOUT_MS = 2000; +function hasSessionSnapshot(subscription: IAgentSubscription): boolean { + return subscription.value !== undefined && !(subscription.value instanceof Error); +} /** * A live session-state subscription plus the memoized readiness wait shared by @@ -500,9 +506,6 @@ export class WorkbenchAgentHostCustomizationService extends AbstractAgentHostCus private readonly _sessionStateSubscriptions = this._register(new DisposableResourceMap()); - /** Overridable so tests can exercise the timeout without real-time waits. */ - protected readonly _snapshotTimeoutMs: number = SESSION_STATE_SNAPSHOT_TIMEOUT_MS; - constructor( @IAgentHostConnectionsService private readonly _connectionsService: IAgentHostConnectionsService, @IAgentHostUntitledProvisionalSessionService private readonly _provisionalSessionService: IAgentHostUntitledProvisionalSessionService, @@ -648,28 +651,24 @@ export class WorkbenchAgentHostCustomizationService extends AbstractAgentHostCus * Session state arrives asynchronously over the protocol, so a freshly * created subscription reports `undefined` until its first snapshot lands. * - * The wait is memoized per subscription so that the many source-folder - * queries behind a single migration hint observe one shared deadline rather - * than restarting it per prompt type. It is bounded because the chat request - * path blocks on this before sending the user's message: once it elapses, - * callers fall back to the current (possibly empty) snapshot rather than - * stalling the send again on every subsequent query. + * The wait is memoized per subscription so that callers can apply their own + * cancellation or timeout policy without affecting other consumers. */ - override async whenCustomizationsReady(sessionResource: URI, token: CancellationToken = CancellationToken.None): Promise { + override async whenCustomizationsReady(sessionResource: URI, token: CancellationToken = CancellationToken.None): Promise { const target = this._resolveSessionTarget(sessionResource); if (!target) { - return; + return false; } const entry = this._ensureSessionStateSubscription(sessionResource, target); - // An `Error` value counts as resolved: the subscription settled, just not with a snapshot. if (!entry || entry.sub.value !== undefined) { - return; + return !!entry && hasSessionSnapshot(entry.sub); } // Each caller races the shared wait against its own token, so one // cancellation cannot settle the wait for the others. entry.readiness ??= this._awaitFirstSnapshot(entry.sub); await raceCancellation(entry.readiness, token); + return hasSessionSnapshot(entry.sub); } private async _awaitFirstSnapshot(subscription: IAgentSubscription): Promise { @@ -682,7 +681,7 @@ export class WorkbenchAgentHostCustomizationService extends AbstractAgentHostCus store.add(onDidError(() => resolve())); } }); - await raceTimeout(firstSnapshot, this._snapshotTimeoutMs); + await firstSnapshot; } finally { store.dispose(); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts index 30c3274c94d84c..9d658d255624e8 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts @@ -57,6 +57,7 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu readonly onDidChange = this._onDidChange.event; private _models: readonly SessionModelInfo[] = []; + private _additionalModels: readonly SessionModelInfo[] = []; constructor( private readonly _sessionType: string, @@ -89,8 +90,15 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu this._onDidChange.fire(); } + /** Adds service-discovered models without overriding metadata or policy published by the host. */ + updateAdditionalModels(models: readonly SessionModelInfo[]): void { + this._additionalModels = models; + this._onDidChange.fire(); + } + async provideLanguageModelChatInfo(_options: unknown, _token: CancellationToken): Promise { - return this._models + const hostModelIds = new Set(this._models.map(model => model.id)); + return [...this._models, ...this._additionalModels.filter(model => !hostModelIds.has(model.id))] .filter(m => m.policyState !== 'disabled') .map(m => { const pricing = readAgentModelPricingMeta(m); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index c2d81421f04bce..5ab58c0722f061 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -34,6 +34,7 @@ import { IModelService } from '../../../../../../editor/common/services/model.js import { localize } from '../../../../../../nls.js'; import { AgentHostAllowSignedOutWhenUsableSettingId, AgentHostMcpToolRoutingEnabledSettingId, AgentProvider, AgentSession, CODEX_AGENT_PROVIDER_ID, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { agentHostAuthority, LOCAL_AGENT_HOST_AUTHORITY } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { isCopilotAgentHostProvider, isCopilotAgentHostSessionType } from '../../../../../../platform/agentHost/common/agentHostSessionType.js'; import { isCustomizationEnabled } from '../../../../../../platform/agentHost/common/customizationEnablement.js'; import { findDeepestContainingWorkingDirectory } from '../../../../../../platform/agentHost/common/agentHostWorkingDirectories.js'; import { AgentHostElementAttachmentDisplayKind, getElementAttachmentCorrelationId, toElementAttachmentMeta } from '../../../../../../platform/agentHost/common/meta/agentElementAttachments.js'; @@ -44,6 +45,7 @@ import { filterSessionConfigValues } from '../../../../../../platform/agentHost/ import { readAttachmentDetail, withAttachmentDetail } from '../../../../../../platform/agentHost/common/meta/attachmentMeta.js'; import { AgentPermissionDecisionSource, readAgentPermissionResponseMeta, toAgentPermissionResponseMeta } from '../../../../../../platform/agentHost/common/meta/agentPermissionResponseMeta.js'; import { readCompletionAttachmentMeta } from '../../../../../../platform/agentHost/common/meta/agentCompletionAttachmentMeta.js'; +import { readSlashCommandResource, type ISlashCommandResource } from '../../../../../../platform/agentHost/common/meta/agentSlashCommandOutputMeta.js'; import { IRemoteAgentHostService } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { resolveAgentHostSessionTrustFolders } from '../../../../../../platform/agentHost/common/agentHostWorkspaceTrust.js'; @@ -67,6 +69,7 @@ import { packErrorForTelemetry } from '../../../../../../platform/telemetry/comm import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { IWorkbenchAssignmentService } from '../../../../../services/assignment/common/assignmentService.js'; import { IPathService } from '../../../../../services/path/common/pathService.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { IAgentHostTerminalService } from '../../../../terminal/browser/agentHostTerminalService.js'; @@ -92,7 +95,7 @@ import { coerceImageBuffer } from '../../../common/chatImageExtraction.js'; import { ChatErrorLevel, ChatRequestQueueKind, ConfirmedReason, ElicitationState, IChatProgress, IChatQuestionAnswers, IChatService, IChatToolInvocation, IRemotePendingRequest, ToolConfirmKind, type IChatAutoModeResolutionPart, type IChatMcpAuthenticationRequired, type IChatMcpAuthenticationRequiredServer, type IChatMcpStartingServer, type IChatMultiSelectAnswer, type IChatPlanReviewResult, type IChatResponseErrorDetails, type IChatSingleSelectAnswer, type IChatTerminalToolInvocationData, type IChatToolInvocationSerialized } from '../../../common/chatService/chatService.js'; import { isInConversationModelChoice } from '../../../common/modelSelection.js'; import { readAgentRuntimeModelConfiguration } from '../../../../../../platform/agentHost/common/meta/agentModelConfigurationMeta.js'; -import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionRequestHistoryItem, isTerminalCommandPrompt, SessionType, type IChatInputCompletionItem, type IChatInputCompletionsParams, type IChatInputCompletionsResult, type IChatSessionServerRequest } from '../../../common/chatSessionsService.js'; +import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionRequestHistoryItem, isTerminalCommandPrompt, type IChatInputCompletionItem, type IChatInputCompletionsParams, type IChatInputCompletionsResult, type IChatSessionServerRequest } from '../../../common/chatSessionsService.js'; import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { IWorkingCopyService } from '../../../../../services/workingCopy/common/workingCopyService.js'; import { ChatMode } from '../../../common/chatModes.js'; @@ -110,7 +113,7 @@ import { ILanguageModelToolsService, IToolData, IToolResult, stringifyPromptTsxP import { IChatWidgetService } from '../../chat.js'; import { getAgentSessionProviderIcon } from '../agentSessions.js'; import { IAgentCustomizationScope, IAgentHostActiveClientService } from './agentHostActiveClientService.js'; -import { IAgentHostCustomizationService } from './agentHostCustomizationService.js'; +import { getMcpServerDisplayLabel, IAgentHostCustomizationService } from './agentHostCustomizationService.js'; import { IAgentHostSessionWorkingDirectoryResolver } from './agentHostSessionWorkingDirectoryResolver.js'; import { IAgentHostSessionWorkingDirectorySynchronizer } from './agentHostSessionWorkingDirectorySynchronizer.js'; import { IAgentHostShellInitSynchronizer } from './agentHostShellInitSynchronizer.js'; @@ -125,7 +128,6 @@ import { IChatResponseFileChangesService } from '../../chatResponseFileChangesSe import { AgentHostSessionReferenceAttachmentDisplayKind, AgentHostSessionReferenceTrajectoryAttachmentDisplayKind, toSessionReferenceAttachmentMeta, toSessionReferenceModelRepresentation } from './agentHostSessionReferenceAttachment.js'; import { buildHostLocalEventsPath } from '../../copilotCliEventsUri.js'; import { toolDataToDefinition } from './agentHostToolUtils.js'; -import { isCopilotCliSessionType } from './agentHostToolSetEnablementService.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; import { IAgentHostImportConversationStore } from './agentHostImportConversationStore.js'; import { activeTurnToProgress, appendToolOutput, BOOLEAN_TRUE_OPTION_ID, canOwnSubagentChat, completedToolCallToEditParts, completedToolCallToSerialized, containsAutomaticReplyAnswer, convertProtocolAnswers, convertProtocolPlanReviewResult, createInputRequestCarousel, createInputRequestPlanReview, finalizeToolInvocation, formatTurnResponseDetails, getAgentHostActivityProgressId, getTerminalContent, getUrlInputRequestPresentation, isSubagentTool, makeAhpTerminalToolSessionId, messageAttachmentsToVariableData, messageToRequestOrigin, messageToRequestSource, messageToVariableData, parseAhpTerminalToolSessionId, rewriteAgentHostLinkTarget, shouldObserveSubagentChat, stringOrMarkdownToString, systemNotificationToChatPart, toolCallAuthenticationServer, toolCallStateToInvocation, toolCallStateToPreparedInvocation, toolCallStateToStreamingInvocation, turnsToHistory, turnToResponseDetails, updateRunningToolSpecificData, updateStreamingToolInvocation, usageInfoToAutoModeResolution, usageInfoToChatUsage, usageInfoToQuotas, type IAgentHostToolInvocationOptions, type ITurnModelInfo, type TurnModelLookup } from './stateToProgressAdapter.js'; @@ -248,6 +250,7 @@ interface IObserveTurnOptions { readonly requireActiveTurn?: boolean; readonly onTurnEnded?: (lastTurn: Turn | undefined) => void; readonly onResponseText?: (text: string) => void; + readonly onOpenResponseResource?: (resource: ISlashCommandResource) => void; /** * When set, a failed turn does NOT emit its error as a markdown progress * part. The caller surfaces it instead as the agent result's @@ -1264,6 +1267,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, @IOpenerService private readonly _openerService: IOpenerService, + @IEditorService private readonly _editorService: IEditorService, @IAgentHostActiveClientService private readonly _activeClientService: IAgentHostActiveClientService, @IChatEntitlementService private readonly _chatEntitlementService: IChatEntitlementService, @IWorkspaceTrustRequestService private readonly _workspaceTrustRequestService: IWorkspaceTrustRequestService, @@ -2944,7 +2948,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC /** The workbench tool a runtime client-tool call maps to, or `undefined` when it is not installed. */ private _resolveClientTool(toolName: string): IToolData | undefined { - const isCopilotSession = isCopilotCliSessionType(this._config.sessionType); + const isCopilotSession = isCopilotAgentHostSessionType(this._config.sessionType); if (isCopilotSession && toolName === SEMANTIC_SEARCH_TOOL_NAME) { return this._toolsService.getTool(CLIENT_SEMANTIC_SEARCH_TOOL_ID); } @@ -3326,7 +3330,24 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // surfaced through the same path: the observer disposes itself when // `cancellationToken` fires, then calls `onTurnEnded(undefined)`. onFailureStage('observeTurn'); - return new Promise(resolve => { + const resourceOpens: Promise[] = []; + let resourceOpenError: Error | undefined; + const openResponseResource = async ({ resource, preview }: ISlashCommandResource): Promise => { + if (this._isInvocationAbandoned(cancellationToken)) { + return; + } + const editor = await this._editorService.openEditor({ + resource: this._config.connection.resourceUris.fromAgentHost(resource), + options: { pinned: true, ...(preview ? { override: 'vscode.markdown.preview.editor' } : {}) }, + }); + if (!editor) { + throw new Error(localize('agentHost.openSandboxPolicyFailed', "Could not open the sandbox policy in the editor.")); + } + if (!this._isInvocationAbandoned(cancellationToken)) { + progress([{ kind: 'markdownContent', content: new MarkdownString(localize('agentHost.openedSandboxPolicy', "Opened the sandbox policy in the editor.")) }]); + } + }; + return new Promise((resolve, reject) => { const store = this._createTurnObservation(resolve); const cancelSub = store.add(cancellationToken.onCancellationRequested(() => { cancelSub.dispose(); @@ -3347,11 +3368,28 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC cancellationToken, suppressErrorMarkdown: true, onResponseText, - onTurnEnded: (lastTurn) => { - resolve(lastTurn); - this._turnObservations.deleteAndDispose(store); - this._clientDispatchedTurnIds.delete(turnId); - this._completeSessionTurn(session, request.sessionResource, turnId); + onOpenResponseResource: request.message.trimStart().startsWith('/') + ? resource => { + resourceOpens.push(openResponseResource(resource).catch(error => { + resourceOpenError = error instanceof Error ? error : new Error(String(error)); + this._logService.error('[AgentHost] Failed to open slash command resource', resourceOpenError); + })); + } + : undefined, + onTurnEnded: async (lastTurn) => { + try { + await raceCancellation(Promise.all(resourceOpens), cancellationToken); + if (resourceOpenError) { + throw resourceOpenError; + } + resolve(lastTurn); + } catch (error) { + reject(error); + } finally { + this._turnObservations.deleteAndDispose(store); + this._clientDispatchedTurnIds.delete(turnId); + this._completeSessionTurn(session, request.sessionResource, turnId); + } }, })); }); @@ -3582,9 +3620,11 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }); const mcpStarting$ = derivedOpts({ equalsFn: equals }, reader => { const state = mergedState$.read(reader); - const backgroundActions = new Map(this._customizationService.getMcpServers(opts.sessionResource) + const sessionServers = this._customizationService.getMcpServers(opts.sessionResource); + const backgroundActions = new Map(sessionServers .filter(server => server.background !== undefined) .map(server => [server.id, server.background])); + const displayLabels = new Map(sessionServers.map(server => [server.id, getMcpServerDisplayLabel(server)])); const servers = state?.customizations?.flatMap(c => c.type === CustomizationType.McpServer ? [c] : c.children?.filter(c => c.type === CustomizationType.McpServer) ?? []) ?? []; @@ -3594,7 +3634,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const id = opts.sessionResource.authority + '/' + server.id; return { id, - name: server.name, + name: displayLabels.get(id) ?? server.name, blocking: server.state.kind === McpServerStatus.Starting && server.state.blocking === true, background: backgroundActions.get(id), }; @@ -3659,6 +3699,11 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC case ResponsePartKind.SystemNotification: // System notifications don't have an id, so we have to identify it by index if (responseParts$.get().indexOf(initial) >= (opts.initialResponsePartCount ?? 0) && opts.subAgentInvocationId === undefined) { + const resource = readSlashCommandResource(initial); + if (resource && opts.onOpenResponseResource) { + opts.onOpenResponseResource(resource); + break; + } const progress = systemNotificationToChatPart(initial.content, this._config.connectionAuthority, initial._meta); if (progress) { if (progress.kind === 'systemNotification' && progress.presentation === 'workspaceTransition' && progress.accessibilityLabel) { @@ -4302,7 +4347,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC opts.sink([invocation]); } } else { - invocation = toolCallStateToInvocation(initial, subAgentInvocationId, opts.backendSession, this._config.connectionAuthority, opts.sessionResource.authority, undefined, this._config.connection.resourceUris); + const mcpServerName = initial.status === ToolCallStatus.AuthRequired ? this._toolAuthenticationServerName(initial, opts) : undefined; + invocation = toolCallStateToInvocation(initial, subAgentInvocationId, opts.backendSession, this._config.connectionAuthority, opts.sessionResource.authority, undefined, this._config.connection.resourceUris, mcpServerName); if (!renderedBySnapshot) { opts.sink([invocation]); } @@ -4363,7 +4409,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC invocation.updatePreparedInvocation(prepared, invocation.parameters); } else if (status === ToolCallStatus.AuthRequired) { this._ensureLeftStreaming(invocation, tc, opts); - invocation.setAuthenticationRequired(toolCallAuthenticationServer(tc, opts.sessionResource.authority), () => { + invocation.setAuthenticationRequired(toolCallAuthenticationServer(tc, opts.sessionResource.authority, this._toolAuthenticationServerName(tc, opts)), () => { this._dispatchAction(opts.backendSession, { type: ActionType.ChatToolCallComplete, turnId: opts.turnId, @@ -4413,6 +4459,29 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC invocation.didExecuteTool(undefined); } })); + this._refreshToolAuthenticationServerName(invocation, opts.sessionResource); + store.add(this._customizationService.onDidChangeCustomizations(() => this._refreshToolAuthenticationServerName(invocation, opts.sessionResource))); + } + + private _refreshToolAuthenticationServerName(invocation: ChatToolInvocation, sessionResource: URI): void { + const state = invocation.state.get(); + if (state.type !== IChatToolInvocation.StateKind.WaitingForAuthentication) { + return; + } + const server = this._customizationService.getMcpServers(sessionResource).find(candidate => candidate.id === state.server.id); + if (!server) { + return; + } + const name = getMcpServerDisplayLabel(server); + if (name !== state.server.name) { + invocation.setAuthenticationRequired({ ...state.server, name }); + } + } + + private _toolAuthenticationServerName(tc: ToolCallState & { status: ToolCallStatus.AuthRequired }, opts: IObserveTurnOptions): string | undefined { + const id = toolCallAuthenticationServer(tc, opts.sessionResource.authority).id; + const server = this._customizationService.getMcpServers(opts.sessionResource).find(candidate => candidate.id === id); + return server ? getMcpServerDisplayLabel(server) : undefined; } /** Transitions an invocation from streaming once its AHP tool call is ready. */ @@ -6620,14 +6689,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return `${uri}#${start.line}:${start.character}-${end.line}:${end.character}`; } - /** - * Whether this backend reads referenced files from disk (rather than seeing the editor's - * in-memory buffer) and therefore needs the live text of an unsaved / dirty editor inlined as - * an embedded resource. Copilot CLI and Codex both run as separate processes with only disk - * access, so a `@path` mention (or an `untitled:` URI) would give them stale or missing content. - */ + /** Backends that read files from disk need unsaved editor content embedded in attachments. */ private _backendInlinesUnsavedEditors(): boolean { - return this._config.provider === SessionType.CopilotCLI || this._config.provider === CODEX_AGENT_PROVIDER_ID; + return isCopilotAgentHostProvider(this._config.provider) || this._config.provider === CODEX_AGENT_PROVIDER_ID; } /** A resource is unsaved when it's untitled or a saved file with in-memory (dirty) changes. */ @@ -6717,7 +6781,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC private _convertVariableToAttachment(v: IChatRequestVariableEntry, sessionResource: URI, messageText: string | undefined, materializePastes = true): MessageAttachment | MessageAttachment[] | undefined { const referenceRange = this._toAttachmentReferenceRange(messageText, v.range); - // Copilot CLI and Codex can't read unsaved content from disk, so inline the live buffer; drop unreadable schemes. + // Copilot agents and Codex can't read unsaved content from disk, so inline the live buffer; drop unreadable schemes. if ((v.kind === 'file' || v.kind === 'implicit') && this._backendInlinesUnsavedEditors()) { const uri = isLocation(v.value) ? v.value.uri : (v.value instanceof URI ? v.value : undefined); if (uri && this._isUnsavedResource(uri)) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index cf28a2c3302865..a74d0f611c43e0 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -11,6 +11,7 @@ import { escapeMarkdownLinkLabel, escapeMarkdownSyntaxTokens, IMarkdownString, M import { escapeIcons } from '../../../../../../base/common/iconLabels.js'; import { type Tokens } from '../../../../../../base/common/marked/marked.js'; import { rewriteMarkdownLinks as rewriteMarkdownSource } from '../../../../../../base/common/markdownLinks.js'; +import { readSlashCommandResource } from '../../../../../../platform/agentHost/common/meta/agentSlashCommandOutputMeta.js'; import { Mimes } from '../../../../../../base/common/mime.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { posix, win32 } from '../../../../../../base/common/path.js'; @@ -549,6 +550,16 @@ export function systemNotificationToChatPart(content: StringOrMarkdown | undefin if (!content) { return undefined; } + if (readSlashCommandResource({ _meta }) && typeof content !== 'string') { + return { + kind: 'markdownContent', + content: new MarkdownString(rewriteMarkdownSource(content.markdown, { + rewriteLink: token => token.type === 'link' + ? new MarkdownString().appendLink(rewriteAgentHostLinkTarget(token.href, connectionAuthority), token.text).value + : undefined, + })), + }; + } const value = stringOrMarkdownToString(content, connectionAuthority); const markdown = typeof value === 'string' ? new MarkdownString(value) : value; switch (meta.kind) { @@ -2530,7 +2541,7 @@ function addCommentReference(tc: ToolCallState, resourceUris: IAgentHostResource * wrapping remote file URIs into `vscode-agent-host:` URIs. Omit to skip * URI wrapping (e.g. in tests that don't exercise the confirmation UI). */ -export function toolCallStateToInvocation(tc: ToolCallState, subAgentInvocationId: string | undefined, sessionResource: URI, connectionAuthority: string, mcpServerAuthority = sessionResource.authority, options?: IAgentHostToolInvocationOptions, resourceUris: IAgentHostResourceUriMapper = createAgentHostResourceUriMapper(connectionAuthority)): ChatToolInvocation { +export function toolCallStateToInvocation(tc: ToolCallState, subAgentInvocationId: string | undefined, sessionResource: URI, connectionAuthority: string, mcpServerAuthority = sessionResource.authority, options?: IAgentHostToolInvocationOptions, resourceUris: IAgentHostResourceUriMapper = createAgentHostResourceUriMapper(connectionAuthority), mcpServerName?: string): ChatToolInvocation { const toolData: IToolData = { id: tc.toolName, source: ToolDataSource.Internal, @@ -2633,7 +2644,7 @@ export function toolCallStateToInvocation(tc: ToolCallState, subAgentInvocationI invocation.presentation = ToolInvocationPresentation.Hidden; } if (tc.status === ToolCallStatus.AuthRequired) { - invocation.setAuthenticationRequired(toolCallAuthenticationServer(tc, mcpServerAuthority)); + invocation.setAuthenticationRequired(toolCallAuthenticationServer(tc, mcpServerAuthority, mcpServerName)); } applyToolCallProgress(invocation, tc); @@ -2695,11 +2706,11 @@ export function toolCallConfirmationMessages(tc: ToolCallPendingConfirmationStat }; } -export function toolCallAuthenticationServer(tc: ToolCallState & { status: ToolCallStatus.AuthRequired }, sessionAuthority: string): IChatMcpAuthenticationRequiredServer { +export function toolCallAuthenticationServer(tc: ToolCallState & { status: ToolCallStatus.AuthRequired }, sessionAuthority: string, name?: string): IChatMcpAuthenticationRequiredServer { const metadata = readToolCallMeta(tc); return { id: `${sessionAuthority}/${tc.contributor.customizationId}`, - name: tc.auth.resource.resource_name ?? metadata.mcpServerName ?? tc.displayName, + name: name ?? tc.auth.resource.resource_name ?? metadata.mcpServerName ?? tc.displayName, resource: tc.auth.resource.resource, oauthClient: tc.auth.oauthClient, authorizationServers: tc.auth.resource.authorization_servers, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessionsConfiguration.ts b/src/vs/workbench/contrib/chat/browser/agentSessionsConfiguration.ts index 9bd31f22bc3e17..5a08a56a1cd764 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessionsConfiguration.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessionsConfiguration.ts @@ -64,7 +64,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis properties: { [ChatConfiguration.UnifiedWorkspacePicker]: { type: 'boolean', - default: product.quality !== 'stable', + default: true, scope: ConfigurationScope.APPLICATION, description: nls.localize('sessions.chat.unifiedWorkspacePicker.enabled', "Controls whether the Agents Window uses the unified workspace picker, which combines GitHub and remote workspaces, provides search, and, when supported, allows creating sessions with no workspace."), tags: ['experimental'], diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index f29000d9bb3b00..334fb4d935fdcd 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -35,7 +35,7 @@ import { IDialogService, IFileDialogService } from '../../../../../platform/dial import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { EditorPane } from '../../../../browser/parts/editor/editorPane.js'; import { IEditorOpenContext } from '../../../../common/editor.js'; -import { IEditorGroup } from '../../../../services/editor/common/editorGroupsService.js'; +import { IEditorGroup, IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { WorkbenchList } from '../../../../../platform/list/browser/listService.js'; @@ -72,6 +72,7 @@ import { } from './aiCustomizationManagement.js'; import { skillIcon } from './aiCustomizationIcons.js'; import { ChatModelsWidget } from '../chatManagement/chatModelsWidget.js'; +import { IChatWidgetService } from '../chat.js'; import { PromptsType, Target } from '../../common/promptSyntax/promptTypes.js'; import { CustomizationMigration, CustomizationMigrationCandidate, CustomizationMigrationType, FileCustomizationMigrationFailureReason, getCustomizationMigrationTargetType, getMcpServerCustomizationMigrationCandidateKey, ICustomizationMigrationService, IMcpServerCustomizationMigrationCandidate, IMcpServerCustomizationMigrationExclusion, IMcpServerCustomizationMigrationResult, isMcpServerCustomizationMigrationCandidate, MigratableConfiguration } from '../../common/promptSyntax/service/customizationMigrationService.js'; import { ICustomizationMigrationTelemetryService } from '../../common/promptSyntax/service/customizationMigrationTelemetryService.js'; @@ -85,6 +86,7 @@ import { showConfigureHooksQuickPick } from '../promptSyntax/hookActions.js'; import { resolveWorkspaceTargetDirectory, resolveUserTargetDirectory, CustomizationLocationPicker } from './customizationCreatorService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { AICustomizationSources, IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; +import { isAgentHostTarget, isRemoteAgentHostTarget } from '../../common/chatSessionsService.js'; import { hasReadableCustomizationContent } from '../../../../../platform/agentHost/common/agentHostCustomizationUri.js'; import { CodeEditorWidget } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; @@ -98,6 +100,7 @@ import { getSimpleEditorOptions } from '../../../codeEditor/browser/simpleEditor import { IWorkingCopyService } from '../../../../services/workingCopy/common/workingCopyService.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { FileSystemProviderCapabilities, IFileService } from '../../../../../platform/files/common/files.js'; +import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; @@ -118,7 +121,7 @@ import { ICustomizationHarnessService, type ICustomizationSourceFolder } from '. import { ChatConfiguration } from '../../common/constants.js'; import { AICustomizationWelcomePage, type ICustomizationMarketplaceOrigin, type ICustomizationMigrationCategorySummary, type IInstalledCustomizationTarget } from './aiCustomizationWelcomePage.js'; import { ICustomizationMarketplaceInstallService } from '../../common/customizationMarketplaceInstallService.js'; -import { type CustomizationMigrationTargetFolders, type IMigratedCustomizationsWithFailureReasonsResult, migrateCustomizations, resolveWorkspaceMigrationTargetFolder } from './customizationMigration.js'; +import { createCustomizationMigrationAgentPrompt, type CustomizationMigrationTargetFolders, type IMigratedCustomizationsWithFailureReasonsResult, migrateCustomizations, resolveWorkspaceMigrationTargetFolder } from './customizationMigration.js'; import { CUSTOMIZATION_MIGRATION_CATEGORIES, CustomizationMigrationCategoryId, getCustomizationMigrationCategory, homepageMigrationCategories, type ICustomizationMigrationCategory } from './customizationMigrationCategories.js'; import { CustomizationMigrationDashboard, @@ -132,7 +135,6 @@ import { IViewsService } from '../../../../services/views/common/viewsService.js import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { showNoFoldersDialog } from '../promptSyntax/pickers/askForPromptSourceFolder.js'; -import { isAgentHostTarget } from '../../common/chatSessionsService.js'; const $ = DOM.$; const CUSTOMIZATION_MIGRATION_IGNORED_GROUPS_STORAGE_KEY = 'chat.customizationMigration.ignoredGroups'; @@ -530,6 +532,9 @@ export class AICustomizationManagementEditor extends EditorPane { @ICustomizationMarketplaceService private readonly marketplaceService: ICustomizationMarketplaceService, @ICustomizationMarketplaceInstallService private readonly marketplaceInstallService: ICustomizationMarketplaceInstallService, @IAgentPluginService private readonly agentPluginService: IAgentPluginService, + @IEnvironmentService private readonly environmentService: IEnvironmentService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, ) { super(AICustomizationManagementEditor.ID, group, telemetryService, themeService, storageService); @@ -1060,6 +1065,9 @@ export class AICustomizationManagementEditor extends EditorPane { this.migrationResult = undefined; this.renderCustomizationMigrationDashboardState(); }, + migrateWithAgent: () => { + void this.startCustomizationMigrationWithAgent(); + }, migrateCategory: (id, storage) => { const category = getCustomizationMigrationCategory(id); const candidates = this.getMigrationCandidates(category, storage) @@ -1633,6 +1641,84 @@ export class AICustomizationManagementEditor extends EditorPane { return [...this.customizationsByMigrationCategory.values()].flat(); } + private async startCustomizationMigrationWithAgent(): Promise { + const harness = this.harnessService.getActiveDescriptor(); + if (isRemoteAgentHostTarget(harness.id)) { + return; + } + const sessionResource = this.harnessService.activeSessionResource.get(); + const customizations = [...this.customizationsByMigrationCategory].flatMap(([categoryId, candidates]) => { + const category = getCustomizationMigrationCategory(categoryId).migrationType; + return candidates + .filter(candidate => !this.isMigrationCategoryIgnored(categoryId, candidate.storage)) + .map(customization => ({ category, customization })); + }); + const inventoryCounts = new Map(); + for (const [categoryId, candidates] of this.customizationsByMigrationCategory) { + const count = candidates.filter(candidate => !this.isMigrationCategoryIgnored(categoryId, candidate.storage)).length; + if (count > 0) { + const category = getCustomizationMigrationCategory(categoryId); + inventoryCounts.set(category.migrationType, (inventoryCounts.get(category.migrationType) ?? 0) + count); + } + } + const migrationFlowId = generateUuid(); + if (!customizations.length) { + return; + } + + try { + const targetTypes = new Set(customizations + .map(({ customization }) => customization) + .filter(customization => !isMcpServerCustomizationMigrationCandidate(customization)) + .map(getCustomizationMigrationTargetType)); + const targetFolderEntries = await Promise.all([...targetTypes].map(async type => [ + type, + await harness.itemProvider?.provideSourceFolders?.(sessionResource, type, CancellationToken.None) ?? [], + ] as const)); + if (!isEqual(sessionResource, this.harnessService.activeSessionResource.get()) || harness.id !== this.harnessService.activeHarness.get()) { + return; + } + + const recoveryBundleFolder = URI.joinPath(this.environmentService.workspaceStorageHome, 'customizationMigrations', migrationFlowId); + await this.fileService.createFolder(recoveryBundleFolder); + + const prompt = createCustomizationMigrationAgentPrompt(harness, migrationFlowId, recoveryBundleFolder, customizations, new Map(targetFolderEntries)); + if (!await this.closeForAgentMigration()) { + return; + } + const migrationSessionResource = await this.commandService.executeCommand(`workbench.action.chat.openNewSessionSidebar.${harness.id}`, { prompt }); + if (migrationSessionResource) { + this.customizationMigrationTelemetryService.watchAgentMigrationResult( + URI.joinPath(recoveryBundleFolder, 'migration-results.json'), + migrationFlowId, + inventoryCounts, + migrationSessionResource, + { + onDidBackground: Event.map( + Event.filter(this.chatWidgetService.onDidBackgroundSession, resource => isEqual(resource, migrationSessionResource)), + () => undefined, + ), + onDidForeground: Event.filter( + this.chatWidgetService.onDidChangeFocusedSession, + () => isEqual(this.chatWidgetService.lastFocusedWidget?.viewModel?.sessionResource, migrationSessionResource), + ), + }, + ); + } + } catch (error) { + onUnexpectedError(error); + this.notificationService.error(localize('startAgentMigrationFailed', "Could not start the agent-guided customization migration.")); + } + } + + private async closeForAgentMigration(): Promise { + const modalEditorPart = this.editorGroupsService.activeModalEditorPart; + if (modalEditorPart?.groups.some(group => group.id === this.group.id)) { + return modalEditorPart.close(); + } + return this.input ? this.group.closeEditor(this.input) : true; + } + private getMigrationCategorySummaries(): readonly ICustomizationMigrationCategorySummary[] { const harnessLabel = this.getActiveHarnessLabel(); const summaries: ICustomizationMigrationCategorySummary[] = []; @@ -2277,6 +2363,7 @@ export class AICustomizationManagementEditor extends EditorPane { }); return { scopes, + supportsAgentMigration: !isRemoteAgentHostTarget(this.harnessService.activeHarness.get()), manualReviewItems: storages.flatMap(storage => this.getMcpMigrationExclusions(storage).map(exclusion => ({ id: `${CustomizationMigrationCategoryId.McpServers}:${storage}:exclusion:${exclusion.id}`, label: exclusion.name, diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigration.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigration.ts index 3cad9617d9fe57..d575a6fb86fd33 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigration.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigration.ts @@ -12,7 +12,7 @@ import { generateUuid } from '../../../../../base/common/uuid.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { getCleanPromptName, getPromptFileExtension, SKILL_FILENAME, VALID_SKILL_NAME_REGEX } from '../../common/promptSyntax/config/promptFileLocations.js'; import { IHeaderAttribute, ParsedPromptFile, PromptFileParser, PromptHeaderAttributes } from '../../common/promptSyntax/promptFileParser.js'; -import { FileCustomizationMigrationFailureReason, getCustomizationMigrationTargetType, MigratableConfiguration } from '../../common/promptSyntax/service/customizationMigrationService.js'; +import { CustomizationMigrationCandidate, CustomizationMigrationType, FileCustomizationMigrationFailureReason, getCustomizationMigrationTargetType, isMcpServerCustomizationMigrationCandidate, MigratableConfiguration } from '../../common/promptSyntax/service/customizationMigrationService.js'; import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { ICustomizationSourceFolder } from '../../common/customizationHarnessService.js'; @@ -57,6 +57,43 @@ export interface ICustomizationMigrationOptions { readonly resolveTargetFolder?: (customization: MigratableConfiguration, targetType: PromptsType) => ICustomizationSourceFolder | undefined; } +export interface ICategorizedCustomizationMigrationCandidate { + readonly category: CustomizationMigrationType; + readonly customization: CustomizationMigrationCandidate; +} + +export function createCustomizationMigrationAgentPrompt( + harness: { readonly id: string; readonly label: string }, + migrationFlowId: string, + recoveryBundleFolder: URI, + customizations: readonly ICategorizedCustomizationMigrationCandidate[], + targetFoldersByType: ReadonlyMap, +): string { + const customizationLocations = customizations.map(({ category, customization }) => { + if (isMcpServerCustomizationMigrationCandidate(customization)) { + return `- ${category}: MCP server "${customization.name}" (${customization.storage}): ${customization.sourceUri.toString(true)} -> ${customization.targetUri.toString(true)}`; + } + return `- ${category}: ${customization.type} (${customization.storage}): ${customization.uri.toString(true)}`; + }); + const targetLocations = [...targetFoldersByType] + .flatMap(([type, folders]) => folders.map(folder => `- ${type} (${folder.source}, ${folder.label}): ${folder.uri.toString(true)}`)); + + return [ + '/migrate-customizations', + '', + `Selected harness: ${harness.label} (${harness.id})`, + `Migration telemetry flow: ${migrationFlowId}`, + `Recovery bundle folder: ${recoveryBundleFolder.toString(true)}`, + `Recovery bundle filesystem path: ${recoveryBundleFolder.fsPath}`, + '', + 'Customizations that need migration:', + ...customizationLocations, + '', + 'Valid target folders reported by the selected harness:', + ...(targetLocations.length ? targetLocations : ['- None reported for these customization types.']), + ].join('\n'); +} + /** * Picks the corresponding target folder in the customization's workspace group. */ diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationAccessibility.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationAccessibility.ts index 2a7de3eac3f9ab..f264956d7ed32a 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationAccessibility.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationAccessibility.ts @@ -36,6 +36,7 @@ class CustomizationMigrationAccessibleView implements IAccessibleViewImplementat () => this.type === AccessibleViewType.Help ? [ localize('migrationHelpOverview', "The migrations tree groups each migration type by workspace or user. Expand a group to inspect each customization, its source location, and its destination."), localize('migrationHelpNavigation', "Use the arrow keys to move through and expand the tree. Use each row's checkbox to include or exclude it. Open a customization to view its editor or details. The More Actions menu can migrate or delete one item."), + localize('migrationHelpAgent', "Use Migrate to have VS Code update the selected customizations. Migrate with Agent closes Agent Customizations and focuses a guided migration in chat for the selected harness. Agent migration uses credits."), localize('migrationHelpActions', "Migrate applies to the selected items in one group and shows the planned source and destination changes before modifying files. Ignore permanently hides a group. Show Ignored Migrations restores hidden groups."), localize('migrationHelpLocations', "Activate a destination path to change the destination. Workspace MCP servers migrate to the root .mcp.json. User MCP servers migrate to mcp-config.json in Copilot home. Disabled user servers may become enabled after migration."), localize('migrationHelpMcpChanges', "MCP servers that migrate with changes explain each property removal in their row. Review these warnings before confirming migration."), diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationDashboard.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationDashboard.ts index b730417e26dd2d..0c59b26f2a77b8 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationDashboard.ts @@ -96,15 +96,17 @@ export interface ICustomizationMigrationDashboardOverview { readonly scopes: readonly ICustomizationMigrationDashboardScope[]; readonly manualReviewItems?: readonly (ICustomizationMigrationDashboardItem & { readonly storage: PromptsStorage })[]; readonly hasIgnoredGroups?: boolean; + readonly supportsAgentMigration?: boolean; /** Most recent activity first. */ readonly activity: readonly ICustomizationMigrationDashboardActivity[]; readonly result?: { readonly migratedCount: number }; } export interface ICustomizationMigrationDashboardCallbacks { - readonly actionClicked: (action: 'retryClicked' | 'destinationsClicked' | 'migrationCategoryClicked' | 'viewChangesClicked' | 'resultDismissed' | 'activityDismissed', categoryId?: CustomizationMigrationCategoryId) => void; + readonly actionClicked: (action: 'retryClicked' | 'destinationsClicked' | 'migrationCategoryClicked' | 'agentMigrationClicked' | 'viewChangesClicked' | 'resultDismissed' | 'activityDismissed', categoryId?: CustomizationMigrationCategoryId) => void; readonly configureLocations: (id: CustomizationMigrationCategoryId, storage: PromptsStorage) => void; readonly dismissResult: () => void; + readonly migrateWithAgent: () => void; readonly migrateCategory: (id: CustomizationMigrationCategoryId, storage: PromptsStorage) => void; readonly setItemSelected: (item: ICustomizationMigrationDashboardItem, selected: boolean) => void; readonly showItemActions: (item: ICustomizationMigrationDashboardItem, storage: PromptsStorage, anchor: HTMLElement) => void; @@ -305,12 +307,19 @@ export class CustomizationMigrationDashboard extends Disposable { const migrationGroups = this.getMigrationGroups(overview); this.allMigrationsComplete = migrationGroups.length === 0 && !overview.hasIgnoredGroups; this.prepareRender(); + const supportsAgentMigration = migrationGroups.length > 0 && overview.supportsAgentMigration !== false; const page = this.renderHeader( localize('migrationsTitle', "Migrations"), this.allMigrationsComplete ? localize('migrationsCompletedDescription', "Your customizations use supported formats and locations.") - : localize('migrationsDescription', "Some of your agent customizations need an update to keep working. Review and migrate them to the new formats and locations."), + : supportsAgentMigration + ? localize('migrationsDescriptionWithAgent', "Some of your agent customizations need an update to keep working. Use Migrate to have VS Code update selected customizations, or Migrate with Agent for a guided migration in chat. Agent migration uses credits.") + : localize('migrationsDescription', "Some of your agent customizations need an update to keep working. Review and migrate them to the new formats and locations."), overview.hasIgnoredGroups === true, + supportsAgentMigration ? () => { + this.callbacks.actionClicked('agentMigrationClicked'); + this.callbacks.migrateWithAgent(); + } : undefined, ); if (overview.result) { @@ -408,15 +417,16 @@ export class CustomizationMigrationDashboard extends Disposable { return [...this.focusTargets].find(([, element]) => element === active)?.[0]; } - private renderHeader(title: string, description: string, hasIgnoredGroups = false): HTMLElement { + private renderHeader(title: string, description: string, hasIgnoredGroups = false, migrateWithAgent?: () => void): HTMLElement { const page = DOM.append(this.element, $('.migration-page')); const header = DOM.append(page, $('.migration-page-header')); const titleRow = DOM.append(header, $('.migration-page-title-row')); const heading = DOM.append(titleRow, $('h1', { tabindex: -1 }, title)); this.focusTargets.set('title', heading); + const actions = DOM.append(titleRow, $('.migration-page-title-actions')); if (hasIgnoredGroups) { this.button( - titleRow, + actions, 'restoreIgnored', localize('showIgnoredMigrations', "Show Ignored Migrations"), localize('showIgnoredMigrationsAriaLabel', "Show and restore ignored migrations"), @@ -424,6 +434,16 @@ export class CustomizationMigrationDashboard extends Disposable { 'link', ); } + if (migrateWithAgent) { + this.button( + actions, + 'migrateWithAgent', + localize('migrateWithAgent', "Migrate with Agent"), + localize('migrateWithAgentAriaLabel', "Start an agent-guided customization migration"), + migrateWithAgent, + 'secondary', + ); + } DOM.append(header, $('p.migration-intro', {}, description)); return page; } @@ -779,10 +799,10 @@ export class CustomizationMigrationDashboard extends Disposable { } } - private button(parent: HTMLElement, key: string, label: string, ariaLabel: string, run: () => void, kind: 'secondary' | 'link' | 'icon' = 'secondary', icon?: ThemeIcon, disposables: DisposableStore = this.renderDisposables): Button { + private button(parent: HTMLElement, key: string, label: string, ariaLabel: string, run: () => void, kind: 'primary' | 'secondary' | 'link' | 'icon' = 'secondary', icon?: ThemeIcon, disposables: DisposableStore = this.renderDisposables): Button { const button = disposables.add(new Button(parent, { ...defaultButtonStyles, - secondary: true, + secondary: kind !== 'primary', buttonSecondaryBackground: 'transparent', buttonSecondaryForeground: kind === 'link' ? 'var(--vscode-textLink-foreground)' : 'var(--vscode-foreground)', buttonSecondaryHoverBackground: 'var(--vscode-list-hoverBackground)', diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationsTelemetryService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationsTelemetryService.ts new file mode 100644 index 00000000000000..256e59457b422f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationsTelemetryService.ts @@ -0,0 +1,266 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { basename, dirname, isEqualOrParent } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { isAgentBuiltinCustomizationUri } from '../../../../../platform/agentHost/common/agentHostCustomizationUri.js'; +import { CustomizationType, type ChildCustomization, type PluginCustomization } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IWorkbenchContribution } from '../../../../common/contributions.js'; +import { IAgentHostActiveClientService } from '../agentSessions/agentHost/agentHostActiveClientService.js'; +import { IAgentHostCustomizationService } from '../agentSessions/agentHost/agentHostCustomizationService.js'; +import { isAgentHostSessionResource } from '../../common/chatSessionsService.js'; +import { IChatService } from '../../common/chatService/chatService.js'; +import { AICustomizationSource, AICustomizationSources } from '../../common/aiCustomizationWorkspaceService.js'; +import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../services/agentHost/common/agentHostFileSystemService.js'; + +export const ICustomizationsTelemetryService = createDecorator('customizationsTelemetryService'); + +export interface ICustomizationsTelemetryService { + readonly _serviceBrand: undefined; + reportNewSession(sessionResource: URI): void; +} + +type CustomizationTelemetryType = 'agent' | 'instructions' | 'prompt' | 'skill' | 'hook' | 'mcpServer' | 'plugin'; + +type CustomizationsDefinedEvent = { + customizationType: CustomizationTelemetryType; + userCount: number; + workspaceCount: number; + extensionCount: number; + pluginCount: number; + builtinCount: number; +}; + +type CustomizationsDefinedClassification = { + owner: 'aeschli'; + comment: 'Tracks how many customizations of each type and source are defined when a new Agent Host chat session is first used.'; + customizationType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The bounded customization type being counted.' }; + userCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of user-level customizations of this type.' }; + workspaceCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of workspace-level customizations of this type.' }; + extensionCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of extension-contributed customizations of this type.' }; + pluginCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of plugin-contributed customizations of this type.' }; + builtinCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of built-in customizations of this type.' }; +}; + +const customizationTypes: readonly CustomizationTelemetryType[] = [ + 'agent', + 'instructions', + 'prompt', + 'skill', + 'hook', + 'mcpServer', + 'plugin', +]; + +type CustomizationSourceCounts = Omit; + +export class CustomizationsTelemetryService implements ICustomizationsTelemetryService { + declare readonly _serviceBrand: undefined; + + private readonly reportedSessions = new Set(); + + constructor( + @IAgentHostCustomizationService private readonly agentHostCustomizationService: IAgentHostCustomizationService, + @IAgentHostActiveClientService private readonly agentHostActiveClientService: IAgentHostActiveClientService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + @ILogService private readonly logService: ILogService, + ) { } + + reportNewSession(sessionResource: URI): void { + if (!isAgentHostSessionResource(sessionResource)) { + return; + } + const key = sessionResource.toString(); + if (this.reportedSessions.has(key)) { + return; + } + this.reportedSessions.add(key); + void this.report(sessionResource).catch(error => { + this.reportedSessions.delete(key); + this.logService.error('Failed to collect customizations telemetry for a new session', error); + }); + } + + private async report(sessionResource: URI): Promise { + const counts = createEmptyCounts(); + const workingDirectories = this.agentHostCustomizationService.getWorkingDirectories(sessionResource); + for (const customization of this.agentHostCustomizationService.getCustomizations(sessionResource)) { + if (customization.type === CustomizationType.Plugin) { + const syntheticBundle = URI.parse(customization.uri).scheme === SYNCED_CUSTOMIZATION_SCHEME; + if (!syntheticBundle) { + incrementSource(counts.plugin, AICustomizationSources.plugin); + } + for (const child of customization.children ?? []) { + const type = toTelemetryType(child.type); + if (type && type !== 'mcpServer') { + const source = syntheticBundle + ? this.getSyncedChildSource(customization, child) + : AICustomizationSources.plugin; + incrementSource(counts[type], source); + } + } + } else if (customization.type === CustomizationType.Directory) { + for (const child of customization.children ?? []) { + const type = toTelemetryType(child.type); + if (type && type !== 'mcpServer') { + incrementSource(counts[type], getDirectoryChildSource(child, workingDirectories)); + } + } + } + } + + const workspaceRoots = this.agentHostCustomizationService.getClientWorkingDirectoryUris(sessionResource); + for (const server of this.agentHostCustomizationService.getMcpServers(sessionResource)) { + switch (server.source) { + case 'workspace': + counts.mcpServer.workspaceCount++; + break; + case 'plugin': + counts.mcpServer.pluginCount++; + break; + case 'builtin': + case 'managed': + counts.mcpServer.builtinCount++; + break; + case 'user': + counts.mcpServer.userCount++; + break; + case undefined: { + const sourceUri = server.sourceUri; + if (server.isPluginProvided) { + counts.mcpServer.pluginCount++; + } else if (sourceUri && workspaceRoots.some(root => isEqualOrParent(sourceUri, root))) { + counts.mcpServer.workspaceCount++; + } else { + counts.mcpServer.userCount++; + } + break; + } + } + } + + for (const customizationType of customizationTypes) { + this.telemetryService.publicLog2('agents/customizationsDefined', { + customizationType, + ...counts[customizationType], + }); + } + } + + private getSyncedChildSource(plugin: PluginCustomization, child: ChildCustomization): AICustomizationSource { + const pluginUri = URI.parse(plugin.uri); + const childUri = URI.parse(child.uri); + const syncedUri = child.type === CustomizationType.Skill + ? URI.joinPath(pluginUri, 'skills', basename(dirname(childUri)), basename(childUri)) + : URI.joinPath(pluginUri, getPluginDirectory(child.type), basename(childUri)); + return this.agentHostActiveClientService.getOrigin(syncedUri)?.source ?? AICustomizationSources.plugin; + } +} + +export class CustomizationsTelemetryContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.customizationsTelemetry'; + + constructor( + @IChatService chatService: IChatService, + @ICustomizationsTelemetryService customizationsTelemetryService: ICustomizationsTelemetryService, + ) { + super(); + this._register(chatService.onDidAcceptRequest(event => { + if (!event.isNewSession) { + return; + } + + const model = chatService.getSession(event.chatSessionResource); + if (!model) { + return; + } + const requestId = model.lastRequest?.id; + this._register(Event.once(Event.filter(model.onDidChange, change => + change.kind === 'completedRequest' && (!requestId || change.request.id === requestId) + ))(() => customizationsTelemetryService.reportNewSession(event.chatSessionResource))); + })); + } +} + +function createEmptyCounts(): Record { + return Object.fromEntries(customizationTypes.map(type => [type, { + userCount: 0, + workspaceCount: 0, + extensionCount: 0, + pluginCount: 0, + builtinCount: 0, + }])) as Record; +} + +function toTelemetryType(type: ChildCustomization['type']): CustomizationTelemetryType | undefined { + switch (type) { + case CustomizationType.Agent: + return 'agent'; + case CustomizationType.Rule: + return 'instructions'; + case CustomizationType.Prompt: + return 'prompt'; + case CustomizationType.Skill: + return 'skill'; + case CustomizationType.Hook: + return 'hook'; + case CustomizationType.McpServer: + return 'mcpServer'; + default: + return undefined; + } +} + +function getPluginDirectory(type: Exclude): string { + switch (type) { + case CustomizationType.Agent: + return 'agents'; + case CustomizationType.Rule: + return 'rules'; + case CustomizationType.Prompt: + return 'commands'; + case CustomizationType.Hook: + return 'hooks'; + case CustomizationType.McpServer: + return ''; + } +} + +function getDirectoryChildSource(child: ChildCustomization, workingDirectories: readonly string[]): AICustomizationSource { + const childUri = URI.parse(child.uri); + if (isAgentBuiltinCustomizationUri(childUri)) { + return AICustomizationSources.builtin; + } + if (childUri.scheme === Schemas.file && workingDirectories.some(root => isEqualOrParent(childUri, URI.parse(root)))) { + return AICustomizationSources.local; + } + return AICustomizationSources.user; +} + +function incrementSource(counts: CustomizationSourceCounts, source: AICustomizationSource): void { + switch (source) { + case AICustomizationSources.local: + counts.workspaceCount++; + break; + case AICustomizationSources.user: + counts.userCount++; + break; + case AICustomizationSources.extension: + counts.extensionCount++; + break; + case AICustomizationSources.plugin: + counts.pluginCount++; + break; + case AICustomizationSources.builtin: + counts.builtinCount++; + break; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 7ee6cfba7c2b2e..726cf158ae5907 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -57,7 +57,7 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; import { AgentPluginItemKind, IAgentPluginItem } from '../agentPluginEditor/agentPluginItems.js'; import { CustomizationMcpServerCompatibilityKind, getCustomizationDisabledLabel, ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; -import { IAgentHostCustomizationService } from '../agentSessions/agentHost/agentHostCustomizationService.js'; +import { getMcpServerDisplayLabel, IAgentHostCustomizationService } from '../agentSessions/agentHost/agentHostCustomizationService.js'; import { CustomizationEnablementKind, McpServerStatus } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { IOutputService } from '../../../../services/output/common/output.js'; import { ChatConfiguration } from '../../common/constants.js'; @@ -470,7 +470,7 @@ export class McpServerItemRenderer extends Disposable implements IListRenderer value.toLowerCase().includes(query))) - .map(server => createConnectorMcpEntry(server, activeSessionMatcher.take([server.serverName]))) + .map(server => createConnectorMcpEntry(server, activeSessionMatcher.takeConnector([server.serverName], [server.connector.name, server.connector.displayName]))) : []; const activeSessionOnlyServers = activeSessionMatcher.unmatched(query); const activeSessionBuiltinEntries = createBuiltinActiveSessionMcpEntries(activeSessionOnlyServers); @@ -3199,7 +3199,7 @@ export class McpListWidget extends Disposable { case 'server-item': return entry.server.label === serverName; case 'session-server-item': - return entry.server.name === serverName; + return entry.server.name === serverName || entry.server.displayName === serverName; case 'builtin-item': return entry.label === serverName || entry.connector?.connector.displayName === serverName; case 'group-header': diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpServerCount.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpServerCount.ts index a67530b4bf1ebd..6280b7e9e0d6c5 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpServerCount.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpServerCount.ts @@ -51,8 +51,22 @@ export class ActiveSessionMcpServerMatcher { return undefined; } + takeConnector(keys: readonly (string | undefined)[], displayNames: readonly (string | undefined)[]): AgentHostMcpServer | undefined { + const exactMatch = this.take(keys); + if (exactMatch) { + return exactMatch; + } + const names = new Set(getUniqueMcpMatchKeys(displayNames)); + const matches = this.servers.filter(server => !this.matchedIds.has(server.id) && server.displayName !== undefined && names.has(server.displayName)); + if (matches.length === 1) { + this.matchedIds.add(matches[0].id); + return matches[0]; + } + return undefined; + } + unmatched(query: string): AgentHostMcpServer[] { - return this.servers.filter(server => !this.matchedIds.has(server.id) && (!query || server.name.toLowerCase().includes(query))); + return this.servers.filter(server => !this.matchedIds.has(server.id) && (!query || [server.name, server.displayName].some(value => value?.toLowerCase().includes(query)))); } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/customizationMigrationDashboard.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/customizationMigrationDashboard.css index d57fa57b891955..316eb3159b2694 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/customizationMigrationDashboard.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/customizationMigrationDashboard.css @@ -33,6 +33,13 @@ gap: var(--vscode-spacing-size160); } +.customization-migration-dashboard .migration-page-title-actions { + display: flex; + align-items: center; + flex-shrink: 0; + gap: var(--vscode-spacing-size120); +} + .customization-migration-dashboard h1, .customization-migration-dashboard h2, .customization-migration-dashboard h3, @@ -53,7 +60,7 @@ } .customization-migration-dashboard .migration-intro { - margin: var(--vscode-spacing-size60) 0 var(--vscode-spacing-size240); + margin: var(--vscode-spacing-size60) 0 var(--vscode-spacing-size80); color: var(--vscode-descriptionForeground); } @@ -139,7 +146,7 @@ flex: 1 1 auto; min-height: 0; width: 100%; - margin-top: var(--vscode-spacing-size160); + margin-top: var(--vscode-spacing-size80); overflow: hidden; } 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 018ab2f94f6e54..d00df7a6b38173 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -138,6 +138,7 @@ import { AgentHostChatDebugContribution } from './chatDebug/agentHostChatDebugPr import { ChatDebugEditor } from './chatDebug/chatDebugEditor.js'; import { ChatDebugEditorInput, ChatDebugEditorInputSerializer } from './chatDebug/chatDebugEditorInput.js'; import { ChatGoalSummaryService, IChatGoalSummaryService } from './chatGoalSummaryService.js'; +import { CustomizationsTelemetryContribution, CustomizationsTelemetryService, ICustomizationsTelemetryService } from './aiCustomization/customizationsTelemetryService.js'; import { ChatSubmitRequestHandlerService, IChatSubmitRequestHandlerService } from './chatSubmitRequestHandlerService.js'; import { PromptsDebugContribution } from './promptsDebugContribution.js'; import { PromptLanguageFeaturesProvider } from './promptSyntax/promptFileContributions.js'; @@ -3281,6 +3282,7 @@ registerWorkbenchContribution2(UsagesToolContribution.ID, UsagesToolContribution registerWorkbenchContribution2(RenameToolContribution.ID, RenameToolContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatAgentSettingContribution.ID, ChatAgentSettingContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatForegroundSessionCountContribution.ID, ChatForegroundSessionCountContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(CustomizationsTelemetryContribution.ID, CustomizationsTelemetryContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatAgentActionsContribution.ID, ChatAgentActionsContribution, WorkbenchPhase.Eventually); registerWorkbenchContribution2(HookSchemaAssociationContribution.ID, HookSchemaAssociationContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ToolReferenceNamesContribution.ID, ToolReferenceNamesContribution, WorkbenchPhase.AfterRestored); @@ -3394,6 +3396,7 @@ registerSingleton(ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesSe registerSingleton(IPromptsService, PromptsService, InstantiationType.Delayed); registerSingleton(ICustomizationMigrationService, CustomizationMigrationService, InstantiationType.Delayed); registerSingleton(ICustomizationMigrationTelemetryService, CustomizationMigrationTelemetryService, InstantiationType.Delayed); +registerSingleton(ICustomizationsTelemetryService, CustomizationsTelemetryService, InstantiationType.Delayed); registerSingleton(IChatContextPickService, ChatContextPickService, InstantiationType.Delayed); registerSingleton(IChatModeService, ChatModeService, InstantiationType.Delayed); registerSingleton(IChatAttachmentResolveService, ChatAttachmentResolveService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/chatPetAchievementPreview.ts b/src/vs/workbench/contrib/chat/browser/chatPetAchievementPreview.ts index 45325a1e50513a..288cea09124d54 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPetAchievementPreview.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPetAchievementPreview.ts @@ -9,7 +9,7 @@ import { FileAccess } from '../../../../base/common/network.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { foreground } from '../../../../platform/theme/common/colorRegistry.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; -import { ChatPetVariant } from './chatPetService.js'; +import { ChatPetColor, getChatPetColorVariant, setChatPetImageSource } from './chatPetColors.js'; import { IChatPetAccessory } from './chatPetAchievements.js'; import { drawChatPetAccessory, drawChatPetComposite, getChatPetAccessoryImageSource, hasChatPetAccessoryImageDimensions, hasChatPetBodyImageDimensions } from './widget/chatPetAccessoryRenderer.js'; @@ -19,7 +19,7 @@ export function renderChatPetAchievementPreview( canvas: HTMLCanvasElement, accessory: IChatPetAccessory | undefined, unlocked: boolean, - variant: ChatPetVariant, + color: ChatPetColor, themeService: IThemeService, logService: ILogService, ): IDisposable { @@ -27,8 +27,9 @@ export function renderChatPetAchievementPreview( const bodyImage = DOM.$('img') as HTMLImageElement; const accessoryImage = accessory ? DOM.$('img') as HTMLImageElement : undefined; const accessorySource = accessory ? getChatPetAccessoryImageSource(accessory) : undefined; - const bodySource = FileAccess.asBrowserUri(`vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-idle-${variant}-96.png`).toString(true); - let bodyLoaded = !unlocked; + const bodySource = FileAccess.asBrowserUri(`vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-idle-${getChatPetColorVariant(color)}-96.png`).toString(true); + const showBody = unlocked || accessory === undefined; + let bodyLoaded = !showBody; let accessoryLoaded = accessory === undefined; const draw = () => { if (!bodyLoaded || !accessoryLoaded) { @@ -54,14 +55,16 @@ export function renderChatPetAchievementPreview( true, accessory?.eyeAccessoryMirrorsWithFacing !== false, accessory?.coversAntennae === true, + color, ); return; } context.clearRect(0, 0, CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE, CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE); - if (!accessoryImage) { - return; + if (accessoryImage) { + drawChatPetAccessory(context, accessoryImage, 'idle', 0, 'right'); + } else { + context.drawImage(bodyImage, 0, 0); } - drawChatPetAccessory(context, accessoryImage, 'idle', 0, 'right'); context.globalCompositeOperation = 'source-in'; const silhouetteColor = themeService.getColorTheme().getColor(foreground); if (!silhouetteColor) { @@ -74,7 +77,7 @@ export function renderChatPetAchievementPreview( context.globalCompositeOperation = 'source-over'; }; - if (unlocked) { + if (showBody) { store.add(DOM.addDisposableListener(bodyImage, 'load', () => { if (!hasChatPetBodyImageDimensions(bodyImage, CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE, CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE, 1)) { logService.error(`[ChatPetAchievementPreview] Invalid preview body dimensions: ${bodySource}`); @@ -86,7 +89,7 @@ export function renderChatPetAchievementPreview( store.add(DOM.addDisposableListener(bodyImage, 'error', () => { logService.error(`[ChatPetAchievementPreview] Failed to load preview body: ${bodySource}`); })); - bodyImage.src = bodySource; + setChatPetImageSource(bodyImage, bodySource); } if (accessoryImage && accessorySource) { store.add(DOM.addDisposableListener(accessoryImage, 'load', () => { @@ -100,7 +103,7 @@ export function renderChatPetAchievementPreview( store.add(DOM.addDisposableListener(accessoryImage, 'error', () => { logService.error(`[ChatPetAchievementPreview] Failed to load preview accessory: ${accessorySource.url}`); })); - accessoryImage.src = accessorySource.url; + setChatPetImageSource(accessoryImage, accessorySource.url); } return store; diff --git a/src/vs/workbench/contrib/chat/browser/chatPetAchievements.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatPetAchievements.contribution.ts index a2ea0a094825c5..52f910efc143a1 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPetAchievements.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPetAchievements.contribution.ts @@ -25,6 +25,7 @@ import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/edit import { IWorkbenchContribution } from '../../../common/contributions.js'; import { EditorExtensions } from '../../../common/editor.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; import { AICustomizationManagementSection } from '../common/aiCustomizationWorkspaceService.js'; import { ICustomizationHarnessService } from '../common/customizationHarnessService.js'; @@ -32,14 +33,15 @@ import { IAICustomizationItemSource, IAICustomizationListItem } from './aiCustom import { IAICustomizationItemsModel } from './aiCustomization/aiCustomizationItemsModel.js'; import { CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID, chatPetAchievements, ChatPetAchievementIds, isUserAuthoredChatPetCustomization } from './chatPetAchievements.js'; import { ChatPetAchievementsContextKeys, ChatPetAchievementsEditor } from './chatPetAchievementsEditor.js'; -import { ChatPetAchievementsEditorInput } from './chatPetAchievementsEditorInput.js'; +import { ChatPetAchievementsEditorInput, IChatPetCustomizationEditorOptions } from './chatPetAchievementsEditorInput.js'; import { ChatPetContextKeys, IChatPetService } from './chatPetService.js'; +import { CHAT_PET_BLOBBY_COMMAND_ID, CHAT_PET_CHANGE_COLOR_COMMAND_ID } from './chatPetColors.js'; Registry.as(EditorExtensions.EditorPane).registerEditorPane( EditorPaneDescriptor.create( ChatPetAchievementsEditor, ChatPetAchievementsEditor.ID, - localize('chatPet.achievements.editor', "Achievements Editor"), + localize('chatPet.achievements.editor', "Blobby Customization Editor"), ), [new SyncDescriptor(ChatPetAchievementsEditorInput)], ); @@ -91,10 +93,11 @@ registerAction2(class extends Action2 { } async run(accessor: ServicesAccessor): Promise { - if (!accessor.get(IChatPetService).enabled.get()) { + if (!accessor.get(IChatPetService).enabled.get() || accessor.get(IChatEntitlementService).sentiment.hidden) { return; } - await accessor.get(IEditorService).openEditor(ChatPetAchievementsEditorInput.getOrCreate(), { pinned: true }); + const options: IChatPetCustomizationEditorOptions = { pinned: true, tab: 'achievements' }; + await accessor.get(IEditorService).openEditor(ChatPetAchievementsEditorInput.getOrCreate(), options); } }); @@ -133,6 +136,51 @@ export class ChatPetContextContribution extends Disposable implements IWorkbench } } +registerAction2(class extends Action2 { + constructor() { + super({ + id: CHAT_PET_BLOBBY_COMMAND_ID, + title: localize2('chatPet.blobby', "Customize Blobby"), + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.Setup.hidden.negate()), + }); + } + + async run(accessor: ServicesAccessor): Promise { + if (accessor.get(IChatEntitlementService).sentiment.hidden) { + return; + } + const chatPetService = accessor.get(IChatPetService); + const wasEnabled = chatPetService.enabled.get(); + if (!wasEnabled) { + chatPetService.toggle(); + } + chatPetService.unlockAchievement(ChatPetAchievementIds.Blobby); + if (wasEnabled) { + await accessor.get(ICommandService).executeCommand(CHAT_PET_CHANGE_COLOR_COMMAND_ID); + } + } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: CHAT_PET_CHANGE_COLOR_COMMAND_ID, + title: localize2('chatPet.changeColor', "Change Blobby's Color"), + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.Setup.hidden.negate(), ChatPetContextKeys.enabled), + f1: true, + }); + } + + async run(accessor: ServicesAccessor): Promise { + const chatPetService = accessor.get(IChatPetService); + if (!chatPetService.enabled.get() || accessor.get(IChatEntitlementService).sentiment.hidden) { + return; + } + const options: IChatPetCustomizationEditorOptions = { pinned: true, tab: 'color' }; + await accessor.get(IEditorService).openEditor(ChatPetAchievementsEditorInput.getOrCreate(), options); + } +}); + const CHAT_PET_KEEP_EDIT_COMMAND_IDS = new Set([ 'chatEditing.acceptFile', 'chatEditing.acceptAllFiles', @@ -337,10 +385,11 @@ export class ChatPetAchievementsAccessibilityHelp implements IAccessibleViewImpl const previouslyFocusedElement = DOM.getActiveElement(); const editorService = _accessor.get(IEditorService); const content = [ - localize('chatPet.achievements.accessibilityHelp.overview', "The Achievements modal lists agent-feature achievements and their pet hat rewards. Locked cards reveal a hint and reward while keeping the achievement name and exact unlock requirement hidden."), + localize('chatPet.achievements.accessibilityHelp.overview', "Customize Blobby has Achievements and Color tabs. When the page opens, press Tab to focus the selected tab. Use the Left and Right Arrow keys, Home, or End while a tab is focused to switch tabs. Press Tab again to reach the selected tab's content. The Achievements tab lists agent-feature achievements and their pet hat and color rewards. Locked cards reveal a hint and reward while keeping the achievement name and exact unlock requirement hidden."), localize('chatPet.achievements.accessibilityHelp.cards', "Use Tab and Shift+Tab to move through No Hat and the achievement cards. Press Enter or Space on No Hat or an unlocked achievement to change what the pet wears. Newly unlocked cards are announced as New until you activate them. Locked achievements announce their hint and reward and cannot be selected."), + localize('chatPet.achievements.accessibilityHelp.colors', "Change Color in Blobby's context menu always opens the Color tab. Stable and Insiders are available without an achievement. Use /blobby in Chat to unlock True Name and its solid and custom colors. If the pet is off, the command turns it on; if it is already on, it opens the Color tab. Activating the True Name achievement also opens Color. Use Tab to reach a color card, then Enter or Space to apply it. For a custom color, use the color picker or enter an opaque hex value such as #ff8800, then activate Apply Color or press Enter in the hex input. The preview does not change the pet until you apply it."), localize('chatPet.achievements.accessibilityHelp.roadmap', "The final TBD card is informational and lists upcoming pet ideas. The VS Code pet and achievements are experimental and may change."), - localize('chatPet.achievements.accessibilityHelp.close', "Press Escape to close the Achievements modal."), + localize('chatPet.achievements.accessibilityHelp.close', "Press Escape to close Customize Blobby. Unapplied custom color changes are discarded."), ].join('\n\n'); return new AccessibleContentProvider( AccessibleViewProviderId.ChatPetAchievements, diff --git a/src/vs/workbench/contrib/chat/browser/chatPetAchievements.ts b/src/vs/workbench/contrib/chat/browser/chatPetAchievements.ts index 7e4a48fd950e37..46bc10f9e6cfa6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPetAchievements.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPetAchievements.ts @@ -26,6 +26,7 @@ export const ChatPetAchievementIds = { McpServerPresent: 'mcpServerPresent', InstructionPresent: 'instructionPresent', ImageRequest: 'imageRequest', + Blobby: 'blobby', } as const; export type ChatPetAchievementId = typeof ChatPetAchievementIds[keyof typeof ChatPetAchievementIds]; @@ -66,13 +67,14 @@ export interface IChatPetAchievement { readonly title: string; readonly description: string; readonly hint: string; - readonly accessories: readonly [IChatPetAccessory]; + readonly accessories: readonly [] | readonly [IChatPetAccessory]; + readonly colorCustomization?: boolean; readonly enabled: boolean; } export type ChatPetAchievementPresentation = | { readonly locked: true; readonly id: ChatPetAchievementId; readonly hint: string; readonly rewardLabels: readonly string[] } - | { readonly locked: false; readonly id: ChatPetAchievementId; readonly title: string; readonly description: string; readonly accessories: readonly IChatPetAccessory[] }; + | { readonly locked: false; readonly id: ChatPetAchievementId; readonly title: string; readonly description: string; readonly accessories: readonly IChatPetAccessory[]; readonly rewardLabels: readonly string[] }; const enabledChatPetAchievements: readonly IChatPetAchievement[] = [ { @@ -270,6 +272,15 @@ const enabledChatPetAchievements: readonly IChatPetAchievement[] = [ coversAntennae: true, }], }, + { + id: ChatPetAchievementIds.Blobby, + title: localize('chatPet.achievement.blobby.title', "True Name"), + description: localize('chatPet.achievement.blobby.description', "Blob the Builder? Blobby McBlobface? Nope, my name is Blobby."), + hint: localize('chatPet.achievement.blobby.hint', "This little blob has a name. Try calling it in Chat."), + enabled: true, + accessories: [], + colorCustomization: true, + }, ]; export const disabledChatPetAchievements: readonly IChatPetAchievement[] = [ @@ -416,6 +427,7 @@ export function getChatPetCustomizationAchievementIds( } export function getChatPetAchievementPresentation(achievement: IChatPetAchievement, unlocked: boolean): ChatPetAchievementPresentation { + const rewardLabels = getChatPetAchievementRewardLabels(achievement); return unlocked ? { locked: false, @@ -423,15 +435,22 @@ export function getChatPetAchievementPresentation(achievement: IChatPetAchieveme title: achievement.title, description: achievement.description, accessories: achievement.accessories, + rewardLabels, } : { locked: true, id: achievement.id, hint: achievement.hint, - rewardLabels: achievement.accessories.map(accessory => accessory.label), + rewardLabels, }; } +export function getChatPetAchievementRewardLabels(achievement: IChatPetAchievement): readonly string[] { + return achievement.colorCustomization + ? [localize('chatPet.achievement.colorCustomization', "Color Customization")] + : achievement.accessories.map(accessory => accessory.label); +} + export function getUnlockedChatPetAccessories(unlockedAchievements: readonly ChatPetAchievementId[]): readonly IChatPetAccessory[] { const unlocked = new Set(unlockedAchievements); return chatPetAchievements diff --git a/src/vs/workbench/contrib/chat/browser/chatPetAchievementsEditor.ts b/src/vs/workbench/contrib/chat/browser/chatPetAchievementsEditor.ts index c8e6cbffeb055f..daf7dd7b216184 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPetAchievementsEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPetAchievementsEditor.ts @@ -4,12 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import * as DOM from '../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; @@ -17,12 +20,14 @@ import { IThemeService } from '../../../../platform/theme/common/themeService.js import { EditorPane } from '../../../browser/parts/editor/editorPane.js'; import { IEditorOpenContext } from '../../../common/editor.js'; import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; +import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; import { ChatPetAchievementsWidget } from './chatPetAchievementsWidget.js'; -import { ChatPetAchievementsEditorInput } from './chatPetAchievementsEditorInput.js'; +import { ChatPetAchievementsEditorInput, ChatPetCustomizationTab, IChatPetCustomizationEditorOptions } from './chatPetAchievementsEditorInput.js'; import { IChatPetService } from './chatPetService.js'; +import { ChatPetColorsWidget } from './chatPetColorsWidget.js'; export const ChatPetAchievementsContextKeys = { - focused: new RawContextKey('chatPetAchievementsFocused', false, localize('chatPet.achievements.context.focused', "Whether the chat pet Achievements modal is focused")), + focused: new RawContextKey('chatPetAchievementsFocused', false, localize('chatPet.achievements.context.focused', "Whether the Blobby customization modal is focused")), }; export class ChatPetAchievementsEditor extends EditorPane { @@ -32,7 +37,10 @@ export class ChatPetAchievementsEditor extends EditorPane { private readonly editorDisposables = this._register(new DisposableStore()); private readonly focusedContextKey: IContextKey; private container: HTMLElement | undefined; - private widget: ChatPetAchievementsWidget | undefined; + private readonly widget = this._register(new MutableDisposable()); + private readonly tabs = new Map(); + private activeTab: ChatPetCustomizationTab = 'achievements'; + private tabBar: HTMLElement | undefined; private dimension: DOM.Dimension | undefined; constructor( @@ -43,43 +51,126 @@ export class ChatPetAchievementsEditor extends EditorPane { @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService contextKeyService: IContextKeyService, @IChatPetService private readonly chatPetService: IChatPetService, + @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, ) { super(ChatPetAchievementsEditor.ID, group, telemetryService, themeService, storageService); this.focusedContextKey = ChatPetAchievementsContextKeys.focused.bindTo(contextKeyService); this._register(toDisposable(() => this.focusedContextKey.reset())); this._register(autorun(reader => { - if (!this.chatPetService.enabled.read(reader) && this.input) { + if ((!this.chatPetService.enabled.read(reader) || this.chatEntitlementService.sentimentObs.read(reader).hidden) && this.input) { void this.group.closeEditor(this.input); } })); } protected override createEditor(parent: HTMLElement): void { + this.widget.clear(); this.editorDisposables.clear(); - this.container = DOM.append(parent, DOM.$('.chat-pet-achievements-editor')); + this.tabs.clear(); + this.container = DOM.append(parent, DOM.$('.chat-pet-achievements-editor', { + role: 'group', + tabindex: '-1', + 'aria-label': localize('chatPet.customization.label', "Customize Blobby"), + })); const focusTracker = this.editorDisposables.add(DOM.trackFocus(this.container)); this.editorDisposables.add(focusTracker.onDidFocus(() => this.focusedContextKey.set(true))); this.editorDisposables.add(focusTracker.onDidBlur(() => this.focusedContextKey.set(false))); - this.widget = this.editorDisposables.add(this.instantiationService.createInstance(ChatPetAchievementsWidget, this.container, () => { - if (this.input) { - void this.group.closeEditor(this.input); - } + this.tabBar = DOM.append(this.container, DOM.$('.chat-pet-customization-tabs', { + role: 'tablist', + 'aria-label': localize('chatPet.customization.tabs', "Blobby customization"), })); + const tabIds: readonly ChatPetCustomizationTab[] = ['achievements', 'color']; + const prefix = generateUuid(); + for (const [index, tab] of tabIds.entries()) { + const button = this.editorDisposables.add(new Button(this.tabBar, {})); + button.label = tab === 'achievements' + ? localize('chatPet.customization.achievements', "Achievements") + : localize('chatPet.customization.color', "Color"); + button.element.setAttribute('role', 'tab'); + button.element.id = `${prefix}-${tab}`; + const panel = DOM.append(this.container, DOM.$('.chat-pet-customization-panel', { + role: 'tabpanel', + id: `${prefix}-${tab}-panel`, + 'aria-labelledby': button.element.id, + })); + button.element.setAttribute('aria-controls', panel.id); + this.tabs.set(tab, { button, panel }); + this.editorDisposables.add(button.onDidClick(() => this.showTab(tab))); + this.editorDisposables.add(button.onDidEscape(() => this.close())); + this.editorDisposables.add(DOM.addDisposableListener(button.element, DOM.EventType.KEY_DOWN, e => { + const event = new StandardKeyboardEvent(e); + const nextIndex = event.equals(KeyCode.LeftArrow) + ? (index + tabIds.length - 1) % tabIds.length + : event.equals(KeyCode.RightArrow) ? (index + 1) % tabIds.length + : event.equals(KeyCode.Home) ? 0 + : event.equals(KeyCode.End) ? tabIds.length - 1 : undefined; + if (nextIndex !== undefined) { + DOM.EventHelper.stop(e, true); + const nextTab = tabIds[nextIndex]; + this.showTab(nextTab); + this.tabs.get(nextTab)?.button.focus(); + } + })); + } + this.showTab(this.activeTab); } - override async setInput(input: ChatPetAchievementsEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { + override async setInput(input: ChatPetAchievementsEditorInput, options: IChatPetCustomizationEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { await super.setInput(input, options, context, token); - if (!this.chatPetService.enabled.get()) { + if (!this.chatPetService.enabled.get() || this.chatEntitlementService.sentiment.hidden) { await this.group.closeEditor(input); return; } + this.showTab(options?.tab ?? 'achievements'); if (this.dimension) { this.layout(this.dimension); } } + override setOptions(options: IChatPetCustomizationEditorOptions | undefined): void { + super.setOptions(options); + if (options?.tab) { + this.showTab(options.tab); + } + } + + private showTab(tab: ChatPetCustomizationTab): void { + const previousPanel = this.tabs.get(this.activeTab)?.panel; + const needsWidget = tab !== this.activeTab || !this.widget.value; + this.activeTab = tab; + for (const [id, entry] of this.tabs) { + const selected = id === tab; + entry.button.element.setAttribute('aria-selected', String(selected)); + entry.button.element.tabIndex = selected ? 0 : -1; + entry.panel.hidden = !selected; + } + const panel = this.tabs.get(tab)?.panel; + if (needsWidget && panel) { + this.widget.clear(); + if (previousPanel) { + DOM.clearNode(previousPanel); + } + this.widget.value = tab === 'color' + ? this.instantiationService.createInstance(ChatPetColorsWidget, panel, () => this.close()) + : this.instantiationService.createInstance(ChatPetAchievementsWidget, panel, () => this.close()); + if (this.dimension) { + this.layout(this.dimension); + } + } + } + + private close(): void { + if (this.input) { + void this.group.closeEditor(this.input); + } + } + override clearInput(): void { this.focusedContextKey.set(false); + this.widget.clear(); + for (const { panel } of this.tabs.values()) { + DOM.clearNode(panel); + } super.clearInput(); } @@ -89,11 +180,11 @@ export class ChatPetAchievementsEditor extends EditorPane { this.container.style.width = `${dimension.width}px`; this.container.style.height = `${dimension.height}px`; } - this.widget?.layout(dimension); + this.widget.value?.layout(new DOM.Dimension(dimension.width, Math.max(0, dimension.height - (this.tabBar?.offsetHeight ?? 0)))); } override focus(): void { super.focus(); - this.widget?.focus(); + this.container?.focus(); } } diff --git a/src/vs/workbench/contrib/chat/browser/chatPetAchievementsEditorInput.ts b/src/vs/workbench/contrib/chat/browser/chatPetAchievementsEditorInput.ts index 0ba4090aeda479..bdf13003b68cea 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPetAchievementsEditorInput.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPetAchievementsEditorInput.ts @@ -6,10 +6,16 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize } from '../../../../nls.js'; -import { IModalEditorOptions, IModalEditorOptionsProvider } from '../../../../platform/editor/common/editor.js'; +import { IEditorOptions, IModalEditorOptions, IModalEditorOptionsProvider } from '../../../../platform/editor/common/editor.js'; import { EditorInputCapabilities, IUntypedEditorInput } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; +export type ChatPetCustomizationTab = 'achievements' | 'color'; + +export interface IChatPetCustomizationEditorOptions extends IEditorOptions { + readonly tab?: ChatPetCustomizationTab; +} + export class ChatPetAchievementsEditorInput extends EditorInput implements IModalEditorOptionsProvider { static readonly ID = 'workbench.editors.chatPetAchievements'; @@ -33,7 +39,7 @@ export class ChatPetAchievementsEditorInput extends EditorInput implements IModa } override getName(): string { - return localize('chatPet.achievements.editorName', "Achievements"); + return localize('chatPet.achievements.editorName', "Customize Blobby"); } override getIcon(): ThemeIcon { diff --git a/src/vs/workbench/contrib/chat/browser/chatPetAchievementsWidget.ts b/src/vs/workbench/contrib/chat/browser/chatPetAchievementsWidget.ts index 0d5d69f85fc611..776d1fae433aa4 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPetAchievementsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPetAchievementsWidget.ts @@ -12,11 +12,13 @@ import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.j import { autorun, observableSignalFromEvent } from '../../../../base/common/observable.js'; import { ScrollbarVisibility } from '../../../../base/common/scrollable.js'; import { localize } from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { renderChatPetAchievementPreview, CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE } from './chatPetAchievementPreview.js'; import { chatPetAchievements, ChatPetAccessoryId, ChatPetAchievementId, getChatPetAccessory, getChatPetAchievementPresentation } from './chatPetAchievements.js'; -import { ChatPetVariant, IChatPetService } from './chatPetService.js'; +import { IChatPetService } from './chatPetService.js'; +import { CHAT_PET_CHANGE_COLOR_COMMAND_ID, ChatPetColor } from './chatPetColors.js'; export class ChatPetAchievementsWidget extends Disposable { @@ -32,6 +34,7 @@ export class ChatPetAchievementsWidget extends Disposable { readonly defaultAriaLabel: string; readonly achievementId?: ChatPetAchievementId; readonly newBadge?: HTMLElement; + readonly colorCustomization?: boolean; }>(); private unseenAchievementIds = new Set(); private focusTarget: (() => void) | undefined; @@ -43,6 +46,7 @@ export class ChatPetAchievementsWidget extends Disposable { @IChatPetService private readonly chatPetService: IChatPetService, @IThemeService private readonly themeService: IThemeService, @ILogService private readonly logService: ILogService, + @ICommandService private readonly commandService: ICommandService, ) { super(); @@ -63,9 +67,9 @@ export class ChatPetAchievementsWidget extends Disposable { const themeChanged = observableSignalFromEvent(this, this.themeService.onDidColorThemeChange); this._register(autorun(reader => { const unlockedAchievements = this.chatPetService.unlockedAchievements.read(reader); - const variant = this.chatPetService.variant.read(reader); + const color = this.chatPetService.color.read(reader); themeChanged.read(reader); - this.render(unlockedAchievements, this.chatPetService.selectedAccessory.read(undefined), variant); + this.render(unlockedAchievements, this.chatPetService.selectedAccessory.read(undefined), color); })); this._register(autorun(reader => { this.updateSelectedAccessory(this.chatPetService.selectedAccessory.read(reader)); @@ -96,9 +100,10 @@ export class ChatPetAchievementsWidget extends Disposable { this.focusTarget?.(); } - private render(unlockedAchievements: readonly ChatPetAchievementId[], selectedAccessory: ChatPetAccessoryId | undefined, variant: ChatPetVariant): void { + private render(unlockedAchievements: readonly ChatPetAchievementId[], selectedAccessory: ChatPetAccessoryId | undefined, color: ChatPetColor): void { const activeElement = DOM.getActiveElement(); - const restoreFocusId = DOM.isHTMLElement(activeElement) ? activeElement.closest('.chat-pet-achievement-card')?.dataset.accessoryId : undefined; + const focusedCard = DOM.isHTMLElement(activeElement) ? activeElement.closest('.chat-pet-achievement-card') : undefined; + const restoreFocusId = focusedCard?.dataset.accessoryId ?? focusedCard?.dataset.achievementId; this.renderDisposables.clear(); DOM.clearNode(this.content); this.accessoryCards.clear(); @@ -106,7 +111,7 @@ export class ChatPetAchievementsWidget extends Disposable { const inner = DOM.append(this.content, DOM.$('.chat-pet-achievements-inner')); DOM.append(inner, DOM.$('h1')).textContent = localize('chatPet.achievements.title', "Achievements"); - DOM.append(inner, DOM.$('p.chat-pet-achievements-intro')).textContent = localize('chatPet.achievements.intro', "Unlock hats as you explore agent features, then choose what your pet wears by selecting an unlocked card."); + DOM.append(inner, DOM.$('p.chat-pet-achievements-intro')).textContent = localize('chatPet.achievements.intro', "Unlock hats and colors as you explore agent features, then customize Blobby by selecting an unlocked card."); const unlockedSet = new Set(unlockedAchievements); const collection = DOM.append(inner, DOM.$('section.chat-pet-achievements-collection')); collection.setAttribute('role', 'region'); @@ -141,7 +146,7 @@ export class ChatPetAchievementsWidget extends Disposable { noHatCard.element.setAttribute('aria-pressed', String(noHatSelected)); noHatCard.element.classList.toggle('wearing', noHatSelected); const noHatPreviews = DOM.append(noHatCard.element, DOM.$('.chat-pet-achievement-previews')); - this.renderCardPreview(noHatPreviews, undefined, true, variant); + this.renderCardPreview(noHatPreviews, undefined, true, color); const noHatContent = DOM.append(noHatCard.element, DOM.$('.chat-pet-achievement-card-content')); DOM.append(noHatContent, DOM.$('h3')).textContent = localize('chatPet.achievements.noHat', "No Hat"); const noHatState = DOM.append(noHatContent, DOM.$('span.chat-pet-achievement-state')); @@ -164,27 +169,32 @@ export class ChatPetAchievementsWidget extends Disposable { const presentation = getChatPetAchievementPresentation(achievement, unlocked); const wearing = unlocked && achievement.accessories.some(accessory => selectedAccessory === accessory.id); const item = DOM.append(list, DOM.$('li.chat-pet-achievements-list-item')); - const accessoryId = achievement.accessories[0].id; + const accessory = achievement.accessories[0]; + const cardId = accessory?.id ?? achievement.id; + const rewardLabel = presentation.rewardLabels.join(', '); + const defaultState = achievement.colorCustomization + ? localize('chatPet.achievement.changeColor', "Change Color") + : localize('chatPet.achievement.unlocked', "Unlocked"); const card = this.renderDisposables.add(new Button(item, { secondary: true, ariaLabel: presentation.locked ? localize('chatPet.achievement.lockedAriaLabel', "Locked. Hint: {0} Rewards: {1}.", presentation.hint, presentation.rewardLabels.join(', ')) - : localize('chatPet.achievement.cardAriaLabel', "{0}. Reward: {1}. {2}", presentation.title, presentation.accessories[0].label, wearing ? localize('chatPet.achievement.wearing', "Wearing") : localize('chatPet.achievement.unlocked', "Unlocked")), + : localize('chatPet.achievement.cardAriaLabel', "{0}. Reward: {1}. {2}", presentation.title, rewardLabel, wearing ? localize('chatPet.achievement.wearing', "Wearing") : defaultState), })); card.element.classList.add('chat-pet-achievement-card'); - card.element.dataset.accessoryId = accessoryId; + card.element.dataset.achievementId = achievement.id; + if (accessory) { + card.element.dataset.accessoryId = accessory.id; + card.element.setAttribute('aria-pressed', String(wearing)); + } card.element.classList.toggle('locked', !unlocked); card.element.classList.toggle('wearing', wearing); - card.element.setAttribute('aria-pressed', String(wearing)); card.enabled = unlocked; const newBadge = DOM.append(card.element, DOM.$('span.chat-pet-achievement-new-badge.hidden')); newBadge.textContent = localize('chatPet.achievement.new', "New"); newBadge.setAttribute('aria-hidden', 'true'); const previews = DOM.append(card.element, DOM.$('.chat-pet-achievement-previews')); - const previewAccessories = unlocked ? achievement.accessories : [achievement.accessories[0]]; - for (const accessory of previewAccessories) { - this.renderCardPreview(previews, accessory, unlocked, variant); - } + this.renderCardPreview(previews, accessory, unlocked, color); const cardContent = DOM.append(card.element, DOM.$('.chat-pet-achievement-card-content')); if (!presentation.locked) { @@ -192,17 +202,18 @@ export class ChatPetAchievementsWidget extends Disposable { const state = DOM.append(cardContent, DOM.$('span.chat-pet-achievement-state')); state.textContent = wearing ? localize('chatPet.achievement.wearing', "Wearing") - : localize('chatPet.achievement.unlocked', "Unlocked"); + : defaultState; DOM.append(cardContent, DOM.$('p.chat-pet-achievement-description')).textContent = presentation.description; - DOM.append(cardContent, DOM.$('p.chat-pet-achievement-reward')).textContent = localize('chatPet.achievement.rewards', "Rewards: {0}", presentation.accessories.map(accessory => accessory.label).join(', ')); - this.accessoryCards.set(accessoryId, { + DOM.append(cardContent, DOM.$('p.chat-pet-achievement-reward')).textContent = localize('chatPet.achievement.rewards', "Rewards: {0}", rewardLabel); + this.accessoryCards.set(cardId, { button: card, state, - defaultState: localize('chatPet.achievement.unlocked', "Unlocked"), - selectedAriaLabel: localize('chatPet.achievement.cardAriaLabel', "{0}. Reward: {1}. {2}", achievement.title, achievement.accessories[0].label, localize('chatPet.achievement.wearing', "Wearing")), - defaultAriaLabel: localize('chatPet.achievement.cardAriaLabel', "{0}. Reward: {1}. {2}", achievement.title, achievement.accessories[0].label, localize('chatPet.achievement.unlocked', "Unlocked")), + defaultState, + selectedAriaLabel: localize('chatPet.achievement.cardAriaLabel', "{0}. Reward: {1}. {2}", achievement.title, rewardLabel, localize('chatPet.achievement.wearing', "Wearing")), + defaultAriaLabel: localize('chatPet.achievement.cardAriaLabel', "{0}. Reward: {1}. {2}", achievement.title, rewardLabel, defaultState), achievementId: achievement.id, newBadge, + colorCustomization: achievement.colorCustomization, }); } else { DOM.append(cardContent, DOM.$('h3')).textContent = localize('chatPet.achievement.locked', "Locked"); @@ -210,9 +221,16 @@ export class ChatPetAchievementsWidget extends Disposable { DOM.append(cardContent, DOM.$('p.chat-pet-achievement-description')).textContent = presentation.hint; DOM.append(cardContent, DOM.$('p.chat-pet-achievement-reward')).textContent = localize('chatPet.achievement.rewards', "Rewards: {0}", presentation.rewardLabels.join(', ')); } - this.renderDisposables.add(card.onDidClick(() => this.selectAccessory(accessoryId, achievement.id))); + this.renderDisposables.add(card.onDidClick(async () => { + if (achievement.colorCustomization) { + this.chatPetService.markAchievementSeen(achievement.id); + await this.commandService.executeCommand(CHAT_PET_CHANGE_COLOR_COMMAND_ID); + } else { + this.selectAccessory(accessory?.id, achievement.id); + } + })); this.renderDisposables.add(card.onDidEscape(() => this.onDidRequestClose())); - cards.set(accessoryId, card); + cards.set(cardId, card); } const roadmapItem = DOM.append(list, DOM.$('li.chat-pet-achievements-list-item')); @@ -226,7 +244,6 @@ export class ChatPetAchievementsWidget extends Disposable { roadmapIntro.textContent = localize('chatPet.achievements.roadmap.intro', "Upcoming pet features:"); const roadmapList = DOM.append(roadmapContent, DOM.$('ul.chat-pet-achievement-roadmap-list')); for (const item of [ - localize('chatPet.achievements.roadmap.namingCompetition', "A naming competition"), localize('chatPet.achievements.roadmap.moreAchievements', "More achievements and built-in hats"), localize('chatPet.achievements.roadmap.customHats', "Customizable hats that you can upload"), ]) { @@ -250,12 +267,12 @@ export class ChatPetAchievementsWidget extends Disposable { } } - private renderCardPreview(previews: HTMLElement, accessory: Parameters[1], unlocked: boolean, variant: ChatPetVariant): void { + private renderCardPreview(previews: HTMLElement, accessory: Parameters[1], unlocked: boolean, color: ChatPetColor): void { const preview = DOM.append(previews, DOM.$('canvas.chat-pet-achievement-preview')) as HTMLCanvasElement; preview.width = CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE; preview.height = CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE; preview.setAttribute('aria-hidden', 'true'); - this.renderDisposables.add(renderChatPetAchievementPreview(preview, accessory, unlocked, variant, this.themeService, this.logService)); + this.renderDisposables.add(renderChatPetAchievementPreview(preview, accessory, unlocked, color, this.themeService, this.logService)); } private selectAccessory(accessoryId: ChatPetAccessoryId | undefined, achievementId?: ChatPetAchievementId): void { @@ -276,7 +293,9 @@ export class ChatPetAchievementsWidget extends Disposable { for (const [id, card] of this.accessoryCards) { const wearing = id === selectedId; card.button.element.classList.toggle('wearing', wearing); - card.button.element.setAttribute('aria-pressed', String(wearing)); + if (!card.colorCustomization) { + card.button.element.setAttribute('aria-pressed', String(wearing)); + } const baseAriaLabel = wearing ? card.selectedAriaLabel : card.defaultAriaLabel; card.button.setAriaLabel(card.achievementId && this.unseenAchievementIds.has(card.achievementId) ? localize('chatPet.achievement.cardNewAriaLabel', "{0}. New", baseAriaLabel) diff --git a/src/vs/workbench/contrib/chat/browser/chatPetColors.ts b/src/vs/workbench/contrib/chat/browser/chatPetColors.ts new file mode 100644 index 00000000000000..e778cbc4f244aa --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatPetColors.ts @@ -0,0 +1,144 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../base/browser/dom.js'; +import { Color, RGBA } from '../../../../base/common/color.js'; +import { LinkedMap, Touch } from '../../../../base/common/map.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; + +export const CHAT_PET_CHANGE_COLOR_COMMAND_ID = 'chat.pet.changeColor'; +export const CHAT_PET_BLOBBY_COMMAND_ID = 'chat.pet.blobby'; + +export type ChatPetVariant = 'stable' | 'insiders'; +export type ChatPetColor = ChatPetVariant | `#${string}`; + +export const chatPetColorPresets: readonly { readonly color: ChatPetColor; readonly label: string }[] = [ + { color: 'stable', label: localize('chatPet.color.stable', "Stable") }, + { color: 'insiders', label: localize('chatPet.color.insiders', "Insiders") }, + { color: '#ff8c00', label: localize('chatPet.color.exploration', "Exploration") }, + { color: '#e6536f', label: localize('chatPet.color.red', "Red") }, + { color: '#e6c94d', label: localize('chatPet.color.yellow', "Yellow") }, + { color: '#55b85a', label: localize('chatPet.color.green', "Green") }, + { color: '#a277e6', label: localize('chatPet.color.purple', "Purple") }, + { color: '#ed83b5', label: localize('chatPet.color.pink', "Pink") }, + { color: '#ffffff', label: localize('chatPet.color.white', "White") }, + { color: '#000000', label: localize('chatPet.color.black', "Black") }, +]; + +export function parseChatPetColor(value: string): ChatPetColor | undefined { + if (value === 'stable' || value === 'insiders') { + return value; + } + const hex = value.trim(); + if (!/^#(?:[\da-f]{3}|[\da-f]{6})$/i.test(hex)) { + return undefined; + } + const digits = hex.slice(1).toLowerCase(); + return `#${digits.length === 3 ? [...digits].map(digit => digit + digit).join('') : digits}`; +} + +export function getChatPetColorVariant(color: ChatPetColor): ChatPetVariant { + return color === 'insiders' ? 'insiders' : 'stable'; +} + +export function isDefaultChatPetColor(color: ChatPetColor): color is ChatPetVariant { + return color === 'stable' || color === 'insiders'; +} + +export function getChatPetBodyColor(color: ChatPetColor): string { + return color === 'stable' ? '#23a8f2' : color === 'insiders' ? '#24bfa5' : color; +} + +export function getChatPetEyeColor(color: ChatPetColor): string { + return color !== 'stable' && color !== 'insiders' && Color.fromHex(color).getContrastRatio(Color.fromHex('#212324')) < 3 + ? '#f5f5f5' + : '#191a1b'; +} + +const coloredSprites = new WeakMap(); +const coloredSpriteSources = new LinkedMap(); +const MAX_CACHED_SPRITE_PIXELS = 2 * 1024 * 1024; +let cachedSpritePixels = 0; + +export function setChatPetImageSource(image: HTMLImageElement, source: string): void { + const scheme = URI.parse(source).scheme; + image.removeAttribute('src'); + image.crossOrigin = scheme === Schemas.http || scheme === Schemas.https ? 'anonymous' : null; + image.src = source; +} + +/** Reuses recolored sheets across sprite buffers, with at most 8 MiB of source pixels held strongly. */ +export function getChatPetColoredSprite(image: HTMLImageElement, color: ChatPetColor): HTMLImageElement | HTMLCanvasElement { + if (color === 'stable' || color === 'insiders') { + return image; + } + const source = image.getAttribute('src'); + const cached = coloredSprites.get(image); + if (cached?.source === source && cached.color === color) { + return cached.canvas; + } + const sourceKey = source ? `${source}:${image.naturalWidth}x${image.naturalHeight}:${color}` : undefined; + const sourceCanvas = sourceKey ? coloredSpriteSources.get(sourceKey, Touch.AsNew) : undefined; + if (sourceCanvas) { + coloredSprites.set(image, { source, color, canvas: sourceCanvas }); + return sourceCanvas; + } + const canvas = DOM.$('canvas'); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Unable to create the Blobby color canvas'); + } + context.imageSmoothingEnabled = false; + context.drawImage(image, 0, 0); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height); + const body = Color.fromHex(color); + const shade = body.darken(0.25).rgba; + const shadow = body.darken(0.5).rgba; + const palette = new Map([ + [0x23a8f2, body.rgba], + [0x24bfa5, body.rgba], + [0x0077b8, shade], + [0x009a7c, shade], + [0x004e7c, shadow], + [0x004538, shadow], + [0x006451, shadow], + [0x006652, shadow], + ]); + const eyeColor = getChatPetEyeColor(color); + if (eyeColor !== '#191a1b') { + const eyes = Color.fromHex(eyeColor).rgba; + palette.set(0x191a1b, eyes); + palette.set(0x212324, eyes); + } + for (let i = 0; i < pixels.data.length; i += 4) { + if (pixels.data[i + 3] === 0) { + continue; + } + const replacement = palette.get((pixels.data[i] << 16) | (pixels.data[i + 1] << 8) | pixels.data[i + 2]); + if (replacement) { + pixels.data[i] = replacement.r; + pixels.data[i + 1] = replacement.g; + pixels.data[i + 2] = replacement.b; + } + } + context.putImageData(pixels, 0, 0); + coloredSprites.set(image, { source, color, canvas }); + const pixelCount = canvas.width * canvas.height; + if (sourceKey && pixelCount <= MAX_CACHED_SPRITE_PIXELS) { + coloredSpriteSources.set(sourceKey, canvas, Touch.AsNew); + cachedSpritePixels += pixelCount; + while (cachedSpritePixels > MAX_CACHED_SPRITE_PIXELS) { + const oldest = coloredSpriteSources.shift(); + if (oldest) { + cachedSpritePixels -= oldest.width * oldest.height; + } + } + } + return canvas; +} diff --git a/src/vs/workbench/contrib/chat/browser/chatPetColorsWidget.ts b/src/vs/workbench/contrib/chat/browser/chatPetColorsWidget.ts new file mode 100644 index 00000000000000..73b30cdb5d16a4 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatPetColorsWidget.ts @@ -0,0 +1,212 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/chatPetAchievements.css'; +import * as DOM from '../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { InputBox, MessageType } from '../../../../base/browser/ui/inputbox/inputBox.js'; +import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { Disposable, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { ScrollbarVisibility } from '../../../../base/common/scrollable.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { localize } from '../../../../nls.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE, renderChatPetAchievementPreview } from './chatPetAchievementPreview.js'; +import { ChatPetAchievementIds } from './chatPetAchievements.js'; +import { ChatPetColor, chatPetColorPresets, getChatPetBodyColor, isDefaultChatPetColor, parseChatPetColor } from './chatPetColors.js'; +import { IChatPetService } from './chatPetService.js'; + +export class ChatPetColorsWidget extends Disposable { + + private readonly container: HTMLElement; + private readonly content: HTMLElement; + private readonly scrollable: DomScrollableElement; + private readonly cards = new Map(); + private readonly hexInput: InputBox; + private readonly colorInput: HTMLInputElement; + private readonly applyButton: Button; + private readonly validationMessage: HTMLElement; + private readonly customPreview: HTMLCanvasElement; + private readonly previewDisposable = this._register(new MutableDisposable()); + private customizationUnlocked = false; + private selectedColor: ChatPetColor | undefined; + private previewColor: ChatPetColor | undefined; + + constructor( + parent: HTMLElement, + onDidRequestClose: () => void, + @IChatPetService private readonly chatPetService: IChatPetService, + @IThemeService private readonly themeService: IThemeService, + @ILogService private readonly logService: ILogService, + ) { + super(); + this.container = DOM.append(parent, DOM.$('.chat-pet-colors-widget')); + this.content = DOM.$('.chat-pet-achievements-content'); + this.scrollable = this._register(new DomScrollableElement(this.content, { + horizontal: ScrollbarVisibility.Hidden, + vertical: ScrollbarVisibility.Auto, + })); + this.container.appendChild(this.scrollable.getDomNode()); + this._register(DOM.addDisposableListener(this.content, DOM.EventType.SCROLL, () => { + this.scrollable.setScrollPosition({ scrollTop: this.content.scrollTop }); + }, { passive: true })); + this._register(DOM.addDisposableListener(this.container, DOM.EventType.KEY_DOWN, e => { + if (new StandardKeyboardEvent(e).equals(KeyCode.Escape)) { + DOM.EventHelper.stop(e, true); + onDidRequestClose(); + } + })); + + const inner = DOM.append(this.content, DOM.$('.chat-pet-achievements-inner')); + DOM.append(inner, DOM.$('h1')).textContent = localize('chatPet.colors.title', "Color"); + DOM.append(inner, DOM.$('p.chat-pet-achievements-intro')).textContent = localize('chatPet.colors.intro', "A little more you. Pick a classic look or give Blobby a color of its own."); + const list = DOM.append(inner, DOM.$('ul.chat-pet-colors-list', { + 'aria-label': localize('chatPet.colors.presets', "Blobby color presets"), + })); + for (const preset of chatPetColorPresets) { + const item = DOM.append(list, DOM.$('li')); + const button = this._register(new Button(item, { secondary: true })); + button.element.classList.add('chat-pet-color-card'); + button.element.dataset.color = preset.color; + const preview = DOM.append(button.element, DOM.$('canvas.chat-pet-color-preview')); + preview.width = CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE; + preview.height = CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE; + preview.setAttribute('aria-hidden', 'true'); + this._register(renderChatPetAchievementPreview(preview, undefined, true, preset.color, themeService, logService)); + DOM.append(button.element, DOM.$('span.chat-pet-color-label')).textContent = preset.label; + const state = DOM.append(button.element, DOM.$('span.chat-pet-color-state')); + this.cards.set(preset.color, { button, state, label: preset.label }); + this._register(button.onDidClick(() => this.selectColor(preset.color))); + this._register(button.onDidEscape(onDidRequestClose)); + } + + const unlockHint = DOM.append(inner, DOM.$('p.chat-pet-colors-unlock-hint')); + unlockHint.textContent = localize('chatPet.colors.unlockHint', "Discover Blobby's True Name to unlock solid colors and custom colors."); + const custom = DOM.append(inner, DOM.$('section.chat-pet-custom-color')); + DOM.append(custom, DOM.$('h2')).textContent = localize('chatPet.colors.custom', "Custom color"); + DOM.append(custom, DOM.$('p')).textContent = localize('chatPet.colors.customDescription', "Choose any color or enter a hex value. Apply it when it feels right."); + const customBody = DOM.append(custom, DOM.$('.chat-pet-custom-color-body')); + this.customPreview = DOM.append(customBody, DOM.$('canvas.chat-pet-color-preview.custom-preview')); + this.customPreview.width = CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE; + this.customPreview.height = CHAT_PET_ACHIEVEMENT_PREVIEW_SIZE; + this.customPreview.setAttribute('aria-hidden', 'true'); + const controls = DOM.append(customBody, DOM.$('.chat-pet-custom-color-controls')); + const row = DOM.append(controls, DOM.$('.chat-pet-custom-color-inputs')); + this.colorInput = DOM.append(row, DOM.$('input.chat-pet-color-picker', { + type: 'color', + 'aria-label': localize('chatPet.colors.chooseCustom', "Choose Blobby's custom color"), + })); + this.hexInput = this._register(new InputBox(row, undefined, { + inputBoxStyles: defaultInputBoxStyles, + ariaLabel: localize('chatPet.colors.hex', "Custom hex color"), + placeholder: '#ff8800', + validationOptions: { + validation: value => { + const color = parseChatPetColor(value); + return color && !isDefaultChatPetColor(color) ? null : { + type: MessageType.ERROR, + content: localize('chatPet.color.invalid', "Enter a hex color such as #ff8800 or #f80."), + }; + }, + }, + })); + this.applyButton = this._register(new Button(row, defaultButtonStyles)); + this.applyButton.label = localize('chatPet.colors.apply', "Apply Color"); + this._register(this.applyButton.onDidEscape(onDidRequestClose)); + this.validationMessage = DOM.append(controls, DOM.$('.chat-pet-color-validation', { role: 'status', id: generateUuid() })); + this.hexInput.inputElement.setAttribute('aria-describedby', this.validationMessage.id); + + this._register(DOM.addDisposableListener(this.colorInput, DOM.EventType.INPUT, () => { + this.hexInput.value = this.colorInput.value; + })); + this._register(this.hexInput.onDidChange(() => this.updateDraft())); + this._register(this.applyButton.onDidClick(() => this.applyCustomColor())); + this._register(DOM.addDisposableListener(this.hexInput.inputElement, DOM.EventType.KEY_DOWN, e => { + if (new StandardKeyboardEvent(e).equals(KeyCode.Enter) && this.applyButton.enabled) { + DOM.EventHelper.stop(e, true); + this.applyCustomColor(); + } + })); + this._register(autorun(reader => { + const wasUnlocked = this.customizationUnlocked; + this.customizationUnlocked = chatPetService.unlockedAchievements.read(reader).includes(ChatPetAchievementIds.Blobby); + const selectedColor = chatPetService.color.read(reader); + for (const [color, card] of this.cards) { + const enabled = isDefaultChatPetColor(color) || this.customizationUnlocked; + const selected = color === selectedColor; + card.button.enabled = enabled; + card.button.element.classList.toggle('selected', selected); + card.button.element.setAttribute('aria-pressed', String(selected)); + card.state.textContent = selected + ? localize('chatPet.colors.selected', "Selected") + : enabled ? '\u00a0' : localize('chatPet.colors.locked', "Locked"); + card.button.setAriaLabel(selected + ? localize('chatPet.colors.selectedLabel', "{0}, selected", card.label) + : enabled ? card.label : localize('chatPet.colors.lockedLabel', "{0}, locked. Discover Blobby's True Name to unlock.", card.label)); + } + unlockHint.hidden = this.customizationUnlocked; + this.colorInput.disabled = !this.customizationUnlocked; + this.hexInput.setEnabled(this.customizationUnlocked); + if (this.selectedColor !== selectedColor || wasUnlocked !== this.customizationUnlocked) { + this.selectedColor = selectedColor; + this.hexInput.value = getChatPetBodyColor(selectedColor); + } + this.updateDraft(); + })); + } + + private updateDraft(): void { + const color = parseChatPetColor(this.hexInput.value); + const validColor = color && !isDefaultChatPetColor(color) ? color : undefined; + const selectedColor = this.chatPetService.color.get(); + const draftColor = validColor === getChatPetBodyColor(selectedColor) ? selectedColor : validColor; + this.validationMessage.textContent = validColor ? '' : localize('chatPet.color.invalid', "Enter a hex color such as #ff8800 or #f80."); + this.applyButton.enabled = this.customizationUnlocked && draftColor !== undefined && draftColor !== selectedColor; + if (validColor) { + this.colorInput.value = validColor; + } + if (draftColor && draftColor !== this.previewColor) { + this.previewColor = draftColor; + this.previewDisposable.value = renderChatPetAchievementPreview(this.customPreview, undefined, true, draftColor, this.themeService, this.logService); + } + this.scrollable.scanDomNode(); + } + + private applyCustomColor(): void { + this.updateDraft(); + const color = parseChatPetColor(this.hexInput.value); + if (!color || isDefaultChatPetColor(color)) { + this.hexInput.focus(); + return; + } + if (this.applyButton.enabled) { + this.selectColor(color); + } + } + + private selectColor(color: ChatPetColor): void { + this.chatPetService.setColor(color); + if (!isDefaultChatPetColor(color)) { + this.chatPetService.markAchievementSeen(ChatPetAchievementIds.Blobby); + } + } + + layout(dimension: DOM.Dimension): void { + this.container.classList.toggle('narrow', dimension.width < 560); + this.content.style.width = `${dimension.width}px`; + this.content.style.height = `${dimension.height}px`; + this.scrollable.getDomNode().style.height = `${dimension.height}px`; + this.scrollable.scanDomNode(); + } + + focus(): void { + (this.cards.get(this.chatPetService.color.get())?.button ?? this.cards.get('stable')?.button)?.focus(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatPetService.ts b/src/vs/workbench/contrib/chat/browser/chatPetService.ts index f22440953dc61e..cb0d58c21f5991 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPetService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPetService.ts @@ -15,9 +15,13 @@ import product from '../../../../platform/product/common/product.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { allChatPetAchievements, chatPetAchievements, ChatPetAccessoryId, ChatPetAchievementId, ChatPetAchievementIds, getChatPetAchievementForAccessory, isChatPetAccessoryId, isChatPetAchievementEnabled, isChatPetAchievementId } from './chatPetAchievements.js'; +import { ChatPetColor, ChatPetVariant, chatPetColorPresets, isDefaultChatPetColor, parseChatPetColor } from './chatPetColors.js'; + +export type { ChatPetVariant } from './chatPetColors.js'; const CHAT_PET_ENABLED_STORAGE_KEY = 'chat.vscodePet.enabled'; const CHAT_PET_VARIANT_STORAGE_KEY = 'chat.vscodePet.variant'; +const CHAT_PET_COLOR_STORAGE_KEY = 'chat.vscodePet.color'; const CHAT_PET_ON_THE_RUN_STORAGE_KEY = 'chat.vscodePet.onTheRun'; const CHAT_PET_ACCESSORY_STORAGE_KEY = 'chat.vscodePet.accessory'; const CHAT_PET_ACHIEVEMENT_SEEN_STORAGE_PREFIX = 'chat.vscodePet.achievementSeen.'; @@ -29,8 +33,6 @@ const CHAT_PET_SCALE_STORAGE_KEY = 'chat.vscodePet.scale'; const CHAT_PET_HORIZONTAL_POSITION_STORAGE_KEY = 'chat.vscodePet.horizontalPosition'; export const CHAT_PET_DEFAULT_SCALE = 1; -export type ChatPetVariant = 'stable' | 'insiders'; - export const ChatPetContextKeys = { enabled: new RawContextKey('chatPetEnabled', false, localize('chatPet.context.enabled', "Whether the VS Code pet is enabled")), }; @@ -69,7 +71,7 @@ export const IChatPetService = createDecorator('chatPetService' export interface IChatPetService { readonly _serviceBrand: undefined; readonly enabled: IObservable; - readonly variant: IObservable; + readonly color: IObservable; readonly onTheRun: IObservable; readonly scale: IObservable; readonly unlockedAchievements: IObservable; @@ -78,7 +80,7 @@ export interface IChatPetService { readonly onDidUnlockAchievement: Event; readonly horizontalPosition: IObservable; toggle(): boolean; - setVariant(variant: ChatPetVariant): void; + setColor(color: ChatPetColor): void; setOnTheRun(onTheRun: boolean): void; setScale(scale: number): void; resetScale(): void; @@ -95,8 +97,8 @@ export class ChatPetService extends Disposable implements IChatPetService { private readonly _enabled; readonly enabled: IObservable; - private readonly _variant; - readonly variant: IObservable; + private readonly _color; + readonly color: IObservable; private readonly _onTheRun; readonly onTheRun: IObservable; private readonly _scale; @@ -110,6 +112,7 @@ export class ChatPetService extends Disposable implements IChatPetService { private readonly _onDidUnlockAchievement = this._register(new Emitter()); readonly onDidUnlockAchievement = this._onDidUnlockAchievement.event; private lastInvalidStoredAccessory: string | undefined; + private lastInvalidStoredColor: string | undefined; private locallyUnlockingAchievement = false; private readonly _horizontalPosition; readonly horizontalPosition: IObservable; @@ -124,8 +127,8 @@ export class ChatPetService extends Disposable implements IChatPetService { this._migrateAchievementStorage(); this._enabled = observableValue(this, this.storageService.getBoolean(CHAT_PET_ENABLED_STORAGE_KEY, StorageScope.APPLICATION, false)); this.enabled = this._enabled; - this._variant = observableValue(this, getChatPetVariant(this.storageService.get(CHAT_PET_VARIANT_STORAGE_KEY, StorageScope.APPLICATION), product.quality)); - this.variant = this._variant; + this._color = observableValue(this, this._readColor()); + this.color = this._color; this._onTheRun = observableValue(this, this.storageService.getBoolean(CHAT_PET_ON_THE_RUN_STORAGE_KEY, StorageScope.APPLICATION, false)); this.onTheRun = this._onTheRun; this._scale = observableValue(this, getChatPetScale(this.storageService.get(CHAT_PET_SCALE_STORAGE_KEY, StorageScope.APPLICATION))); @@ -143,7 +146,10 @@ export class ChatPetService extends Disposable implements IChatPetService { this._setEnabled(this.storageService.getBoolean(CHAT_PET_ENABLED_STORAGE_KEY, StorageScope.APPLICATION, false)); })); this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION, CHAT_PET_VARIANT_STORAGE_KEY, this._store)(() => { - this._variant.set(getChatPetVariant(this.storageService.get(CHAT_PET_VARIANT_STORAGE_KEY, StorageScope.APPLICATION), product.quality), undefined); + this._color.set(this._readColor(), undefined); + })); + this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION, CHAT_PET_COLOR_STORAGE_KEY, this._store)(() => { + this._color.set(this._readColor(), undefined); })); this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION, CHAT_PET_ON_THE_RUN_STORAGE_KEY, this._store)(() => { this._onTheRun.set(this.storageService.getBoolean(CHAT_PET_ON_THE_RUN_STORAGE_KEY, StorageScope.APPLICATION, false), undefined); @@ -190,12 +196,36 @@ export class ChatPetService extends Disposable implements IChatPetService { this.telemetryService.publicLog2('chatPetEnablement', { enabled, source }); } - setVariant(variant: ChatPetVariant): void { - this._variant.set(variant, undefined); - this.storageService.store(CHAT_PET_VARIANT_STORAGE_KEY, variant, StorageScope.APPLICATION, StorageTarget.USER); - status(variant === 'stable' - ? localize('chatPet.variant.stable', "VS Code pet changed to the Stable colors") - : localize('chatPet.variant.insiders', "VS Code pet changed to the Insiders colors")); + setColor(color: ChatPetColor): void { + const normalizedColor = parseChatPetColor(color); + if (!normalizedColor) { + throw new Error(`Invalid chat pet color: ${color}`); + } + if (!isDefaultChatPetColor(normalizedColor) && !this._unlockedAchievements.get().includes(ChatPetAchievementIds.Blobby)) { + throw new Error(localize('chatPet.color.locked', "Use /blobby in Chat to unlock color customization.")); + } + this._color.set(normalizedColor, undefined); + this.storageService.store(CHAT_PET_COLOR_STORAGE_KEY, normalizedColor, StorageScope.APPLICATION, StorageTarget.USER); + const label = chatPetColorPresets.find(preset => preset.color === normalizedColor)?.label ?? normalizedColor; + status(localize('chatPet.color.changed', "Blobby's color changed to {0}", label)); + } + + private _readColor(): ChatPetColor { + const storedColor = this.storageService.get(CHAT_PET_COLOR_STORAGE_KEY, StorageScope.APPLICATION); + if (storedColor !== undefined) { + const color = parseChatPetColor(storedColor); + if (color && (isDefaultChatPetColor(color) || this._isAchievementStored(ChatPetAchievementIds.Blobby))) { + this.lastInvalidStoredColor = undefined; + return color; + } + if (this.lastInvalidStoredColor !== storedColor) { + this.lastInvalidStoredColor = storedColor; + this.logService.warn(`[ChatPetService] Ignoring invalid or locked stored color: ${storedColor}`); + } + } else { + this.lastInvalidStoredColor = undefined; + } + return getChatPetVariant(this.storageService.get(CHAT_PET_VARIANT_STORAGE_KEY, StorageScope.APPLICATION), product.quality); } setOnTheRun(onTheRun: boolean): void { @@ -281,6 +311,10 @@ export class ChatPetService extends Disposable implements IChatPetService { resetAchievements(): void { this.locallyUnlockingAchievement = true; try { + const storedColor = this.storageService.get(CHAT_PET_COLOR_STORAGE_KEY, StorageScope.APPLICATION); + if (storedColor !== 'stable' && storedColor !== 'insiders') { + this.storageService.remove(CHAT_PET_COLOR_STORAGE_KEY, StorageScope.APPLICATION); + } for (const achievement of allChatPetAchievements) { const key = this._getAchievementStorageKey(achievement.id); this.storageService.remove(key, StorageScope.APPLICATION_SHARED); @@ -401,6 +435,7 @@ export class ChatPetService extends Disposable implements IChatPetService { } this._refreshUnseenAchievementState(); this._refreshSelectedAccessory(); + this._color.set(this._readColor(), undefined); } private _readUnseenAchievements(): readonly ChatPetAchievementId[] { diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 4378f8729c4868..b853fbef8a139e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -727,9 +727,9 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ }); } - async run(accessor: ServicesAccessor, chatOptions?: { prompt: string; attachedContext?: IChatRequestVariableEntry[] }): Promise { + async run(accessor: ServicesAccessor, chatOptions?: { prompt: string; attachedContext?: IChatRequestVariableEntry[] }): Promise { const { type, displayName } = contribution; - await openChatSession(accessor, { type, displayName, position: ChatSessionPosition.Editor }, chatOptions); + return openChatSession(accessor, { type, displayName, position: ChatSessionPosition.Editor }, chatOptions); } }), // New chat in sidebar chat (+ button) @@ -749,9 +749,9 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ }); } - async run(accessor: ServicesAccessor, chatOptions?: { prompt: string; attachedContext?: IChatRequestVariableEntry[] }): Promise { + async run(accessor: ServicesAccessor, chatOptions?: { prompt: string; attachedContext?: IChatRequestVariableEntry[] }): Promise { const { type, displayName } = contribution; - await openChatSession(accessor, { type, displayName, position: ChatSessionPosition.Sidebar }, chatOptions); + return openChatSession(accessor, { type, displayName, position: ChatSessionPosition.Sidebar }, chatOptions); } }) ); @@ -1745,7 +1745,7 @@ export type NewChatSessionOpenOptions = { readonly replaceEditorForResource?: URI; }; -export async function openChatSession(accessor: ServicesAccessor, openOptions: NewChatSessionOpenOptions, chatSendOptions?: NewChatSessionSendOptions): Promise { +export async function openChatSession(accessor: ServicesAccessor, openOptions: NewChatSessionOpenOptions, chatSendOptions?: NewChatSessionSendOptions): Promise { const viewsService = accessor.get(IViewsService); const chatService = accessor.get(IChatService); const chatSessionService = accessor.get(IChatSessionsService); @@ -1759,6 +1759,7 @@ export async function openChatSession(accessor: ServicesAccessor, openOptions: N // Determine resource to open const sessionResource = getResourceForNewChatSession(openOptions); + let openedSessionResource = sessionResource; // Stash any imported ("Continue in…") conversation before the session is // opened: opening can eagerly pre-create the backend session (via the chat @@ -1836,7 +1837,7 @@ export async function openChatSession(accessor: ServicesAccessor, openOptions: N logService.error(`Failed to open '${openOptions.type}' chat session with openOptions: ${JSON.stringify(openOptions)}`, e); sessionsListSuppression?.dispose(); transitionProgress?.complete(); - return; + return undefined; } // Send initial prompt if provided @@ -1856,6 +1857,7 @@ export async function openChatSession(accessor: ServicesAccessor, openOptions: N const result = await chatService.sendRequest(sessionResource, chatSendOptions.prompt, { agentIdSilent: openOptions.type, attachedContext }); const newSessionResource = result.kind === 'sent' || result.kind === 'rejected' ? result.newSessionResource : undefined; if (newSessionResource && !resources.isEqual(newSessionResource, sessionResource)) { + openedSessionResource = newSessionResource; switch (openOptions.position) { case ChatSessionPosition.Sidebar: { const view = await viewsService.openView(ChatViewId) as ChatViewPane; @@ -1885,6 +1887,7 @@ export async function openChatSession(accessor: ServicesAccessor, openOptions: N // working indicator. sessionsListSuppression?.dispose(); transitionProgress?.complete(); + return openedSessionResource; } /** diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index 1103794ab7eb9d..df65a276f501ce 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -42,6 +42,7 @@ import { IWorkbenchEnvironmentService } from '../../../services/environment/comm import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { AICustomizationManagementCommands, AICustomizationManagementSection } from './aiCustomization/aiCustomizationManagement.js'; import { IChatPetService } from './chatPetService.js'; +import { CHAT_PET_BLOBBY_COMMAND_ID } from './chatPetColors.js'; import { ChatSessionArchiveActionWording, ChatSessionArchiveActionWordingSettingId, getChatSessionArchiveActionWording } from '../../../../platform/chat/common/sessionArchiveActions.js'; export class ChatSlashCommandsContribution extends Disposable { @@ -76,6 +77,18 @@ export class ChatSlashCommandsContribution extends Disposable { }, async () => { chatPetService.toggle(); })); + this._register(slashCommandService.registerSlashCommand({ + command: 'blobby', + detail: nls.localize('blobby', "Show Blobby or open its color customization"), + sortText: 'z3_blobby', + executeImmediately: true, + executeDuringRequest: true, + silent: true, + locations: [ChatAgentLocation.Chat], + when: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.Setup.hidden.negate()), + }, async () => { + await commandService.executeCommand(CHAT_PET_BLOBBY_COMMAND_ID); + })); const clearCommandRegistration = this._register(new MutableDisposable()); const registerClearCommand = () => { const wording = getChatSessionArchiveActionWording(configurationService); diff --git a/src/vs/workbench/contrib/chat/browser/media/chatPetAchievements.css b/src/vs/workbench/contrib/chat/browser/media/chatPetAchievements.css index c5d1534a1304ea..bdb01153a5869a 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chatPetAchievements.css +++ b/src/vs/workbench/contrib/chat/browser/media/chatPetAchievements.css @@ -4,12 +4,74 @@ *--------------------------------------------------------------------------------------------*/ .chat-pet-achievements-editor, -.chat-pet-achievements-widget { +.chat-pet-achievements-widget, +.chat-pet-colors-widget { width: 100%; height: 100%; overflow: hidden; } +.chat-pet-achievements-editor { + display: flex; + flex-direction: column; +} + +.monaco-workbench .chat-pet-achievements-editor[tabindex='-1']:focus { + outline: none; +} + +.chat-pet-customization-tabs { + display: flex; + flex-shrink: 0; + gap: var(--vscode-spacing-size40); + padding: var(--vscode-spacing-size80) var(--vscode-spacing-size160) var(--vscode-spacing-size40); + border-bottom: var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border); +} + +.chat-pet-customization-tabs .monaco-button { + position: relative; + width: auto; + padding: var(--vscode-spacing-size60) var(--vscode-spacing-size80); + border: 0; + border-radius: var(--vscode-cornerRadius-small); + background: transparent; + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-body1); +} + +.chat-pet-customization-tabs .monaco-button[aria-selected='true'] { + color: var(--vscode-foreground); +} + +.chat-pet-customization-tabs .monaco-button[aria-selected='true']::after { + content: ''; + position: absolute; + inset-inline: var(--vscode-spacing-size80); + bottom: calc(-1 * var(--vscode-spacing-size40)); + height: var(--vscode-strokeThickness); + background: var(--vscode-focusBorder); + pointer-events: none; +} + +.chat-pet-customization-tabs .monaco-button:hover { + color: var(--vscode-foreground); + background: var(--vscode-toolbar-hoverBackground); +} + +.chat-pet-customization-tabs .monaco-button:focus:not(:focus-visible) { + outline: none; +} + +.chat-pet-customization-tabs .monaco-button:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.chat-pet-customization-panel { + min-height: 0; + flex: 1; +} + .chat-pet-achievements-content { box-sizing: border-box; min-width: 0; @@ -70,6 +132,174 @@ padding: var(--vscode-spacing-size160) var(--vscode-spacing-size120); } +.chat-pet-colors-widget.narrow .chat-pet-achievements-content { + padding: var(--vscode-spacing-size160) var(--vscode-spacing-size120); +} + +.chat-pet-colors-widget .chat-pet-achievements-intro { + margin-bottom: var(--vscode-spacing-size200); +} + +.chat-pet-colors-list { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: var(--vscode-spacing-size120); + list-style: none; + margin: 0 0 var(--vscode-spacing-size240); + padding: 0; +} + +.chat-pet-colors-widget.narrow .chat-pet-colors-list { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.chat-pet-color-card.monaco-button { + display: flex; + flex-direction: column; + align-items: center; + box-sizing: border-box; + width: 100%; + min-width: 0; + gap: var(--vscode-spacing-size40); + padding: var(--vscode-spacing-size80); + border: var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border); + border-radius: var(--vscode-cornerRadius-small); + background: var(--vscode-editorWidget-background); + color: var(--vscode-foreground); + font-size: var(--vscode-fontSize-label1); + text-align: center; +} + +.chat-pet-color-card.monaco-button:not(.disabled), +.chat-pet-achievement-card.monaco-button:not(.disabled) { + cursor: pointer; +} + +.chat-pet-color-card.monaco-button.selected { + border-color: var(--vscode-focusBorder); + background: var(--vscode-list-inactiveSelectionBackground); +} + +.chat-pet-color-card.monaco-button:not(.disabled):hover { + background: var(--vscode-list-hoverBackground); +} + +.chat-pet-color-card.monaco-button:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.chat-pet-color-preview { + width: 64px; + height: 64px; + image-rendering: pixelated; + flex-shrink: 0; +} + +.chat-pet-color-label { + overflow-wrap: anywhere; + white-space: normal; + font-weight: var(--vscode-fontWeight-semiBold); +} + +.chat-pet-color-state { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label2); +} + +.chat-pet-colors-unlock-hint, +.chat-pet-custom-color p { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-body1); +} + +.chat-pet-custom-color { + padding: var(--vscode-spacing-size160); + border: var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border); + border-radius: var(--vscode-cornerRadius-medium); + background: var(--vscode-editorWidget-background); +} + +.chat-pet-custom-color h2 { + font-size: var(--vscode-fontSize-heading3); + font-weight: var(--vscode-fontWeight-semiBold); + margin-bottom: var(--vscode-spacing-size80); +} + +.chat-pet-custom-color-body, +.chat-pet-custom-color-inputs { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size120); +} + +.chat-pet-custom-color-controls { + flex: 1; + min-width: 0; +} + +.chat-pet-custom-color-inputs { + flex-wrap: wrap; +} + +.chat-pet-custom-color-inputs .monaco-inputbox { + width: 120px; +} + +.chat-pet-custom-color-inputs .monaco-inputbox input { + cursor: text; +} + +.chat-pet-custom-color-inputs .monaco-inputbox input:disabled { + cursor: default; +} + +.chat-pet-custom-color-inputs .monaco-button { + width: auto; +} + +.chat-pet-color-picker { + box-sizing: border-box; + width: 32px; + height: 28px; + padding: var(--vscode-spacing-size20); + border: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-editorWidget-border)); + border-radius: var(--vscode-cornerRadius-small); + background: var(--vscode-input-background); + cursor: pointer; +} + +.chat-pet-color-picker:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: var(--vscode-spacing-size20); +} + +.chat-pet-color-picker:disabled { + cursor: default; +} + +.chat-pet-color-validation { + color: var(--vscode-errorForeground); + font-size: var(--vscode-fontSize-label1); + margin-top: var(--vscode-spacing-size80); +} + +.chat-pet-color-validation:empty { + display: none; +} + +.hc-black .chat-pet-color-card.monaco-button, +.hc-light .chat-pet-color-card.monaco-button, +.hc-black .chat-pet-custom-color, +.hc-light .chat-pet-custom-color { + border-color: var(--vscode-contrastBorder); +} + +.hc-black .chat-pet-color-card.monaco-button.selected, +.hc-light .chat-pet-color-card.monaco-button.selected { + border-color: var(--vscode-focusBorder); +} + .chat-pet-achievements-widget.narrow .chat-pet-achievements-list { grid-template-columns: minmax(0, 1fr); } @@ -101,6 +331,7 @@ border-radius: var(--vscode-cornerRadius-medium); background: var(--vscode-editorWidget-background); color: var(--vscode-foreground); + cursor: default; } .chat-pet-achievement-roadmap-preview { diff --git a/src/vs/workbench/contrib/chat/browser/pluginSources.ts b/src/vs/workbench/contrib/chat/browser/pluginSources.ts index 1729d36f935d23..ef268c0187d082 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginSources.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginSources.ts @@ -22,6 +22,7 @@ import { IProgressService, ProgressLocation } from '../../../../platform/progres import { TerminalCapability, type ITerminalCommand } from '../../../../platform/terminal/common/capabilities/capabilities.js'; import { ITerminalInstance, ITerminalService } from '../../terminal/browser/terminal.js'; import { IEnsureRepositoryOptions, IPullRepositoryOptions } from '../common/plugins/agentPluginRepositoryService.js'; +import { getGitUrlCacheSegments, getPluginCacheUri, gitRevisionCacheSuffix } from '../common/plugins/marketplaceReference.js'; import { IGitHubPluginSource, IGitUrlPluginSource, IMarketplacePlugin, INpmPluginSource, IPipPluginSource, IPluginSourceDescriptor, PluginSourceKind } from '../common/plugins/pluginMarketplaceService.js'; import { IPluginSource } from '../common/plugins/pluginSource.js'; import { IPluginGitService } from '../common/plugins/pluginGitService.js'; @@ -34,16 +35,6 @@ function sanitizeCacheSegment(name: string): string { return name.replace(/[\\/:*?"<>|]/g, '_'); } -function gitRevisionCacheSuffix(ref?: string, sha?: string): string[] { - if (sha) { - return [`sha_${sanitizeCacheSegment(sha)}`]; - } - if (ref) { - return [`ref_${sanitizeCacheSegment(ref)}`]; - } - return []; -} - function shellEscapeArg(value: string): string { if (isWindows) { return `"${value.replace(/[`$"]/g, '`$&')}"`; @@ -276,7 +267,7 @@ export class GitHubPluginSource extends AbstractGitPluginSource { protected override _getRepoDir(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI { const gh = descriptor as IGitHubPluginSource; const [owner, repo] = gh.repo.split('/'); - return joinPath(cacheRoot, 'github.com', owner, repo, ...gitRevisionCacheSuffix(gh.ref, gh.sha)); + return getPluginCacheUri(cacheRoot, ['github.com', owner, repo, ...gitRevisionCacheSuffix(gh.ref, gh.sha)]); } getLabel(descriptor: IPluginSourceDescriptor): string { @@ -319,8 +310,8 @@ export class GitUrlPluginSource extends AbstractGitPluginSource { /** Returns the cloned repository root (without sub-path). */ protected override _getRepoDir(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI { const git = descriptor as IGitUrlPluginSource; - const segments = this._gitUrlCacheSegments(git.url, git.ref, git.sha); - return joinPath(cacheRoot, ...segments); + const segments = getGitUrlCacheSegments(git.url); + return getPluginCacheUri(cacheRoot, [...segments, ...gitRevisionCacheSuffix(git.ref, git.sha)]); } getLabel(descriptor: IPluginSourceDescriptor): string { @@ -336,17 +327,6 @@ export class GitUrlPluginSource extends AbstractGitPluginSource { return (descriptor as IGitUrlPluginSource).url; } - private _gitUrlCacheSegments(url: string, ref?: string, sha?: string): string[] { - try { - const parsed = URI.parse(url); - const authority = (parsed.authority || 'unknown').replace(/[\\/:*?"<>|]/g, '_').toLowerCase(); - const pathPart = parsed.path.replace(/^\/+/, '').replace(/\.git$/i, '').replace(/\/+$/g, ''); - const segments = pathPart.split('/').map(s => s.replace(/[\\/:*?"<>|]/g, '_')); - return [authority, ...segments, ...gitRevisionCacheSuffix(ref, sha)]; - } catch { - return ['git', url.replace(/[\\/:*?"<>|]/g, '_'), ...gitRevisionCacheSuffix(ref, sha)]; - } - } } // --------------------------------------------------------------------------- diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.ts index f1d4e11eaa90c7..b211a193a5474d 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.ts @@ -9,6 +9,7 @@ import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; import { CancellationError, isCancellationError } from '../../../../../base/common/errors.js'; import { Emitter } from '../../../../../base/common/event.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; import { StopWatch } from '../../../../../base/common/stopwatch.js'; import { CLOUD_SANDBOX_AGENT_SLUG, @@ -24,9 +25,12 @@ import { ICloudSandboxDiscoveredSession, ICloudSandboxDiscoveryResult, ICloudSandboxEnvironment, + ICloudSandboxModelCatalog, } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { IReplayedTaskHistory, parseTaskEventsResponse, replayTaskAhpEvents, TaskEventReplayError } from '../../../../../platform/agentHost/common/taskEventReplay.js'; import { SessionStatus } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { SessionModelInfo } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { createPricingMetaFromBilling, normalizeCAPIBilling } from '../../../../../platform/agentHost/common/meta/agentModelMeta.js'; import { COPILOT_INTEGRATION_ID } from '../../../../../platform/endpoint/common/licenseAgreement.js'; import { GITHUB_DOT_COM_COPILOT_API_BASE_URI, deriveGitHubEndpoints } from '../../../../../platform/github/common/githubEndpoints.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -79,6 +83,20 @@ interface ICachedSandboxTask { const LOG_PREFIX = '[CloudSandboxApi]'; +interface ICloudModel { + readonly id: string; + readonly name: string; + readonly model_picker_enabled?: boolean; + readonly policy?: { readonly state?: string }; + readonly billing?: unknown; + readonly model_picker_category?: string; + readonly model_picker_price_category?: string; + readonly capabilities?: { + readonly limits?: { readonly max_context_window_tokens?: number; readonly max_prompt_tokens?: number; readonly max_output_tokens?: number }; + readonly supports?: { readonly vision?: boolean; readonly reasoning_effort?: readonly string[] }; + }; +} + function taskSessionStatus(state: string | undefined, logService: ILogService): SessionStatus | undefined { switch (state) { case 'queued': @@ -294,7 +312,10 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA let checkpoint: number | undefined; let latestUpdate: number | undefined; // Separate repository scopes include workspace-less sandboxes as well as repository sessions. - for (const withRepository of [true, false]) { + for (const { withRepository, archived } of [ + { withRepository: true, archived: false }, { withRepository: false, archived: false }, + { withRepository: true, archived: true }, { withRepository: false, archived: true }, + ]) { for (let page = 1; page <= DISCOVERY_TASK_PAGE_LIMIT; page++) { if (token.isCancellationRequested) { throw new CancellationError(); @@ -305,6 +326,7 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA sort: 'updated_at', direction: 'desc', with_repo: String(withRepository), + is_archived: String(archived), ...(since ? { since, include_environment_kinds: 'managed-sandbox' } : {}), }; try { @@ -357,7 +379,7 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA const removedTaskIds: string[] = []; const sandboxTasks: ITaskSummary[] = []; for (const task of tasks.values()) { - if (!task.archived_at && isCloudSandboxTask(task)) { + if (isCloudSandboxTask(task)) { sandboxTasks.push(task); } else { if (cache.has(task.id) || isCloudSandboxTask(task)) { @@ -376,17 +398,12 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA throw new CancellationError(); } let cached = cache.get(task.id); - if (cached?.needsRefresh || !cached?.session || !task.updated_at || task.updated_at !== cached.summary.updated_at) { + if (cached?.needsRefresh || !cached?.session || !task.updated_at || task.updated_at !== cached.summary.updated_at || task.archived_at !== cached.summary.archived_at) { const context = await this._sendTask(`${this._tasksBaseUrl()}/tasks/${encodeURIComponent(task.id)}`, 'get', token); const full = await this._readJson(context); if (!full) { throw new Error('getTask returned no task'); } - if (full.archived_at) { - removedTaskIds.push(task.id); - cache.delete(task.id); - return undefined; - } const binding = getTaskEnvironmentBinding(full); if (!binding && cached?.session) { removedTaskIds.push(task.id); @@ -404,6 +421,7 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA name: full.name ?? task.name ?? `Sandbox ${task.id}`, updatedAt: full.updated_at ?? task.updated_at, ...(status !== undefined ? { status } : {}), + ...(full.archived_at ? { isArchived: true } : {}), } : undefined, }; } @@ -556,6 +574,19 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA this._discoveryGeneration++; } + async setTaskArchived(taskId: string, archived: boolean, token: CancellationToken): Promise { + const action = archived ? 'archive' : 'unarchive'; + const context = await this._request(`${this._tasksBaseUrl()}/tasks/${encodeURIComponent(taskId)}/${action}`, `mc.taskClient.${action}`, archived ? 'archiveTask' : 'unarchiveTask', { + 'Accept': 'application/json', + 'Copilot-Integration-Id': COPILOT_INTEGRATION_ID, + }, token, REQUEST_TIMEOUT_MS, undefined, 'POST'); + if (!isSuccess(context)) { + await this._throwForStatus(`task ${action}`, context); + } + this._discoveredTasks.delete(taskId); + this._discoveryGeneration++; + } + /** * Delete a task we created but cannot use. Best-effort: the caller is already failing, and a * failed cleanup must not replace the error that explains why. @@ -785,6 +816,54 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA return `${GITHUB_DOT_COM_COPILOT_API_BASE_URI}/agents`; } + async listModels(token: CancellationToken): Promise { + const context = await this._request(`${this._tasksBaseUrl()}/swe/models`, 'mc.models.list', 'listModels', { + 'Accept': 'application/json', + 'Copilot-Integration-Id': COPILOT_INTEGRATION_ID, + }, token, DISCOVERY_TIMEOUT_MS); + if (!isSuccess(context)) { + await this._throwForStatus('model catalog', context); + } + const catalog = await this._readJson<{ data?: ICloudModel[]; default_model?: string }>(context); + if (!catalog || !Array.isArray(catalog.data) || (catalog.default_model !== undefined && typeof catalog.default_model !== 'string')) { + throw new Error('Mission Control returned an invalid model catalog.'); + } + const models: SessionModelInfo[] = []; + for (const model of catalog.data) { + const efforts = model?.capabilities?.supports?.reasoning_effort; + if (!model || typeof model.id !== 'string' || !model.id || typeof model.name !== 'string' + || (efforts !== undefined && (!Array.isArray(efforts) || !efforts.every(value => typeof value === 'string')))) { + throw new Error('Mission Control returned invalid model metadata.'); + } + if (model.model_picker_enabled === false || model.policy?.state === 'disabled') { + continue; + } + models.push({ + id: model.id, + name: model.name, + provider: 'copilot', + maxContextWindow: model.capabilities?.limits?.max_context_window_tokens, + maxPromptTokens: model.capabilities?.limits?.max_prompt_tokens, + maxOutputTokens: model.capabilities?.limits?.max_output_tokens, + supportsVision: model.capabilities?.supports?.vision, + _meta: createPricingMetaFromBilling(normalizeCAPIBilling(model.billing), model.model_picker_price_category, model.model_picker_category), + ...(efforts?.length ? { + configSchema: { + type: 'object', + properties: { + reasoningEffort: { + type: 'string', + title: localize('cloudSandbox.reasoningEffort', "Reasoning Effort"), + enum: [...efforts], + }, + }, + }, + } : {}), + }); + } + return { models, defaultModel: catalog.default_model }; + } + private async _readJson(context: IRequestContext): Promise { const body = await asText(context); if (!body) { diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxConnectionCustomization.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxConnectionCustomization.ts index 7a743fa14e36c1..bb371d634c8c38 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxConnectionCustomization.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxConnectionCustomization.ts @@ -53,25 +53,27 @@ export function createCloudSandboxConnectionCustomization( if (environmentId === undefined) { return undefined; } + const resolveAuthentication = async (resource: string, scopes: readonly string[] | undefined, renew: boolean): Promise => { + if (!isGitHubResource(resource)) { + throw new Error(`Cloud sandbox cannot authenticate the non-GitHub resource '${resource}'.`); + } + const sealed = renew + ? await sandboxService.refreshSealedGitHubToken(environmentId) + : sandboxService.getSealedGitHubToken(environmentId); + if (!sealed || !isCloudSandboxSealedToken(sealed)) { + throw new Error(`No sealed GitHub token is available for cloud sandbox ${address}; refusing to forward a plaintext bearer.`); + } + return { resource, scopes, token: sealed }; + }; return { requiresWorkspaceTrust: false, authenticate: async (request: IAgentHostAuthenticateRequest, reason?: AuthRequiredReason): Promise => { if (reason !== AuthRequiredReason.Expired && isCloudSandboxSealedToken(request.token)) { return request; } - // The sandbox host only accepts the sealed GitHub token for GitHub resources; there is no - // per-resource sealing for other hosts over the sandbox relay today. - if (!isGitHubResource(request.resource)) { - throw new Error(`Cloud sandbox cannot authenticate the non-GitHub resource '${request.resource}'.`); - } - const sealed = reason === AuthRequiredReason.Expired - ? await sandboxService.refreshSealedGitHubToken(environmentId) - : sandboxService.getSealedGitHubToken(environmentId); - if (!sealed || !isCloudSandboxSealedToken(sealed)) { - throw new Error(`No sealed GitHub token is available for cloud sandbox ${address}; refusing to forward a plaintext bearer.`); - } - return { resource: request.resource, scopes: request.scopes, token: sealed }; + return resolveAuthentication(request.resource, request.scopes, reason === AuthRequiredReason.Expired); }, + renewAuthentication: resource => resolveAuthentication(resource.resource, resource.scopes_supported, true), backendSessionScheme: (provider: string): string | undefined => provider === CLOUD_SANDBOX_AGENT_PROVIDER ? CLOUD_SANDBOX_SESSION_SCHEME : undefined, createSessionPreparation: (connection, owner) => { diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxModels.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxModels.ts new file mode 100644 index 00000000000000..f2374e4109b0f4 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxModels.ts @@ -0,0 +1,161 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { cancelOnDispose, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { isCancellationError, onUnexpectedError } from '../../../../../base/common/errors.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable, IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import { ICloudSandboxApiService, ICloudSandboxModelCatalog } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; +import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../common/languageModels.js'; +import { AgentHostLanguageModelProvider } from '../agentSessions/agentHost/agentHostLanguageModelProvider.js'; +import { ChatAgentLocation } from '../../common/constants.js'; +import { createDecorator, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; + +export const ICloudSandboxModelCatalogService = createDecorator('cloudSandboxModelCatalogService'); + +export interface ICloudSandboxModelCatalogService { + readonly _serviceBrand: undefined; + readonly onDidChange: Event; + readonly catalog: ICloudSandboxModelCatalog | undefined; + load(retry?: boolean): void; +} + +/** Shares account-scoped discovery and retry notifications across all sandbox model providers. */ +export class CloudSandboxModelCatalogService extends Disposable implements ICloudSandboxModelCatalogService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + private readonly _token = cancelOnDispose(this._store); + private readonly _requestCancellation = this._register(new MutableDisposable()); + private readonly _notification = this._register(new MutableDisposable()); + private _request: Promise | undefined; + private _requested = false; + private _generation = 0; + private _catalog: ICloudSandboxModelCatalog | undefined; + get catalog(): ICloudSandboxModelCatalog | undefined { return this._catalog; } + + constructor( + @ICloudSandboxApiService private readonly _api: ICloudSandboxApiService, + @ILogService private readonly _logService: ILogService, + @INotificationService private readonly _notificationService: INotificationService, + ) { + super(); + this._register(this._api.onDidChangeAccount(() => { + this._generation++; + this._requestCancellation.value?.cancel(); + this._requestCancellation.clear(); + this._notification.clear(); + this._request = undefined; + this._catalog = undefined; + this._onDidChange.fire(); + if (this._requested) { + this.load(true); + } + })); + } + + load(retry = false): void { + if (this._request || (this._requested && !retry)) { + return; + } + this._requested = true; + this._notification.clear(); + const generation = this._generation; + const cancellation = new CancellationTokenSource(this._token); + this._requestCancellation.value = cancellation; + this._request = (async () => { + try { + const catalog = await this._api.listModels(cancellation.token); + if (this._store.isDisposed || generation !== this._generation) { + return; + } + this._catalog = catalog; + this._onDidChange.fire(); + } catch (error) { + if (isCancellationError(error) || this._store.isDisposed || generation !== this._generation) { + return; + } + this._logService.error('[CloudSandboxModels] Failed to load cloud models', error); + const notification = this._notificationService.prompt(Severity.Error, localize('cloudSandbox.modelsFailed', "Could not load models for GitHub sandboxes."), [{ + label: localize('cloudSandbox.retryModels', "Retry"), + run: () => this.load(true), + }]); + this._notification.value = toDisposable(() => notification.close()); + } finally { + if (generation === this._generation) { + this._request = undefined; + this._requestCancellation.clear(); + } + } + })(); + } +} + +/** Adapts the shared cloud catalog to one draft or connected host's model identifier space. */ +export class CloudSandboxModels extends Disposable { + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + private readonly _token = cancelOnDispose(this._store); + private readonly _provider: AgentHostLanguageModelProvider; + private _generation = 0; + models: readonly ILanguageModelChatMetadataAndIdentifier[] = []; + ready = false; + + constructor( + sessionType: string, + vendor: string, + modelProvider: AgentHostLanguageModelProvider | undefined, + @ICloudSandboxModelCatalogService private readonly _catalogService: ICloudSandboxModelCatalogService, + @ILanguageModelsService languageModelsService: ILanguageModelsService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + if (modelProvider) { + this._provider = modelProvider; + this._register(toDisposable(() => modelProvider.updateAdditionalModels([]))); + } else { + const descriptor = { vendor, displayName: localize('cloudSandbox.modelsProvider', "GitHub Sandboxes"), configuration: undefined, managementCommand: undefined, when: undefined }; + languageModelsService.deltaLanguageModelChatProviderDescriptors([descriptor], []); + this._register(toDisposable(() => languageModelsService.deltaLanguageModelChatProviderDescriptors([], [descriptor]))); + this._provider = this._register(instantiationService.createInstance(AgentHostLanguageModelProvider, sessionType, vendor)); + this._register(languageModelsService.registerLanguageModelProvider(vendor, this._provider)); + } + this._register(this._catalogService.onDidChange(() => { + void this._updateCatalog().catch(onUnexpectedError); + })); + void this._updateCatalog().catch(onUnexpectedError); + } + + load(retry = false): void { + this._catalogService.load(retry); + } + + private async _updateCatalog(): Promise { + const generation = ++this._generation; + const catalog = this._catalogService.catalog; + if (!catalog) { + this.models = []; + this.ready = false; + this._provider.updateAdditionalModels([]); + this._onDidChange.fire(); + return; + } + this._provider.updateAdditionalModels(catalog.models); + const models = await this._provider.provideLanguageModelChatInfo({}, this._token); + if (this._store.isDisposed || generation !== this._generation) { + return; + } + this.models = models.map(model => ({ + ...model, + metadata: { ...model.metadata, isDefaultForLocation: { [ChatAgentLocation.Chat]: model.metadata.id === catalog.defaultModel } }, + })); + this.ready = true; + this._onDidChange.fire(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionContribution.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionContribution.ts index 69ec9fd84d5c09..073ac30e68aa3f 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionContribution.ts @@ -64,6 +64,7 @@ export interface ICloudSandboxSessionEnvironment { readonly name: string; readonly repoName?: string; readonly updatedAt?: string; + readonly isArchived?: boolean; } function isDiscoveredSandboxSession(value: unknown): value is ICloudSandboxDiscoveredSession { @@ -75,7 +76,8 @@ function isDiscoveredSandboxSession(value: unknown): value is ICloudSandboxDisco && typeof candidate.name === 'string' && (candidate.eventType === undefined || typeof candidate.eventType === 'string') && (candidate.repoName === undefined || typeof candidate.repoName === 'string') - && (candidate.updatedAt === undefined || typeof candidate.updatedAt === 'string'); + && (candidate.updatedAt === undefined || typeof candidate.updatedAt === 'string') + && (candidate.isArchived === undefined || typeof candidate.isArchived === 'boolean'); } /** @@ -100,6 +102,7 @@ export interface ICloudSandboxSessionList extends IDisposable { setLabel?(label: string): void; setConnection(connection: IAgentConnection, defaultDirectory: string | undefined): void; setConnectionStatus(status: RemoteAgentHostConnectionStatus): void; + setSessionArchived(rawId: string, archived: boolean): void; } export abstract class CloudSandboxSessionContribution extends Disposable implements IWorkbenchContribution { @@ -301,7 +304,7 @@ export abstract class CloudSandboxSessionContribution { + const environment = this._environments.get(address); + if (!environment?.taskId || environment.sessionId !== rawId) { + throw new Error(localize('cloudSandbox.archiveSessionNotFound', "Mission Control sandbox session not found.")); + } + if (token.isCancellationRequested) { + throw new CancellationError(); + } + const store = new DisposableStore(); + const source = store.add(new CancellationTokenSource(this._enabledCts.token)); + store.add(token.onCancellationRequested(() => source.cancel())); + try { + await this._apiService.setTaskArchived(environment.taskId, archived, source.token); + if (source.token.isCancellationRequested) { + throw new CancellationError(); + } + } finally { + store.dispose(); + } + const current = this._environments.get(address); + if (current?.taskId !== environment.taskId || current.sessionId !== rawId) { + throw new CancellationError(); + } + this._environments.set(address, { ...current, isArchived: archived }); + this._persistInventory(); + } + protected async _deleteSandboxSession(address: string, sessionIds: readonly string[], removeSession: (rawId: string) => void, token: CancellationToken = CancellationToken.None): Promise { const environment = this._environments.get(address); if (!environment?.taskId || !environment.sessionId || sessionIds.some(id => id !== environment.sessionId)) { @@ -579,11 +613,11 @@ export abstract class CloudSandboxSessionContribution(this, undefined); private readonly _discoveredRepositories = observableValue>(this, new Map()); private readonly _items: IObservable; + private readonly _archivedStates = observableValue>(this, new Map()); + private readonly _archiveSequencer = new Sequencer(); constructor( address: string, workspaceRepositories: IObservable | undefined>, private readonly _deleteSession: (rawId: string, token: CancellationToken) => Promise, + private readonly _archiveSession: (rawId: string, archived: boolean, token: CancellationToken) => Promise, @IInstantiationService instantiationService: IInstantiationService, @IRemoteAgentHostAuthenticationService authenticationService: IRemoteAgentHostAuthenticationService, + @INotificationService private readonly _notificationService: INotificationService, ) { super(); const authority = agentHostAuthority(address); @@ -71,6 +79,7 @@ export class CloudSandboxSessionListController extends Disposable implements ICl const repositories = workspaceRepositories.read(reader); const discoveredRepositories = this._discoveredRepositories.read(reader); const connected = !!this._connection.read(reader); + const archivedStates = this._archivedStates.read(reader); const visibleSessions = repositories && new Set(this._sessionListStore.getSessions(CLOUD_SANDBOX_AGENT_PROVIDER).filter(entry => { const projectUri = entry.summary.project?.uri; const project = projectUri ? getGitHubRepositoryFromRemoteUrl(projectUri) : undefined; @@ -83,7 +92,7 @@ export class CloudSandboxSessionListController extends Disposable implements ICl }).map(entry => entry.rawId)); return this._controller.items .filter(item => !visibleSessions || visibleSessions.has(AgentSession.id(item.resource))) - .map(item => this._listItem(item, connected)); + .map(item => this._listItem(item, connected, archivedStates)); }); let previousResources = new ResourceSet(); this._register(autorun(reader => { @@ -184,11 +193,46 @@ export class CloudSandboxSessionListController extends Disposable implements ICl this._sessionListStore.removeSession(CLOUD_SANDBOX_AGENT_PROVIDER, rawId); } - private _listItem(item: IChatSessionItem, connected: boolean): IChatSessionItem { + setSessionArchived(rawId: string, archived: boolean): void { + if (this._archivedStates.get().get(rawId) === archived) { + return; + } + const states = new Map(this._archivedStates.get()); + states.set(rawId, archived); + this._archivedStates.set(states, undefined); + } + + setChatSessionItemArchived(resource: URI, archived: boolean): void { + void this._archiveSequencer.queue(async () => { + if (this._store.isDisposed) { + throw new CancellationError(); + } + if (resource.scheme !== this.sessionType || resource.fragment) { + throw new Error(localize('cloudSandbox.archiveInvalidSession', "The session does not belong to this sandbox.")); + } + const rawId = AgentSession.id(resource); + if (await this._archiveSession(rawId, archived, CancellationToken.None)) { + if (this._store.isDisposed) { + throw new CancellationError(); + } + this.setSessionArchived(rawId, archived); + } else { + this._requireConnection(); + this._controller.setChatSessionItemArchived(resource, archived); + } + }).catch(error => { + if (!isCancellationError(error)) { + this._notificationService.error(localize('cloudSandbox.archiveFailed', "Unable to change the sandbox session's archive state: {0}", toErrorMessage(error))); + } + }); + } + + private _listItem(item: IChatSessionItem, connected: boolean, archivedStates: ReadonlyMap): IChatSessionItem { return { ...item, iconPath: Codicon.cloud, ...(!connected || item.isRead === undefined ? { archived: undefined, isRead: undefined } : {}), + ...(archivedStates.has(AgentSession.id(item.resource)) ? { archived: archivedStates.get(AgentSession.id(item.resource)) } : {}), }; } diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxTelemetry.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxTelemetry.ts index 1a8fd859b8ae5a..9026b2637a7bc3 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxTelemetry.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxTelemetry.ts @@ -13,7 +13,7 @@ import { createDecorator } from '../../../../../platform/instantiation/common/in import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; /** The Mission Control call being reported. A closed set, so it is safe to send verbatim. */ -export type CloudSandboxRequestAction = 'connect' | 'reconnect' | 'getEnvironment' | 'listTasks' | 'getTask' | 'createTask' | 'deleteTask' | 'renameTask' | 'getTaskEvents' | 'getRepository'; +export type CloudSandboxRequestAction = 'connect' | 'reconnect' | 'getEnvironment' | 'listTasks' | 'getTask' | 'createTask' | 'deleteTask' | 'renameTask' | 'archiveTask' | 'unarchiveTask' | 'getTaskEvents' | 'getRepository' | 'listModels'; /** * How a Mission Control request ended, bucketed so a count is meaningful without carrying the @@ -591,7 +591,7 @@ type CloudSandboxRequestsEvent = { }; type CloudSandboxRequestsClassification = { - action: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Which Mission Control call was counted (connect, reconnect, getEnvironment, listTasks or getTask).' }; + action: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Which Mission Control operation was counted, including model discovery, task operations, and environment connections.' }; windowMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds covered by these counts.' }; total: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Requests issued for this action during the window.' }; succeeded: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Requests that returned a success status.' }; diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/editorCloudSandboxContribution.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/editorCloudSandboxContribution.ts index e3097ecf8c8a0e..90fa8e41482e03 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/editorCloudSandboxContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/editorCloudSandboxContribution.ts @@ -162,6 +162,13 @@ export class EditorCloudSandboxSessionContribution extends CloudSandboxSessionCo } await this._deleteSandboxSession(address, [rawId], id => provider.removeDeletedSession(id), token); return true; + }, + async (rawId, archived, token) => { + if (!this._ownsSandboxSession(address, rawId)) { + return false; + } + await this._setSandboxSessionArchived(address, rawId, archived, token); + return true; })); store.add(this._connectionsService.registerSessionResolutionPolicy(agentHostAuthority(address), { connectionAddress: address, diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHost.contribution.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHost.contribution.ts index 88189388f1770f..38e5228c35159d 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHost.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHost.contribution.ts @@ -12,6 +12,7 @@ import { Registry } from '../../../../../platform/registry/common/platform.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; import { CloudSandboxAgentHostService } from './cloudSandboxAgentHostService.js'; import { CloudSandboxApiService } from './cloudSandboxApiService.js'; +import { CloudSandboxModelCatalogService, ICloudSandboxModelCatalogService } from './cloudSandboxModels.js'; import { CloudSandboxTelemetryService, ICloudSandboxTelemetryService } from './cloudSandboxTelemetry.js'; import { EditorCloudSandboxContribution } from './editorCloudSandboxContribution.js'; import { RemoteAgentHostContribution } from './remoteAgentHostChatContribution.js'; @@ -19,6 +20,7 @@ import { IRemoteAgentHostConnectionCustomizationService, RemoteAgentHostConnecti registerSingleton(ICloudSandboxTelemetryService, CloudSandboxTelemetryService, InstantiationType.Delayed); registerSingleton(ICloudSandboxApiService, CloudSandboxApiService, InstantiationType.Delayed); +registerSingleton(ICloudSandboxModelCatalogService, CloudSandboxModelCatalogService, InstantiationType.Delayed); registerSingleton(ICloudSandboxAgentHostService, CloudSandboxAgentHostService, InstantiationType.Delayed); registerSingleton(IRemoteAgentHostConnectionCustomizationService, RemoteAgentHostConnectionCustomizationService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostChatContribution.ts index 7ba4349d4b8788..af31b3bc6773f5 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostChatContribution.ts @@ -44,6 +44,8 @@ import { IWorkbenchEnvironmentService } from '../../../../services/environment/c import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { isCloudSandboxConnectionAddress } from './cloudSandboxConnectionCustomization.js'; import { IRemoteAgentHostAuthenticationService } from './remoteAgentHostAuthentication.js'; +import { CloudSandboxModels } from './cloudSandboxModels.js'; +import { CLOUD_SANDBOX_AGENT_PROVIDER } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; Registry.as(ChatSessionsExtensions.AsyncActivation).register({ matchSessionType: sessionType => isRemoteAgentHostSessionType(sessionType), @@ -460,6 +462,11 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc agentStore.add(toDisposable(() => connState.modelProviders.delete(agent.provider))); agentStore.add(this._languageModelsService.registerLanguageModelProvider(vendor, modelProvider)); modelProvider.updateModels(agent.models); + if (isCloudSandboxConnectionAddress(address) && agent.provider === CLOUD_SANDBOX_AGENT_PROVIDER) { + // Sandbox runtimes can accept cloud-service models omitted from their AHP catalog. + const cloudModels = agentStore.add(this._instantiationService.createInstance(CloudSandboxModels, sessionType, vendor, modelProvider)); + cloudModels.load(); + } this._logService.info(`[RemoteAgentHost] Registered agent ${agent.provider} from ${address} as ${sessionType}`); } @@ -541,9 +548,20 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc if (!connState) { return; } + const renewAuthentication = reason === AuthRequiredReason.Expired + ? this._connectionCustomizations.get(address)?.renewAuthentication + : undefined; this._instantiationService.invokeFunction(accessor => connState.authRecovery.recover(accessor, protectedResource, { authTokenCache: connState.authTokenCache, logPrefix: '[RemoteAgentHost]', + isCurrent: () => this._connections.get(address) === connState, + renewAuthentication: renewAuthentication ? async resource => { + const request = await renewAuthentication(resource); + if (this._connections.get(address) !== connState) { + throw new CancellationError(); + } + await connection.authenticate(request); + } : undefined, authenticate: this._authenticateCallback(address, connection, reason), })) .catch(err => { diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostConnectionCustomization.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostConnectionCustomization.ts index a6a664b6a925f7..f55140335bee78 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostConnectionCustomization.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHostConnectionCustomization.ts @@ -8,6 +8,7 @@ import { DisposableStore, IDisposable, toDisposable } from '../../../../../base/ import { URI } from '../../../../../base/common/uri.js'; import { IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; import { AuthRequiredReason } from '../../../../../platform/agentHost/common/state/sessionActions.js'; +import { type ProtectedResourceMetadata } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { IAgentHostAuthenticateRequest } from '../agentSessions/agentHost/agentHostAuth.js'; @@ -28,6 +29,9 @@ export interface IRemoteAgentHostConnectionCustomization { */ readonly authenticate?: (request: IAgentHostAuthenticateRequest, reason?: AuthRequiredReason) => Promise; + /** Resolves a fresh connection-owned credential for an expired challenge without requiring a new user sign-in. */ + readonly renewAuthentication?: (resource: ProtectedResourceMetadata) => Promise; + /** * The backend session URI scheme for an agent provider when it differs from the provider itself. * Return `undefined` to keep scheme == provider. diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpAuthenticationContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpAuthenticationContentPart.ts index c93786cc8bed9b..4ea14356885022 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpAuthenticationContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpAuthenticationContentPart.ts @@ -6,7 +6,7 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { IRenderedMarkdown } from '../../../../../../base/browser/markdownRenderer.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { escapeMarkdownSyntaxTokens, MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { appendEscapedMarkdownInlineCode, MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { autorun, observableValue } from '../../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; @@ -14,7 +14,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; import { McpServerStatus } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { IMarkdownRendererService } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; -import { IAgentHostCustomizationService } from '../../agentSessions/agentHost/agentHostCustomizationService.js'; +import { getMcpServerDisplayLabel, IAgentHostCustomizationService } from '../../agentSessions/agentHost/agentHostCustomizationService.js'; import { IChatMcpAuthenticationRequired, IChatMcpAuthenticationRequiredServer } from '../../../common/chatService/chatService.js'; import { ChatTreeItem } from '../../chat.js'; import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; @@ -86,7 +86,7 @@ export class ChatMcpAuthenticationContentPart extends Disposable implements ICha const dataServerIds = new Set(dataServers.map(server => server.id)); return this.agentHostCustomizationService.getMcpServers(sessionResource) .filter(server => dataServerIds.has(server.id) && server.enabled && server.status === McpServerStatus.AuthRequired) - .map(server => ({ id: server.id, name: server.name })); + .map(server => ({ id: server.id, name: getMcpServerDisplayLabel(server) })); } private render(servers: readonly Pick[], authenticating: IChatMcpAuthenticationRequiredServer | undefined): void { @@ -98,7 +98,7 @@ export class ChatMcpAuthenticationContentPart extends Disposable implements ICha if (authenticating) { this._renderMessage( ThemeIcon.modify(Codicon.loading, 'spin'), - localize('mcp.auth.authenticating', 'Authenticating {0}...', '`' + escapeMarkdownSyntaxTokens(authenticating.name) + '`'), + localize('mcp.auth.authenticating', 'Authenticating {0}...', appendEscapedMarkdownInlineCode(authenticating.name)), ); if (actionHadFocus) { this.options.onDidRemoveFocusedAction?.(); @@ -114,7 +114,7 @@ export class ChatMcpAuthenticationContentPart extends Disposable implements ICha } const links = servers - .map(server => '`' + escapeMarkdownSyntaxTokens(server.name) + '`') + .map(server => appendEscapedMarkdownInlineCode(server.name)) .join(', '); const content = servers.length === 1 ? localize('mcp.auth.single', 'The MCP server {0} requires authentication. [Authenticate](#authenticate)?', links) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersStartingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersStartingContentPart.ts index 5ccd67323a5c61..48133f1668b7b6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersStartingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersStartingContentPart.ts @@ -7,7 +7,7 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { IRenderedMarkdown } from '../../../../../../base/browser/markdownRenderer.js'; import { createPixelSpinner, IPixelSpinner } from '../../../../../../base/browser/ui/pixelSpinner/pixelSpinner.js'; import { onUnexpectedError } from '../../../../../../base/common/errors.js'; -import { escapeMarkdownSyntaxTokens, MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { appendEscapedMarkdownInlineCode, MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { autorun } from '../../../../../../base/common/observable.js'; import { localize } from '../../../../../../nls.js'; @@ -81,7 +81,7 @@ export class ChatMcpServersStartingContentPart extends Disposable implements ICh const backgroundableServers = blockingServers.filter(server => server.background !== undefined); const visibleServers = blockingServers.length ? blockingServers : servers; const links = visibleServers - .map(server => '`' + escapeMarkdownSyntaxTokens(server.name) + '`') + .map(server => appendEscapedMarkdownInlineCode(server.name)) .join(', '); this.skipAction = this._renderMessage( blockingServers.length diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolAuthenticationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolAuthenticationSubPart.ts index d3996277336801..6faa916c28bfaf 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolAuthenticationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolAuthenticationSubPart.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../../../../base/common/codicons.js'; +import { MarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { localize } from '../../../../../../../nls.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; -import { IAgentHostCustomizationService } from '../../../../browser/agentSessions/agentHost/agentHostCustomizationService.js'; +import { getMcpServerDisplayLabel, IAgentHostCustomizationService } from '../../../../browser/agentSessions/agentHost/agentHostCustomizationService.js'; import { IChatToolInvocation } from '../../../../common/chatService/chatService.js'; import { IChatWidgetService } from '../../../chat.js'; import { ChatCustomConfirmationWidget } from '../chatConfirmationWidget.js'; @@ -30,6 +31,7 @@ export class ChatToolAuthenticationSubPart extends BaseChatToolInvocationSubPart throw new Error('Tool authentication state is missing'); } const mcpServer = customizationService.getMcpServers(context.element.sessionResource).find(server => server.id === state.server.id); + const serverName = mcpServer ? getMcpServerDisplayLabel(mcpServer) : state.server.name; const widget = this._register(instantiationService.createInstance( ChatCustomConfirmationWidget<() => Promise>, @@ -37,7 +39,7 @@ export class ChatToolAuthenticationSubPart extends BaseChatToolInvocationSubPart { title: localize('chat.toolAuthentication.title', "MCP authentication required"), icon: Codicon.mcp, - subtitle: state.server.name, + subtitle: serverName, buttons: [ { label: localize('chat.toolAuthentication.authenticate', "Authenticate"), @@ -61,7 +63,7 @@ export class ChatToolAuthenticationSubPart extends BaseChatToolInvocationSubPart isSecondary: true, }] : []), ], - message: localize('chat.toolAuthentication.message', "The MCP server {0} requires authentication to continue this tool call.", state.server.name), + message: new MarkdownString().appendText(localize('chat.toolAuthentication.message', "The MCP server {0} requires authentication to continue this tool call.", serverName)), toolbarData: { arg: toolInvocation, partType: 'chatToolAuthentication', diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 043ee74700d799..836c890001d4ed 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -47,7 +47,7 @@ import { IMarkdownRenderer } from '../../../../../platform/markdown/browser/mark import { isDark } from '../../../../../platform/theme/common/theme.js'; import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; -import { parseRemoteAgentHostSessionTypeAuthority } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; +import { isCopilotAgentHostSessionType } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; import { isCreateChatTool, isCreateSessionTool, isSendMessageTool } from '../../../../../platform/agentHost/common/openSessionLink.js'; import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { CodiconActionViewItem } from '../../../notebook/browser/view/cellParts/cellActionView.js'; @@ -61,7 +61,7 @@ import { TerminalToolId } from '../../common/tools/terminalToolIds.js'; import { ChatAgentVoteDirection, ChatErrorLevel, ChatRequestQueueKind, ElicitationState, IChatConfirmation, IChatContentReference, IChatDisabledClaudeHooksPart, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatExternalEdit, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatPlanReview, IChatPlanReviewResult, IChatPullRequestContent, IChatQuestionAnswerValue, IChatQuestionAnswers, IChatQuestionCarousel, IChatService, IChatTask, IChatTaskSerialized, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsageModelTotal, isChatFollowup } from '../../common/chatService/chatService.js'; import { ChatPlanReviewData } from '../../common/model/chatProgressTypes/chatPlanReviewData.js'; import { ChatQuestionCarouselData } from '../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; -import { localChatSessionType, SessionType } from '../../common/chatSessionsService.js'; +import { localChatSessionType } from '../../common/chatSessionsService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { getExplicitFileOrImageAttachmentSummary, IChatRequestVariableEntry, isExplicitFileOrImageVariableEntry, isPasteVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { getStickyScrollTargetItem, IChatChangesSummaryPart, IChatCodeCitations, IChatErrorDetailsPart, IChatReferences, IChatRendererContent, IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, IChatWorkingProgress, isRequestVM, isResponseVM, IChatPendingDividerViewModel, isPendingDividerVM, IChatTurnPillsPart } from '../../common/model/chatViewModel.js'; @@ -136,7 +136,7 @@ import { IWorkbenchEnvironmentService } from '../../../../services/environment/c import { AccessibilityWorkbenchSettingId } from '../../../accessibility/browser/accessibilityConfiguration.js'; import { isActiveBackgroundTerminalToolInvocation, isAskQuestionsToolInvocation, isCarouselToolConfirmation, isMcpToolInvocation } from './chatContentParts/toolInvocationParts/chatToolPartUtilities.js'; import { isToolResultInputOutputDetails } from '../../common/tools/languageModelToolsService.js'; -import { AgentSessionProviders, isAgentHostTarget } from '../agentSessions/agentSessions.js'; +import { isAgentHostTarget } from '../agentSessions/agentSessions.js'; const $ = dom.$; @@ -681,16 +681,11 @@ const mostRecentResponseClassName = 'chat-most-recent-response'; export function shouldHideChatUserIdentity(username: string, sessionResource: URI, isResponse: boolean, isSessionsWindow: boolean, isSystemInitiatedRequest: boolean): boolean { const sessionType = getChatSessionType(sessionResource); return username === COPILOT_USERNAME || - (isResponse && isAgentHostCopilotSessionType(sessionType)) || + (isResponse && isCopilotAgentHostSessionType(sessionType)) || isSessionsWindow || isSystemInitiatedRequest; } -function isAgentHostCopilotSessionType(sessionType: string): boolean { - return sessionType === AgentSessionProviders.AgentHostCopilot || - parseRemoteAgentHostSessionTypeAuthority(sessionType, SessionType.CopilotCLI) !== undefined; -} - function upvoteAnimationSettingToEnum(value: string | undefined): ClickAnimation | undefined { switch (value) { case 'confetti': return ClickAnimation.Confetti; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatPetAccessoryRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatPetAccessoryRenderer.ts index d64df84b315889..ca8da4bb26a8fc 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatPetAccessoryRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatPetAccessoryRenderer.ts @@ -5,6 +5,7 @@ import { FileAccess } from '../../../../../base/common/network.js'; import type { IChatPetAccessory } from '../chatPetAchievements.js'; +import { ChatPetColor, getChatPetColoredSprite } from '../chatPetColors.js'; import type { ChatPetState } from './chatPetWidget.js'; import { ChatPetAccessoryRigPose, getChatPetAccessoryRigFrame, getChatPetAntennaeOcclusionBounds, getChatPetEyeAccessoryAnchor } from './chatPetAccessoryRig.js'; @@ -62,13 +63,15 @@ export function drawChatPetComposite( includeEyeAccessory = true, eyeAccessoryMirrorsWithFacing = true, coversAntennae = false, + color?: ChatPetColor, ): void { context.clearRect(0, 0, frameWidth, frameHeight); const sourceX = bodyFrameIndex * frameWidth; + const coloredBody = color ? getChatPetColoredSprite(bodyImage, color) : bodyImage; const rigFrame = getChatPetAccessoryRigFrame(state, rigFrameIndex); if (fixedOrientationDecorations !== undefined && facingDirection === 'left') { - drawMirroredChatPetLayer(context, bodyImage, sourceX, 0, frameWidth, frameHeight); - drawFixedOrientationDecorations(context, bodyImage, bodyFrameIndex, frameWidth, fixedOrientationDecorations); + drawMirroredChatPetLayer(context, coloredBody, sourceX, 0, frameWidth, frameHeight); + drawFixedOrientationDecorations(context, coloredBody, bodyFrameIndex, frameWidth, fixedOrientationDecorations); if (accessoryImage) { if (coversAntennae) { clearChatPetAntennae(context, state, rigFrameIndex, frameWidth, true); @@ -91,7 +94,7 @@ export function drawChatPetComposite( if (accessoryImage) { drawTrackedAccessoryLayer(context, accessoryImage, rigFrame, 'back', frameWidth); } - context.drawImage(bodyImage, sourceX, 0, frameWidth, frameHeight, 0, 0, frameWidth, frameHeight); + context.drawImage(coloredBody, sourceX, 0, frameWidth, frameHeight, 0, 0, frameWidth, frameHeight); if (accessoryImage) { if (coversAntennae) { clearChatPetAntennae(context, state, rigFrameIndex, frameWidth, false); @@ -135,7 +138,7 @@ export function drawChatPetEyeAccessory(context: CanvasRenderingContext2D, acces } : anchor); } -function drawMirroredChatPetLayer(context: CanvasRenderingContext2D, image: HTMLImageElement, sourceX: number, sourceY: number, frameWidth: number, frameHeight: number): void { +function drawMirroredChatPetLayer(context: CanvasRenderingContext2D, image: CanvasImageSource, sourceX: number, sourceY: number, frameWidth: number, frameHeight: number): void { context.save(); context.translate(frameWidth, 0); context.scale(-1, 1); @@ -152,7 +155,7 @@ function clearChatPetAntennae(context: CanvasRenderingContext2D, state: ChatPetS context.clearRect(x, bounds.y, bounds.width, bounds.height); } -function drawFixedOrientationDecorations(context: CanvasRenderingContext2D, bodyImage: HTMLImageElement, frameIndex: number, frameWidth: number, decorations: readonly IChatPetFixedOrientationDecoration[]): void { +function drawFixedOrientationDecorations(context: CanvasRenderingContext2D, bodyImage: CanvasImageSource, frameIndex: number, frameWidth: number, decorations: readonly IChatPetFixedOrientationDecoration[]): void { for (const decoration of decorations) { const currentBounds = decoration.frameBounds[frameIndex]; const canonicalBounds = decoration.frameBounds[decoration.sourceFrame]; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts index a08e999457cc41..3ce9e46d6ef361 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts @@ -28,6 +28,7 @@ import { IHostService } from '../../../../services/host/browser/host.js'; import { IChatModel } from '../../common/model/chatModel.js'; import { CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID, ChatPetAccessoryId, getChatPetAccessory, getChatPetAchievement } from '../chatPetAchievements.js'; import { CHAT_PET_DEFAULT_SCALE, ChatPetVariant, IChatPetService } from '../chatPetService.js'; +import { CHAT_PET_CHANGE_COLOR_COMMAND_ID, ChatPetColor, getChatPetBodyColor, getChatPetColoredSprite, getChatPetColorVariant, getChatPetEyeColor, setChatPetImageSource } from '../chatPetColors.js'; import { drawChatPetComposite, drawChatPetEyeAccessory, getChatPetAccessoryImageSource, hasChatPetAccessoryImageDimensions, hasChatPetBodyImageDimensions, IChatPetAccessoryImageSource, IChatPetFixedOrientationDecoration } from './chatPetAccessoryRenderer.js'; import { getChatPetAccessoryRigFrame, getChatPetReducedMotionRigFrame } from './chatPetAccessoryRig.js'; @@ -176,8 +177,10 @@ interface ChatPetSpriteElement { readonly image: HTMLImageElement; readonly accessoryImages?: readonly HTMLImageElement[]; readonly canvas: HTMLCanvasElement; + color: ChatPetColor; activeAccessory?: ChatPetAccessoryId; activeAccessoryImage?: HTMLImageElement; + redrawFrame?: () => void; } interface ChatPetPendingRender { @@ -408,7 +411,7 @@ export function getChatPetRespawnFrameDurations(): readonly number[] { return RESPAWN_FRAME_DURATIONS; } -function getSpriteSources(variant: ChatPetVariant): Record { +export function getChatPetSpriteSources(variant: ChatPetVariant): Record { let sources = spriteSources.get(variant); if (!sources) { const createStateSpriteSources = (state: ChatPetState) => createSpriteSources(getChatPetSpriteName(state, variant), state, doesChatPetStateTrackCursor(state)); @@ -496,8 +499,8 @@ function doesChatPetStateSpeak(state: ChatPetState | undefined): boolean { return state === 'rendering' || state === 'achievementUnlocked'; } -export function drawChatPetAchievementStar(context: CanvasRenderingContext2D, variant: ChatPetVariant): void { - context.fillStyle = variant === 'stable' ? 'rgb(35, 168, 242)' : 'rgb(36, 191, 165)'; +export function drawChatPetAchievementStar(context: CanvasRenderingContext2D, color: ChatPetColor): void { + context.fillStyle = getChatPetBodyColor(color); context.fillRect(56, 34, 24, 24); context.fillStyle = 'rgb(255, 205, 15)'; const rows = ['..#..', '.###.', '#####', '.#.#.', '#...#']; @@ -1322,6 +1325,7 @@ export class ChatPetWidget extends Disposable { private _respawnPosition: readonly [number, number] | undefined; private readonly _resizeObserver: dom.DisposableResizeObserver; private _variant: ChatPetVariant; + private _color: ChatPetColor; private _selectedAccessory: ChatPetAccessoryId | undefined; private _scale = 1; @@ -1341,7 +1345,8 @@ export class ChatPetWidget extends Disposable { this.dragBounds = host.dragBounds; this.movementBounds = host.movementBounds; this._host = observableValue(this, host); - this._variant = this.chatPetService.variant.get(); + this._color = this.chatPetService.color.get(); + this._variant = getChatPetColorVariant(this._color); this._selectedAccessory = this.chatPetService.selectedAccessory.get(); this.parent.classList.add('chat-pet-host'); this._overlay = dom.$(`.${CHAT_PET_OVERLAY_CLASS}`); @@ -1372,7 +1377,7 @@ export class ChatPetWidget extends Disposable { const respawnEffectImage = dom.append(this._overlay, dom.$('img.chat-pet-spritesheet')) as HTMLImageElement; respawnEffectImage.alt = ''; respawnEffectImage.setAttribute('aria-hidden', 'true'); - this._respawnEffect = { container: respawnEffectCanvas, image: respawnEffectImage, canvas: respawnEffectCanvas }; + this._respawnEffect = { container: respawnEffectCanvas, image: respawnEffectImage, canvas: respawnEffectCanvas, color: this._color }; this._register(dom.addDisposableListener(respawnEffectImage, 'load', () => this._startRespawnEffectAnimation())); this._register(dom.addDisposableListener(respawnEffectImage, 'error', () => { this.logService.error(`[ChatPetWidget] Failed to load respawn sprite: ${respawnEffectImage.getAttribute('src')}`); @@ -1397,7 +1402,7 @@ export class ChatPetWidget extends Disposable { accessoryImage.setAttribute('aria-hidden', 'true'); return accessoryImage; }); - const sprite: ChatPetSpriteElement = { container, image, accessoryImages, canvas }; + const sprite: ChatPetSpriteElement = { container, image, accessoryImages, canvas, color: this._color }; this._register(dom.addDisposableListener(image, 'load', () => this._onBodyImageLoad(sprite))); this._register(dom.addDisposableListener(image, 'error', () => this._onBodyImageError(sprite))); for (const accessoryImage of accessoryImages) { @@ -1407,6 +1412,7 @@ export class ChatPetWidget extends Disposable { return sprite; }); this._eyes = dom.append(this._visual, dom.$('.chat-pet-eyes')); + this._eyes.style.color = getChatPetEyeColor(this._color); this._eyes.setAttribute('aria-hidden', 'true'); for (const side of ['left', 'right']) { const eye = dom.append(this._eyes, dom.$(`.chat-pet-eye.${side}`)); @@ -1428,7 +1434,7 @@ export class ChatPetWidget extends Disposable { const speechBubbleImage = dom.append(speechBubbleContainer, dom.$('img.chat-pet-spritesheet')) as HTMLImageElement; speechBubbleImage.alt = ''; speechBubbleImage.setAttribute('aria-hidden', 'true'); - this._speechBubble = { container: speechBubbleContainer, image: speechBubbleImage, canvas: speechBubbleCanvas }; + this._speechBubble = { container: speechBubbleContainer, image: speechBubbleImage, canvas: speechBubbleCanvas, color: this._color }; this._resizeObserver = this._register(new dom.DisposableResizeObserver('ChatPetWidget.dragBounds', () => this._handleHostLayoutChange(), dom.getWindow(this._button.element), { resizeObserverCtor })); this._observeHost(host); if (this._getHorizontalBounds() !== undefined) { @@ -1609,9 +1615,21 @@ export class ChatPetWidget extends Disposable { this._setScale(scale); } const enabled = isChatPetVisible(serviceEnabled, isWindowActive); - const variant = this.chatPetService.variant.read(reader); + const color = this.chatPetService.color.read(reader); + const colorChanged = color !== this._color; + this._color = color; + const variant = getChatPetColorVariant(color); const variantChanged = variant !== this._variant; this._variant = variant; + if (colorChanged && (!variantChanged || color.startsWith('#'))) { + this._eyes.style.color = getChatPetEyeColor(color); + for (const sprite of [this._activeSprite, this._speechBubble, this._respawnEffect]) { + if (sprite) { + sprite.color = color; + sprite.redrawFrame?.(); + } + } + } const selectedAccessory = this.chatPetService.selectedAccessory.read(reader); const accessoryChanged = selectedAccessory !== this._selectedAccessory; this._selectedAccessory = selectedAccessory; @@ -2480,10 +2498,13 @@ export class ChatPetWidget extends Disposable { true, () => this.commandService.executeCommand(CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID) )); - const stable = actions.add(new Action('chat.pet.variant.stable', localize('chatPet.variant.stable.action', "Stable Colors"), undefined, true, () => this.chatPetService.setVariant('stable'))); - stable.checked = this.chatPetService.variant.get() === 'stable'; - const insiders = actions.add(new Action('chat.pet.variant.insiders', localize('chatPet.variant.insiders.action', "Insiders Colors"), undefined, true, () => this.chatPetService.setVariant('insiders'))); - insiders.checked = this.chatPetService.variant.get() === 'insiders'; + const changeColor = actions.add(new Action( + CHAT_PET_CHANGE_COLOR_COMMAND_ID, + localize('chatPet.changeColor.action', "Change Color"), + undefined, + true, + () => this.commandService.executeCommand(CHAT_PET_CHANGE_COLOR_COMMAND_ID), + )); const grow = actions.add(new Action('chat.pet.grow', localize('chatPet.grow.action', "Grow"), undefined, true, () => { const scale = getChatPetScale(this._scale, CHAT_PET_SCALE_STEP); this.chatPetService.setScale(scale); @@ -2520,8 +2541,7 @@ export class ChatPetWidget extends Disposable { shrink, resetSize, appearanceSeparator, - stable, - insiders, + changeColor, ], onHide: () => { this._contextMenuVisible = false; @@ -2803,8 +2823,7 @@ export class ChatPetWidget extends Disposable { const source = this._motionReduced ? sources.reducedMotion : sources.animated; if (!isChatPetImageSource(this._respawnEffect.image, source.url)) { this._respawnAnimation.clear(); - this._respawnEffect.image.removeAttribute('src'); - this._respawnEffect.image.src = source.url; + setChatPetImageSource(this._respawnEffect.image, source.url); return; } if (this._respawnEffect.image.complete && this._respawnEffect.image.naturalWidth > 0) { @@ -3213,7 +3232,7 @@ export class ChatPetWidget extends Disposable { if (state !== 'idle' || useStaticSprite) { this._facingController.setState(state, useStaticSprite); } - const sources = getSpriteSources(this._variant)[state]; + const sources = getChatPetSpriteSources(this._variant)[state]; const source = this._motionReduced || useStaticSprite ? sources.reducedMotion : sources.animated; if (!restart && this._activeSprite && isChatPetImageSource(this._activeSprite.image, source.url)) { this._pendingRender = undefined; @@ -3249,11 +3268,9 @@ export class ChatPetWidget extends Disposable { state, useStaticSprite, }; - sprite.image.removeAttribute('src'); - sprite.image.src = source.url; + setChatPetImageSource(sprite.image, source.url); if (accessoryImage && accessorySource && accessoryImage !== cachedAccessoryImage) { - accessoryImage.removeAttribute('src'); - accessoryImage.src = accessorySource.url; + setChatPetImageSource(accessoryImage, accessorySource.url); } } @@ -3417,8 +3434,7 @@ export class ChatPetWidget extends Disposable { return; } this._pendingAccessorySwitch = { generation, sprite, source, image, accessory }; - image.removeAttribute('src'); - image.src = source.url; + setChatPetImageSource(image, source.url); } private _tryCompleteAccessorySwitch(): void { @@ -3514,6 +3530,7 @@ export class ChatPetWidget extends Disposable { } private _startSpriteAnimation(source: ChatPetSpriteSource, sprite: ChatPetSpriteElement, animationDisposable: MutableDisposable, onComplete?: () => void, reverse = false, onFrame?: (frameIndex: number) => void, state?: ChatPetState): void { + sprite.color = this._color; const { frameDurations } = source; const { image, canvas } = sprite; const displaySize = sprite === this._speechBubble ? 72 : sprite === this._respawnEffect ? this._getDisplaySize() : CHAT_PET_DISPLAY_SIZE; @@ -3532,7 +3549,9 @@ export class ChatPetWidget extends Disposable { return; } context.imageSmoothingEnabled = false; + let renderedFrameIndex = 0; const drawFrame = (frameIndex: number) => { + renderedFrameIndex = frameIndex; if (state) { const activeAccessory = sprite.activeAccessory ? getChatPetAccessory(sprite.activeAccessory) : undefined; drawChatPetComposite( @@ -3549,18 +3568,25 @@ export class ChatPetWidget extends Disposable { false, activeAccessory?.eyeAccessoryMirrorsWithFacing !== false, activeAccessory?.coversAntennae === true, + sprite.color, ); this._drawEyeAccessory(sprite.activeAccessory, sprite.activeAccessoryImage, source, state, source.accessoryRigFrame ?? frameIndex); } else { context.clearRect(0, 0, source.frameWidth, frameHeight); - context.drawImage(image, frameIndex * source.frameWidth, 0, source.frameWidth, frameHeight, 0, 0, source.frameWidth, frameHeight); + context.drawImage(getChatPetColoredSprite(image, sprite.color), frameIndex * source.frameWidth, 0, source.frameWidth, frameHeight, 0, 0, source.frameWidth, frameHeight); } if (sprite === this._activeSprite) { this._activeFrameIndex = frameIndex; } onFrame?.(frameIndex); }; + sprite.redrawFrame = () => { + if (image.complete && image.naturalWidth > 0 && isChatPetImageSource(image, source.url)) { + drawFrame(renderedFrameIndex); + } + }; if (sprite === this._activeSprite) { + this._eyes.style.color = getChatPetEyeColor(sprite.color); this._redrawActiveFrame = () => drawFrame(this._activeFrameIndex); this._redrawEyeAccessory = state ? () => this._drawEyeAccessory(sprite.activeAccessory, sprite.activeAccessoryImage, source, state, source.accessoryRigFrame ?? this._activeFrameIndex) @@ -3679,8 +3705,7 @@ export class ChatPetWidget extends Disposable { const source = this._motionReduced ? sources.reducedMotion : sources.animated; if (!isChatPetImageSource(this._speechBubble.image, source.url)) { this._speechAnimation.clear(); - this._speechBubble.image.removeAttribute('src'); - this._speechBubble.image.src = source.url; + setChatPetImageSource(this._speechBubble.image, source.url); return; } if ((restart || stateChanged) && this._speechBubble.image.complete && this._speechBubble.image.naturalWidth > 0) { @@ -3694,7 +3719,7 @@ export class ChatPetWidget extends Disposable { state === 'achievementUnlocked' ? () => { const context = this._speechBubble.canvas.getContext('2d'); if (context) { - drawChatPetAchievementStar(context, this._variant); + drawChatPetAchievementStar(context, this._speechBubble.color); } } : undefined, ); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/agentHostInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/agentHostInputCompletions.ts index bf4cfd23c6199a..310ddea494e03a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/agentHostInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/agentHostInputCompletions.ts @@ -176,13 +176,7 @@ export class AgentHostInputCompletions extends AgentHostInputCompletionsBase e const insert = new Range(start.lineNumber, start.column, position.lineNumber, position.column); return { insert, replace }; } + + protected static buildTextCompletionItem(position: Position, item: IChatInputCompletionItem): CompletionItem { + return { + label: item.insertText.startsWith('/') && item.label !== undefined && item.label !== item.insertText && item.label !== item.insertText.trimEnd() + ? { label: item.insertText.trimEnd(), description: item.label } + : item.label ?? item.insertText, + insertText: item.insertText, + filterText: item.insertText, + range: this.computeRange(position, item), + kind: CompletionItemKind.Text, + }; + } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet.css b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet.css index 80810ee059360c..c862e3fe332450 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet.css @@ -337,6 +337,7 @@ } .chat-pet-eyes { + color: #191a1b; position: absolute; inset: 0; visibility: hidden; @@ -386,7 +387,7 @@ left: 2px; width: 4px; height: 4px; - background: #191a1b; + background: currentColor; } .chat-pet-button[data-state='idle'] .chat-pet-eye, @@ -445,7 +446,7 @@ left: 0; width: 8px; height: 2px; - background: #191a1b; + background: currentColor; } .chat-pet-button.dragging .chat-pet-eye::before { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 5e1ee1c6f22ca2..a3249351825980 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -1023,7 +1023,8 @@ export class ChatService extends Disposable implements IChatService { } const currentRequests = model.getRequests(); const existing = turn.id === undefined ? undefined : requestsById.get(turn.id); - if (existing && equals(turn.items, previousTurns.get(turn.id)?.items)) { + // An unchanged turn may be absent because the user removed it locally. + if (equals(turn.items, previousTurns.get(turn.id)?.items)) { continue; } let insertionIndex = existing ? currentRequests.indexOf(existing) : currentRequests.length; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts index 4b5756c3187f2e..75bfd18f4c7f33 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts @@ -15,7 +15,7 @@ import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../constan import { COPILOT_VENDOR_ID, ILanguageModelsService } from '../languageModels.js'; import { chatSessionResourceToId, getChatSessionType } from '../model/chatUri.js'; import { getAgentHostProviderForTelemetry, IChatSessionsService, isAgentHostSessionResource } from '../chatSessionsService.js'; -import { isRemoteAgentHostSessionType, parseRemoteAgentHostHarness } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; +import { isCopilotAgentHostProvider, isRemoteAgentHostSessionType, parseRemoteAgentHostHarness } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { isByokModel } from '../chatSelectedModel.js'; @@ -378,7 +378,7 @@ export class ChatRequestTelemetry { : undefined; this.selectedModelSource = !metadata ? 'unknown' : isByokModel(metadata) ? 'byok' - : metadata.vendor === COPILOT_VENDOR_ID || modelProvider === 'copilotcli' ? 'copilot' + : metadata.vendor === COPILOT_VENDOR_ID || isCopilotAgentHostProvider(modelProvider) ? 'copilot' : metadata.targetChatSessionType && (!modelProvider || modelProvider === 'unknown') ? 'unknown' : 'other'; } diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index ff26657a9c82fe..48a54bc31a4315 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -451,7 +451,10 @@ export interface IChatSession extends IDisposable { readonly title?: string; readonly history: readonly IChatSessionHistoryItem[]; - /** Updated persisted transcript; applying it must preserve the current draft and locally running requests. */ + /** + * Updated persisted transcript; applying it must preserve the draft and locally running requests. + * Unchanged turns must not resurrect locally removed requests. + */ readonly onDidChangeHistory?: Event; diff --git a/src/vs/workbench/contrib/chat/common/editorChatUsage.ts b/src/vs/workbench/contrib/chat/common/editorChatUsage.ts index e3cf761a278920..664e8751807e13 100644 --- a/src/vs/workbench/contrib/chat/common/editorChatUsage.ts +++ b/src/vs/workbench/contrib/chat/common/editorChatUsage.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { parseRemoteAgentHostHarness } from '../../../../platform/agentHost/common/agentHostSessionType.js'; +import { isCopilotAgentHostProvider, parseRemoteAgentHostHarness } from '../../../../platform/agentHost/common/agentHostSessionType.js'; import { SessionType } from './chatSessionsService.js'; const storagePrefix = 'chat.editorUsage.'; @@ -29,8 +29,11 @@ function getProvider(sessionType: string): typeof providers[number] { case SessionType.Codex: return 'codexExtension'; case SessionType.Growth: return 'growth'; } - switch (parseRemoteAgentHostHarness(sessionType)) { - case 'copilotcli': return 'remoteCopilot'; + const remoteProvider = parseRemoteAgentHostHarness(sessionType); + if (isCopilotAgentHostProvider(remoteProvider)) { + return 'remoteCopilot'; + } + switch (remoteProvider) { case 'claude': return 'remoteClaude'; case 'codex': return 'remoteCodex'; default: return 'other'; diff --git a/src/vs/workbench/contrib/chat/common/plugins/marketplaceReference.ts b/src/vs/workbench/contrib/chat/common/plugins/marketplaceReference.ts index f8e27b4300f4dd..53963d0222ebe6 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/marketplaceReference.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/marketplaceReference.ts @@ -4,7 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../../base/common/uri.js'; +import { isValidBasename } from '../../../../../base/common/extpath.js'; import { ExtraKnownMarketplacesConfigDict, IExtraKnownMarketplaceConfigValue } from '../../../../../base/common/managedSettings.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { isWeb, isWindows } from '../../../../../base/common/platform.js'; +import { extUri, extUriIgnorePathCase, joinPath, normalizePath, removeTrailingPathSeparator } from '../../../../../base/common/resources.js'; +import { localize } from '../../../../../nls.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ChatConfiguration } from '../constants.js'; @@ -215,6 +220,9 @@ export function parseMarketplaceReference(value: string): IMarketplaceReference const owner = shorthandMatch[1]; const repo = shorthandMatch[2]; const ref = shorthandMatch[3]; + if (!isValidCacheSegments([owner, repo, ...getRefCacheSegments(ref)])) { + return undefined; + } return { rawValue, displayLabel: rawValue, @@ -266,6 +274,9 @@ function parseUriMarketplaceReference(rawValue: string): IMarketplaceReference | const ref = uri.fragment || undefined; const cloneUri = uri.fragment ? uri.with({ fragment: '' }) : uri; const sanitizedAuthority = sanitizePathSegment(uri.authority.toLowerCase()); + if (!isValidCacheSegments([sanitizedAuthority, ...getRefCacheSegments(ref)])) { + return undefined; + } const trimmedPath = uri.path.replace(/\/+/g, '/').replace(/\/+$/g, '').replace(/^\/+/, ''); // Host-only marketplace endpoint (e.g. `https://plugins.internal.example.com`). @@ -286,7 +297,13 @@ function parseUriMarketplaceReference(rawValue: string): IMarketplaceReference | const gitSuffix = '.git'; const pathHasGitSuffix = trimmedPath.toLowerCase().endsWith(gitSuffix); const pathWithoutGit = pathHasGitSuffix ? trimmedPath.slice(0, trimmedPath.length - gitSuffix.length) : trimmedPath; + if (hasDotSegments(trimmedPath) || hasDotSegments(pathWithoutGit)) { + return undefined; + } const pathSegments = pathWithoutGit.split('/').map(sanitizePathSegment); + if (!isValidCacheSegments(pathSegments)) { + return undefined; + } // Always normalize the canonical path to include .git so that URLs with and without the suffix deduplicate. const canonicalPath = pathHasGitSuffix ? trimmedPath.toLowerCase() : `${trimmedPath.toLowerCase()}${gitSuffix}`; @@ -331,7 +348,14 @@ function parseScpMarketplaceReference(rawValue: string): IMarketplaceReference | } const pathWithoutGit = pathWithGit.slice(0, -gitSuffix.length); + if (hasDotSegments(pathWithGit) || hasDotSegments(pathWithoutGit)) { + return undefined; + } const pathSegments = pathWithoutGit.split('/').map(sanitizePathSegment); + const cacheSegments = [sanitizePathSegment(authority.toLowerCase()), ...pathSegments, ...getRefCacheSegments(ref)]; + if (!isValidCacheSegments(cacheSegments)) { + return undefined; + } const githubRepo = extractGitHubRepo(authority, pathWithoutGit); // Normalize git@github.com:/.git to the same canonical id the @@ -349,7 +373,7 @@ function parseScpMarketplaceReference(rawValue: string): IMarketplaceReference | displayLabel: rawValue, cloneUrl: `${match[1]}@${authority}:${pathWithGit}`, canonicalId, - cacheSegments: [sanitizePathSegment(authority.toLowerCase()), ...pathSegments, ...getRefCacheSegments(ref)], + cacheSegments, kind: MarketplaceReferenceKind.GitUri, ref, githubRepo, @@ -361,7 +385,7 @@ function extractGitHubRepo(authority: string, pathWithoutGit: string): string | return undefined; } const parts = pathWithoutGit.split('/'); - if (parts.length >= 2 && parts[0] && parts[1]) { + if (parts.length === 2 && parts[0] && parts[1]) { return `${parts[0]}/${parts[1]}`; } return undefined; @@ -382,3 +406,62 @@ function getRefCacheSegments(ref: string | undefined): string[] { function sanitizePathSegment(value: string): string { return value.replace(/[\\/:*?"<>|]/g, '_'); } + +function hasDotSegments(path: string): boolean { + return path.split(/[\\/]/).some(segment => segment === '.' || segment === '..'); +} + +function isValidCacheSegments(segments: readonly string[], windows = isWindows && !isWeb): boolean { + return segments.every(segment => !hasDotSegments(segment) + && (!windows || !segment || (isValidBasename(segment, true) && !/[\u0000-\u001f\u007f]/.test(segment)))); +} + +export function gitRevisionCacheSuffix(ref?: string, sha?: string): string[] { + const segments = sha ? [`sha_${sanitizePathSegment(sha)}`] : ref ? [`ref_${sanitizePathSegment(ref)}`] : []; + if (!isValidCacheSegments(segments)) { + throw new Error(localize('invalidPluginCacheSegments', "Invalid plugin cache path '{0}'.", segments.join('/'))); + } + return segments; +} + +export function getGitUrlCacheSegments(url: string): string[] { + let path: string; + let segments: string[]; + try { + const parsed = URI.parse(url); + path = parsed.path; + const authority = sanitizePathSegment(parsed.authority || 'unknown').toLowerCase(); + const pathPart = path.replace(/^\/+/, '').replace(/\.git$/i, '').replace(/\/+$/g, ''); + segments = [authority, ...pathPart.split('/').map(sanitizePathSegment)]; + } catch { + path = /^[^@\s]+@[^:\s]+:(?.+)$/.exec(url)?.groups?.path ?? url; + segments = ['git', sanitizePathSegment(url)]; + } + if (hasDotSegments(path) || !isValidCacheSegments(segments)) { + throw new Error(localize('invalidPluginCacheSegments', "Invalid plugin cache path '{0}'.", url)); + } + return segments; +} + +/** Resolves remote plugin cache segments without permitting traversal or the cache root itself. */ +export function getPluginCacheUri(cacheRoot: URI, segments: readonly string[]): URI { + if (!isValidCacheSegments(segments, cacheRoot.scheme === Schemas.file && isWindows)) { + throw new Error(localize('invalidPluginCacheSegments', "Invalid plugin cache path '{0}'.", segments.join('/'))); + } + return validatePluginCacheUri(cacheRoot, joinPath(cacheRoot, ...segments)); +} + +/** Validates computed and persisted remote repository locations before filesystem access. */ +export function validatePluginCacheUri(cacheRoot: URI, resource: URI): URI { + // Revived URI caches must not override the structural path we validate. + const directory = removeTrailingPathSeparator(normalizePath(URI.from(resource))); + const root = removeTrailingPathSeparator(normalizePath(URI.from(cacheRoot))); + const windows = root.scheme === Schemas.file && isWindows; + const comparer = windows ? extUriIgnorePathCase : extUri; + const relative = comparer.relativePath(root, directory); + if (!comparer.isEqualOrParent(directory, root) || comparer.isEqual(directory, root) + || (windows && (relative === undefined || !isValidCacheSegments(relative.split('/'), true)))) { + throw new Error(localize('invalidPluginCachePath', "Invalid plugin cache path '{0}'.", resource.toString())); + } + return directory; +} diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index 574afb2e6c86a4..5b823aba64e691 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -35,7 +35,7 @@ import { FileBackedInstalledPluginsStore, IStoredInstalledPlugin } from './fileB import { IWorkspacePluginSettingsService } from './workspacePluginSettingsService.js'; import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; import { readAgentPluginManifest } from '../../../../../platform/agentPlugins/common/agentPluginParser.js'; -import { type IMarketplaceReference, deduplicateMarketplaceReferences, MarketplaceReferenceKind, parseMarketplaceObjectEntry, parseMarketplaceReference, parseMarketplaceReferences, readConfiguredMarketplaces } from './marketplaceReference.js'; +import { type IMarketplaceReference, deduplicateMarketplaceReferences, getGitUrlCacheSegments, gitRevisionCacheSuffix, MarketplaceReferenceKind, parseMarketplaceObjectEntry, parseMarketplaceReference, parseMarketplaceReferences, readConfiguredMarketplaces } from './marketplaceReference.js'; import { getStrictKnownMarketplaces, isMarketplaceReferenceAllowed } from './strictKnownMarketplaces.js'; // Re-export marketplace reference types for downstream consumers. @@ -273,7 +273,8 @@ const SINGLE_PLUGIN_MANIFEST_DEFINITIONS: { type: MarketplaceType; path: string ]; const GITHUB_MARKETPLACE_CACHE_TTL_MS = 8 * 60 * 60 * 1000; -const GITHUB_MARKETPLACE_CACHE_STORAGE_KEY = 'chat.plugins.marketplaces.githubCache.v1'; +// Ignore catalogs persisted before Git source path validation. +const GITHUB_MARKETPLACE_CACHE_STORAGE_KEY = 'chat.plugins.marketplaces.githubCache.v2'; /** Interval between periodic plugin update checks (24 hours). */ const PLUGIN_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; @@ -1273,6 +1274,12 @@ export function parsePluginSource( logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source 'path' must be a string when provided`); return undefined; } + try { + gitRevisionCacheSuffix(rawSource.ref, rawSource.sha); + } catch (error) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source revision contains invalid path segments`, error); + return undefined; + } return { kind: PluginSourceKind.GitHub, repo: rawSource.repo, @@ -1308,6 +1315,13 @@ export function parsePluginSource( logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': url source 'path' must be a string when provided`); return undefined; } + try { + getGitUrlCacheSegments(rawSource.url); + gitRevisionCacheSuffix(rawSource.ref, rawSource.sha); + } catch (error) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': ${rawSource.source} source URL or revision contains invalid path segments`, error); + return undefined; + } return { kind: PluginSourceKind.GitUrl, url: rawSource.url, @@ -1363,7 +1377,7 @@ function isOptionalGitSha(value: unknown): value is string | undefined { } function isValidGitHubRepo(repo: string): boolean { - return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo); + return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) && !!parseMarketplaceReference(repo); } /** diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationTelemetryService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationTelemetryService.ts index 9dec781f0fc3d7..1cc12fe14202db 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationTelemetryService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/customizationMigrationTelemetryService.ts @@ -3,12 +3,22 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { disposableTimeout, RunOnceScheduler } from '../../../../../../base/common/async.js'; +import { Event } from '../../../../../../base/common/event.js'; +import { Disposable, DisposableMap, DisposableStore, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { dirname, isEqual } from '../../../../../../base/common/resources.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { IFileService } from '../../../../../../platform/files/common/files.js'; import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { IChatService } from '../../chatService/chatService.js'; import { CustomizationMigrationFailureReason, CustomizationMigrationType, ICustomizationMigrationHint } from './customizationMigrationService.js'; export const ICustomizationMigrationTelemetryService = createDecorator('customizationMigrationTelemetryService'); +const AGENT_MIGRATION_BACKGROUND_TIMEOUT = 10 * 60 * 1000; + type CustomizationMigrationAction = | 'hintShown' | 'hintReviewClicked' @@ -17,6 +27,7 @@ type CustomizationMigrationAction = | 'migrationCategoryShown' | 'migrationOverviewClicked' | 'migrationCategoryClicked' + | 'agentMigrationClicked' | 'migrationClicked' | 'migrationCompleted' | 'backClicked' @@ -36,6 +47,8 @@ type CustomizationMigrationEvent = { requestedCount?: number; migratedCount?: number; failedCount?: number; + cancelled?: boolean; + timedOut?: boolean; migrationFailedReasons?: string; }; @@ -47,6 +60,8 @@ type CustomizationMigrationClassification = { requestedCount?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of customizations selected for migration.' }; migratedCount?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of customizations successfully migrated.' }; failedCount?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of customizations that failed to migrate.' }; + cancelled?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the customization migration flow was cancelled before completion.' }; + timedOut?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the customization migration result was not produced within ten minutes after its session entered the background.' }; migrationFailedReasons?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'A semicolon-separated list of bounded failure reason identifiers. Does not contain customization names, paths, content, or error messages.' }; owner: 'digitarald'; comment: 'Tracks aggregate customization migration impressions, actions, and outcomes without collecting customization names, paths, or content.'; @@ -73,17 +88,57 @@ export interface ICustomizationMigrationTelemetryService { hintShown(hint: ICustomizationMigrationHint): void; hintClicked(hint: ICustomizationMigrationHint, action: 'review' | 'dismiss'): void; pageShown(category?: CustomizationMigrationType): void; - actionClicked(action: 'migrationOverviewClicked' | 'migrationCategoryClicked' | 'backClicked' | 'destinationsClicked' | 'workspaceSkipped' | 'workspaceIncluded' | 'retryClicked' | 'viewChangesClicked' | 'resultDismissed' | 'activityDismissed', category?: CustomizationMigrationType): void; + actionClicked(action: 'migrationOverviewClicked' | 'migrationCategoryClicked' | 'agentMigrationClicked' | 'backClicked' | 'destinationsClicked' | 'workspaceSkipped' | 'workspaceIncluded' | 'retryClicked' | 'viewChangesClicked' | 'resultDismissed' | 'activityDismissed', category?: CustomizationMigrationType): void; migrationClicked(category: CustomizationMigrationType, requestedCount: number, migrationFlowId?: string): void; - migrationCompleted(category: CustomizationMigrationType, requestedCount: number, migratedCount: number, failedCount: number, failureReasons: readonly CustomizationMigrationFailureReason[], migrationFlowId?: string): void; + migrationCompleted(category: CustomizationMigrationType, requestedCount: number, migratedCount: number, failedCount: number, failureReasons: readonly CustomizationMigrationFailureReason[], migrationFlowId?: string, cancelled?: boolean, timedOut?: boolean): void; + watchAgentMigrationResult(resultResource: URI, migrationFlowId: string, inventoryCounts: ReadonlyMap, sessionResource: URI, lifecycle: ICustomizationMigrationSessionLifecycle): void; +} + +export interface ICustomizationMigrationSessionLifecycle { + readonly onDidBackground: Event; + readonly onDidForeground: Event; +} + +interface ICustomizationMigrationAgentResultFile { + readonly version: 1; + readonly migrationFlowId: string; + readonly cancelled: boolean; + readonly results: readonly ICustomizationMigrationAgentResult[]; +} + +interface ICustomizationMigrationAgentResult { + readonly category: CustomizationMigrationType; + readonly scope: 'user' | 'workspace'; + readonly customizationType: 'agent' | 'instructions' | 'skill' | 'mcpServer'; + readonly outcome: 'migrated' | 'skipped' | 'failed'; + readonly count: number; } -export class CustomizationMigrationTelemetryService implements ICustomizationMigrationTelemetryService { +export interface ICustomizationMigrationAgentOutcome { + readonly category: CustomizationMigrationType; + readonly requestedCount: number; + readonly migratedCount: number; + readonly failedCount: number; +} + +export interface ICustomizationMigrationAgentReport { + readonly cancelled: boolean; + readonly outcomes: readonly ICustomizationMigrationAgentOutcome[]; +} + +export class CustomizationMigrationTelemetryService extends Disposable implements ICustomizationMigrationTelemetryService { declare readonly _serviceBrand: undefined; + private readonly agentMigrationResultWatches = this._register(new DisposableMap()); + constructor( @ITelemetryService private readonly telemetryService: ITelemetryService, - ) { } + @IFileService private readonly fileService: IFileService, + @ILogService private readonly logService: ILogService, + @IChatService private readonly chatService: IChatService, + ) { + super(); + } hintComputed(hint: ICustomizationMigrationHint): void { for (const { type, count } of hint.counts) { @@ -103,7 +158,7 @@ export class CustomizationMigrationTelemetryService implements ICustomizationMig this.send({ action: category ? 'migrationCategoryShown' : 'migrationOverviewShown', category }); } - actionClicked(action: 'migrationOverviewClicked' | 'migrationCategoryClicked' | 'backClicked' | 'destinationsClicked' | 'workspaceSkipped' | 'workspaceIncluded' | 'retryClicked' | 'viewChangesClicked' | 'resultDismissed' | 'activityDismissed', category?: CustomizationMigrationType): void { + actionClicked(action: 'migrationOverviewClicked' | 'migrationCategoryClicked' | 'agentMigrationClicked' | 'backClicked' | 'destinationsClicked' | 'workspaceSkipped' | 'workspaceIncluded' | 'retryClicked' | 'viewChangesClicked' | 'resultDismissed' | 'activityDismissed', category?: CustomizationMigrationType): void { this.send({ action, category }); } @@ -116,7 +171,7 @@ export class CustomizationMigrationTelemetryService implements ICustomizationMig }); } - migrationCompleted(category: CustomizationMigrationType, requestedCount: number, migratedCount: number, failedCount: number, failureReasons: readonly CustomizationMigrationFailureReason[], migrationFlowId?: string): void { + migrationCompleted(category: CustomizationMigrationType, requestedCount: number, migratedCount: number, failedCount: number, failureReasons: readonly CustomizationMigrationFailureReason[], migrationFlowId?: string, cancelled?: boolean, timedOut?: boolean): void { const migrationFailedReasons = Array.from(new Set(failureReasons)).sort().join(';'); this.send({ action: 'migrationCompleted', @@ -125,10 +180,84 @@ export class CustomizationMigrationTelemetryService implements ICustomizationMig migratedCount, failedCount, ...(migrationFlowId ? { migrationFlowId } : {}), + ...(cancelled ? { cancelled: true } : {}), + ...(timedOut ? { timedOut: true } : {}), ...(migrationFailedReasons ? { migrationFailedReasons } : {}), }); } + watchAgentMigrationResult(resultResource: URI, migrationFlowId: string, inventoryCounts: ReadonlyMap, sessionResource: URI, lifecycle: ICustomizationMigrationSessionLifecycle): void { + const disposables = new DisposableStore(); + let active = true; + disposables.add(toDisposable(() => active = false)); + const finishIncompleteMigration = (cancelled: boolean, timedOut: boolean) => { + if (!active) { + return; + } + this.agentMigrationResultWatches.deleteAndDispose(migrationFlowId); + for (const [category] of inventoryCounts) { + this.migrationCompleted(category, 0, 0, 0, [], migrationFlowId, cancelled, timedOut); + } + }; + const backgroundTimeout = disposables.add(new MutableDisposable()); + disposables.add(lifecycle.onDidBackground(() => { + backgroundTimeout.value = disposableTimeout( + () => finishIncompleteMigration(false, true), + AGENT_MIGRATION_BACKGROUND_TIMEOUT, + ); + })); + disposables.add(lifecycle.onDidForeground(() => backgroundTimeout.clear())); + const reportResult = disposables.add(new RunOnceScheduler(async () => { + try { + const content = await this.fileService.readFile(resultResource, { limits: { size: 64 * 1024 } }); + if (!active) { + return; + } + const report = parseCustomizationMigrationAgentResult(content.value.toString(), migrationFlowId, inventoryCounts); + this.agentMigrationResultWatches.deleteAndDispose(migrationFlowId); + for (const outcome of report.outcomes) { + if (outcome.requestedCount > 0 || report.cancelled) { + this.migrationCompleted( + outcome.category, + outcome.requestedCount, + outcome.migratedCount, + outcome.failedCount, + [], + migrationFlowId, + report.cancelled, + ); + } + } + } catch (error) { + this.logService.warn('Could not read the agent customization migration result.', error); + } + }, 500)); + disposables.add(this.fileService.watch(dirname(resultResource))); + disposables.add(this.fileService.onDidFilesChange(event => { + if (event.affects(resultResource)) { + reportResult.schedule(); + } + })); + let cancelWhenStopped: (() => void) | undefined; + const model = this.chatService.getSession(sessionResource); + if (model) { + cancelWhenStopped = () => { + const response = model.lastRequest?.response; + if (response?.isCanceled || response?.result?.errorDetails?.code === 'canceled') { + finishIncompleteMigration(true, false); + } + }; + disposables.add(model.onDidChange(cancelWhenStopped)); + } + disposables.add(this.chatService.onDidDisposeSession(event => { + if (event.reason === 'cleared' && event.sessionResources.some(resource => isEqual(resource, sessionResource))) { + finishIncompleteMigration(true, false); + } + })); + this.agentMigrationResultWatches.set(migrationFlowId, disposables); + cancelWhenStopped?.(); + } + private send(event: CustomizationMigrationEvent): void { this.telemetryService.publicLog2('chat.customizationMigration', event); } @@ -140,4 +269,53 @@ export class CustomizationMigrationTelemetryService implements ICustomizationMig count: hint.counts.reduce((total, { count }) => total + count, 0), }); } + +} + +export function parseCustomizationMigrationAgentResult( + content: string, + migrationFlowId: string, + inventoryCounts: ReadonlyMap, +): ICustomizationMigrationAgentReport { + const resultFile = JSON.parse(content) as ICustomizationMigrationAgentResultFile; + if (resultFile.version !== 1 || resultFile.migrationFlowId !== migrationFlowId || typeof resultFile.cancelled !== 'boolean' || !Array.isArray(resultFile.results) || resultFile.results.length === 0 || !resultFile.results.every(isValidCustomizationMigrationAgentResult)) { + throw new Error('Invalid customization migration result file.'); + } + + const reportedCounts = new Map(); + const outcomesByCategory = new Map(); + for (const result of resultFile.results) { + const reportedCount = (reportedCounts.get(result.category) ?? 0) + result.count; + if (reportedCount > (inventoryCounts.get(result.category) ?? 0)) { + throw new Error('Customization migration result exceeds the supplied inventory.'); + } + reportedCounts.set(result.category, reportedCount); + + const previous = outcomesByCategory.get(result.category) ?? { + category: result.category, + requestedCount: 0, + migratedCount: 0, + failedCount: 0, + }; + outcomesByCategory.set(result.category, { + category: result.category, + requestedCount: previous.requestedCount + (result.outcome === 'skipped' ? 0 : result.count), + migratedCount: previous.migratedCount + (result.outcome === 'migrated' ? result.count : 0), + failedCount: previous.failedCount + (result.outcome === 'failed' ? result.count : 0), + }); + } + + return { + cancelled: resultFile.cancelled, + outcomes: [...outcomesByCategory.values()], + }; +} + +function isValidCustomizationMigrationAgentResult(result: ICustomizationMigrationAgentResult): boolean { + return Object.values(CustomizationMigrationType).includes(result.category) + && (result.scope === 'user' || result.scope === 'workspace') + && (result.customizationType === 'agent' || result.customizationType === 'instructions' || result.customizationType === 'skill' || result.customizationType === 'mcpServer') + && (result.outcome === 'migrated' || result.outcome === 'skipped' || result.outcome === 'failed') + && Number.isSafeInteger(result.count) + && result.count > 0; } diff --git a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts index fd13940c786f55..20fbabec6b0ed9 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts @@ -313,7 +313,7 @@ type NewAgentHostSessionSendOptions = Parameters[2]; // type picker command and the static sidebar/editor commands below. // Delegates to `openChatSession` so the session type picker, context keys, // and welcome flows all stay in sync with the dynamic per-agent path. -async function openNewAgentHostSession(accessor: ServicesAccessor, position: ChatSessionPosition, chatSendOptions?: NewAgentHostSessionSendOptions): Promise { +async function openNewAgentHostSession(accessor: ServicesAccessor, position: ChatSessionPosition, chatSendOptions?: NewAgentHostSessionSendOptions): Promise { // Snapshot the services we need synchronously — `accessor` is only valid // before the first `await`. Use the instantiation service to mint a fresh // accessor for the downstream `openChatSession` call. diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts index 04406699963628..b2435d99708136 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts @@ -258,17 +258,19 @@ suite('Chat Accessibility Help', () => { assert.deepStrictEqual({ keybinding: helpText.includes(''), navigation: helpText.includes('use the up and down arrow keys to choose'), - actions: helpText.includes('Go on the Run') && helpText.includes('Grow') && helpText.includes('Shrink') && helpText.includes('Reset Size') && helpText.includes('Stable Colors') && helpText.includes('Insiders Colors'), + actions: helpText.includes('Go on the Run') && helpText.includes('Grow') && helpText.includes('Shrink') && helpText.includes('Reset Size') && helpText.includes('Change Color'), + colorCustomization: helpText.includes('True Name') && helpText.includes('Stable and Insiders are free choices') && helpText.includes('Apply Color') && helpText.includes('Unapplied custom colors are discarded'), petMovement: helpText.includes('Drag it around the chat with the mouse') && helpText.includes('left and right arrows to make it hop'), petHopping: helpText.includes('make it hop along the input until it reaches an edge'), petThrowing: helpText.includes('flick it in any direction') && helpText.includes('gravity pulls it down') && helpText.includes('Hold Shift with the left or right arrow to throw it toward a wall'), petBouncing: helpText.includes('Pointer collisions are ignored for half a second after a drag release') && helpText.includes('while the pet is falling, move the pointer into it to bounce it upward') && helpText.includes('Sideways and upward travel do not start the bounce counter') && helpText.includes('counter beside the pet tracks consecutive bounces and remains for up to five seconds after landing') && helpText.includes('until the pet next reacts or interacts') && helpText.includes('at least twenty bounces triggers confetti unless reduced motion is enabled') && helpText.includes('press Enter or Space to bounce it upward'), petRevival: helpText.includes('a despawn effect appears at the bottom') && helpText.includes('a respawn effect appears at the top') && helpText.includes('automatically returns to the input'), - petScale: helpText.includes('position and selected size are shared across chats and windows') && helpText.includes('remembered after you restart'), + petScale: helpText.includes('position, size, and color are shared across chats and windows') && helpText.includes('remembered after you restart'), }, { keybinding: true, navigation: true, actions: true, + colorCustomization: true, petMovement: true, petHopping: true, petThrowing: true, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentCustomizationItemProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentCustomizationItemProvider.test.ts index 6306091045d0c4..16ee0be49976d5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentCustomizationItemProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentCustomizationItemProvider.test.ts @@ -190,6 +190,41 @@ suite('AgentCustomizationItemProvider', () => { }]); }); + test('bounds and shares the source folder readiness wait', async () => { + class TestCustomizationService extends NullAgentHostCustomizationService { + readinessCalls = 0; + + override whenCustomizationsReady(): Promise { + this.readinessCalls++; + return new Promise(() => { }); + } + } + class TestAgentCustomizationItemProvider extends AgentCustomizationItemProvider { + protected override readonly _sourceFolderReadinessTimeoutMs = 1; + } + + const customizationService = new TestCustomizationService(); + const provider = disposables.add(new TestAgentCustomizationItemProvider( + 'local', + undefined, + undefined, + upcastPartial({}), + new NullLogService(), + customizationService, + makePromptsService(), + )); + const sessionResource = URI.parse('agent-host-copilotcli:///session'); + + const first = await provider.provideSourceFolders(sessionResource, PromptsType.agent, CancellationToken.None); + const second = await provider.provideSourceFolders(sessionResource, PromptsType.instructions, CancellationToken.None); + + assert.deepStrictEqual({ first, second, readinessCalls: customizationService.readinessCalls }, { + first: [], + second: [], + readinessCalls: 1, + }); + }); + test('classifies a directory child by its real file URI when its container is synthetic', async () => { const skillUri = 'file:///workspace/.agents/skills/launch/SKILL.md'; const customizations: Customization[] = [{ diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index c40730b8a41293..d9f23010fb2117 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -40,6 +40,7 @@ import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, Ag import type { ChatInputRequestWithPlanReview } from '../../../../../../platform/agentHost/common/agentHostPlanReview.js'; import { agentHostAuthority, createAgentHostResourceUriMapper, fromAgentHostUri, identityAgentHostResourceUriMapper, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { withChatInputState } from '../../../../../../platform/agentHost/common/meta/agentHostChatInputState.js'; +import { toSlashCommandResourceMeta } from '../../../../../../platform/agentHost/common/meta/agentSlashCommandOutputMeta.js'; import { AgentFeedbackAttachmentDisplayKind, AgentFeedbackAttachmentMetadataKey } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAttachments.js'; import { VSCODE_EPHEMERAL_SESSION_META_KEY } from '../../../../../../platform/agentHost/common/meta/agentEphemeralSessionMeta.js'; import { getElementAttachmentCorrelationId, toElementAttachmentMeta } from '../../../../../../platform/agentHost/common/meta/agentElementAttachments.js'; @@ -93,6 +94,10 @@ import { AgentHostSessionListController } from '../../../browser/agentSessions/a import { AgentHostSessionListStore, type IAgentHostSessionListConnection } from '../../../browser/agentSessions/agentHost/agentHostSessionListStore.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { TestFileService } from '../../../../../test/common/workbenchTestServices.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; +import { isResourceEditorInput, ITextDiffEditorPane, IUntypedEditorInput } from '../../../../../common/editor.js'; +import { IResourceEditorInput } from '../../../../../../platform/editor/common/editor.js'; +import { EditorInput } from '../../../../../common/editor/editorInput.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { MockLabelService } from '../../../../../services/label/test/common/mockLabelService.js'; import { IAgentHostFileSystemService } from '../../../../../services/agentHost/common/agentHostFileSystemService.js'; @@ -1123,6 +1128,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv instantiationService.stub(IAgentHostActiveClientService, activeClientService); instantiationService.stub(IAgentHostProtectedResourcesService, { onDidChange: Event.None, getProtectedResources: () => undefined }); instantiationService.stub(IOpenerService, openerService as IOpenerService); + instantiationService.stub(IEditorService, new class extends mock() { }); return { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService, activeClientService, seedActiveClient, chatSessionContributions, chatSessionItemControllers, newSessionFolderService, trustController, modelService, workingCopyService, commandService }; } @@ -1925,6 +1931,74 @@ suite('AgentHostChatContribution', () => { }); suite('response resource links', () => { + for (const host of ['local', 'WSL']) { + for (const message of ['/sandbox policy', '/SB policy ', 'Hello']) { + test(`opens the ${host} sandbox policy only for its submitted slash command (${message})`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables); + const opened: IResourceEditorInput[] = []; + instantiationService.stub(IEditorService, upcastPartial({ + async openEditor(input: EditorInput | IUntypedEditorInput) { + assert.ok(isResourceEditorInput(input)); + opened.push(input); + return upcastPartial({}); + }, + })); + const authority = host === 'local' ? 'local' : agentHostAuthority('vscode-remote://wsl+Ubuntu'); + agentHostService.resourceUris = host === 'local' ? identityAgentHostResourceUriMapper : createAgentHostResourceUriMapper(authority); + const handler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + provider: 'copilot', agentId: 'agent-host-copilot', sessionType: 'agent-host-copilot', + fullName: 'Test', description: 'test', connection: agentHostService, connectionAuthority: authority, + })); + const { turnPromise, collected, chatSession, turnId, fire } = await startTurn(handler, agentHostService, chatAgentService, disposables, { message }); + const resource = URI.parse('agenthost-content:///reports/policy.md?version=1'); + const content = '[Open Sandbox Policy](agenthost-content:///reports/policy.md?vscodeLinkType=markdown-preview)'; + fire({ + type: ActionType.ChatResponsePart, turnId, + part: { kind: ResponsePartKind.SystemNotification, content: { markdown: content }, _meta: toSlashCommandResourceMeta(resource, true) }, + }); + fire({ type: ActionType.ChatTurnComplete, turnId, duration: 0 }); + await turnPromise; + const fallbackResource = toAgentHostUri(resource.with({ query: '' }), authority); + assert.deepStrictEqual({ + opened: opened.map(input => ({ resource: input.resource?.toString(), options: input.options })), + markdown: collected.flat().filter(part => part.kind === 'markdownContent' || part.kind === 'systemNotification').map(part => part.content.value), + }, message.startsWith('/') ? { + opened: [{ + resource: agentHostService.resourceUris.fromAgentHost(resource).toString(), + options: { pinned: true, override: 'vscode.markdown.preview.editor' }, + }], + markdown: ['Opened the sandbox policy in the editor.'], + } : { + opened: [], + markdown: [`[Open Sandbox Policy](${fallbackResource.with({ query: `${fallbackResource.query}&vscodeLinkType=markdown-preview` }).toString()})`], + }); + chatSession.dispose(); + const restored = await handler.provideChatSessionContent(URI.parse('agent-host-copilot:/new-turntest'), CancellationToken.None); + disposables.add(toDisposable(() => restored.dispose())); + assert.strictEqual(opened.length, message.startsWith('/') ? 1 : 0, 'Restoring history must not reopen the policy'); + })); + } + } + + test('reports failure to open the sandbox policy instead of claiming success', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables); + instantiationService.stub(IEditorService, upcastPartial({ + async openEditor() { throw new Error('Preview failed'); }, + })); + const handler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + provider: 'copilot', agentId: 'agent-host-copilot', sessionType: 'agent-host-copilot', + fullName: 'Test', description: 'test', connection: agentHostService, connectionAuthority: 'local', + })); + const { turnPromise, collected, turnId, fire } = await startTurn(handler, agentHostService, chatAgentService, disposables, { message: '/sandbox policy' }); + fire({ + type: ActionType.ChatResponsePart, turnId, + part: { kind: ResponsePartKind.SystemNotification, content: { markdown: '[Open Sandbox Policy](file:///policy.md)' }, _meta: toSlashCommandResourceMeta(URI.file('/policy.md'), true) }, + }); + fire({ type: ActionType.ChatTurnComplete, turnId, duration: 0 }); + await assert.rejects(turnPromise, /Preview failed/); + assert.deepStrictEqual(collected.flat().filter(part => part.kind === 'markdownContent'), []); + })); + test('uses the WSL connection for file links despite a local session authority', () => { const { sessionHandler, agentHostService } = createContribution(disposables); const authority = agentHostAuthority('vscode-remote://wsl+Ubuntu'); @@ -11436,6 +11510,56 @@ suite('AgentHostChatContribution', () => { suite('attachment context', () => { + for (const implicit of [false, true]) { + for (const { name, uri, dirty, text, expected } of [ + { name: 'untitled editor', uri: URI.from({ scheme: Schemas.untitled, path: '/Untitled-1' }), dirty: false, text: 'draft', expected: 'embedded' }, + { name: 'dirty saved editor', uri: URI.file('/workspace/test.ts'), dirty: true, text: 'edited but not saved', expected: 'embedded' }, + { name: 'clean saved editor', uri: URI.file('/workspace/test.ts'), dirty: false, text: 'saved', expected: 'resource' }, + { name: 'oversized untitled editor', uri: URI.from({ scheme: Schemas.untitled, path: '/Untitled-1' }), dirty: false, text: 'x'.repeat(1024 * 1024 + 1), expected: 'none' }, + { name: 'oversized dirty saved editor', uri: URI.file('/workspace/test.ts'), dirty: true, text: 'x'.repeat(1024 * 1024 + 1), expected: 'resource' }, + ]) { + test(`sandbox Copilot handles ${implicit ? 'implicit' : 'explicit'} ${name} attachments`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { instantiationService, agentHostService, chatAgentService, chatWidgetService, modelService, workingCopyService } = createTestServices(disposables); + const sessionType = 'remote-cloudsandbox_environment-one-copilot'; + const sessionResource = URI.from({ scheme: sessionType, path: '/session-one' }); + const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + provider: 'copilot', + backendSessionScheme: 'ahp-session', + agentId: 'agent-host-copilot', + sessionType, + fullName: 'Copilot [Sandbox session title]', + description: 'Sandbox Copilot', + connection: agentHostService, + connectionAuthority: 'cloudsandbox_environment-one', + })); + modelService.setModelContent(uri, text); + workingCopyService.setDirty(uri, dirty); + if (implicit) { + chatWidgetService.setWidgetForSession(sessionResource, [ + { kind: 'implicit', id: 'vscode.implicit.file', name: 'test', isSelection: false, uri, value: uri }, + ]); + } + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'check this file', + sessionResource, + variables: { variables: implicit ? [] : [{ kind: 'file', id: 'v-file', name: 'test', value: uri }] }, + }); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); + await turnPromise; + + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual({ session, attachments: turnAction.message.attachments }, { + session: buildDefaultChatUri(AgentSession.uri('ahp-session', 'session-one').toString()), + attachments: expected === 'embedded' + ? [{ type: MessageAttachmentKind.EmbeddedResource, label: 'test', displayKind: 'document', data: encodeBase64(VSBuffer.fromString(text)), contentType: 'text/plain' }] + : expected === 'resource' + ? [{ type: MessageAttachmentKind.Resource, uri: uri.toString(), label: 'test', displayKind: 'document' }] + : undefined, + }); + })); + } + } + test('file variable with file:// URI becomes file attachment', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); @@ -12167,8 +12291,8 @@ suite('AgentHostChatContribution', () => { ]); })); - test('active editor implicit context is not forwarded for untitled editors on non-Copilot-CLI backends', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService, chatAgentService, chatWidgetService } = createContribution(disposables); + test('active editor implicit context is not forwarded for untitled editors on non-Copilot backends', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, chatWidgetService } = createContribution(disposables, { provider: 'claude' }); const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/new-implicit-untitled' }); const untitledUri = URI.from({ scheme: 'untitled', path: '/Untitled-1' }); chatWidgetService.setWidgetForSession(sessionResource, [ @@ -12327,7 +12451,7 @@ suite('AgentHostChatContribution', () => { })); test('non-file URI variables (e.g. untitled documents) are forwarded as attachments', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables, { provider: 'claude' }); const uri = URI.from({ scheme: 'untitled', path: '/foo' }); const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { @@ -12447,29 +12571,30 @@ suite('AgentHostChatContribution', () => { ]); })); - test('inlined unsaved attachment preserves _meta and selection for the Copilot CLI backend', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService, chatAgentService, modelService, workingCopyService } = createContribution(disposables, { provider: 'copilotcli' }); - const fileUri = URI.file('/workspace/foo.ts'); - modelService.setModelContent(fileUri, 'first line\nsecond line\nthird line\nfourth line content'); - workingCopyService.setDirty(fileUri, true); + for (const provider of ['copilotcli', 'copilot', 'codex']) { + test(`inlined unsaved attachment preserves _meta and selection for ${provider}`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, modelService, workingCopyService } = createContribution(disposables, { provider }); + const fileUri = URI.file('/workspace/foo.ts'); + modelService.setModelContent(fileUri, 'first line\nsecond line\nthird line\nfourth line content'); + workingCopyService.setDirty(fileUri, true); - const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { - message: 'check this', - variables: { - variables: [ - upcastPartial({ kind: 'file', id: 'v-file', name: 'foo.ts', value: { uri: fileUri, range: new Range(2, 1, 4, 10) }, _meta: { provider: 'fs', score: 0.42 } }), - ], - }, - }); - fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); - await turnPromise; + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'check this', + variables: { + variables: [ + upcastPartial({ kind: 'file', id: 'v-file', name: 'foo.ts', value: { uri: fileUri, range: new Range(2, 1, 4, 10) }, _meta: { provider: 'fs', score: 0.42 } }), + ], + }, + }); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); + await turnPromise; - assert.strictEqual(agentHostService.turnActions.length, 1); - const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; - assert.deepStrictEqual(turnAction.message.attachments, [ - { type: MessageAttachmentKind.EmbeddedResource, label: 'foo.ts', displayKind: 'selection', data: encodeBase64(VSBuffer.fromString('second line\nthird line\nfourth li')), contentType: 'text/plain', selection: { range: { start: { line: 1, character: 0 }, end: { line: 3, character: 9 } } }, _meta: { provider: 'fs', score: 0.42 } }, - ]); - })); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [ + { type: MessageAttachmentKind.EmbeddedResource, label: 'foo.ts', displayKind: 'selection', data: encodeBase64(VSBuffer.fromString('second line\nthird line\nfourth li')), contentType: 'text/plain', selection: { range: { start: { line: 1, character: 0 }, end: { line: 3, character: 9 } } }, _meta: { provider: 'fs', score: 0.42 } }, + ]); + })); + } test('dirty non-file resource that cannot be inlined is dropped for the Copilot CLI backend', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService, workingCopyService } = createContribution(disposables, { provider: 'copilotcli' }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts index 978c82be91dfa7..68529db531e9d5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts @@ -51,6 +51,7 @@ import { IFileService } from '../../../../../../platform/files/common/files.js'; import { TestFileService } from '../../../../../test/common/workbenchTestServices.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { MockLabelService } from '../../../../../services/label/test/common/mockLabelService.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; import { IAgentHostFileSystemService } from '../../../../../services/agentHost/common/agentHostFileSystemService.js'; import { IAgentHostImportConversationStore } from '../../../browser/agentSessions/agentHost/agentHostImportConversationStore.js'; import { IStorageService, InMemoryStorageService, StorageScope } from '../../../../../../platform/storage/common/storage.js'; @@ -89,6 +90,7 @@ suite('AgentHostClientTools', () => { /** A remote agent host running the same Copilot CLI harness (`remote-{authority}-{provider}`). */ const REMOTE_COPILOT_CLI_SESSION_TYPE = 'remote-devbox-copilotcli'; + const CLOUD_SANDBOX_SESSION_TYPE = 'remote-cloudsandbox_environment-one-copilot'; const disposables = new DisposableStore(); @@ -348,12 +350,16 @@ suite('AgentHostClientTools', () => { localDisabled: await publishedTools(tools, AGENT_HOST_COPILOT_CLI_SESSION_TYPE, false), localEnabled: await publishedTools(tools, AGENT_HOST_COPILOT_CLI_SESSION_TYPE, true), remoteEnabled: await publishedTools(tools, REMOTE_COPILOT_CLI_SESSION_TYPE, true), + sandboxDisabled: await publishedTools(tools, CLOUD_SANDBOX_SESSION_TYPE, false), + sandboxEnabled: await publishedTools(tools, CLOUD_SANDBOX_SESSION_TYPE, true), otherEnabled: await publishedTools(tools, 'agent-host-claude', true), withoutCanonical: await publishedTools([collidingCodebaseTool, collidingSemanticSearchTool, readFileTool], AGENT_HOST_COPILOT_CLI_SESSION_TYPE, true), }, { localDisabled: [['readFile', 'Read File']], localEnabled: [[SEMANTIC_SEARCH_TOOL_NAME, 'Search Codebase'], ['readFile', 'Read File']], remoteEnabled: [[SEMANTIC_SEARCH_TOOL_NAME, 'Search Codebase'], ['readFile', 'Read File']], + sandboxDisabled: [['readFile', 'Read File']], + sandboxEnabled: [[SEMANTIC_SEARCH_TOOL_NAME, 'Search Codebase'], ['readFile', 'Read File']], otherEnabled: [[CLIENT_SEMANTIC_SEARCH_REFERENCE_NAME, 'Other Codebase'], [SEMANTIC_SEARCH_TOOL_NAME, 'Other Semantic Search'], ['readFile', 'Read File']], withoutCanonical: [['readFile', 'Read File']], }); @@ -974,6 +980,7 @@ suite('AgentHostClientTools', () => { refreshResolvedConfig: async () => { }, } as Partial as IAgentHostUntitledProvisionalSessionService); instantiationService.stub(ILanguageModelToolsService, toolsService); + instantiationService.stub(IEditorService, new class extends mock() { }); instantiationService.stub(IAgentHostToolSetEnablementService, { observe: () => constObservable({ toolSets: new Map(), tools: new Map() }), getState: () => ({ toolSets: new Map(), tools: new Map() }), @@ -2749,7 +2756,7 @@ suite('AgentHostClientTools', () => { test('maps semantic search to codebase only for Copilot sessions', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const invoke = async (sessionType: string, toolCallId: string) => { - const isCopilot = sessionType === AGENT_HOST_COPILOT_CLI_SESSION_TYPE || sessionType === REMOTE_COPILOT_CLI_SESSION_TYPE; + const isCopilot = sessionType === AGENT_HOST_COPILOT_CLI_SESSION_TYPE || sessionType === REMOTE_COPILOT_CLI_SESSION_TYPE || sessionType === CLOUD_SANDBOX_SESSION_TYPE; const codebaseTool = isCopilot ? semanticSearchTool : { ...semanticSearchTool, canRequestPreApproval: true }; @@ -2792,9 +2799,10 @@ suite('AgentHostClientTools', () => { [ await invoke(AGENT_HOST_COPILOT_CLI_SESSION_TYPE, 'copilot-semantic'), await invoke(REMOTE_COPILOT_CLI_SESSION_TYPE, 'remote-copilot-semantic'), + await invoke(CLOUD_SANDBOX_SESSION_TYPE, 'sandbox-copilot-semantic'), await invoke('agent-host-claude', 'claude-semantic'), ], - [semanticSearchTool.id, semanticSearchTool.id, collidingSemanticSearchTool.id], + [semanticSearchTool.id, semanticSearchTool.id, semanticSearchTool.id, collidingSemanticSearchTool.id], ); })); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts index dcc4ae707e9c9e..5d04782a566a32 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { timeout } from '../../../../../../base/common/async.js'; +import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; import { IReference } from '../../../../../../base/common/lifecycle.js'; @@ -14,7 +15,7 @@ import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; -import { withMcpServerSourceMeta } from '../../../../../../platform/agentHost/common/meta/mcpCustomizationMeta.js'; +import { withMcpServerDisplayNameMeta, withMcpServerSourceMeta } from '../../../../../../platform/agentHost/common/meta/mcpCustomizationMeta.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ActionType, type ActionEnvelope } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { CustomizationEnablementKind, CustomizationType, McpAuthRequiredReason, McpServerCustomization, McpServerStatus, type Customization, type CustomizationEnablement } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -137,7 +138,7 @@ class TestSessionSubscription extends mock>() { suite('AbstractAgentHostCustomizationService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - function createSut(authenticationError?: Error): TestAgentHostCustomizationService { + function createSut(authenticationError?: Error, authenticationTargets?: Array<{ id: string; name: string }>): TestAgentHostCustomizationService { const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(ILoggerService, store.add(new NullLoggerService())); instantiationService.stub(ILogService, new NullLogService()); @@ -153,7 +154,9 @@ suite('AbstractAgentHostCustomizationService', () => { }); instantiationService.stub(IAuthenticationMcpAccessService, { isAccessAllowedForUrl: () => true }); instantiationService.stub(IAuthenticationMcpService, { getAccountPreference: () => undefined }); - instantiationService.stub(IAuthenticationMcpUsageService, { addAccountUsage: () => { } }); + instantiationService.stub(IAuthenticationMcpUsageService, { + addAccountUsage: (_providerId, _accountName, _scopes, id, name) => authenticationTargets?.push({ id, name }), + }); instantiationService.stub(IDynamicAuthenticationProviderStorageService, {}); instantiationService.stub(IOutputService, { getChannel: () => undefined, @@ -292,19 +295,19 @@ suite('AbstractAgentHostCustomizationService', () => { ]); }); - test('preserves host-only MCP configuration sources without requiring a source file', () => { + test('preserves host-only MCP presentation metadata without requiring a source file', () => { const sut = createSut(); const session = URI.parse('vscode-agent-session:///session-1'); const sources = ['user', 'workspace', 'plugin', 'builtin', 'managed'] as const; sut.setTarget(session, new FakeTarget(sources.map(source => ({ ...mcpServer(source, source), uri: `mcp-top-level:copilot:session-1:${source}`, - _meta: withMcpServerSourceMeta(undefined, source), + _meta: withMcpServerDisplayNameMeta(withMcpServerSourceMeta(undefined, source), `${source} display`), })))); assert.deepStrictEqual(sut.getMcpServers(session).map(server => ({ - source: server.source, sourceUri: server.sourceUri, - })), sources.map(source => ({ source, sourceUri: undefined }))); + displayName: server.displayName, source: server.source, sourceUri: server.sourceUri, + })), sources.map(source => ({ displayName: `${source} display`, source, sourceUri: undefined }))); }); test('preserves global and session decisions when re-enabling workspace enablement', () => { @@ -368,10 +371,12 @@ suite('AbstractAgentHostCustomizationService', () => { }); test('starts an unchanged auth-required server before forwarding root authentication results', async () => { - const sut = createSut(); + const authenticationTargets: Array<{ id: string; name: string }> = []; + const sut = createSut(undefined, authenticationTargets); const session = URI.parse('vscode-agent-session:///session-1'); const target = new FakeTarget([{ ...mcpServer('server-1', 'Server One'), + _meta: withMcpServerDisplayNameMeta(undefined, 'Connector One'), state: { kind: McpServerStatus.AuthRequired, reason: McpAuthRequiredReason.Required, @@ -389,6 +394,7 @@ suite('AbstractAgentHostCustomizationService', () => { startCalls: target.startCalls, operationLog: target.operationLog, authenticateCalls: target.authenticateCalls, + authenticationTargets, }, { authentication: [true, true], startCalls: ['server-1', 'server-1'], @@ -397,6 +403,10 @@ suite('AbstractAgentHostCustomizationService', () => { { resource: 'https://mcp.example.com', scopes: [], token: 'token' }, { resource: 'https://mcp.example.com', scopes: [], token: 'token' }, ], + authenticationTargets: [ + { id: 'agent-host-mcp:/Server%20One/https%3A%2F%2Fmcp.example.com', name: 'Connector One (Connector)' }, + { id: 'agent-host-mcp:/Server%20One/https%3A%2F%2Fmcp.example.com', name: 'Connector One (Connector)' }, + ], }); target.startError = new Error('start cancelled'); @@ -723,10 +733,6 @@ suite('WorkbenchAgentHostCustomizationService', () => { } function createReadinessSut(provider = 'copilot') { - /** Keeps the bounded wait short so timeout coverage costs no real time. */ - class TestTimeoutCustomizationService extends WorkbenchAgentHostCustomizationService { - protected override readonly _snapshotTimeoutMs = 20; - } const sessionResource = URI.parse('untitled:chat'); const backendSession = URI.parse(`${provider}:/session`); const subscription = store.add(new LiveSessionSubscription()); @@ -755,7 +761,7 @@ suite('WorkbenchAgentHostCustomizationService', () => { getChannelDescriptor: () => undefined, showChannel: async () => { }, }); - const service = store.add(new TestTimeoutCustomizationService( + const service = store.add(new WorkbenchAgentHostCustomizationService( new class extends mock() { override readonly ambientConnection = connection; }(), @@ -844,28 +850,29 @@ suite('WorkbenchAgentHostCustomizationService', () => { assert.strictEqual(resolved, true); }); - test('whenCustomizationsReady shares one bounded wait across every prompt-type query', async () => { - const { service, subscription, sessionResource } = createReadinessSut(); - - // `createFileMigration` queries source folders once per target prompt - // type, sequentially, so a never-hydrating subscription must cost one - // deadline for the whole hint rather than one per type. Counting - // listeners keeps this deterministic; a wall-clock bound would be flaky. - // The expected two are the subscription entry's own listener plus the - // single shared readiness wait; the point is that it stops growing. - await service.whenCustomizationsReady(sessionResource); + test('whenCustomizationsReady shares readiness while callers control cancellation', async () => { + const { service, subscription, sessionResource, stateWithDirectory } = createReadinessSut(); + const firstCancellation = store.add(new CancellationTokenSource()); + const first = service.whenCustomizationsReady(sessionResource, firstCancellation.token); + await timeout(0); const afterFirstQuery = subscription.listenerCount; - await service.whenCustomizationsReady(sessionResource); - await service.whenCustomizationsReady(sessionResource); + firstCancellation.cancel(); - assert.deepStrictEqual({ - afterFirstQuery, - afterThreeQueries: subscription.listenerCount, - stillUnresolved: subscription.value === undefined, - }, { + const secondCancellation = store.add(new CancellationTokenSource()); + const second = service.whenCustomizationsReady(sessionResource, secondCancellation.token); + await timeout(0); + const afterSecondQuery = subscription.listenerCount; + secondCancellation.cancel(); + const cancelledResults = await Promise.all([first, second]); + + subscription.setSnapshot(stateWithDirectory); + const ready = await service.whenCustomizationsReady(sessionResource); + + assert.deepStrictEqual({ afterFirstQuery, afterSecondQuery, cancelledResults, ready }, { afterFirstQuery: 2, - afterThreeQueries: 2, - stillUnresolved: true, + afterSecondQuery: 2, + cancelledResults: [false, false], + ready: true, }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts index be22179533ebd2..ccca5b82b3698f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts @@ -9,7 +9,7 @@ import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { SessionModelInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { PolicyState, SessionModelInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ILanguageModelChatMetadata } from '../../../common/languageModels.js'; import { AgentHostLanguageModelProvider } from '../../../browser/agentSessions/agentHost/agentHostLanguageModelProvider.js'; @@ -24,6 +24,29 @@ suite('AgentHostLanguageModelProvider', () => { return store.add(new AgentHostLanguageModelProvider('agent-host-copilotcli', 'copilotcli')); } + test('retains cloud-service models omitted from the host catalog across root updates', async () => { + const provider = createProvider(); + provider.updateAdditionalModels([ + { ...makeModel('claude-sonnet-4.6'), configSchema: { type: 'object', properties: { reasoningEffort: { type: 'string', title: 'Effort', enum: ['low', 'high'] } } } }, + makeModel('disabled'), + ]); + provider.updateModels([makeModel('auto'), { ...makeModel('disabled'), policyState: PolicyState.Disabled }]); + const first = await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None); + provider.updateModels([makeModel('auto'), makeModel('host-only'), { ...makeModel('disabled'), policyState: PolicyState.Disabled }]); + const updated = await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None); + provider.updateAdditionalModels([]); + const cleared = await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None); + assert.deepStrictEqual({ + first: first.map(model => model.metadata.id), + efforts: first[1].metadata.configurationSchema?.properties?.reasoningEffort.enum, + updated: updated.map(model => model.metadata.id), + cleared: cleared.map(model => model.metadata.id), + }, { + first: ['auto', 'claude-sonnet-4.6'], efforts: ['low', 'high'], + updated: ['auto', 'host-only', 'claude-sonnet-4.6'], cleared: ['auto', 'host-only'], + }); + }); + test('groups native autoTier without inventing a default or losing fast', async () => { const provider = createProvider(); provider.updateModels([{ diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 3b45a7ce87c0c8..0425384c960ac1 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -1912,7 +1912,7 @@ suite('stateToProgressAdapter', () => { }, requiredScopes: ['repo'], }, - }, undefined, URI.parse('agent-host-copilot://backend/session'), 'remote', 'frontend'); + }, undefined, URI.parse('agent-host-copilot://backend/session'), 'remote', 'frontend', undefined, undefined, 'Example MCP (Connector)'); const state = invocation.state.get(); assert.strictEqual(state.type, IChatToolInvocation.StateKind.WaitingForAuthentication); @@ -1928,7 +1928,7 @@ suite('stateToProgressAdapter', () => { confirmationMessages: undefined, server: { id: 'frontend/mcp-1', - name: 'Example MCP', + name: 'Example MCP (Connector)', resource: 'https://mcp.example.com', oauthClient: { clientId: 'configured-client-id', diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts index 2f380a1a1029dc..93e29ef6bd9351 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts @@ -437,6 +437,7 @@ suite('aiCustomizationManagementEditor', () => { actionClicked: () => { }, migrationClicked: () => { }, migrationCompleted: () => { }, + watchAgentMigrationResult: () => { }, }; editor.dialogService = { confirm: async () => ({ confirmed: false }), diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigration.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigration.test.ts index 8457b7e427aef7..1921902dab607c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigration.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigration.test.ts @@ -19,7 +19,7 @@ import { PromptFileSource, PromptsType } from '../../../common/promptSyntax/prom import { CustomizationMigrationType, FileCustomizationMigrationFailureReason, IMcpServerCustomizationMigrationCandidate, McpServerCustomizationMigrationFailureReason, type MigratableConfiguration } from '../../../common/promptSyntax/service/customizationMigrationService.js'; import { PromptsStorage, type IPromptPath } from '../../../common/promptSyntax/service/promptsService.js'; import { ICustomizationSourceFolder } from '../../../common/customizationHarnessService.js'; -import { createSkillFileUri, migrateCustomizations, migratePromptFileToSkill, resolveWorkspaceMigrationTargetFolder, type CustomizationMigrationTargetFolders } from '../../../browser/aiCustomization/customizationMigration.js'; +import { createCustomizationMigrationAgentPrompt, createSkillFileUri, migrateCustomizations, migratePromptFileToSkill, resolveWorkspaceMigrationTargetFolder, type CustomizationMigrationTargetFolders } from '../../../browser/aiCustomization/customizationMigration.js'; import { CUSTOMIZATION_MIGRATION_CATEGORIES, CustomizationMigrationCategoryId, getCustomizationMigrationCategory } from '../../../browser/aiCustomization/customizationMigrationCategories.js'; class DeleteFailingFileSystemProvider extends InMemoryFileSystemProvider { @@ -130,6 +130,61 @@ suite('customizationMigration', () => { }); }); + test('builds an agent prompt from discovered sources and harness-reported targets', () => { + const recoveryBundleFolder = URI.file('/recovery/vscode-customization-migration'); + const prompt = createCustomizationMigrationAgentPrompt( + { id: 'agent-host-copilotcli', label: 'Copilot' }, + 'migration-flow-id', + recoveryBundleFolder, + [ + { + category: CustomizationMigrationType.PromptFiles, + customization: { uri: URI.file('/workspace/.github/prompts/review.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt }, + }, + { + category: CustomizationMigrationType.McpServers, + customization: { + type: CustomizationMigrationType.McpServers, + storage: PromptsStorage.user, + id: 'server', + name: 'Server', + sourceUri: URI.file('/profile/mcp.json'), + targetUri: URI.file('/home/.copilot/mcp-config.json'), + projectedConfiguration: { type: McpServerType.LOCAL, command: 'node' }, + }, + }, + { + category: CustomizationMigrationType.ConfiguredLocations, + customization: { uri: URI.file('/workspace/custom/review.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions }, + }, + ], + new Map([ + [PromptsType.skill, [ + { uri: URI.file('/workspace/.github/skills'), label: 'Workspace skills', source: 'local' }, + { uri: URI.file('/home/.copilot/skills'), label: 'User skills', source: 'user' }, + ]], + ]), + ); + + assert.strictEqual(prompt.replace(/\\/g, '/'), [ + '/migrate-customizations', + '', + 'Selected harness: Copilot (agent-host-copilotcli)', + 'Migration telemetry flow: migration-flow-id', + 'Recovery bundle folder: file:///recovery/vscode-customization-migration', + `Recovery bundle filesystem path: ${recoveryBundleFolder.path}`, + '', + 'Customizations that need migration:', + '- promptFiles: prompt (local): file:///workspace/.github/prompts/review.prompt.md', + '- mcpServers: MCP server "Server" (user): file:///profile/mcp.json -> file:///home/.copilot/mcp-config.json', + '- configuredLocations: instructions (local): file:///workspace/custom/review.instructions.md', + '', + 'Valid target folders reported by the selected harness:', + '- skill (local, Workspace skills): file:///workspace/.github/skills', + '- skill (user, User skills): file:///home/.copilot/skills', + ].join('\n')); + }); + test('configured locations copy explains harness discovery and setting scope', () => { const category = getCustomizationMigrationCategory(CustomizationMigrationCategoryId.ConfiguredLocations); const agent: IPromptPath = { diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationDashboard.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationDashboard.test.ts index 7c8a3202ecfc54..7606a4fe2ab899 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationDashboard.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationDashboard.test.ts @@ -33,6 +33,7 @@ suite('CustomizationMigrationDashboard', () => { actionClicked: (action, categoryId) => telemetryActions.push(categoryId ? `${action}:${categoryId}` : action), configureLocations: () => { }, dismissResult: () => { }, + migrateWithAgent: () => { }, migrateCategory: () => { }, setItemSelected: () => { }, showItemActions: () => { }, @@ -107,12 +108,23 @@ suite('CustomizationMigrationDashboard', () => { const actions: string[] = []; const { parent, dashboard, telemetryActions } = createDashboard({ configureLocations: (id, storage) => actions.push(`destinations:${id}:${storage}`), + migrateWithAgent: () => actions.push('agent'), migrateCategory: (id, storage) => actions.push(`migrate:${id}:${storage}`), }); + + test('hides agent migration for unsupported harnesses', () => { + const { parent, dashboard } = createDashboard(); + dashboard.showOverview({ ...overview(), supportsAgentMigration: false }); + + assert.strictEqual(parent.querySelector('[aria-label="Start an agent-guided customization migration"]'), null); + }); dashboard.showOverview(overview()); dashboard.focus(); const initialFocus = document.activeElement?.getAttribute('aria-label'); - button(parent, 'Migrate Convert Prompt to Skills (Workspace)').click(); + const agentMigrationButton = button(parent, 'Start an agent-guided customization migration'); + const migrateButton = button(parent, 'Migrate Convert Prompt to Skills (Workspace)'); + agentMigrationButton.click(); + migrateButton.click(); button(parent, 'Migrate Convert Prompt to Skills (User)').click(); button(parent, 'Migrate MCP Servers (Workspace)').click(); button(parent, 'Migrate User Data (User)').click(); @@ -127,6 +139,10 @@ suite('CustomizationMigrationDashboard', () => { sources: [...parent.querySelectorAll('.migration-tree-item-source')].map(element => element.textContent), changes: [...parent.querySelectorAll('.migration-tree-item-changes')].filter(element => (element as HTMLElement).style.display !== 'none').map(element => element.textContent), checklistCopy: parent.textContent?.includes('Your migration checklist'), + intro: parent.querySelector('.migration-intro')?.textContent, + agentButtonInTitleRow: agentMigrationButton.parentElement?.classList.contains('migration-page-title-actions'), + agentButtonIsSecondary: agentMigrationButton.classList.contains('migration-secondary-button'), + migrateButtonIsSecondary: migrateButton.classList.contains('migration-secondary-button'), initialFocus, focus: document.activeElement?.getAttribute('aria-label'), actions, @@ -139,10 +155,14 @@ suite('CustomizationMigrationDashboard', () => { sources: ['.github/prompts/build.prompt.md', '~/.copilot/prompts/release.prompt.md', '~/.copilot/prompts/triage.prompt.md', '.vscode/mcp.json', '~/.copilot/agents/planner.agent.md', '~/.copilot/instructions/review.instructions.md', 'team/rules.instructions.md'], changes: ['The gallery property will be removed.'], checklistCopy: false, + intro: 'Some of your agent customizations need an update to keep working. Use Migrate to have VS Code update selected customizations, or Migrate with Agent for a guided migration in chat. Agent migration uses credits.', + agentButtonInTitleRow: true, + agentButtonIsSecondary: true, + migrateButtonIsSecondary: true, initialFocus: 'Migrate Convert Prompt to Skills (Workspace)', focus: 'Change destination for user prompt migrations', - actions: ['migrate:promptFiles:local', 'migrate:promptFiles:user', 'migrate:mcpServers:local', 'migrate:userData:user', 'migrate:configuredLocations:local', 'destinations:promptFiles:user'], - telemetryActions: ['migrationCategoryClicked:promptFiles', 'migrationCategoryClicked:promptFiles', 'migrationCategoryClicked:mcpServers', 'migrationCategoryClicked:userData', 'migrationCategoryClicked:configuredLocations', 'destinationsClicked'], + actions: ['agent', 'migrate:promptFiles:local', 'migrate:promptFiles:user', 'migrate:mcpServers:local', 'migrate:userData:user', 'migrate:configuredLocations:local', 'destinations:promptFiles:user'], + telemetryActions: ['agentMigrationClicked', 'migrationCategoryClicked:promptFiles', 'migrationCategoryClicked:promptFiles', 'migrationCategoryClicked:mcpServers', 'migrationCategoryClicked:userData', 'migrationCategoryClicked:configuredLocations', 'destinationsClicked'], }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationsTelemetryService.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationsTelemetryService.test.ts new file mode 100644 index 00000000000000..edd484a0978493 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationsTelemetryService.test.ts @@ -0,0 +1,217 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { CustomizationType, McpServerStatus, type ChildCustomization, type Customization } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { NullTelemetryServiceShape } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; +import { IAgentHostActiveClientService } from '../../../browser/agentSessions/agentHost/agentHostActiveClientService.js'; +import { IAgentHostCustomizationService } from '../../../browser/agentSessions/agentHost/agentHostCustomizationService.js'; +import { AICustomizationSources } from '../../../common/aiCustomizationWorkspaceService.js'; +import { IChatRequestAcceptedEvent, IChatService } from '../../../common/chatService/chatService.js'; +import { IChatChangeEvent, IChatModel, IChatRequestModel } from '../../../common/model/chatModel.js'; +import { CustomizationsTelemetryContribution, CustomizationsTelemetryService, ICustomizationsTelemetryService } from '../../../browser/aiCustomization/customizationsTelemetryService.js'; + +suite('CustomizationsTelemetryService', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('reports source counts once from the completed session snapshot', () => { + const customizationService = new class extends mock() { + override readonly onDidChangeCustomAgents = Event.None; + override readonly onDidChangeCustomizations = Event.None; + override getClientWorkingDirectoryUris() { + return []; + } + override getWorkingDirectories() { + return ['file:///workspace']; + } + override getCustomizations(): readonly Customization[] { + return [ + plugin('file:///plugin', [ + child(CustomizationType.Prompt, 'file:///plugin/commands/prompt.md'), + ]), + plugin('vscode-synced-customization:/bundle', [ + child(CustomizationType.Agent, 'file:///host/agents/extension.agent.md'), + child(CustomizationType.Skill, 'file:///host/skills/builtin/SKILL.md'), + ]), + directory([ + child(CustomizationType.Rule, 'file:///workspace/.github/instructions/workspace.instructions.md'), + child(CustomizationType.Hook, 'file:///user/hooks/hooks.json'), + ]), + ]; + } + override getMcpServers() { + return [ + mcpServer('workspace'), + mcpServer('plugin'), + mcpServer('builtin'), + mcpServer('managed'), + mcpServer(undefined), + ]; + } + }(); + const activeClientService = new class extends mock() { + override getOrigin(resource: URI) { + if (resource.path.endsWith('/agents/extension.agent.md')) { + return { uri: URI.file('/extension.agent.md'), source: AICustomizationSources.extension }; + } + if (resource.path.endsWith('/skills/builtin/SKILL.md')) { + return { uri: URI.file('/builtin/SKILL.md'), source: AICustomizationSources.builtin }; + } + return undefined; + } + }(); + const events: { eventName: string; data: unknown }[] = []; + const telemetryService = new class extends NullTelemetryServiceShape { + override publicLog2(eventName?: string, data?: unknown): void { + if (eventName) { + events.push({ eventName, data }); + } + } + }(); + const errors: unknown[][] = []; + const service = new CustomizationsTelemetryService( + customizationService, + activeClientService, + telemetryService as ITelemetryService, + new class extends mock() { + override error(...args: unknown[]): void { + errors.push(args); + } + }(), + ); + const sessionResource = URI.from({ scheme: 'agent-host-copilotcli', path: '/session' }); + + service.reportNewSession(sessionResource); + service.reportNewSession(sessionResource); + assert.deepStrictEqual({ + errors, + events, + }, { + errors: [], + events: [ + event('agent', { extensionCount: 1 }), + event('instructions', { workspaceCount: 1 }), + event('prompt', { pluginCount: 1 }), + event('skill', { builtinCount: 1 }), + event('hook', { userCount: 1 }), + event('mcpServer', { userCount: 1, workspaceCount: 1, pluginCount: 1, builtinCount: 2 }), + event('plugin', { pluginCount: 1 }), + ], + }); + }); + + test('reports after the first accepted request for a new session completes', () => { + const requests = new Emitter(); + const reported: string[] = []; + const firstRequest = new class extends mock() { override readonly id = 'first'; }(); + const secondRequest = new class extends mock() { override readonly id = 'second'; }(); + const firstModelChanges = new Emitter(); + const secondModelChanges = new Emitter(); + const models = new Map([ + ['agent-host-copilotcli:/one', new class extends mock() { + override readonly onDidChange = firstModelChanges.event; + override readonly lastRequest = firstRequest; + }()], + ['agent-host-claude:/two', new class extends mock() { + override readonly onDidChange = secondModelChanges.event; + override readonly lastRequest = secondRequest; + }()], + ]); + const contribution = new CustomizationsTelemetryContribution(new class extends mock() { + override readonly onDidAcceptRequest = requests.event; + override getSession(resource: URI): IChatModel | undefined { + return models.get(resource.toString()); + } + }(), new class extends mock() { + override reportNewSession(resource: URI): void { + reported.push(resource.toString()); + } + }()); + + requests.fire({ chatSessionResource: URI.parse('agent-host-copilotcli:/one'), isNewSession: true }); + requests.fire({ chatSessionResource: URI.parse('agent-host-copilotcli:/one'), isNewSession: false }); + requests.fire({ chatSessionResource: URI.parse('agent-host-claude:/two'), isNewSession: true }); + assert.deepStrictEqual(reported, []); + + firstModelChanges.fire({ kind: 'completedRequest', request: firstRequest }); + secondModelChanges.fire({ kind: 'completedRequest', request: secondRequest }); + contribution.dispose(); + requests.dispose(); + firstModelChanges.dispose(); + secondModelChanges.dispose(); + + assert.deepStrictEqual(reported, [ + 'agent-host-copilotcli:/one', + 'agent-host-claude:/two', + ]); + }); +}); + +function child(type: ChildCustomization['type'], uri: string): ChildCustomization { + return { + id: uri, + uri, + type, + name: uri, + } as ChildCustomization; +} + +function plugin(uri: string, children: ChildCustomization[]): Customization { + return { + id: uri, + uri, + type: CustomizationType.Plugin, + name: uri, + children, + }; +} + +function directory(children: ChildCustomization[]): Customization { + return { + id: 'directory', + uri: 'file:///user', + type: CustomizationType.Directory, + name: 'directory', + enabled: true, + contents: CustomizationType.Rule, + writable: true, + children, + }; +} + +function mcpServer(source: 'user' | 'workspace' | 'plugin' | 'builtin' | 'managed' | undefined) { + return { + id: `server-${source}`, + name: `server-${source}`, + source, + enabled: true, + status: McpServerStatus.Ready, + state: { kind: McpServerStatus.Ready } as const, + start: async () => { }, + stop: async () => { }, + setEnabled: () => { }, + }; +} + +function event(customizationType: string, counts: Partial>) { + return { + eventName: 'agents/customizationsDefined', + data: { + customizationType, + userCount: 0, + workspaceCount: 0, + extensionCount: 0, + pluginCount: 0, + builtinCount: 0, + ...counts, + }, + }; +} diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts index 451b7d8a0a053b..47b49bcbadb471 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts @@ -691,6 +691,25 @@ suite('mcpListWidget', () => { }, { matched: servers.slice(0, 2).map(server => server.id), rows: 13, enabled: 11 }); }); + test('restricts display-name matching to connector rows', () => { + const connectorServer = createAgentHostServer({ + id: 'session/github-copilot-connector-94d26095770df60673dd', + name: 'github-copilot-connector-94d26095770df60673dd', + displayName: 'GitHub', + }); + const matcher = new ActiveSessionMcpServerMatcher([connectorServer]); + + assert.deepStrictEqual({ + genericMatch: matcher.take(['GitHub'])?.id, + connectorMatch: matcher.takeConnector(['github-mcp'], ['github', 'GitHub'])?.id, + unmatched: matcher.unmatched('').map(server => server.id), + }, { + genericMatch: undefined, + connectorMatch: connectorServer.id, + unmatched: [], + }); + }); + test('classifies active-session-only MCP servers as built-in entries', () => { const server = createAgentHostServer({ name: 'node_repl' }); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatConfiguration.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatConfiguration.test.ts index 7dd12eb3a195ae..15bfe05af7f30a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatConfiguration.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatConfiguration.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { ConfigurationMigration, Extensions as WorkbenchConfigurationExtensions, IConfigurationMigrationRegistry } from '../../../../common/configuration.js'; import { ChatConfiguration } from '../../common/constants.js'; @@ -21,6 +21,7 @@ const registeredAgentSessionsSettings = [ ChatConfiguration.AutoMarkAsDoneMergedSessionsAfterDays, ChatConfiguration.AutoDeleteMarkedAsDoneMergedSessionsAfterDays, ].map(key => configurationProperties[key] !== undefined); +const unifiedWorkspacePickerSetting = configurationProperties[ChatConfiguration.UnifiedWorkspacePicker]; const migrations = Registry.as(WorkbenchConfigurationExtensions.ConfigurationMigration).migrations; const legacyAutoArchiveMigration = migrations.find(migration => migration.key === 'chat.agentSessions.autoArchiveMergedSessionsAfterDays'); const legacyAutoDeleteArchivedMigration = migrations.find(migration => migration.key === 'chat.agentSessions.autoDeleteArchivedMergedSessionsAfterDays'); @@ -36,6 +37,20 @@ suite('Chat configuration', () => { assert.deepStrictEqual(registeredAgentSessionsSettings, [true, true, true]); }); + test('enables the unified workspace picker by default while allowing experiment overrides', () => { + assert.deepStrictEqual({ + type: unifiedWorkspacePickerSetting.type, + default: unifiedWorkspacePickerSetting.default, + scope: unifiedWorkspacePickerSetting.scope, + experiment: unifiedWorkspacePickerSetting.experiment, + }, { + type: 'boolean', + default: true, + scope: ConfigurationScope.APPLICATION, + experiment: { mode: 'auto' }, + }); + }); + test('Marketplace visibility is experiment-controlled and default-off while the GitHub Feed is default-on', () => { assert.deepStrictEqual({ marketplace: customizationMarketplaceConfigurationProperties[CustomizationMarketplaceConfiguration.MarketplaceEnabled], diff --git a/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsEditor.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsEditor.test.ts index b99f30042c3d3d..15734817ceb042 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsEditor.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsEditor.test.ts @@ -4,26 +4,48 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import sinon from 'sinon'; +import { Dimension } from '../../../../../base/browser/dom.js'; import { mainWindow } from '../../../../../base/browser/window.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { FileAccess, Schemas } from '../../../../../base/common/network.js'; import { toDisposable } from '../../../../../base/common/lifecycle.js'; import { constObservable, observableValue } from '../../../../../base/common/observable.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; +import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; -import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { NullTelemetryServiceShape } from '../../../../../platform/telemetry/common/telemetryUtils.js'; import { TestThemeService } from '../../../../../platform/theme/test/common/testThemeService.js'; +import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; import { EditorInputCapabilities } from '../../../../common/editor.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IEditorGroup } from '../../../../services/editor/common/editorGroupsService.js'; +import { TestChatEntitlementService, TestStorageService } from '../../../../test/common/workbenchTestServices.js'; +import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID, ChatPetAccessoryId, ChatPetAccessoryIds, ChatPetAchievementId, ChatPetAchievementIds } from '../../browser/chatPetAchievements.js'; import '../../browser/chatPetAchievements.contribution.js'; import { ChatPetAchievementsEditorInput } from '../../browser/chatPetAchievementsEditorInput.js'; +import { ChatPetAchievementsEditor } from '../../browser/chatPetAchievementsEditor.js'; import { ChatPetAchievementsWidget } from '../../browser/chatPetAchievementsWidget.js'; -import { ChatPetVariant, IChatPetService } from '../../browser/chatPetService.js'; +import { ChatPetService, ChatPetVariant, IChatPetService } from '../../browser/chatPetService.js'; +import { CHAT_PET_CHANGE_COLOR_COMMAND_ID, getChatPetBodyColor } from '../../browser/chatPetColors.js'; suite('Chat Pet Achievements Editor', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + setup(() => { + if (mainWindow.location.protocol === `${Schemas.file}:`) { + // Keep preview assets on the file: origin of the Electron test document. + sinon.stub(FileAccess, 'asBrowserUri').callsFake(resource => FileAccess.asFileUri(resource)); + } + }); + teardown(() => sinon.restore()); + test('opens a standalone modal editor input', async () => { let openedInput: ChatPetAchievementsEditorInput | undefined; let pinned: boolean | undefined; @@ -38,10 +60,13 @@ suite('Chat Pet Achievements Editor', () => { override readonly enabled = constObservable(true); }(); const accessor = { - get: (service: typeof IEditorService | typeof IChatPetService) => { + get: (service: typeof IEditorService | typeof IChatPetService | typeof IChatEntitlementService) => { if (service === IChatPetService) { return chatPetService; } + if (service === IChatEntitlementService) { + return new TestChatEntitlementService(); + } assert.strictEqual(service, IEditorService); return editorService; }, @@ -59,7 +84,7 @@ suite('Chat Pet Achievements Editor', () => { requiresModal: openedInput.hasCapability(EditorInputCapabilities.RequiresModal), modalOptions: openedInput.getModalEditorOptions(), }, { - name: 'Achievements', + name: 'Customize Blobby', pinned: true, singleton: true, requiresModal: true, @@ -98,7 +123,7 @@ suite('Chat Pet Achievements Editor', () => { override readonly unlockedAchievements = constObservable([]); override readonly unseenAchievements = constObservable([]); override readonly selectedAccessory = constObservable(undefined); - override readonly variant = constObservable('stable'); + override readonly color = constObservable('stable'); }(); store.add(new ChatPetAchievementsWidget( parent, @@ -106,6 +131,7 @@ suite('Chat Pet Achievements Editor', () => { chatPetService, new TestThemeService(), store.add(new NullLogService()), + new class extends mock() { }(), )); const lockedCard = parent.querySelector(`[data-accessory-id="${ChatPetAccessoryIds.TopHatMonocle}"]`); @@ -157,7 +183,7 @@ suite('Chat Pet Achievements Editor', () => { ]); override readonly unseenAchievements = constObservable([]); override readonly selectedAccessory = selectedAccessory; - override readonly variant = constObservable('stable'); + override readonly color = constObservable('stable'); override markAchievementSeen(): boolean { return false; @@ -174,6 +200,7 @@ suite('Chat Pet Achievements Editor', () => { chatPetService, new TestThemeService(), store.add(new NullLogService()), + new class extends mock() { }(), )); const unlockedCards = Array.from(parent.querySelectorAll('.chat-pet-achievement-card.monaco-button:not(.locked)')); @@ -183,6 +210,10 @@ suite('Chat Pet Achievements Editor', () => { assert.deepStrictEqual({ unlockedCardIds: unlockedCards.map(card => card.dataset.accessoryId), + unlockedCardCursors: unlockedCards.map(card => mainWindow.getComputedStyle(card).cursor), + lockedCardCursors: [...new Set(Array.from(parent.querySelectorAll('.chat-pet-achievement-card.locked')).map(card => mainWindow.getComputedStyle(card).cursor))], + contentCursors: Array.from(bambooHatCard.querySelectorAll('h3, p, canvas')).map(element => mainWindow.getComputedStyle(element).cursor), + roadmapCursor: mainWindow.getComputedStyle(parent.querySelector('.chat-pet-achievement-roadmap')!).cursor, firstMessageTitleCount: Array.from(parent.querySelectorAll('h3')).filter(title => title.textContent === 'Welcome to the Wild West').length, trustButVerifyTitleCount: Array.from(parent.querySelectorAll('h3')).filter(title => title.textContent === 'Trust but Verify').length, selected, @@ -195,6 +226,10 @@ suite('Chat Pet Achievements Editor', () => { ChatPetAccessoryIds.CowboyHat, ChatPetAccessoryIds.BambooHat, ], + unlockedCardCursors: ['pointer', 'pointer', 'pointer'], + lockedCardCursors: ['default'], + contentCursors: ['pointer', 'pointer', 'pointer', 'pointer'], + roadmapCursor: 'default', firstMessageTitleCount: 1, trustButVerifyTitleCount: 1, selected: ChatPetAccessoryIds.BambooHat, @@ -214,7 +249,7 @@ suite('Chat Pet Achievements Editor', () => { override readonly unlockedAchievements = constObservable([ChatPetAchievementIds.FirstChatMessage]); override readonly unseenAchievements = constObservable([]); override readonly selectedAccessory = constObservable(undefined); - override readonly variant = constObservable('stable'); + override readonly color = constObservable('stable'); }(); const widget = store.add(new ChatPetAchievementsWidget( parent, @@ -222,6 +257,7 @@ suite('Chat Pet Achievements Editor', () => { chatPetService, new TestThemeService(), store.add(new NullLogService()), + new class extends mock() { }(), )); const noHatCard = parent.querySelector('[data-accessory-id="none"]'); @@ -234,4 +270,131 @@ suite('Chat Pet Achievements Editor', () => { assert.strictEqual(closeCount, 2); }); + + test('unlocks a keyboard-accessible color reward without changing the selected hat', async () => { + const parent = mainWindow.document.createElement('div'); + mainWindow.document.body.appendChild(parent); + store.add(toDisposable(() => parent.remove())); + const service = store.add(new ChatPetService(store.add(new TestStorageService()), new NullTelemetryServiceShape(), new NullLogService())); + service.toggle(); + service.unlockAchievement(ChatPetAchievementIds.FirstChatMessage); + service.setAccessory(ChatPetAccessoryIds.CowboyHat); + const commands: string[] = []; + store.add(new ChatPetAchievementsWidget( + parent, + () => { }, + service, + new TestThemeService(), + new NullLogService(), + new class extends mock() { + override async executeCommand(id: string): Promise { + commands.push(id); + return undefined; + } + }(), + )); + const locked = parent.querySelector('[data-achievement-id="blobby"]'); + const lockedState = { title: locked?.querySelector('h3')?.textContent, disabled: locked?.getAttribute('aria-disabled') }; + service.unlockAchievement(ChatPetAchievementIds.Blobby); + const card = parent.querySelector('[data-achievement-id="blobby"]'); + assert.ok(card); + card.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true })); + await Promise.resolve(); + assert.deepStrictEqual({ + lockedState, + commands, + label: card.getAttribute('aria-label'), + pressed: card.getAttribute('aria-pressed'), + hat: service.selectedAccessory.get(), + unseen: service.unseenAchievements.get().includes(ChatPetAchievementIds.Blobby), + }, { + lockedState: { title: 'Locked', disabled: 'true' }, + commands: [CHAT_PET_CHANGE_COLOR_COMMAND_ID], + label: 'True Name. Reward: Color Customization. Change Color', + pressed: null, + hat: ChatPetAccessoryIds.CowboyHat, + unseen: false, + }); + }); + + test('navigates between Achievements and Color with editor options and accessible tab keys', async () => { + const parent = mainWindow.document.createElement('div'); + mainWindow.document.body.appendChild(parent); + store.add(toDisposable(() => parent.remove())); + const storage = store.add(new TestStorageService()); + const service = store.add(new ChatPetService(storage, new NullTelemetryServiceShape(), new NullLogService())); + service.toggle(); + const theme = new TestThemeService(); + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IChatPetService, service); + instantiationService.stub(IThemeService, theme); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(ICommandService, new class extends mock() { }()); + const entitlement = new TestChatEntitlementService(); + let closeCount = 0; + const editor = store.add(new ChatPetAchievementsEditor( + new class extends mock() { + override windowId = mainWindow.vscodeWindowId; + override async closeEditor(): Promise { closeCount++; return true; } + }(), + new NullTelemetryServiceShape(), + theme, + storage, + instantiationService, + store.add(new ContextKeyService(new TestConfigurationService())), + service, + entitlement, + )); + editor.create(parent); + editor.layout(new Dimension(800, 600)); + await editor.setInput(store.add(ChatPetAchievementsEditorInput.getOrCreate()), { tab: 'color' }, {}, CancellationToken.None); + const colorTab = parent.querySelector('[role="tab"][aria-selected="true"]'); + assert.ok(colorTab); + editor.focus(); + const page = parent.querySelector('.chat-pet-achievements-editor'); + assert.deepStrictEqual({ + pageFocused: mainWindow.document.activeElement === page, + tabFocused: colorTab === mainWindow.document.activeElement, + role: page?.getAttribute('role'), + label: page?.getAttribute('aria-label'), + selectedTabReachable: colorTab.tabIndex, + }, { + pageFocused: true, + tabFocused: false, + role: 'group', + label: 'Customize Blobby', + selectedTabReachable: 0, + }); + const initialTab = colorTab.textContent; + colorTab.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', keyCode: 37, bubbles: true })); + const afterLeft = parent.querySelector('[role="tab"][aria-selected="true"]')?.textContent; + const activeTab = parent.querySelector('[role="tab"][aria-selected="true"]'); + activeTab?.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', keyCode: 35, bubbles: true })); + const afterEnd = parent.querySelector('[role="tab"][aria-selected="true"]')?.textContent; + colorTab.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', keyCode: 35, bubbles: true })); + const repeatedEnd = parent.querySelector('[role="tab"][aria-selected="true"]')?.textContent; + editor.setOptions({ tab: 'achievements' }); + assert.deepStrictEqual({ + initialTab, + afterLeft, + afterEnd, + repeatedEnd, + afterOptions: parent.querySelector('[role="tab"][aria-selected="true"]')?.textContent, + tabStops: Array.from(parent.querySelectorAll('[role="tab"]')).map(tab => tab.tabIndex), + visiblePanels: parent.querySelectorAll('[role="tabpanel"]:not([hidden])').length, + colorsDisposed: parent.querySelector('.chat-pet-colors-widget') === null, + }, { initialTab: 'Color', afterLeft: 'Achievements', afterEnd: 'Color', repeatedEnd: 'Color', afterOptions: 'Achievements', tabStops: [0, -1], visiblePanels: 1, colorsDisposed: true }); + + service.unlockAchievement(ChatPetAchievementIds.Blobby); + editor.setOptions({ tab: 'color' }); + const customInput = parent.querySelector('input[aria-label="Custom hex color"]'); + assert.ok(customInput); + customInput.value = '#123456'; + customInput.dispatchEvent(new Event('input', { bubbles: true })); + editor.clearInput(); + await editor.setInput(ChatPetAchievementsEditorInput.getOrCreate(), { tab: 'color' }, {}, CancellationToken.None); + assert.strictEqual(parent.querySelector('input[aria-label="Custom hex color"]')?.value, getChatPetBodyColor(service.color.get())); + entitlement.sentimentObs.set({ hidden: true }, undefined); + assert.strictEqual(closeCount, 1, 'The open customization editor closes when AI features are hidden'); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatPetColors.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatPetColors.test.ts new file mode 100644 index 00000000000000..120e9248c6cda4 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatPetColors.test.ts @@ -0,0 +1,680 @@ +/*--------------------------------------------------------------------------------------------- + * 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 sinon from 'sinon'; +import * as DOM from '../../../../../base/browser/dom.js'; +import { mainWindow } from '../../../../../base/browser/window.js'; +import { Color } from '../../../../../base/common/color.js'; +import { FileAccess, Schemas } from '../../../../../base/common/network.js'; +import { Disposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { MenuRegistry } from '../../../../../platform/actions/common/actions.js'; +import { IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; +import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { NullTelemetryServiceShape } from '../../../../../platform/telemetry/common/telemetryUtils.js'; +import { TestThemeService } from '../../../../../platform/theme/test/common/testThemeService.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { TestChatEntitlementService, TestStorageService } from '../../../../test/common/workbenchTestServices.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; +import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; +import { CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID, ChatPetAchievementIds, getChatPetAchievement, getChatPetAchievementPresentation } from '../../browser/chatPetAchievements.js'; +import '../../browser/chatPetAchievements.contribution.js'; +import { CHAT_PET_BLOBBY_COMMAND_ID, CHAT_PET_CHANGE_COLOR_COMMAND_ID, ChatPetColor, chatPetColorPresets, getChatPetBodyColor, getChatPetColoredSprite, getChatPetEyeColor, parseChatPetColor, setChatPetImageSource } from '../../browser/chatPetColors.js'; +import { ChatPetContextKeys, ChatPetService, IChatPetService } from '../../browser/chatPetService.js'; +import { ChatPetAchievementsEditorInput, IChatPetCustomizationEditorOptions } from '../../browser/chatPetAchievementsEditorInput.js'; +import { ChatPetColorsWidget } from '../../browser/chatPetColorsWidget.js'; +import { IChatWidgetService } from '../../browser/chat.js'; +import { IAgentHostSessionWorkingDirectoryResolver } from '../../browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; +import { IAgentHostUntitledProvisionalSessionService } from '../../browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; +import { IAgentSessionsService } from '../../browser/agentSessions/agentSessionsService.js'; +import { ChatSlashCommandsContribution } from '../../browser/chatSlashCommands.js'; +import { getChatPetSpriteSources } from '../../browser/widget/chatPetWidget.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { IChatService } from '../../common/chatService/chatService.js'; +import { IChatAgentService } from '../../common/participants/chatAgents.js'; +import { IChatSlashCommandService, IChatSlashData } from '../../common/participants/chatSlashCommands.js'; + +suite('Chat Pet Colors', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + setup(() => { + if (mainWindow.location.protocol === `${Schemas.file}:`) { + // Keep preview assets on the file: origin of the Electron test document. + sinon.stub(FileAccess, 'asBrowserUri').callsFake(resource => FileAccess.asFileUri(resource)); + } + }); + teardown(() => sinon.restore()); + + function createService(storage = store.add(new TestStorageService())) { + return store.add(new ChatPetService(storage, new NullTelemetryServiceShape(), new NullLogService())); + } + + function createCommandHarness() { + const service = createService(); + const instantiationService = store.add(new TestInstantiationService()); + const entitlement = new class extends TestChatEntitlementService { + override readonly sentiment = { hidden: false }; + }(); + instantiationService.stub(IChatEntitlementService, entitlement); + instantiationService.stub(IChatPetService, service); + instantiationService.stub(IEditorService, new class extends mock() { }()); + const opened: IChatPetCustomizationEditorOptions[] = []; + instantiationService.stub(IEditorService, 'openEditor', async (input: ChatPetAchievementsEditorInput, options: IChatPetCustomizationEditorOptions) => { + store.add(input); + opened.push(options); + return undefined; + }); + const run = async (id: string) => { + const command = CommandsRegistry.getCommand(id); + assert.ok(command, id); + await instantiationService.invokeFunction(accessor => command.handler(accessor)); + }; + instantiationService.stub(ICommandService, new class extends mock() { + override async executeCommand(id: string): Promise { + await run(id); + return undefined; + } + }()); + return { service, opened, run, entitlement }; + } + + function createColorWidget(unlocked = false) { + const parent = DOM.$('div'); + mainWindow.document.body.append(parent); + store.add(toDisposable(() => parent.remove())); + const service = createService(); + service.toggle(); + service.setColor('stable'); + if (unlocked) { + service.unlockAchievement(ChatPetAchievementIds.Blobby); + } + let closeCount = 0; + const widget = store.add(new ChatPetColorsWidget(parent, () => closeCount++, service, new TestThemeService(), new NullLogService())); + widget.layout(new DOM.Dimension(800, 500)); + const input = parent.querySelector('input[aria-label="Custom hex color"]'); + const picker = parent.querySelector('input[type="color"]'); + const apply = parent.querySelector('.chat-pet-custom-color-inputs .monaco-button'); + assert.ok(input && picker && apply); + const setInput = (value: string) => { + input.value = value; + input.dispatchEvent(new Event('input', { bubbles: true })); + }; + return { parent, service, widget, input, picker, apply, setInput, getCloseCount: () => closeCount }; + } + + async function loadImage(url: string): Promise { + const image = DOM.$('img'); + setImageSource(image, url); + await image.decode(); + return image; + } + + function setImageSource(image: HTMLImageElement, url: string): void { + // Electron's test document uses file:, unlike the workbench's vscode-file: origin. + image.src = mainWindow.location.protocol === `${Schemas.file}:` ? FileAccess.uriToFileUri(URI.parse(url)).toString(true) : url; + } + + function getPixels(source: HTMLImageElement | HTMLCanvasElement): ImageData { + const canvas = DOM.$('canvas'); + canvas.width = source.width; + canvas.height = source.height; + const context = canvas.getContext('2d'); + assert.ok(context); + context.drawImage(source, 0, 0); + return context.getImageData(0, 0, canvas.width, canvas.height); + } + + test('accepts opaque hex colors and presets, normalizing shorthand', () => { + assert.deepStrictEqual( + ['stable', 'insiders', '#F80', ' #AbC123 ', '#000000', '#ffffff', '#1234', '#12345678', '#ggg', 'red', '', 'transparent', 'rgb(1, 2, 3)'].map(parseChatPetColor), + ['stable', 'insiders', '#ff8800', '#abc123', '#000000', '#ffffff', undefined, undefined, undefined, undefined, undefined, undefined, undefined], + ); + assert.ok(chatPetColorPresets.every(preset => parseChatPetColor(preset.color) === preset.color)); + }); + + test('uses the official Exploration orange instead of a generic orange preset', () => { + assert.deepStrictEqual({ + exploration: chatPetColorPresets.find(preset => preset.label === 'Exploration'), + firstRow: chatPetColorPresets.slice(0, 5).map(preset => preset.label), + hasGenericOrange: chatPetColorPresets.some(preset => preset.label === 'Orange'), + previousOrangeRemainsValid: parseChatPetColor('#ed9844'), + }, { + exploration: { color: '#ff8c00', label: 'Exploration' }, + firstRow: ['Stable', 'Insiders', 'Exploration', 'Red', 'Yellow'], + hasGenericOrange: false, + previousOrangeRemainsValid: '#ed9844', + }); + }); + + test('preserves legacy appearance and keeps Stable and Insiders free', () => { + const storage = store.add(new TestStorageService()); + storage.store('chat.vscodePet.variant', 'stable', StorageScope.APPLICATION, StorageTarget.USER); + const service = createService(storage); + assert.strictEqual(service.color.get(), 'stable'); + service.setColor('insiders'); + assert.strictEqual(createService(storage).color.get(), 'insiders'); + service.resetAchievements(); + assert.strictEqual(service.color.get(), 'insiders'); + service.setColor('stable'); + assert.throws(() => service.setColor('#ff8800'), /Use \/blobby/); + service.toggle(); + assert.throws(() => service.setColor('#ff8800'), /Use \/blobby/); + service.unlockAchievement(ChatPetAchievementIds.Blobby); + assert.throws(() => service.setColor('#invalid'), /Invalid chat pet color/); + }); + + test('persists, synchronizes, and resets custom colors independently of hats', () => { + const storage = store.add(new TestStorageService()); + storage.store('chat.vscodePet.variant', 'stable', StorageScope.APPLICATION, StorageTarget.USER); + const service = createService(storage); + service.toggle(); + service.unlockAchievement(ChatPetAchievementIds.Blobby); + service.unlockAchievement(ChatPetAchievementIds.FirstChatMessage); + service.setAccessory('cowboyHat'); + service.setColor('#F80'); + const restored = createService(storage); + const persisted = { + color: restored.color.get(), + stored: storage.get('chat.vscodePet.color', StorageScope.APPLICATION), + accessory: restored.selectedAccessory.get(), + }; + service.setColor('insiders'); + const preset = restored.color.get(); + service.setColor('#abc123'); + service.toggle(); + const disabled = restored.color.get(); + service.resetAchievements(); + assert.deepStrictEqual({ + persisted, + preset, + disabled, + reset: restored.color.get(), + unlocked: restored.unlockedAchievements.get(), + storedAfterReset: storage.get('chat.vscodePet.color', StorageScope.APPLICATION), + }, { + persisted: { color: '#ff8800', stored: '#ff8800', accessory: 'cowboyHat' }, + preset: 'insiders', + disabled: '#abc123', + reset: 'stable', + unlocked: [], + storedAfterReset: undefined, + }); + }); + + test('reports invalid or locked stored colors instead of rendering them', () => { + const storage = store.add(new TestStorageService()); + storage.store('chat.vscodePet.variant', 'insiders', StorageScope.APPLICATION, StorageTarget.USER); + storage.store('chat.vscodePet.color', '#ff8800', StorageScope.APPLICATION, StorageTarget.USER); + const warnings: string[] = []; + const service = store.add(new ChatPetService(storage, new NullTelemetryServiceShape(), new class extends NullLogService { + override warn(message: string): void { warnings.push(message); } + }())); + const locked = service.color.get(); + service.toggle(); + service.unlockAchievement(ChatPetAchievementIds.Blobby); + const unlocked = service.color.get(); + storage.store('chat.vscodePet.color', '#broken', StorageScope.APPLICATION, StorageTarget.USER); + assert.deepStrictEqual({ + locked, + unlocked, + invalid: service.color.get(), + warningCount: warnings.length, + }, { locked: 'insiders', unlocked: '#ff8800', invalid: 'insiders', warningCount: 2 }); + }); + + test('invoking Blobby turns on an inactive pet without opening the page, then opens Color while active', async () => { + const { service, opened, run } = createCommandHarness(); + const unlocks: string[] = []; + store.add(service.onDidUnlockAchievement(id => unlocks.push(id))); + await run(CHAT_PET_BLOBBY_COMMAND_ID); + const firstInvocation = { enabled: service.enabled.get(), opened: opened.length, unlocks: [...unlocks] }; + await run(CHAT_PET_BLOBBY_COMMAND_ID); + await run(CHAT_PET_BLOBBY_COMMAND_ID); + assert.deepStrictEqual({ + firstInvocation, + enabled: service.enabled.get(), + unlocks, + opened, + }, { + firstInvocation: { enabled: true, opened: 0, unlocks: [ChatPetAchievementIds.Blobby] }, + enabled: true, + unlocks: [ChatPetAchievementIds.Blobby], + opened: [{ pinned: true, tab: 'color' }, { pinned: true, tab: 'color' }], + }); + }); + + test('Change Color opens the color page before earning True Name and does not alter the color', async () => { + const { service, opened, run } = createCommandHarness(); + await run(CHAT_PET_CHANGE_COLOR_COMMAND_ID); + assert.deepStrictEqual(opened, []); + service.toggle(); + service.setColor('stable'); + await run(CHAT_PET_CHANGE_COLOR_COMMAND_ID); + assert.deepStrictEqual({ + opened, + color: service.color.get(), + unlocked: service.unlockedAchievements.get(), + }, { + opened: [{ pinned: true, tab: 'color' }], + color: 'stable', + unlocked: [], + }); + }); + + test('pet customization commands respect the AI opt-out even when invoked directly', async () => { + const { service, opened, run, entitlement } = createCommandHarness(); + entitlement.sentiment.hidden = true; + await run(CHAT_PET_BLOBBY_COMMAND_ID); + const activated = service.enabled.get(); + service.toggle(); + for (const command of [CHAT_PET_BLOBBY_COMMAND_ID, CHAT_PET_CHANGE_COLOR_COMMAND_ID, CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID]) { + await run(command); + } + assert.deepStrictEqual({ activated, unlocked: service.unlockedAchievements.get(), opened }, { activated: false, unlocked: [], opened: [] }); + }); + + test('workbench slash command and Command Palette availability respect the AI opt-out independently of agent registration', () => { + const slashCommands: IChatSlashData[] = []; + const configuration = new TestConfigurationService(); + store.add(new ChatSlashCommandsContribution( + new class extends mock() { + override registerSlashCommand(data: IChatSlashData) { + slashCommands.push(data); + return Disposable.None; + } + }(), + new class extends mock() { }(), + new class extends mock() { }(), + store.add(new TestInstantiationService()), + new class extends mock() { }(), + new class extends mock() { }(), + configuration, + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { }(), + createService(), + new class extends mock() { override readonly isSessionsWindow = false; }(), + )); + const blobby = slashCommands.find(command => command.command === 'blobby'); + const changeColor = MenuRegistry.getCommand(CHAT_PET_CHANGE_COLOR_COMMAND_ID); + assert.ok(blobby?.when && changeColor?.precondition); + const context = store.add(new ContextKeyService(configuration)); + const chatEnabled = ChatContextKeys.enabled.bindTo(context); + const chatHidden = ChatContextKeys.Setup.hidden.bindTo(context); + const petEnabled = ChatPetContextKeys.enabled.bindTo(context); + const cases = [ + { enabled: true, hidden: false, pet: true }, + { enabled: true, hidden: true, pet: true }, + { enabled: false, hidden: false, pet: true }, + { enabled: true, hidden: false, pet: false }, + { enabled: true, hidden: false, pet: true }, + ]; + assert.deepStrictEqual(cases.map(value => { + chatEnabled.set(value.enabled); + chatHidden.set(value.hidden); + petEnabled.set(value.pet); + return { + slash: context.contextMatchesRules(blobby.when), + palette: context.contextMatchesRules(changeColor.precondition), + }; + }), [ + { slash: true, palette: true }, + { slash: false, palette: false }, + { slash: false, palette: false }, + { slash: true, palette: false }, + { slash: true, palette: true }, + ]); + }); + + test('represents color customization as an achievement without inventing a hat', () => { + const achievement = getChatPetAchievement(ChatPetAchievementIds.Blobby); + const unlocked = getChatPetAchievementPresentation(achievement, true); + const locked = getChatPetAchievementPresentation(achievement, false); + assert.deepStrictEqual({ + accessories: achievement.accessories, + title: achievement.title, + description: achievement.description, + colorCustomization: achievement.colorCustomization, + unlockedRewards: unlocked.rewardLabels, + lockedRewards: locked.rewardLabels, + lockedRevealsTitle: JSON.stringify(locked).includes('True Name'), + }, { + accessories: [], + title: 'True Name', + description: 'Blob the Builder? Blobby McBlobface? Nope, my name is Blobby.', + colorCustomization: true, + unlockedRewards: ['Color Customization'], + lockedRewards: ['Color Customization'], + lockedRevealsTitle: false, + }); + }); + + test('shows compact preset cards with only Stable and Insiders available before True Name', () => { + const { parent, service, input, picker } = createColorWidget(); + const cards = Array.from(parent.querySelectorAll('.chat-pet-color-card')); + const freeCards = cards.filter(card => card.getAttribute('aria-disabled') !== 'true').map(card => card.dataset.color); + cards.find(card => card.dataset.color === 'insiders')?.click(); + const selectedPreset = service.color.get(); + const initiallyDisabled = input.disabled && picker.disabled; + service.unlockAchievement(ChatPetAchievementIds.Blobby); + assert.deepStrictEqual({ + count: cards.length, + freeCards, + selectedPreset, + initiallyDisabled, + unlockedCardCount: cards.filter(card => card.getAttribute('aria-disabled') !== 'true').length, + customEnabled: !input.disabled && !picker.disabled, + previewSizes: [...new Set(cards.map(card => card.querySelector('canvas')?.getBoundingClientRect().width))], + }, { + count: 10, + freeCards: ['stable', 'insiders'], + selectedPreset: 'insiders', + initiallyDisabled: true, + unlockedCardCount: 10, + customEnabled: true, + previewSizes: [64], + }); + }); + + test('uses theme colors to distinguish the selected preset card', () => { + const { parent, service } = createColorWidget(true); + parent.style.setProperty('--vscode-focusBorder', '#0088ff'); + parent.style.setProperty('--vscode-editorWidget-border', '#666666'); + parent.style.setProperty('--vscode-list-inactiveSelectionBackground', '#333333'); + service.setColor('#ed83b5'); + const selected = parent.querySelector('.chat-pet-color-card.selected'); + assert.ok(selected); + const styles = mainWindow.getComputedStyle(selected); + assert.deepStrictEqual({ + label: selected.getAttribute('aria-label'), + border: styles.borderTopColor, + background: styles.backgroundColor, + }, { label: 'Pink, selected', border: 'rgb(0, 136, 255)', background: 'rgb(51, 51, 51)' }); + }); + + test('cursors distinguish clickable color controls from disabled controls and text input', () => { + const { parent, service, input, picker, apply, setInput } = createColorWidget(); + const cursors = (selector: string) => Array.from(parent.querySelectorAll(selector)).map(element => mainWindow.getComputedStyle(element).cursor); + const locked = { + freeCard: cursors('[data-color="stable"], [data-color="stable"] > *'), + lockedCard: cursors('[data-color="#e6536f"], [data-color="#e6536f"] > *'), + hex: mainWindow.getComputedStyle(input).cursor, + picker: mainWindow.getComputedStyle(picker).cursor, + apply: mainWindow.getComputedStyle(apply).cursor, + }; + service.unlockAchievement(ChatPetAchievementIds.Blobby); + setInput('#123abc'); + assert.deepStrictEqual({ + locked, + unlocked: { + colorCard: cursors('[data-color="#e6536f"], [data-color="#e6536f"] > *'), + hex: mainWindow.getComputedStyle(input).cursor, + picker: mainWindow.getComputedStyle(picker).cursor, + apply: mainWindow.getComputedStyle(apply).cursor, + }, + }, { + locked: { + freeCard: ['pointer', 'pointer', 'pointer', 'pointer'], + lockedCard: ['default', 'default', 'default', 'default'], + hex: 'default', + picker: 'default', + apply: 'default', + }, + unlocked: { + colorCard: ['pointer', 'pointer', 'pointer', 'pointer'], + hex: 'text', + picker: 'pointer', + apply: 'pointer', + }, + }); + }); + + test('previews a custom color without applying it and validates hex input inline', () => { + const { parent, service, setInput, input, apply } = createColorWidget(true); + setInput('#F80'); + const previewOnly = service.color.get(); + setInput('#xyz'); + const invalid = { + message: parent.querySelector('.chat-pet-color-validation')?.textContent, + ariaInvalid: input.getAttribute('aria-invalid'), + disabled: apply.getAttribute('aria-disabled'), + }; + apply.click(); + const afterInvalidApply = service.color.get(); + setInput('#F80'); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true })); + assert.deepStrictEqual({ + previewOnly, + invalid, + afterInvalidApply, + applied: service.color.get(), + normalizedInput: input.value, + }, { + previewOnly: 'stable', + invalid: { message: 'Enter a hex color such as #ff8800 or #f80.', ariaInvalid: 'true', disabled: 'true' }, + afterInvalidApply: 'stable', + applied: '#ff8800', + normalizedInput: '#ff8800', + }); + }); + + test('uses the color picker and preserves the selected hat when applying custom colors', () => { + const { service, picker, input, apply } = createColorWidget(true); + service.unlockAchievement(ChatPetAchievementIds.FirstChatMessage); + service.setAccessory('cowboyHat'); + picker.value = '#123456'; + picker.dispatchEvent(new Event('input', { bubbles: true })); + const beforeApply = service.color.get(); + apply.click(); + assert.deepStrictEqual({ + beforeApply, + input: input.value, + color: service.color.get(), + hat: service.selectedAccessory.get(), + seen: !service.unseenAchievements.get().includes(ChatPetAchievementIds.Blobby), + }, { beforeApply: 'stable', input: '#123456', color: '#123456', hat: 'cowboyHat', seen: true }); + }); + + test('Escape closes the color page without applying the custom draft', () => { + const { service, input, setInput, getCloseCount } = createColorWidget(true); + setInput('#aa33cc'); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true })); + assert.deepStrictEqual({ closed: getCloseCount(), color: service.color.get() }, { closed: 1, color: 'stable' }); + }); + + test('Escape on color cards and Apply closes the page even when the buttons are disabled', () => { + const { parent, service, getCloseCount } = createColorWidget(); + const buttons = Array.from(parent.querySelectorAll('.monaco-button')); + for (const button of buttons) { + button.focus(); + button.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true })); + } + assert.deepStrictEqual({ closed: getCloseCount(), color: service.color.get() }, { closed: buttons.length, color: 'stable' }); + }); + + test('unrelated achievements do not discard an unapplied color draft', () => { + const { service, input, picker, apply, setInput } = createColorWidget(true); + setInput('#123abc'); + service.unlockAchievement(ChatPetAchievementIds.FirstChatMessage); + assert.deepStrictEqual({ + draft: input.value, + picker: picker.value, + applyEnabled: apply.getAttribute('aria-disabled'), + selected: service.color.get(), + }, { + draft: '#123abc', + picker: '#123abc', + applyEnabled: 'false', + selected: 'stable', + }); + }); + + test('an unchanged default color draft does not replace the original palette', () => { + const { service, input, apply } = createColorWidget(true); + const results = []; + for (const color of ['stable', 'insiders'] as const) { + service.setColor(color); + const disabled = apply.getAttribute('aria-disabled'); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true })); + apply.click(); + results.push({ color: service.color.get(), disabled }); + } + assert.deepStrictEqual(results, [{ color: 'stable', disabled: 'true' }, { color: 'insiders', disabled: 'true' }]); + }); + + test('updates the scroll range when custom color validation adds content', () => { + const { parent, widget, setInput } = createColorWidget(true); + widget.layout(new DOM.Dimension(240, 240)); + const slider = parent.querySelector('.scrollbar.vertical .slider'); + const content = parent.querySelector('.chat-pet-achievements-content'); + assert.ok(slider && content); + const before = { slider: Number.parseFloat(slider.style.height), scrollHeight: content.scrollHeight, clientHeight: content.clientHeight }; + setInput('#invalid'); + const after = { slider: Number.parseFloat(slider.style.height), scrollHeight: content.scrollHeight, clientHeight: content.clientHeight }; + assert.ok(after.slider < before.slider, JSON.stringify({ before, after })); + }); + + test('refreshes color cards after an external change and relocks custom controls when achievements reset', () => { + const { service, parent, input, picker, apply } = createColorWidget(true); + service.setColor('#ed83b5'); + const selected = parent.querySelector('.chat-pet-color-card.selected')?.getAttribute('data-color'); + service.resetAchievements(); + assert.deepStrictEqual({ + selected, + locked: input.disabled && picker.disabled && apply.getAttribute('aria-disabled') === 'true', + freeCards: Array.from(parent.querySelectorAll('.chat-pet-color-card')).filter(card => card.getAttribute('aria-disabled') !== 'true').map(card => card.dataset.color), + }, { selected: '#ed83b5', locked: true, freeCards: ['stable', 'insiders'] }); + }); + + test('recolors every animated and reduced-motion source without changing alpha, eyes, or props', async () => { + const urls = new Set(Object.values(getChatPetSpriteSources('stable')).flatMap(sources => [sources.animated.url, sources.reducedMotion.url])); + for (const effect of ['speech', 'respawn']) { + for (const suffix of ['', '.spritesheet']) { + urls.add(FileAccess.asBrowserUri(`vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-${effect}-stable-96${suffix}.png`).toString(true)); + } + } + const bodyColors = new Set([0x23a8f2, 0x0077b8, 0x004e7c]); + const failures: string[] = []; + for (const url of urls) { + const image = await loadImage(url); + const before = getPixels(image); + const colored = getChatPetColoredSprite(image, '#cc44aa'); + const after = getPixels(colored); + if (before.width !== after.width || before.height !== after.height) { + failures.push(`Dimensions changed: ${url}`); + } + let changed = 0; + for (let i = 0; i < before.data.length; i += 4) { + const rgb = (before.data[i] << 16) | (before.data[i + 1] << 8) | before.data[i + 2]; + if (before.data[i + 3] !== after.data[i + 3]) { + failures.push(`Alpha changed at ${i}: ${url}`); + break; + } + if (!bodyColors.has(rgb)) { + if (before.data[i] !== after.data[i] || before.data[i + 1] !== after.data[i + 1] || before.data[i + 2] !== after.data[i + 2]) { + failures.push(`Non-body pixel changed at ${i}: ${url}`); + break; + } + } else if (rgb === 0x23a8f2 && before.data[i + 3] !== 0) { + changed++; + if (after.data[i] !== 204 || after.data[i + 1] !== 68 || after.data[i + 2] !== 170) { + failures.push(`Incorrect custom color at ${i}: ${url}`); + break; + } + } + } + if (changed === 0) { + failures.push(`No body pixels tested: ${url}`); + } + } + assert.deepStrictEqual(failures, []); + }); + + test('caches recolored sheets and leaves original presets untouched', async () => { + const image = await loadImage(getChatPetSpriteSources('stable').idle.reducedMotion.url); + const first = getChatPetColoredSprite(image, '#ff8800'); + assert.strictEqual(getChatPetColoredSprite(image, '#ff8800'), first); + const second = getChatPetColoredSprite(image, '#00ff00'); + assert.notStrictEqual(second, first); + assert.strictEqual(getChatPetColoredSprite(image, 'stable'), image); + assert.strictEqual(getChatPetColoredSprite(image, 'insiders'), image); + setImageSource(image, getChatPetSpriteSources('stable').sleep.reducedMotion.url); + await image.decode(); + assert.notStrictEqual(getChatPetColoredSprite(image, '#00ff00'), second); + }); + + test('network sprite sources request anonymous CORS without applying it to local schemes', () => { + const image = DOM.$('img'); + const requests: { source: string; crossOrigin: string | null }[] = []; + sinon.stub(image, 'src').set((source: string) => requests.push({ source, crossOrigin: image.crossOrigin })); + const sources = [ + 'https://vscode-cdn.example/sprite.png', + 'http://localhost/sprite.png', + 'vscode-file://vscode-app/sprite.png', + 'file:///sprite.png', + 'data:image/png;base64,', + ]; + for (const source of sources) { + setChatPetImageSource(image, source); + } + assert.deepStrictEqual(requests, sources.map((source, index) => ({ source, crossOrigin: index < 2 ? 'anonymous' : null }))); + }); + + test('reuses recolored sheets between image buffers and when returning to a previous state', async () => { + const idle = getChatPetSpriteSources('stable').idle.animated.url; + const first = await loadImage(idle); + const second = await loadImage(idle); + const colored = getChatPetColoredSprite(first, '#ef1234'); + const fromSecondBuffer = getChatPetColoredSprite(second, '#ef1234'); + setImageSource(first, getChatPetSpriteSources('stable').sleep.animated.url); + await first.decode(); + getChatPetColoredSprite(first, '#ef1234'); + setImageSource(first, idle); + await first.decode(); + assert.ok(fromSecondBuffer === colored && getChatPetColoredSprite(first, '#ef1234') === colored); + }); + + test('bounds shared recolor memory while preserving each active image cache', async () => { + const source = getChatPetSpriteSources('stable').idle.animated.url; + const image = await loadImage(source); + const firstColor = '#120001'; + const first = getChatPetColoredSprite(image, firstColor); + const temporary = await loadImage(source); + for (const color of ['#120002', '#120003', '#120004', '#120005'] as const) { + getChatPetColoredSprite(temporary, color); + } + const another = await loadImage(source); + assert.deepStrictEqual({ + activePreserved: getChatPetColoredSprite(image, firstColor) === first, + oldSourceEvicted: getChatPetColoredSprite(another, firstColor) !== first, + }, { activePreserved: true, oldSourceEvicted: true }); + }); + + test('keeps baked and runtime eyes readable on black and other dark colors', async () => { + const colors: readonly ChatPetColor[] = [...chatPetColorPresets.map(preset => preset.color), '#191a1b', '#000080', '#004400']; + assert.ok(colors.every(color => Color.fromHex(getChatPetEyeColor(color)).getContrastRatio(Color.fromHex(getChatPetBodyColor(color))) >= 3)); + const image = await loadImage(FileAccess.asBrowserUri('vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-idle-stable-96.png').toString(true)); + const before = getPixels(image).data; + const after = getPixels(getChatPetColoredSprite(image, '#000000')).data; + const eyePixels: number[][] = []; + for (let i = 0; i < before.length; i += 4) { + if (before[i] === 25 && before[i + 1] === 26 && before[i + 2] === 27 && before[i + 3] === 255) { + eyePixels.push(Array.from(after.slice(i, i + 4))); + } + } + assert.deepStrictEqual(eyePixels, Array.from({ length: 256 }, () => [245, 245, 245, 255])); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts index 2da2b9f1598a72..ec82ee9868199c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; +import { isWindows } from '../../../../../../base/common/platform.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; @@ -19,7 +20,7 @@ import { IProgressService } from '../../../../../../platform/progress/common/pro import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js'; import { AgentPluginRepositoryService } from '../../../browser/agentPluginRepositoryService.js'; -import { IMarketplacePlugin, MarketplaceType, parseMarketplaceReference, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; +import { IMarketplacePlugin, IPluginSourceDescriptor, MarketplaceType, parseMarketplaceReference, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; import { IPluginGitService } from '../../../common/plugins/pluginGitService.js'; suite('AgentPluginRepositoryService', () => { @@ -83,6 +84,7 @@ suite('AgentPluginRepositoryService', () => { pluginGitStub?: Partial, fileServiceStub?: Partial, storageService?: IStorageService, + logService?: ILogService, ): AgentPluginRepositoryService { const instantiationService = store.add(new TestInstantiationService()); @@ -106,7 +108,7 @@ suite('AgentPluginRepositoryService', () => { instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as unknown as IEnvironmentService); instantiationService.stub(IUserDataProfileService, { currentProfile: { agentPluginsHome: URI.file('/cache/agentPlugins') } } as unknown as IUserDataProfileService); instantiationService.stub(IFileService, fileService); - instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(ILogService, logService ?? new NullLogService()); instantiationService.stub(INotificationService, { notify: () => undefined } as unknown as INotificationService); instantiationService.stub(IPluginGitService, stubPluginGit({ ...pluginGitStub, @@ -117,6 +119,148 @@ suite('AgentPluginRepositoryService', () => { return instantiationService.createInstance(AgentPluginRepositoryService); } + for (const [name, cacheSegments] of [ + ['outside cache', ['host', 'a', '..', '..', '..', '..', 'outside']], + ['cache root', ['host', '..']], + ['empty cache path', []], + ] as const) { + test(`rejects marketplace cache destination ${name} before filesystem access`, async () => { + const operations: string[] = []; + const service = createService(async () => { + operations.push('exists'); + return false; + }, undefined, { + cloneRepository: async () => { operations.push('clone'); }, + }, { + createFolder: async resource => { operations.push('createFolder'); return directoryStat(resource); }, + }); + const reference = { ...createPlugin('microsoft/vscode', '').marketplaceReference, cacheSegments }; + await assert.rejects(service.ensureRepository(reference), /Invalid plugin cache path/); + assert.deepStrictEqual(operations, []); + }); + } + + for (const [name, cacheSegments] of [ + ['repository segments', ['host', '..', '..', '..', 'ref_main']], + ['revision segment', ['host', '..']], + ] as const) { + test(`rejects ref-specific marketplace cache traversal in ${name}`, async () => { + const operations: string[] = []; + const service = createService(async () => { + operations.push('exists'); + return false; + }, undefined, { + cloneRepository: async () => { operations.push('clone'); }, + }, { + createFolder: async resource => { operations.push('createFolder'); return directoryStat(resource); }, + }); + const reference = { ...createPlugin('microsoft/vscode#main', '').marketplaceReference, cacheSegments }; + await assert.rejects(service.ensureRepository(reference), /Invalid plugin cache path/); + assert.deepStrictEqual(operations, []); + }); + } + + for (const [name, path] of [ + ['outside cache', '/outside'], + ['cache root', '/cache/agentPlugins'], + ['unnormalized escape', '/cache/agentPlugins/host/../../outside'], + ['cache root with trailing separator', '/cache/agentPlugins/'], + ['cache root after normalization', '/cache/agentPlugins/host/../'], + ...(isWindows ? [ + ['Windows parent alias', '/cache/agentPlugins/host/.. /repo'], + ['Windows reserved name', '/cache/agentPlugins/host/NUL/repo'], + ] : []), + ]) { + test(`recovers from indexed marketplace destination ${name} without accessing it`, async () => { + const storage = store.add(new InMemoryStorageService()); + storage.store('chat.plugins.marketplaces.index.v1', JSON.stringify({ + 'github:microsoft/vscode': { repositoryUri: URI.file(path) }, + }), StorageScope.APPLICATION, StorageTarget.MACHINE); + const warnings: (string | Error)[] = []; + const logService = store.add(new class extends NullLogService { + override warn(message: string | Error): void { warnings.push(message); } + }()); + const operations: { operation: string; path: string }[] = []; + const service = createService(async resource => { + operations.push({ operation: 'exists', path: resource.path }); + return false; + }, undefined, { + cloneRepository: async (_url, directory) => { operations.push({ operation: 'clone', path: directory.path }); }, + }, { + createFolder: async directory => { operations.push({ operation: 'createFolder', path: directory.path }); return directoryStat(directory); }, + }, storage, logService); + const reference = createPlugin('microsoft/vscode', '').marketplaceReference; + const result = service.ensureRepository(reference); + await assert.doesNotReject(result); + const directory = await result; + const expected = '/cache/agentPlugins/github.com/microsoft/vscode'; + const restarted = createService(undefined, undefined, undefined, undefined, storage); + assert.deepStrictEqual({ + directory: directory.path, + operations, + restoredDirectory: restarted.getRepositoryUri(reference).path, + warnings: warnings.length, + }, { + directory: expected, + operations: [ + { operation: 'exists', path: expected }, + { operation: 'createFolder', path: '/cache/agentPlugins/github.com/microsoft' }, + { operation: 'clone', path: expected }, + ], + restoredDirectory: expected, + warnings: 1, + }); + }); + } + + test('uses structural URI components for indexed marketplace filesystem paths', async () => { + const expected = URI.file('/cache/agentPlugins/github.com/microsoft/vscode'); + const storage = store.add(new InMemoryStorageService()); + storage.store('chat.plugins.marketplaces.index.v1', JSON.stringify({ + 'github:microsoft/vscode': { + repositoryUri: { ...expected.toJSON(), fsPath: URI.file('/outside').fsPath, _sep: isWindows ? 1 : undefined }, + }, + }), StorageScope.APPLICATION, StorageTarget.MACHINE); + const filesystemPaths: string[] = []; + const service = createService(async resource => { + filesystemPaths.push(resource.fsPath); + return false; + }, undefined, { + cloneRepository: async (_url, directory) => { filesystemPaths.push(directory.fsPath); }, + }, undefined, storage); + const directory = await service.ensureRepository(createPlugin('microsoft/vscode', '').marketplaceReference); + assert.deepStrictEqual({ directory: directory.fsPath, filesystemPaths }, { + directory: expected.fsPath, + filesystemPaths: [expected.fsPath, expected.fsPath], + }); + }); + + const unsafeSources: { name: string; descriptor: IPluginSourceDescriptor }[] = [ + { name: 'Git URL escape', descriptor: { kind: PluginSourceKind.GitUrl, url: 'http://example.com/a/../../../../outside' } }, + { name: 'encoded Git URL escape', descriptor: { kind: PluginSourceKind.GitUrl, url: 'http://example.com/a/%2e%2e/%2e%2e/%2e%2e/%2e%2e/outside' } }, + { name: 'Git URL dot segment inside cache', descriptor: { kind: PluginSourceKind.GitUrl, url: 'http://example.com/team/../other.git' } }, + { name: 'Git URL suffix', descriptor: { kind: PluginSourceKind.GitUrl, url: 'http://example.com/...git' } }, + { name: 'GitHub cache root', descriptor: { kind: PluginSourceKind.GitHub, repo: '../..' } }, + { name: 'backslash Git URL', descriptor: { kind: PluginSourceKind.GitUrl, url: String.raw`https://example.com/a\..\b.git` } }, + { name: 'encoded backslash Git URL', descriptor: { kind: PluginSourceKind.GitUrl, url: 'https://example.com/a%5c..%5cb.git' } }, + ]; + for (const { name, descriptor } of unsafeSources) { + test(`rejects plugin source cache traversal: ${name}`, async () => { + const operations: string[] = []; + const service = createService(async () => { + operations.push('exists'); + return false; + }, undefined, { + cloneRepository: async () => { operations.push('clone'); }, + }, { + createFolder: async resource => { operations.push('createFolder'); return directoryStat(resource); }, + }); + const plugin = { ...createPlugin('microsoft/vscode', ''), sourceDescriptor: descriptor }; + await assert.rejects(service.ensurePluginSource(plugin), /Invalid plugin cache path/); + assert.deepStrictEqual(operations, []); + }); + } + test('uses cacheSegments path for GitHub shorthand plugin references', () => { const service = createService(); const plugin = createPlugin('microsoft/vscode', 'plugins/myPlugin'); diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts index 3d995bbc26d22d..c43d02f2087c77 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts @@ -29,6 +29,7 @@ import { ContributionEnablementState } from '../../../common/enablement.js'; import { AgentPluginEnablementService, IAgentPluginEnablementService } from '../../../common/plugins/agentPluginEnablement.js'; import { IFetchMarketplacePluginsOptions, IMarketplaceInstalledPlugin, IMarketplacePlugin, IMarketplaceReference, IPluginMarketplaceService, IPluginSourceDescriptor, MarketplaceType, parseMarketplaceReference, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; import { IPluginSource } from '../../../common/plugins/pluginSource.js'; +import { isMarketplaceReferenceAllowed, StrictKnownMarketplaces } from '../../../common/plugins/strictKnownMarketplaces.js'; suite('PluginInstallService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -83,6 +84,7 @@ suite('PluginInstallService', () => { marketplaceTrusted: boolean; /** Whether the strict-marketplace enterprise policy is active */ strictMarketplacePolicyActive?: boolean; + strictMarketplaces?: StrictKnownMarketplaces; installedPlugins: IMarketplaceInstalledPlugin[]; durablePluginUris: URI[]; removedPluginUris: string[]; @@ -356,8 +358,8 @@ suite('PluginInstallService', () => { installedPlugins.set(state.installedPlugins, undefined); return true; }, - isMarketplaceTrusted: () => state.marketplaceTrusted, - isStrictMarketplacePolicyActive: () => state.strictMarketplacePolicyActive ?? false, + isMarketplaceTrusted: (ref: IMarketplaceReference) => state.strictMarketplaces !== undefined ? isMarketplaceReferenceAllowed(state.strictMarketplaces, ref) : state.marketplaceTrusted, + isStrictMarketplacePolicyActive: () => state.strictMarketplaces !== undefined || (state.strictMarketplacePolicyActive ?? false), isMarketplaceAutoUpdateEnabled: (ref: IMarketplaceReference) => state.autoUpdateByMarketplace.get(ref.canonicalId) ?? true, fetchMarketplacePlugins: async (_token: CancellationToken, marketplaceIds?: ReadonlySet, options?: IFetchMarketplacePluginsOptions) => { state.fetchMarketplaceCalls.push([...marketplaceIds ?? []]); @@ -1448,6 +1450,26 @@ suite('PluginInstallService', () => { suite('installPluginFromSource', () => { + test('rejects marketplace traversal before cloning a pinned source', async () => { + const { service, state } = createService({ + strictMarketplaces: [{ source: 'github', repo: 'microsoft/vscode', ref: 'marketplace' }], + }); + const result = await service.installPluginFromSource('https://github.com/microsoft/vscode/../../example/unapproved.git#marketplace'); + assert.deepStrictEqual({ + success: result.success, + invalidSource: result.message?.includes('is not a valid plugin source'), + cloned: state.ensurePluginSourceDescriptors, + installed: state.addedPlugins, + trusted: state.trustedMarketplaces, + }, { + success: false, + invalidSource: true, + cloned: [], + installed: [], + trusted: [], + }); + }); + test('keeps legacy source handling for repository-root plugins', async () => { const { service, state } = createService({ singlePluginManifestResult: createPlugin({ diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxApiService.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxApiService.test.ts index 3f8b28a968b365..cfe7c8574cefcf 100644 --- a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxApiService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxApiService.test.ts @@ -157,6 +157,9 @@ function createService(store: Pick<{ add(t: T): T } const query = new URL(url).searchParams; const tasks = options.tasks.filter(task => { + if (!!task.archived_at !== (query.get('is_archived') === 'true')) { + return false; + } if (query.has('with_repo') && (task.repository?.id !== undefined) !== (query.get('with_repo') === 'true')) { return false; } @@ -250,6 +253,50 @@ suite('CloudSandboxApiService connection credentials', () => { }; } + test('loads cloud models and reasoning metadata without creating a task or environment', async () => { + const requests: { path: string; method: string | undefined; integration: string | string[] | undefined }[] = []; + const { service } = createService(store, { + tasks: [], repositories: new Map(), + onRequest: (url, _token, options) => { + requests.push({ path: url.pathname, method: options.type, integration: options.headers?.['Copilot-Integration-Id'] }); + return jsonResponse({ + default_model: 'auto', + data: [ + { id: 'auto', name: 'Auto' }, + { id: 'brand-new-model', name: 'New Model', capabilities: { supports: { vision: true, reasoning_effort: ['low', 'high', 'new-effort'] }, limits: { max_prompt_tokens: 1000 } } }, + { id: 'disabled', name: 'Disabled', policy: { state: 'disabled' } }, + { id: 'hidden', name: 'Hidden', model_picker_enabled: false }, + ], + }); + }, + }); + const catalog = await service.listModels(CancellationToken.None); + assert.deepStrictEqual({ + requests, + defaultModel: catalog.defaultModel, + models: catalog.models.map(model => ({ id: model.id, vision: model.supportsVision, input: model.maxPromptTokens, efforts: model.configSchema?.properties.reasoningEffort.enum })), + }, { + requests: [{ path: '/agents/swe/models', method: 'GET', integration: COPILOT_INTEGRATION_ID }], + defaultModel: 'auto', + models: [ + { id: 'auto', vision: undefined, input: undefined, efforts: undefined }, + { id: 'brand-new-model', vision: true, input: 1000, efforts: ['low', 'high', 'new-effort'] }, + ], + }); + }); + + for (const body of [{}, { data: [null] }, { data: [{ id: 'bad', name: 'Bad', capabilities: { supports: { reasoning_effort: [1] } } }] }]) { + test(`rejects invalid cloud model metadata: ${JSON.stringify(body)}`, async () => { + const { service } = createService(store, { tasks: [], repositories: new Map(), onRequest: () => jsonResponse(body) }); + await assert.rejects(service.listModels(CancellationToken.None), /invalid model/); + }); + } + + test('reports cloud catalog HTTP failures instead of treating them as an empty catalog', async () => { + const { service } = createService(store, { tasks: [], repositories: new Map(), onRequest: () => jsonResponse({}, 403) }); + await assert.rejects(service.listModels(CancellationToken.None), /model catalog failed: HTTP 403/); + }); + for (const action of ['connect', 'reconnect'] as const) { test(`${action} logs safe upstream correlation for an HTTP failure`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { const logService = new TestLogService(); @@ -582,7 +629,7 @@ suite('CloudSandboxApiService repository resolution', () => { }, { kind: 'complete', found: ['sess-old'], - listPages: 3, + listPages: 5, }); }); @@ -604,7 +651,7 @@ suite('CloudSandboxApiService repository resolution', () => { }, { kind: 'complete', found: ['sess-old'], - listPages: 3, + listPages: 5, }); }); @@ -623,7 +670,7 @@ suite('CloudSandboxApiService repository resolution', () => { }, { kind: 'partial', sessions: 1000, - listPages: 11, + listPages: 13, }); }); }); @@ -662,6 +709,8 @@ suite('CloudSandboxApiService discovery logs', () => { taskId: 'bound', sessionId: 'session-1', environmentId: 'env-1', name: 'Work on repository', repoName: 'owner/repository', updatedAt: bound.updated_at, status: SessionStatus.InputNeeded, + })}`, `[CloudSandboxApi] Discovered sandbox session ${JSON.stringify({ + taskId: 'archived', sessionId: 'session-2', environmentId: 'env-2', name: 'Old task', })}`], info: [], exposedPrompt: false, @@ -769,7 +818,7 @@ suite('CloudSandboxApiService stalled sandbox discovery', () => { }, { kind: 'complete', sessions: age >= oneHour ? [] : ['stalled'], - requests: ['/agents/tasks', '/agents/tasks', '/agents/tasks/stalled'], + requests: ['/agents/tasks', '/agents/tasks', '/agents/tasks', '/agents/tasks', '/agents/tasks/stalled'], }); })); } @@ -945,7 +994,7 @@ suite('CloudSandboxApiService incremental discovery', () => { return { ...task(id, id, repositoryId, `session-${id}`, `env-${id}`), updated_at: updatedAt }; } - test('queries both repository scopes and resolves no task details when nothing changed', async () => { + test('queries both repository and archive scopes and resolves no task details when nothing changed', async () => { const { service, requestedUrls } = createService(store, { tasks: [updatedTask('old')], repositories: new Map(), @@ -961,10 +1010,10 @@ suite('CloudSandboxApiService incremental discovery', () => { queries: requestedUrls.map(url => Object.fromEntries(new URL(url).searchParams)), }, { result: { kind: 'incremental', sessions: [], removedTaskIds: [] }, - queries: [true, false].map(withRepository => ({ + queries: [false, true].flatMap(archived => [true, false].map(withRepository => ({ per_page: '100', page: '1', sort: 'updated_at', direction: 'desc', - with_repo: String(withRepository), since: checkpoint, include_environment_kinds: 'managed-sandbox', - })), + with_repo: String(withRepository), is_archived: String(archived), since: checkpoint, include_environment_kinds: 'managed-sandbox', + }))), }); }); @@ -1003,7 +1052,7 @@ suite('CloudSandboxApiService incremental discovery', () => { kind: result.kind, since: new URL(requestedUrls[0]).searchParams.get('since'), requests: requestedUrls.length, - }, { kind: 'incremental', since: '2026-09-22T09:57:00.000Z', requests: 2 }); + }, { kind: 'incremental', since: '2026-09-22T09:57:00.000Z', requests: 4 }); }); test('preserves incremental filters on every page even when pagination links omit them', async () => { @@ -1021,16 +1070,16 @@ suite('CloudSandboxApiService incremental discovery', () => { assert.deepStrictEqual({ kind: result.kind, count: result.kind === 'failed' ? 0 : result.sessions.length, - pages: queries.map(query => [query.get('with_repo'), query.get('page')]), + pages: queries.map(query => [query.get('with_repo'), query.get('is_archived'), query.get('page')]), retainedFilters: queries.every(query => query.get('since') === checkpoint && query.get('include_environment_kinds') === 'managed-sandbox'), }, { kind: 'incremental', count: 101, - pages: [['true', '1'], ['false', '1'], ['false', '2']], + pages: [['true', 'false', '1'], ['false', 'false', '1'], ['false', 'false', '2'], ['true', 'true', '1'], ['false', 'true', '1']], retainedFilters: true, }); }); - test('reports explicit task archives and discovers unarchives without retaining stale details', async () => { + test('discovers task archives and unarchives without removing their sessions', async () => { const tasks = [updatedTask('first')]; const { service, requestedUrls } = createService(store, { tasks, repositories: new Map(), discoveryDate: () => firstScanDate, @@ -1044,14 +1093,15 @@ suite('CloudSandboxApiService incremental discovery', () => { const unarchived = await service.listSessions(CancellationToken.None, { incremental: true }); assert.deepStrictEqual({ - archived, archivedRequests, - unarchived: unarchived.kind === 'failed' ? [] : unarchived.sessions.map(session => session.taskId), + archived: archived.kind === 'failed' ? archived : { kind: archived.kind, sessions: archived.sessions.map(session => [session.taskId, session.isArchived]), removedTaskIds: archived.kind === 'incremental' ? archived.removedTaskIds : [] }, + archivedRequests, + unarchived: unarchived.kind === 'failed' ? [] : unarchived.sessions.map(session => [session.taskId, session.isArchived]), detailFetches: requestedUrls.filter(url => url.endsWith('/tasks/first')).length, }, { - archived: { kind: 'incremental', sessions: [], removedTaskIds: ['first'] }, - archivedRequests: 2, - unarchived: ['first'], - detailFetches: 1, + archived: { kind: 'incremental', sessions: [['first', true]], removedTaskIds: [] }, + archivedRequests: 5, + unarchived: [['first', undefined]], + detailFetches: 2, }); }); @@ -1434,6 +1484,91 @@ function createServiceForCreate(store: Pick<{ add return { service: store.add(instantiationService.createInstance(CloudSandboxApiService)), calls, errors, warnings }; } +suite('CloudSandboxApiService task archiving', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('another client discovers a task archive and the original client discovers its unarchive', async () => { + const tasks: ITestTask[] = [task('task-1', 'Task', undefined, 'session-1', 'env-1')]; + const createClient = () => createService(store, { + tasks, repositories: new Map(), + onRequest: (url, _token, options) => { + if (options.type === 'POST') { + tasks[0] = { ...tasks[0], archived_at: url.pathname.endsWith('/archive') ? new Date().toISOString() : undefined }; + return jsonResponse({}); + } + return undefined; + }, + }).service; + const first = createClient(); + const second = createClient(); + await first.listSessions(CancellationToken.None); + await first.setTaskArchived('task-1', true, CancellationToken.None); + const archived = await second.listSessions(CancellationToken.None); + await second.setTaskArchived('task-1', false, CancellationToken.None); + const unarchived = await first.listSessions(CancellationToken.None, { incremental: true }); + assert.deepStrictEqual({ + archived: archived.kind === 'failed' ? archived : archived.sessions.map(session => [session.taskId, session.isArchived === true]), + unarchived: unarchived.kind === 'failed' ? unarchived : unarchived.sessions.map(session => [session.taskId, session.isArchived === true]), + }, { archived: [['task-1', true]], unarchived: [['task-1', false]] }); + }); + + test('posts archive and unarchive to the owning task without an environment request', async () => { + const { service, calls } = createServiceForCreate(store, {}); + await service.setTaskArchived('task/with spaces', true, CancellationToken.None); + await service.setTaskArchived('task/with spaces', false, CancellationToken.None); + assert.deepStrictEqual(calls.map(call => ({ + path: new URL(call.url).pathname, method: call.type, body: call.body, + timeout: call.timeout, integration: call.headers['Copilot-Integration-Id'], + })), [ + { path: '/agents/tasks/task%2Fwith%20spaces/archive', method: 'POST', body: undefined, timeout: 10_000, integration: COPILOT_INTEGRATION_ID }, + { path: '/agents/tasks/task%2Fwith%20spaces/unarchive', method: 'POST', body: undefined, timeout: 10_000, integration: COPILOT_INTEGRATION_ID }, + ]); + }); + + for (const archived of [true, false]) { + for (const statusCode of [400, 403, 404, 422, 429, 500]) { + test(`surfaces rejected task ${archived ? 'archive' : 'unarchive'}: HTTP ${statusCode}`, async () => { + const { service } = createServiceForCreate(store, { message: 'archive rejected' }, statusCode); + await assert.rejects(service.setTaskArchived('task-1', archived, CancellationToken.None), new RegExp(`task ${archived ? 'archive' : 'unarchive'} failed: HTTP ${statusCode}`)); + }); + } + } + + test('requires authentication before archiving', async () => { + const { service, requestedUrls } = createService(store, { + tasks: [], repositories: new Map(), authenticationSessions: async () => [], + }); + await assert.rejects(service.setTaskArchived('task-1', true, CancellationToken.None), /signed-in GitHub account/); + assert.deepStrictEqual(requestedUrls, []); + }); + + test('invalidates discovery responses started before an archive mutation', async () => { + const pending = new DeferredPromise(); + const entered = new DeferredPromise(); + let paused = false; + const tasks = [task('task-1', 'Task', undefined, 'session-1', 'env-1')]; + const { service } = createService(store, { + tasks, repositories: new Map(), + onRequest: async (url, _token, options) => { + if (options.type === 'POST') { + return jsonResponse({}); + } + if (paused && url.pathname.endsWith('/tasks/task-1')) { + await entered.complete(); + return pending.p; + } + return undefined; + }, + }); + paused = true; + const discovery = service.listSessions(CancellationToken.None); + await entered.p; + await service.setTaskArchived('task-1', true, CancellationToken.None); + await pending.complete(jsonResponse(tasks[0])); + assert.strictEqual((await discovery).kind, 'failed'); + }); +}); + suite('CloudSandboxApiService task renaming', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxConnectionCustomization.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxConnectionCustomization.test.ts index 752ca6d082f5d0..9b6c2689f1466a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxConnectionCustomization.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxConnectionCustomization.test.ts @@ -39,10 +39,22 @@ suite('CloudSandboxConnectionCustomization authentication', () => { } }(); const customization = createCloudSandboxConnectionCustomization(cloudSandboxAddress('env-1'), service); - assert.ok(customization?.authenticate); - return { authenticate: customization.authenticate, requests }; + assert.ok(customization?.authenticate && customization.renewAuthentication); + return { authenticate: customization.authenticate, renewAuthentication: customization.renewAuthentication, requests }; } + test('renews a protected resource without a user authentication token', async () => { + const fixture = createFixture(); + const result = await fixture.renewAuthentication({ + resource: 'https://api.github.com', + scopes_supported: ['repo', 'read:user'], + }); + assert.deepStrictEqual({ result, requests: fixture.requests }, { + result: { resource: 'https://api.github.com', scopes: ['repo', 'read:user'], token: 'copilot-sealed.v1.key.fresh' }, + requests: ['refresh:env-1'], + }); + }); + for (const token of ['plaintext', 'copilot-sealed.v1.key.challenged']) { test(`renews an expired challenge instead of forwarding ${token}`, async () => { const fixture = createFixture(); @@ -76,14 +88,16 @@ suite('CloudSandboxConnectionCustomization authentication', () => { const failure = new Error('refresh unavailable'); const fixture = createFixture(async () => { throw failure; }); await assert.rejects(fixture.authenticate({ resource: 'https://api.github.com', token: 'plaintext' }, AuthRequiredReason.Expired), failure); - assert.deepStrictEqual(fixture.requests, ['refresh:env-1']); + await assert.rejects(fixture.renewAuthentication({ resource: 'https://api.github.com' }), failure); + assert.deepStrictEqual(fixture.requests, ['refresh:env-1', 'refresh:env-1']); }); for (const token of [undefined, 'plaintext']) { test(`rejects an unusable renewed credential: ${token}`, async () => { const fixture = createFixture(async () => token); await assert.rejects(fixture.authenticate({ resource: 'https://api.github.com', token: 'plaintext' }, AuthRequiredReason.Expired), /No sealed GitHub token/); - assert.deepStrictEqual(fixture.requests, ['refresh:env-1']); + await assert.rejects(fixture.renewAuthentication({ resource: 'https://api.github.com' }), /No sealed GitHub token/); + assert.deepStrictEqual(fixture.requests, ['refresh:env-1', 'refresh:env-1']); }); } @@ -91,6 +105,7 @@ suite('CloudSandboxConnectionCustomization authentication', () => { const fixture = createFixture(); for (const resource of ['https://example.com', 'https://github.com.example.com', 'not a URL']) { await assert.rejects(fixture.authenticate({ resource, token: 'copilot-sealed.v1.key.challenged' }, AuthRequiredReason.Expired), /non-GitHub resource/); + await assert.rejects(fixture.renewAuthentication({ resource }), /non-GitHub resource/); } assert.deepStrictEqual(fixture.requests, []); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxModels.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxModels.test.ts new file mode 100644 index 00000000000000..35fae82f25e336 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxModels.test.ts @@ -0,0 +1,163 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise } from '../../../../../../base/common/async.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { ICloudSandboxApiService } from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { INotificationService, IPromptChoice, NoOpNotification } from '../../../../../../platform/notification/common/notification.js'; +import { CloudSandboxModelCatalogService, CloudSandboxModels, ICloudSandboxModelCatalogService } from '../../../browser/remoteAgentHost/cloudSandboxModels.js'; +import { ILanguageModelsService } from '../../../common/languageModels.js'; +import { AgentHostLanguageModelProvider } from '../../../browser/agentSessions/agentHost/agentHostLanguageModelProvider.js'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { CancellationError } from '../../../../../../base/common/errors.js'; + +suite('CloudSandboxModels', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function setup(listModels: ICloudSandboxApiService['listModels'], modelProvider?: AgentHostLanguageModelProvider) { + const instantiation = store.add(new TestInstantiationService()); + const accounts = store.add(new Emitter()); + const errors: string[] = []; + const retries: IPromptChoice[] = []; + const notified = new DeferredPromise(); + instantiation.stub(ICloudSandboxApiService, { onDidChangeAccount: accounts.event, listModels }); + instantiation.stub(IInstantiationService, instantiation); + instantiation.stub(ILanguageModelsService, { deltaLanguageModelChatProviderDescriptors: () => { }, registerLanguageModelProvider: () => toDisposable(() => { }) }); + instantiation.stub(ILogService, new NullLogService()); + instantiation.stub(INotificationService, { + prompt: (_severity, message, choices) => { + errors.push(String(message)); + retries.push(...choices); + notified.complete(); + return new NoOpNotification(); + }, + }); + const sharedCatalog = store.add(instantiation.createInstance(CloudSandboxModelCatalogService)); + instantiation.stub(ICloudSandboxModelCatalogService, sharedCatalog); + const createCatalog = (vendor = 'sandbox-catalog', provider = modelProvider) => store.add(instantiation.createInstance(CloudSandboxModels, 'sandbox', vendor, provider)); + const catalog = createCatalog(); + return { catalog, sharedCatalog, createCatalog, accounts, errors, retries, notified }; + } + + test('supplements the connected host catalog without dropping its models on account changes', async () => { + const provider = store.add(new AgentHostLanguageModelProvider('sandbox', 'sandbox')); + provider.updateModels([{ id: 'host-model', name: 'Host Model', provider: 'copilot' }]); + const { catalog, accounts } = setup(async () => ({ models: [{ id: 'claude-sonnet-4.6', name: 'Sonnet 4.6', provider: 'copilot' }] }), provider); + const ready = Event.toPromise(Event.filter(catalog.onDidChange, () => catalog.ready)); + catalog.load(); + await ready; + const loaded = await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None); + const refreshed = Event.toPromise(Event.filter(catalog.onDidChange, () => catalog.ready)); + accounts.fire('changed'); + const duringRefresh = await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None); + await refreshed; + catalog.dispose(); + const afterDispose = await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None); + assert.deepStrictEqual({ + loaded: loaded.map(model => model.metadata.id), duringRefresh: duringRefresh.map(model => model.metadata.id), afterDispose: afterDispose.map(model => model.metadata.id), + }, { loaded: ['host-model', 'claude-sonnet-4.6'], duringRefresh: ['host-model'], afterDispose: ['host-model'] }); + }); + + test('coalesces reads and caches the catalog until the account changes', async () => { + let calls = 0; + const { catalog, createCatalog, accounts } = setup(async () => ({ + defaultModel: 'new-model', + models: [{ id: `model-${++calls}`, provider: 'copilot', name: 'New Model', configSchema: { type: 'object', properties: { reasoningEffort: { type: 'string', title: 'Effort', enum: ['low', 'high'] } } } }], + })); + const secondCatalog = createCatalog('second'); + const thirdCatalog = createCatalog('third'); + const consumers = [catalog, secondCatalog, thirdCatalog]; + const first = Promise.all(consumers.map(consumer => Event.toPromise(Event.filter(consumer.onDidChange, () => consumer.ready)))); + catalog.load(); + secondCatalog.load(); + thirdCatalog.load(); + await first; + const cachedCatalog = createCatalog('cached'); + const cached = Event.toPromise(Event.filter(cachedCatalog.onDidChange, () => cachedCatalog.ready)); + cachedCatalog.load(); + await cached; + consumers.push(cachedCatalog); + const before = consumers.map(consumer => consumer.models.map(model => model.metadata.id)); + const second = Promise.all(consumers.map(consumer => Event.toPromise(Event.filter(consumer.onDidChange, () => consumer.ready)))); + accounts.fire('another-account'); + await second; + assert.deepStrictEqual({ calls, before, after: consumers.map(consumer => consumer.models.map(model => model.metadata.id)), efforts: catalog.models[0].metadata.configurationSchema?.properties?.reasoningEffort.enum }, { + calls: 2, before: [['model-1'], ['model-1'], ['model-1'], ['model-1']], after: [['model-2'], ['model-2'], ['model-2'], ['model-2']], efforts: ['low', 'high'], + }); + }); + + test('offers an explicit retry after a failed request', async () => { + let calls = 0; + const { catalog, createCatalog, errors, retries, notified } = setup(async () => { + if (++calls === 1) { + throw new Error('catalog unavailable'); + } + return { models: [{ id: 'model', provider: 'copilot', name: 'Model' }] }; + }); + const otherCatalog = createCatalog('other'); + catalog.load(); + otherCatalog.load(); + await notified.p; + const lateCatalog = createCatalog('late'); + lateCatalog.load(); + const consumers = [catalog, otherCatalog, lateCatalog]; + const ready = Promise.all(consumers.map(consumer => Event.toPromise(Event.filter(consumer.onDidChange, () => consumer.ready)))); + await retries[0].run(); + await ready; + assert.deepStrictEqual({ calls, errors, ready: consumers.map(consumer => consumer.ready), ids: consumers.map(consumer => consumer.models.map(model => model.metadata.id)) }, { + calls: 2, errors: ['Could not load models for GitHub sandboxes.'], ready: [true, true, true], ids: [['model'], ['model'], ['model']], + }); + }); + + test('cancels an in-flight catalog request when its owner is disposed', async () => { + const cancelled = new DeferredPromise(); + const { catalog, sharedCatalog, errors } = setup(async token => { + store.add(token.onCancellationRequested(() => cancelled.complete())); + await cancelled.p; + throw new CancellationError(); + }); + catalog.load(); + sharedCatalog.dispose(); + await cancelled.p; + assert.deepStrictEqual(errors, []); + }); + + test('disposing one provider does not cancel discovery needed by another', async () => { + const response = new DeferredPromise>>(); + let cancelled = false; + let calls = 0; + const { catalog, createCatalog } = setup(token => { + calls++; + store.add(token.onCancellationRequested(() => cancelled = true)); + return response.p; + }); + const otherCatalog = createCatalog('other'); + const ready = Event.toPromise(Event.filter(otherCatalog.onDidChange, () => otherCatalog.ready)); + catalog.load(); + otherCatalog.load(); + catalog.dispose(); + await response.complete({ models: [{ id: 'model', name: 'Model', provider: 'copilot' }] }); + await ready; + assert.deepStrictEqual({ calls, cancelled, ids: otherCatalog.models.map(model => model.metadata.id) }, { calls: 1, cancelled: false, ids: ['model'] }); + }); + + test('ignores a late response from the previous account', async () => { + const old = new DeferredPromise>>(); + let calls = 0; + const { catalog, accounts } = setup(() => ++calls === 1 ? old.p : Promise.resolve({ models: [{ id: 'current', provider: 'copilot', name: 'Current' }] })); + catalog.load(); + const ready = Event.toPromise(Event.filter(catalog.onDidChange, () => catalog.ready)); + accounts.fire('current-account'); + await ready; + await old.complete({ models: [{ id: 'old', provider: 'copilot', name: 'Old' }] }); + assert.deepStrictEqual(catalog.models.map(model => model.metadata.id), ['current']); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts index 0a830a833c31da..1407e13e9ba42c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts @@ -34,6 +34,7 @@ import { ContextKeyService } from '../../../../../../platform/contextkey/browser import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { INotificationService, NotificationMessage } from '../../../../../../platform/notification/common/notification.js'; import { InMemoryStorageService, IStorageService } from '../../../../../../platform/storage/common/storage.js'; import { IWorkspaceContextService, IWorkspaceFoldersChangeEvent, toWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; @@ -144,6 +145,7 @@ function createHarness(store: Pick, options?: { readonly openRepository?: (root: URI) => Promise; readonly connect?: (options: ICloudSandboxConnectOptions, token: CancellationToken) => Promise; readonly deleteTask?: (taskId: string, token: CancellationToken) => Promise; + readonly setTaskArchived?: (taskId: string, archived: boolean, token: CancellationToken) => Promise; }) { const instantiationService = store.add(new TestInstantiationService()); const configuration = new TestConfigurationService({ @@ -165,7 +167,11 @@ function createHarness(store: Pick, options?: { const contentProviders = new Map(); const initialRefreshes: Promise[] = []; const discoveryModes: boolean[] = []; - const calls = { discovered: 0, created: 0, connected: [] as ICloudSandboxConnectOptions[], connectTokens: [] as CancellationToken[], history: [] as string[], removed: [] as string[], repositoryErrors: [] as string[], deletedTasks: [] as string[], disposedSessions: [] as string[] }; + const errorNotifications: NotificationMessage[] = []; + instantiationService.stub(INotificationService, new class extends mock() { + override error(message: NotificationMessage): void { errorNotifications.push(message); } + }()); + const calls = { discovered: 0, created: 0, connected: [] as ICloudSandboxConnectOptions[], connectTokens: [] as CancellationToken[], history: [] as string[], removed: [] as string[], repositoryErrors: [] as string[], deletedTasks: [] as string[], disposedSessions: [] as string[], archivedTasks: [] as { taskId: string; archived: boolean }[] }; const state = { workspaceFolders: (options?.workspaceFolders ?? [workspaceFolder]).map(toWorkspaceFolder), repositories: [...(options?.repositories ?? [repository(['https://github.com/example/project.git'])])], @@ -264,6 +270,10 @@ function createHarness(store: Pick, options?: { calls.deletedTasks.push(taskId); await options?.deleteTask?.(taskId, token); } + override async setTaskArchived(taskId: string, archived: boolean, token: CancellationToken): Promise { + calls.archivedTasks.push({ taskId, archived }); + await options?.setTaskArchived?.(taskId, archived, token); + } }()); instantiationService.stub(ICloudSandboxAgentHostService, new class extends mock() { override async connect(connectOptions: ICloudSandboxConnectOptions, token: CancellationToken) { @@ -352,7 +362,7 @@ function createHarness(store: Pick, options?: { instantiationService.stub(IAgentHostNewSessionFolderService, new class extends mock() { }()); const contribution = store.add(instantiationService.createInstance(TestEditorCloudSandboxContribution)); return { - instantiationService, contribution, controllers, contributions, contentProviders, chatSessionsService, state, calls, policies, notifications, resolvers, sentimentChanged, accountChanged, authenticationPending, initialRefreshes, connectionsChanged, focusChanged, discoveryModes, workspaceTrust, + instantiationService, contribution, controllers, contributions, contentProviders, chatSessionsService, state, calls, policies, notifications, errorNotifications, resolvers, sentimentChanged, accountChanged, authenticationPending, initialRefreshes, connectionsChanged, focusChanged, discoveryModes, workspaceTrust, refresh: async () => { await contribution.refresh(CancellationToken.None); await Promise.all(initialRefreshes); @@ -394,6 +404,84 @@ function createHarness(store: Pick, options?: { suite('Editor cloud sandbox discovery', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + test('archives and unarchives a discovered task without connecting', async () => { + const h = createHarness(store); + await h.refresh(); + const controller = h.controllers.get(sessionType)!; + controller.setChatSessionItemArchived!(resource, true); + await timeout(0); + const archived = h.items()[0].archived; + controller.setChatSessionItemArchived!(resource, false); + await timeout(0); + assert.deepStrictEqual({ + archived, unarchived: h.items()[0].archived, + tasks: h.calls.archivedTasks, connected: h.calls.connected, + }, { + archived: true, unarchived: false, + tasks: [{ taskId: discovered.taskId, archived: true }, { taskId: discovered.taskId, archived: false }], + connected: [], + }); + }); + + test('reconciles remote task archive state while connected and after disconnection', async () => { + const h = createHarness(store); + await h.refresh(); + h.state.online = true; + await h.contribution.activate(); + await h.controllers.get(sessionType)!.refresh(CancellationToken.None); + h.state.result = { kind: 'complete', sessions: [{ ...discovered, isArchived: true }] }; + await h.refresh(); + const connectedArchived = h.items()[0].archived; + h.state.connected = false; + h.connectionsChanged.fire(); + const disconnectedArchived = h.items()[0].archived; + h.state.result = { kind: 'complete', sessions: [{ ...discovered, isArchived: false }] }; + await h.refresh(); + assert.deepStrictEqual({ + connectedArchived, disconnectedArchived, unarchived: h.items()[0].archived, + }, { connectedArchived: true, disconnectedArchived: true, unarchived: false }); + }); + + test('notifies the user when Mission Control rejects archiving and preserves the current flag', async () => { + const h = createHarness(store, { setTaskArchived: async () => { throw new Error('archive rejected'); } }); + await h.refresh(); + h.controllers.get(sessionType)!.setChatSessionItemArchived!(resource, true); + await timeout(0); + assert.deepStrictEqual({ + archived: h.items()[0].archived, notifications: h.errorNotifications, + }, { + archived: false, notifications: ['Unable to change the sandbox session\'s archive state: archive rejected'], + }); + }); + + test('drops old queued archive operations after disable and re-enable without notifying cancellation', async () => { + const pending = new DeferredPromise(); + const entered = new DeferredPromise(); + const h = createHarness(store, { + setTaskArchived: async () => { + await entered.complete(); + await pending.p; + }, + }); + await h.refresh(); + const controller = h.controllers.get(sessionType)!; + controller.setChatSessionItemArchived!(resource, true); + await entered.p; + controller.setChatSessionItemArchived!(resource, false); + await h.setEnabled(CloudSandboxEnabledSettingId, false); + await h.setEnabled(CloudSandboxEnabledSettingId, true); + await h.refresh(); + await pending.complete(); + await timeout(0); + assert.deepStrictEqual({ + mutations: h.calls.archivedTasks, notifications: h.errorNotifications, + replaced: h.controllers.get(sessionType) !== controller, archived: h.items()[0].archived, + }, { + mutations: [{ taskId: discovered.taskId, archived: true }], notifications: [], + replaced: true, archived: false, + }); + }); + test('trusts only discovered sandbox authorities and releases trust on teardown', async () => { const h = createHarness(store); await h.refresh(); @@ -550,7 +638,7 @@ suite('Editor cloud sandbox discovery', () => { isNew: h.resolvers.get(sessionType)?.isNew(resource), group: h.contributions.get(sessionType)?.sessionListGroup, }, { - items: [{ resource: resource.toString(), title: discovered.name, status: ChatSessionStatus.NeedsInput, archived: undefined, isRead: undefined }], + items: [{ resource: resource.toString(), title: discovered.name, status: ChatSessionStatus.NeedsInput, archived: false, isRead: undefined }], created: 0, connected: [], isNew: false, group: SessionType.CopilotCloud, }); }); @@ -1132,7 +1220,7 @@ suite('Editor cloud sandbox discovery', () => { }); }); - test('waits for authentication before adopting host read and archive flags', async () => { + test('waits for authentication before adopting host read flags and preserves task archive state', async () => { const h = createHarness(store); await h.refresh(); h.state.online = true; @@ -1144,12 +1232,12 @@ suite('Editor cloud sandbox discovery', () => { h.authenticationPending.set(false, undefined); await h.controllers.get(sessionType)!.refresh(CancellationToken.None); assert.deepStrictEqual({ before, after: h.items().map(item => [item.label, item.isRead, item.archived]) }, { - before: [[discovered.name, undefined, undefined]], - after: [['Host title', true, true]], + before: [[discovered.name, undefined, false]], + after: [['Host title', true, false]], }); }); - test('publishes unknown read and archive flags on disconnect without changing activity', async () => { + test('publishes unknown read flags on disconnect without changing task archive state or activity', async () => { const h = createHarness(store); await h.refresh(); h.state.online = true; @@ -1173,16 +1261,16 @@ suite('Editor cloud sandbox discovery', () => { })), items: controller.items.map(item => [item.resource.toString(), item.status, item.isRead, item.archived]), }, { - before: [[resource.toString(), ChatSessionStatus.NeedsInput, true, true]], + before: [[resource.toString(), ChatSessionStatus.NeedsInput, true, false]], deltas: [{ - items: [[resource.toString(), ChatSessionStatus.NeedsInput, undefined, undefined]], + items: [[resource.toString(), ChatSessionStatus.NeedsInput, undefined, false]], removed: undefined, }], - items: [[resource.toString(), ChatSessionStatus.NeedsInput, undefined, undefined]], + items: [[resource.toString(), ChatSessionStatus.NeedsInput, undefined, false]], }); }); - test('publishes host read and archive flags again when connection availability returns', async () => { + test('publishes host read flags again when connection availability returns without overriding task archive state', async () => { const h = createHarness(store); await h.refresh(); h.state.online = true; @@ -1199,7 +1287,7 @@ suite('Editor cloud sandbox discovery', () => { h.state.connected = true; h.connectionsChanged.fire(); - assert.deepStrictEqual(deltas.map(delta => delta.addedOrUpdated?.map(item => [item.isRead, item.archived])), [[[true, true]]]); + assert.deepStrictEqual(deltas.map(delta => delta.addedOrUpdated?.map(item => [item.isRead, item.archived])), [[[true, false]]]); }); test('falls back to original history after a failed live connection, without creating a replacement', async () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/remoteAgentHostCustomizationHarness.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/remoteAgentHostCustomizationHarness.test.ts index 0bc8acd08c4aed..c4957fe75a342c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/remoteAgentHostCustomizationHarness.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/remoteAgentHostCustomizationHarness.test.ts @@ -183,7 +183,7 @@ function createTestCustomAgentsService(connection: MockAgentConnection, rootCust return [...rootCustomizations, ...(sessionState.customizations ?? [])]; }, getFolderPickerDecision: () => undefined, - whenCustomizationsReady: () => Promise.resolve(), + whenCustomizationsReady: () => Promise.resolve(true), getWorkingDirectory(sessionResource: URI): string | undefined { return undefined; }, diff --git a/src/vs/workbench/contrib/chat/test/browser/sessionBackgroundShellsControl.test.ts b/src/vs/workbench/contrib/chat/test/browser/sessionBackgroundShellsControl.test.ts index 1d5ef7887e548e..f7ac8c36ebf1bf 100644 --- a/src/vs/workbench/contrib/chat/test/browser/sessionBackgroundShellsControl.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/sessionBackgroundShellsControl.test.ts @@ -6,19 +6,27 @@ import assert from 'assert'; import { isHTMLElement } from '../../../../../base/browser/dom.js'; import type { IRenderedMarkdown } from '../../../../../base/browser/markdownRenderer.js'; +import { Action } from '../../../../../base/common/actions.js'; import { timeout } from '../../../../../base/common/async.js'; import { autorun, constObservable, observableValue } from '../../../../../base/common/observable.js'; import type { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { runWithFakedTimers } from '../../../../../base/test/common/virtualScheduling/index.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { IModelService } from '../../../../../editor/common/services/model.js'; import { IAccessibleViewService } from '../../../../../platform/accessibility/browser/accessibleView.js'; +import type { IActionListItem } from '../../../../../platform/actionWidget/browser/actionList.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; +import { ChatDropdownPillActionViewItem } from '../../../../browser/chatDropdownPill.js'; import { IDetachedTerminalInstance, IDetachedXtermTerminal, ITerminalService } from '../../../terminal/browser/terminal.js'; import { SessionBackgroundShellsControl, type IChatBackgroundShellsSource } from '../../browser/sessionBackgroundShellsControl.js'; +import { sessionBackgroundShellsPillOptions } from '../../browser/sessionChatPillOptions.js'; import type { ChatBackgroundShellOutput, IChatBackgroundShell } from '../../common/sessionChatPills.js'; suite('SessionBackgroundShellsControl', () => { @@ -213,4 +221,56 @@ suite('SessionBackgroundShellsControl', () => { released: true, }); }); + + test('keeps the live output shown while the picker refreshes elapsed time', () => runWithFakedTimers({}, async () => { + let terminals = 0; + instantiationService.stub(ITerminalService, new class extends mock() { + override createDetachedTerminal(): Promise { + terminals++; + return new Promise(() => { }); + } + }()); + instantiationService.stub(IMarkdownRendererService, new class extends mock() { + override render(): IRenderedMarkdown { return { element: document.createElement('div'), dispose: () => { } }; } + }()); + instantiationService.stub(IAccessibleViewService, new class extends mock() { + override getOpenAriaHint(): string | null { return null; } + }()); + instantiationService.stub(IContextKeyService, new MockContextKeyService()); + let rows: readonly IActionListItem[] = []; + instantiationService.stub(IActionWidgetService, new class extends mock() { + override get isVisible(): boolean { return false; } + override show(_user: string, _supportsPreview: boolean, items: readonly IActionListItem[]): void { rows = items; } + override updateItems(items: readonly IActionListItem[]): void { rows = items; } + override hide(): void { } + }()); + instantiationService.stub(ILanguageService, {}); + instantiationService.stub(IModelService, {}); + instantiationService.stub(IFileService, {}); + const shells = constObservable([{ + id: 'stream', description: 'Stream', command: 'stream.sh', startedAt: new Date(0).toISOString(), + output: constObservable({ status: 'running', text: 'step 1\n' }), + }]); + const control = store.add(instantiationService.createInstance(SessionBackgroundShellsControl, constObservable({ backgroundShells: shells }))); + const pill = store.add(instantiationService.createInstance(ChatDropdownPillActionViewItem, store.add(new Action('shells', 'Background Shells')), {}, control.sections, sessionBackgroundShellsPillOptions)); + const container = document.createElement('div'); + pill.render(container); + container.querySelector('.chat-pill-button')!.click(); + // The picker asks the open row for its details again each time the list updates. + const showDetails = () => { + const content = rows[1].hover?.content; + return typeof content === 'function' ? content().querySelector('.chat-background-shell-output') : null; + }; + const view = showDetails(); + const opened = { badge: rows[1].badge, shown: !!view }; + await timeout(2100); + const refreshed = { badge: rows[1].badge, sameView: showDetails() === view }; + pill.dispose(); + + assert.deepStrictEqual({ opened, refreshed, terminals }, { + opened: { badge: '0ms', shown: true }, + refreshed: { badge: '2s', sameView: true }, + terminals: 1, + }); + })); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMcpAuthenticationContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMcpAuthenticationContentPart.test.ts index 4d5fb03470fbf7..56aa2591e38332 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMcpAuthenticationContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMcpAuthenticationContentPart.test.ts @@ -32,10 +32,11 @@ suite('ChatMcpAuthenticationContentPart', () => { instantiationService = workbenchInstantiationService(undefined, disposables); }); - function server(id: string, name: string, status: McpServerStatus, enabled = true): McpServer { + function server(id: string, name: string, status: McpServerStatus, enabled = true, displayName?: string): McpServer { return new class extends mock() { override readonly id = id; override readonly name = name; + override readonly displayName = displayName; override readonly status = status; override readonly enabled = enabled; }(); @@ -131,6 +132,26 @@ suite('ChatMcpAuthenticationContentPart', () => { }); }); + test('uses the presentation name without changing the authentication target', async () => { + const runtimeServerId = 'github-copilot-connector-94d26095770df60673dd'; + const { part, authenticated } = createPart( + [{ id: runtimeServerId, name: runtimeServerId, resource: 'https://api.github.com' }], + [server(runtimeServerId, runtimeServerId, McpServerStatus.AuthRequired, true, 'GitHub')], + ); + + const text = part.domNode.textContent; + part.domNode.querySelector('[role="button"]')?.click(); + await timeout(0); + + assert.deepStrictEqual({ + text, + authenticated, + }, { + text: 'The MCP server GitHub (Connector) requires authentication. Authenticate?', + authenticated: [runtimeServerId], + }); + }); + test('remains unused initially and completes once when servers are populated then resolved', () => { let completionCount = 0; const { part, data, servers, setLiveServers } = createPart([], [], undefined, () => completionCount++); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMcpServersStartingContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMcpServersStartingContentPart.test.ts index 41036727a93c50..5500c9d571075c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMcpServersStartingContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMcpServersStartingContentPart.test.ts @@ -154,6 +154,12 @@ suite('ChatMcpServersStartingContentPart', () => { disposedSpinners: 0, finishedCount: 1, }); + + test('renders connector labels without visible markdown escapes', () => { + const { part } = createPart([{ id: 'connector', name: 'GitHub (Connector)' }]); + + assert.strictEqual(part.domNode.textContent, 'Starting MCP servers GitHub (Connector)...'); + }); }); test('reflects a blocking server becoming nonblocking', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolAuthenticationSubPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolAuthenticationSubPart.test.ts index a33866b80e5711..d4de68544d18ea 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolAuthenticationSubPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolAuthenticationSubPart.test.ts @@ -22,12 +22,14 @@ import { ToolDataSource } from '../../../../common/tools/languageModelToolsServi suite('ChatToolAuthenticationSubPart', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - test('can disable the MCP server for the session', async () => { + test('uses the presentation name while actions target the MCP server id', async () => { const sessionResource = URI.parse('chat-session://local/session'); const enabledChanges: boolean[] = []; + const authenticationTargets: string[] = []; const server = upcastPartial({ - id: 'local/docs', - name: 'Documentation', + id: 'local/github-copilot-connector-94d26095770df60673dd', + name: 'github-copilot-connector-94d26095770df60673dd', + displayName: 'GitHub [Enterprise]', enabled: true, status: McpServerStatus.AuthRequired, state: { @@ -45,6 +47,11 @@ suite('ChatToolAuthenticationSubPart', () => { assert.strictEqual(resource, sessionResource); return [server]; } + override authenticateMcpServer(resource: URI, serverId: string): Promise { + assert.strictEqual(resource, sessionResource); + authenticationTargets.push(serverId); + return Promise.resolve(true); + } } let focused = false; const instantiationService = workbenchInstantiationService(undefined, store); @@ -72,15 +79,31 @@ suite('ChatToolAuthenticationSubPart', () => { const part = store.add(instantiationService.createInstance(ChatToolAuthenticationSubPart, invocation, context)); const buttons = [...part.domNode.querySelectorAll('.monaco-button')]; - assert.deepStrictEqual(buttons.map(button => button.textContent), ['Authenticate', 'Cancel', 'Disable for This Session']); + const presentation = { + title: part.domNode.querySelector('.chat-query-title-part')?.textContent?.trim().replace(/\u00a0/g, ' '), + message: part.domNode.querySelector('.chat-confirmation-widget-message')?.textContent?.replace(/\u00a0/g, ' '), + messageLinks: part.domNode.querySelectorAll('.chat-confirmation-widget-message a').length, + buttons: buttons.map(button => button.textContent), + }; + buttons[0].click(); + await timeout(0); buttons[2].click(); await timeout(0); assert.deepStrictEqual({ + presentation, + authenticationTargets, enabledChanges, focused, cancelled, }, { + presentation: { + title: 'MCP authentication requiredGitHub [Enterprise] (Connector)', + message: 'The MCP server GitHub [Enterprise] (Connector) requires authentication to continue this tool call.', + messageLinks: 0, + buttons: ['Authenticate', 'Cancel', 'Disable for This Session'], + }, + authenticationTargets: [server.id], enabledChanges: [false], focused: true, cancelled: true, diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts index a35b1327d52b09..0a91d7fa6f83ca 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts @@ -20,6 +20,9 @@ import { Range } from '../../../../../../editor/common/core/range.js'; import { ICodeEditorService } from '../../../../../../editor/browser/services/codeEditorService.js'; import { IActionViewItemFactory, IActionViewItemService, NullActionViewItemService } from '../../../../../../platform/actions/browser/actionViewItemService.js'; import { IMenu, IMenuService, MenuId, MenuItemAction } from '../../../../../../platform/actions/common/actions.js'; +import { agentHostAuthority } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { remoteAgentHostSessionTypeId } from '../../../../../../platform/agentHost/common/agentHostSessionType.js'; +import { CLOUD_SANDBOX_AGENT_PROVIDER, cloudSandboxAddress } from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { ConfirmationOptionKind, McpServerStatus, ToolCallStatus } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { CommandsRegistry } from '../../../../../../platform/commands/common/commands.js'; @@ -1213,6 +1216,20 @@ suite('ChatListRenderer', () => { })); suite('shouldHideChatUserIdentity', () => { + test('hides cloud sandbox response identity without hiding user identity', () => { + const sessionResource = URI.from({ + scheme: remoteAgentHostSessionTypeId(agentHostAuthority(cloudSandboxAddress('environment-one')), CLOUD_SANDBOX_AGENT_PROVIDER), + path: '/session-one', + }); + assert.deepStrictEqual([ + shouldHideChatUserIdentity('Copilot [Fix the issue with a long session title]', sessionResource, true, false, false), + shouldHideChatUserIdentity('Copilot [Renamed session]', sessionResource, true, false, false), + shouldHideChatUserIdentity('User', sessionResource, false, false, false), + shouldHideChatUserIdentity('Copilot [Remote host]', URI.from({ scheme: 'remote-test-authority-copilot' }), true, false, false), + shouldHideChatUserIdentity('User', URI.from({ scheme: 'remote-test-authority-copilot' }), false, false, false), + ], [true, true, false, true, false]); + }); + test('hides local Copilot and Agent Host Copilot response identity', () => { assert.deepStrictEqual([ shouldHideChatUserIdentity('GitHub Copilot', URI.from({ scheme: 'vscode-chat-editor' }), true, false, false), diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts index 26ff801ce90693..c13da9730027d0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts @@ -10,6 +10,7 @@ import { mainWindow } from '../../../../../../base/browser/window.js'; import { timeout } from '../../../../../../base/common/async.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { FileAccess, Schemas } from '../../../../../../base/common/network.js'; import { constObservable } from '../../../../../../base/common/observable.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -24,6 +25,7 @@ import { IHostService } from '../../../../../services/host/browser/host.js'; import { IChatModel } from '../../../common/model/chatModel.js'; import { CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID, chatPetAchievements, ChatPetAccessoryIds, ChatPetAchievementIds, didExplicitlyEnableChatPetAutopilot, disabledChatPetAchievements, getChatPetAchievement, getChatPetAchievementPresentation, getChatPetCustomizationAchievementIds, getUnlockedChatPetAccessories, isUserAuthoredChatPetCustomization, shouldUnlockChatPetIntegratedBrowserShare } from '../../../browser/chatPetAchievements.js'; import { ChatPetService, getChatPetVariant } from '../../../browser/chatPetService.js'; +import { CHAT_PET_CHANGE_COLOR_COMMAND_ID, ChatPetColor, getChatPetColoredSprite } from '../../../browser/chatPetColors.js'; import '../../../browser/widget/media/chat.css'; import { getChatPetAccessoryImageSource, hasChatPetAccessoryImageDimensions, hasChatPetBodyImageDimensions } from '../../../browser/widget/chatPetAccessoryRenderer.js'; import { getChatPetAccessoryRigFrame, getChatPetAccessoryRigPose, getChatPetAccessoryTrack, getChatPetAntennaeOcclusionBounds, getChatPetEyeAccessoryAnchor, getChatPetReducedMotionRigFrame } from '../../../browser/widget/chatPetAccessoryRig.js'; @@ -33,6 +35,11 @@ suite('ChatPetWidget', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + setup(() => { + if (mainWindow.location.protocol === `${Schemas.file}:`) { + sinon.stub(FileAccess, 'asBrowserUri').callsFake(resource => FileAccess.asFileUri(resource)); + } + }); teardown(() => sinon.restore()); class TestTelemetryService extends NullTelemetryServiceShape { @@ -74,7 +81,7 @@ suite('ChatPetWidget', () => { }; } - function createHostTransitionHarness(motionReduced = false, initialTransition: IChatPetWidgetHost['transition'] = 'teleport') { + function createHostTransitionHarness(motionReduced = false, initialTransition: IChatPetWidgetHost['transition'] = 'teleport', color?: ChatPetColor) { const root = mainWindow.document.createElement('div'); root.style.cssText = 'position:fixed;left:0;top:0;width:800px;height:640px'; const firstParent = mainWindow.document.createElement('div'); @@ -113,6 +120,10 @@ suite('ChatPetWidget', () => { }; const service = disposables.add(new ChatPetService(disposables.add(new TestStorageService()), new TestTelemetryService(), new NullLogService())); service.toggle(); + if (color) { + service.unlockAchievement(ChatPetAchievementIds.Blobby); + service.setColor(color); + } const widget = disposables.add(new ChatPetWidget( firstHost, TestResizeObserver, service, accessibilityService, new class extends mock() { }(), @@ -152,6 +163,40 @@ suite('ChatPetWidget', () => { : []); } + for (const reducedMotion of [false, true]) { + test(`live color changes preserve the current frame and double buffering (${reducedMotion ? 'reduced motion' : 'animated'})`, async () => { + const clock = sinon.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + try { + const { button, service } = createHostTransitionHarness(reducedMotion, 'teleport', '#ff8800'); + const firstImage = button.querySelector('.chat-pet-sprite img[src]')!; + await firstImage.decode(); + await Promise.resolve(); + clock.tick(800); + const activeCanvas = button.querySelector('.chat-pet-sprite:not(.hidden) canvas')!; + const expected = mainWindow.document.createElement('canvas'); + expected.width = expected.height = 96; + const expectedContext = expected.getContext('2d')!; + service.setColor('#12abcd'); + expectedContext.drawImage(getChatPetColoredSprite(firstImage, '#12abcd'), (reducedMotion ? 0 : 20) * 96, 0, 96, 96, 0, 0, 96, 96); + assert.strictEqual(activeCanvas.toDataURL(), expected.toDataURL(), 'Recolor the current frame rather than restarting at frame zero'); + const previousFrame = activeCanvas.toDataURL(); + service.setColor('insiders'); + assert.strictEqual(activeCanvas.toDataURL(), previousFrame, 'Keep the previous composite until the new variant loads'); + const insiders = button.querySelector('.chat-pet-sprite.hidden img[src*="insiders"]')!; + await insiders.decode(); + await Promise.resolve(); + assert.ok(insiders.parentElement?.classList.contains('hidden') === false); + service.setColor('#12abcd'); + const immediateCanvas = insiders.parentElement!.querySelector('canvas')!; + expectedContext.clearRect(0, 0, 96, 96); + expectedContext.drawImage(getChatPetColoredSprite(insiders, '#12abcd'), 0, 0, 96, 96, 0, 0, 96, 96); + assert.strictEqual(immediateCanvas.toDataURL(), expected.toDataURL(), 'Recolor the currently displayed Insiders sheet while Stable loads'); + } finally { + clock.restore(); + } + }); + } + test('runs one timed hop for a single key press', () => { const clock = sinon.useFakeTimers(); const { controller, events } = createHopHarness(); @@ -305,6 +350,63 @@ suite('ChatPetWidget', () => { }); }); + test('always offers Change Color without revealing the slash command', async () => { + const parent = mainWindow.document.createElement('div'); + mainWindow.document.body.append(parent); + disposables.add(toDisposable(() => parent.remove())); + let menu: IContextMenuDelegate | undefined; + const commands: string[] = []; + const service = disposables.add(new ChatPetService(disposables.add(new TestStorageService()), new TestTelemetryService(), new NullLogService())); + service.toggle(); + disposables.add(new ChatPetWidget( + createPetHost(parent, parent, parent), + undefined, + service, + new TestAccessibilityService(), + new class extends mock() { + override showContextMenu(delegate: IContextMenuDelegate): void { menu = delegate; } + }(), + new class extends mock() { + override async executeCommand(id: string): Promise { + commands.push(id); + return undefined; + } + }(), + new NullLogService(), + new class extends mock() { + override readonly hasFocus = true; + override readonly onDidChangeFocus = Event.None; + override readonly onDidChangeActiveWindow = Event.None; + }(), + )); + const button = parent.querySelector('.chat-pet-button'); + assert.ok(button); + button.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); + const locked = menu?.getActions().find(action => action.id === CHAT_PET_CHANGE_COLOR_COMMAND_ID); + const lockedState = { label: locked?.label, enabled: locked?.enabled }; + assert.ok(locked); + await locked.run(); + service.unlockAchievement(ChatPetAchievementIds.Blobby); + button.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); + const actions = menu?.getActions(); + const unlocked = actions?.find(action => action.id === CHAT_PET_CHANGE_COLOR_COMMAND_ID); + assert.ok(unlocked); + await unlocked.run(); + assert.deepStrictEqual({ + lockedState, + unlockedState: { label: unlocked.label, enabled: unlocked.enabled }, + legacyVariants: actions?.filter(action => action.id.startsWith('chat.pet.variant.')).length, + revealsSlashCommand: actions?.some(action => action.label.includes('/blobby')), + commands, + }, { + lockedState: { label: 'Change Color', enabled: true }, + unlockedState: { label: 'Change Color', enabled: true }, + legacyVariants: 0, + revealsSlashCommand: false, + commands: [CHAT_PET_CHANGE_COLOR_COMMAND_ID, CHAT_PET_CHANGE_COLOR_COMMAND_ID], + }); + }); + test('resets pet size from the context menu', async () => { const parent = mainWindow.document.createElement('div'); const dragBounds = mainWindow.document.createElement('div'); @@ -1657,7 +1759,7 @@ suite('ChatPetWidget', () => { ]); }); - test('defines unique covered-antennae rewards for each achievement', () => { + test('defines unique hats and a color customization reward', () => { const accessoryIds = chatPetAchievements.flatMap(achievement => achievement.accessories.map(accessory => accessory.id)); assert.deepStrictEqual({ count: chatPetAchievements.length, @@ -1672,7 +1774,7 @@ suite('ChatPetWidget', () => { disabledAchievementIds: disabledChatPetAchievements.map(achievement => achievement.id), disabledAccessoryIds: disabledChatPetAchievements.flatMap(achievement => achievement.accessories.map(accessory => accessory.id)), }, { - count: 13, + count: 14, achievementIds: [ ChatPetAchievementIds.RequestRevision, ChatPetAchievementIds.FirstChatMessage, @@ -1687,6 +1789,7 @@ suite('ChatPetWidget', () => { ChatPetAchievementIds.ChatReferenceOpened, ChatPetAchievementIds.UsefulOutputCopied, ChatPetAchievementIds.AutopilotEnabled, + ChatPetAchievementIds.Blobby, ], accessoryIds: [ ChatPetAccessoryIds.TopHatMonocle, @@ -1720,7 +1823,7 @@ suite('ChatPetWidget', () => { 'wizard-hat', ], atlasCellSizes: Array(13).fill(96), - rewardCounts: Array(13).fill(1), + rewardCounts: [...Array(13).fill(1), 0], coversAntennae: true, crownAccessoryId: 'crown', disabledAchievementIds: [ @@ -1770,7 +1873,7 @@ suite('ChatPetWidget', () => { firstMessageRewards: getChatPetAchievement(ChatPetAchievementIds.FirstChatMessage).accessories.map(accessory => accessory.id), newAchievements: achievementIds.map(id => { const achievement = getChatPetAchievement(id); - return { title: achievement.title, reward: achievement.accessories[0].id }; + return { title: achievement.title, reward: achievement.accessories[0]?.id }; }), }, { firstMessageRewards: [ChatPetAccessoryIds.CowboyHat], @@ -1839,12 +1942,12 @@ suite('ChatPetWidget', () => { modelSwitch: { title: modelSwitch.title, description: modelSwitch.description, - accessoryId: modelSwitch.accessories[0].id, + accessoryId: modelSwitch.accessories[0]?.id, }, customSkill: { title: customSkill.title, description: customSkill.description, - accessoryId: customSkill.accessories[0].id, + accessoryId: customSkill.accessories[0]?.id, }, }, { modelSwitch: { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCompletions.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCompletions.test.ts index 1be16b2a176c76..b75dd9ac0c1c09 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCompletions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCompletions.test.ts @@ -15,6 +15,9 @@ import { Range } from '../../../../../../../../editor/common/core/range.js'; import { CompletionItem, CompletionItemKind, CompletionTriggerKind } from '../../../../../../../../editor/common/languages.js'; import { ITextModel } from '../../../../../../../../editor/common/model.js'; import { LanguageFeaturesService } from '../../../../../../../../editor/common/services/languageFeaturesService.js'; +import { CompletionModel } from '../../../../../../../../editor/contrib/suggest/browser/completionModel.js'; +import { CompletionItem as SuggestCompletionItem } from '../../../../../../../../editor/contrib/suggest/browser/suggest.js'; +import { WordDistance } from '../../../../../../../../editor/contrib/suggest/browser/wordDistance.js'; import { createTextModel } from '../../../../../../../../editor/test/common/testTextModel.js'; import { AgentHostInputCompletionsBase } from '../../../../../browser/widget/input/editor/agentHostInputCompletionsBase.js'; import { AgentHostInputCompletions } from '../../../../../browser/widget/input/editor/agentHostInputCompletions.js'; @@ -277,6 +280,76 @@ suite('AgentHostInputCompletions plain text', () => { kind: CompletionItemKind.Text, }); }); + + test('preserves slash command labels that differ only by an acceptance space', () => { + const completions = store.add(new TestableAgentHostInputCompletions( + new LanguageFeaturesService(), + new MockChatWidgetService(), + new TestChatSessionsService(), + new TestConfigurationService(), + )); + const results = ['/review', '/review '].map(label => { + const built = completions.buildItem(new Position(1, 2), { + insertText: '/review ', + label, + attachment: { kind: 'text' }, + }, upcastPartial({})); + return { + label: built?.label, + insertText: built?.insertText, + filterText: built?.filterText, + }; + }); + + assert.deepStrictEqual(results, [ + { label: '/review', insertText: '/review ', filterText: '/review ' }, + { label: '/review ', insertText: '/review ', filterText: '/review ' }, + ]); + }); + + test('keeps plain-text slash commands visible when the host labels them with descriptions', () => { + const completions = store.add(new TestableAgentHostInputCompletions( + new LanguageFeaturesService(), + new MockChatWidgetService(), + new TestChatSessionsService(), + new TestConfigurationService(), + )); + const results = ['/', '/rev'].map(text => { + const position = new Position(1, text.length + 1); + const built = completions.buildItem(position, { + insertText: '/review ', + label: 'Review the workspace', + start: { lineNumber: 1, column: 1 }, + end: position, + attachment: { kind: 'text' }, + }, upcastPartial({}))!; + const list = { suggestions: [built] }; + const provider = { _debugDisplayName: 'testPlainTextSlashCommands', provideCompletionItems: () => list }; + const model = new CompletionModel( + [new SuggestCompletionItem(position, built, list, provider)], + position.column, + { leadingLineContent: text, characterCountDelta: 0 }, + WordDistance.None, + EditorOptions.suggest.defaultValue, + EditorOptions.snippetSuggestions.defaultValue, + undefined, + ); + return model.items.map(item => ({ + label: item.completion.label, + insertText: item.completion.insertText, + filterText: item.completion.filterText, + command: item.completion.command, + })); + }); + + const expected = [{ + label: { label: '/review', description: 'Review the workspace' }, + insertText: '/review ', + filterText: '/review ', + command: undefined, + }]; + assert.deepStrictEqual(results, [expected, expected]); + }); }); suite('AgentHostInputCompletions skills', () => { diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatRequestTelemetry.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatRequestTelemetry.test.ts index 0cca7d1c15338f..d2978cf82d3210 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatRequestTelemetry.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatRequestTelemetry.test.ts @@ -31,8 +31,9 @@ suite('ChatRequestTelemetry request context', () => { instantiationService.stub(IChatSessionsService, new class extends mock() { override getChatSessionContribution(sessionType: string) { const provider = sessionType === 'agent-host-copilotcli' ? 'copilotcli' - : sessionType === 'agent-host-claude' ? 'claude' - : sessionType === 'agent-host-unresolved' ? 'unknown' : undefined; + : sessionType === 'remote-cloudsandbox_environment-one-copilot' ? 'copilot' + : sessionType === 'agent-host-claude' ? 'claude' + : sessionType === 'agent-host-unresolved' ? 'unknown' : undefined; return provider ? { type: sessionType, name: 'Test', displayName: 'Test', description: 'Test', agentHostProviderId: provider, @@ -114,6 +115,8 @@ suite('ChatRequestTelemetry request context', () => { { name: 'local Copilot', metadata: model, expected: 'copilot' }, { name: 'local BYOK', metadata: { ...model, isBYOK: true }, expected: 'byok' }, { name: 'host Copilot', metadata: { ...model, vendor: 'host', targetChatSessionType: 'agent-host-copilotcli' }, expected: 'copilot' }, + { name: 'sandbox Copilot', metadata: { ...model, vendor: 'host', targetChatSessionType: 'remote-cloudsandbox_environment-one-copilot' }, expected: 'copilot' }, + { name: 'sandbox BYOK bridge', metadata: { ...model, vendor: 'host', targetChatSessionType: 'remote-cloudsandbox_environment-one-copilot', byokModelIdentifier: 'private/model' }, expected: 'byok' }, { name: 'known non-Copilot host provider', metadata: { ...model, vendor: 'host', targetChatSessionType: 'agent-host-claude' }, expected: 'other' }, { name: 'explicitly unknown host provider', metadata: { ...model, vendor: 'host', targetChatSessionType: 'agent-host-unresolved' }, expected: 'unknown' }, { name: 'host BYOK bridge', metadata: { ...model, vendor: 'host', targetChatSessionType: 'agent-host-copilotcli', byokModelIdentifier: 'private/model' }, expected: 'byok' }, diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index bf13f9df670050..e74900ebd15b0d 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -3741,6 +3741,145 @@ suite('ChatService', () => { }); }); + for (const withRequestId of [true, false]) { + test(`passive history preserves checkpoint removals when a later local response changes (request ID: ${withRequestId})`, async () => { + const changes = testDisposables.add(new Emitter()); + const first: IChatSessionHistoryItem[] = [ + { type: 'request', id: 'retained', prompt: 'Retained request', participant: remoteScheme }, + { type: 'response', parts: [], participant: remoteScheme }, + { type: 'request', id: withRequestId ? 'removed' : undefined, prompt: 'Removed request', participant: remoteScheme }, + { type: 'response', parts: [], participant: remoteScheme }, + ]; + const { resource } = setupRemoteProvider({ history: first, onDidChangeHistory: changes.event }); + const service = createChatService(); + const ref = await service.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + const model = ref.object as ChatModel; + const viewModel = testDisposables.add(instantiationService.createInstance(ChatViewModel, model, undefined)); + const [retained, removed] = model.getRequests(); + const local = model.addRequest({ parts: [], text: 'Local request' }, { variables: [] }, 0); + local.response?.complete(); + const localHistory: IChatSessionHistoryItem[] = [ + { type: 'request', id: local.id, prompt: 'Local request', participant: remoteScheme }, + { type: 'response', parts: [], participant: remoteScheme, elapsedMs: 1000 }, + ]; + changes.fire([...first, ...localHistory]); + + model.setCheckpoint(removed.id); + for (const request of [...model.getRequests()].reverse()) { + if (request.shouldBeBlocked.get()) { + await service.removeRequest(resource, request.id); + } + } + model.setCheckpoint(undefined); + + const updated: IChatSessionHistoryItem[] = [ + ...first, + localHistory[0], + { type: 'response', parts: [], participant: remoteScheme, elapsedMs: 2000 }, + ]; + changes.fire(updated); + changes.fire(updated); + const next = model.addRequest({ parts: [], text: 'New branch' }, { variables: [] }, 0); + next.response?.complete(); + + assert.deepStrictEqual({ + requests: model.getRequests().map(request => request.message.text), + items: viewModel.getItems().filter(isRequestVM).map(item => item.messageText), + retained: model.getRequests()[0] === retained, + previousRequest: model.getRequests()[model.getRequests().indexOf(next) - 1] === retained, + }, { + requests: ['Retained request', 'New branch'], + items: ['Retained request', 'New branch'], + retained: true, + previousRequest: true, + }); + }); + } + + for (const withRequestId of [true, false]) { + for (const removedLocally of [true, false]) { + for (const omittedFirst of [true, false]) { + test(`passive history applies changed or reintroduced turns (removed locally: ${removedLocally}, request ID: ${withRequestId}, omitted first: ${omittedFirst})`, async () => { + const changes = testDisposables.add(new Emitter()); + const first: IChatSessionHistoryItem[] = [ + { type: 'request', id: 'retained', prompt: 'Retained request', participant: remoteScheme }, + { type: 'response', parts: [], participant: remoteScheme }, + { type: 'request', id: withRequestId ? 'repeated' : undefined, prompt: 'Repeated request', participant: remoteScheme }, + { type: 'response', parts: [], participant: remoteScheme }, + ]; + const { resource } = setupRemoteProvider({ history: first, onDidChangeHistory: changes.event }); + const service = createChatService(); + const ref = await service.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + const model = ref.object as ChatModel; + const viewModel = testDisposables.add(instantiationService.createInstance(ChatViewModel, model, undefined)); + if (removedLocally) { + await service.removeRequest(resource, model.getRequests()[1].id); + } + if (omittedFirst) { + changes.fire(first.slice(0, 2)); + } + for (const elapsedMs of [1000, 2000]) { + changes.fire([ + ...first.slice(0, 3), + { type: 'response', parts: [{ kind: 'markdownContent', content: new MarkdownString('New response') }], participant: remoteScheme, elapsedMs }, + ]); + } + const next = model.addRequest({ parts: [], text: 'New branch' }, { variables: [] }, 0); + next.response?.complete(); + + assert.deepStrictEqual({ + requests: model.getRequests().map(request => [request.message.text, request.response?.response.toString()]), + items: viewModel.getItems().filter(isRequestVM).map(item => item.messageText), + previousRequest: model.getRequests()[model.getRequests().indexOf(next) - 1].message.text, + }, { + requests: [['Retained request', ''], ['Repeated request', 'New response'], ['New branch', '']], + items: ['Retained request', 'Repeated request', 'New branch'], + previousRequest: 'Repeated request', + }); + }); + } + } + } + + for (const [previousCount, nextCount] of [[1, 1], [2, 1], [1, 2]]) { + test(`passive history does not reuse removed ID-less identities after omission: ${previousCount} to ${nextCount}`, async () => { + const changes = testDisposables.add(new Emitter()); + const first: IChatSessionHistoryItem[] = Array.from({ length: previousCount }, () => [ + { type: 'request' as const, prompt: 'Repeated request', participant: remoteScheme }, + { type: 'response' as const, parts: [], participant: remoteScheme }, + ]).flat(); + const updated: IChatSessionHistoryItem[] = Array.from({ length: nextCount }, () => [ + { type: 'request' as const, prompt: 'Repeated request', participant: remoteScheme }, + { type: 'response' as const, parts: [{ kind: 'markdownContent' as const, content: new MarkdownString('New response') }], participant: remoteScheme }, + ]).flat(); + const { resource } = setupRemoteProvider({ history: first, onDidChangeHistory: changes.event }); + const service = createChatService(); + const ref = await service.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + const model = ref.object as ChatModel; + const viewModel = testDisposables.add(instantiationService.createInstance(ChatViewModel, model, undefined)); + const previousIds = new Set(model.getRequests().map(request => request.id)); + for (const requestId of previousIds) { + await service.removeRequest(resource, requestId); + } + changes.fire([]); + changes.fire(updated); + + assert.deepStrictEqual({ + count: model.getRequests().length, + items: viewModel.getItems().filter(isRequestVM).length, + reusedRemovedId: model.getRequests().some(request => previousIds.has(request.id)), + uniqueIds: new Set(model.getRequests().map(request => request.id)).size, + responses: model.getRequests().map(request => request.response?.response.toString()), + }, { count: nextCount, items: nextCount, reusedRemovedId: false, uniqueIds: nextCount, responses: Array.from({ length: nextCount }, () => 'New response') }); + }); + } + test('passive history removals update the last request and cost before notifying observers', async () => { const changes = testDisposables.add(new Emitter()); const history: IChatSessionHistoryItem[] = [ diff --git a/src/vs/workbench/contrib/chat/test/common/editorChatUsage.test.ts b/src/vs/workbench/contrib/chat/test/common/editorChatUsage.test.ts index ea801ff6d78f84..ef786e2224abf1 100644 --- a/src/vs/workbench/contrib/chat/test/common/editorChatUsage.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/editorChatUsage.test.ts @@ -83,12 +83,12 @@ suite('EditorChatUsage', () => { test('normalizes remote authorities and unknown providers, and clamps clock skew', () => { const storage = disposables.add(new InMemoryStorageService()); const usage = new EditorChatUsage(storage); - for (const provider of ['remote-private-host-copilotcli', 'remote-private-host-claude', 'remote-private-host-codex', 'private-extension']) { + for (const provider of ['remote-private-host-copilotcli', 'remote-cloudsandbox_private-environment-copilot', 'remote-private-host-claude', 'remote-private-host-codex', 'private-extension']) { usage.recordSubmission(provider, true, false, false, 5000); } assert.deepStrictEqual(usage.getTelemetry(4000), { - editorSessionsByProvider: '{"remoteCopilot":1,"remoteClaude":1,"remoteCodex":1,"other":1}', - editorMessages: 4, + editorSessionsByProvider: '{"remoteCopilot":2,"remoteClaude":1,"remoteCodex":1,"other":1}', + editorMessages: 5, editorMessagesWithOtherSessionInProgress: 0, editorMessagesWithOtherSessionInProgressAcrossWindows: 0, editorLastMessageSecondsAgo: 0, diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts index c0659469232d22..c23e01dcd5f995 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts @@ -2198,14 +2198,17 @@ suite('ChatResponseModel', () => { response.updateContent(tool); tool.setAuthenticationRequired({ id: 'server', name: 'Test MCP', resource: 'https://example.com/mcp' }); const authentication = pending(response); + tool.setAuthenticationRequired({ id: 'server', name: 'Test MCP (Connector)', resource: 'https://example.com/mcp' }); + const refreshedAuthentication = pending(response); tool.requestConfirmation({ confirmationMessages: { title: 'Approve', message: new MarkdownString('Confirm'), confirmResults: true } }); IChatToolInvocation.confirmWith(tool, { type: ToolConfirmKind.UserAction }); await tool.didExecuteTool({ content: [] }); const postApproval = pending(response); IChatToolInvocation.confirmWith(tool, { type: ToolConfirmKind.UserAction }); - assert.deepStrictEqual([authentication, postApproval, pending(response)], [ + assert.deepStrictEqual([authentication, refreshedAuthentication, postApproval, pending(response)], [ { detail: 'Authenticate Test MCP to continue...', tools: ['tool'] }, + { detail: 'Authenticate Test MCP (Connector) to continue...', tools: ['tool'] }, { detail: 'Approve tool result?', tools: ['tool'] }, { detail: undefined, tools: [] }, ]); diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts index b0973f62175055..72f573beaa9dc4 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts @@ -10,9 +10,10 @@ import { bufferToStream, VSBuffer } from '../../../../../../base/common/buffer.j import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../../base/common/network.js'; import { observableValue } from '../../../../../../base/common/observable.js'; -import { isWeb } from '../../../../../../base/common/platform.js'; -import { joinPath } from '../../../../../../base/common/resources.js'; +import { isWeb, isWindows } from '../../../../../../base/common/platform.js'; +import { basename, joinPath } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { AGENT_PLUGIN_SCHEMA } from '../../../../../../platform/agentPlugins/common/agentPluginParser.js'; @@ -29,6 +30,7 @@ import { IEnvironmentService } from '../../../../../../platform/environment/comm import { AutoUpdateConfigurationValue, IExtensionsWorkbenchService } from '../../../../extensions/common/extensions.js'; import { ChatConfiguration } from '../../../common/constants.js'; import { IAgentPluginRepositoryService } from '../../../common/plugins/agentPluginRepositoryService.js'; +import { getPluginCacheUri, parseMarketplaceObjectEntry, validatePluginCacheUri } from '../../../common/plugins/marketplaceReference.js'; import { IMarketplacePlugin, IMarketplaceReference, IPluginSourceDescriptor, MarketplaceReferenceKind, MarketplaceType, PluginMarketplaceService, PluginSourceKind, extraKnownMarketplacesToConfigDict, getPluginSourceLabel, parseMarketplaceReference, parseMarketplaceReferences, parsePluginSource, readConfiguredMarketplaces } from '../../../common/plugins/pluginMarketplaceService.js'; import { IWorkspacePluginSettingsService } from '../../../common/plugins/workspacePluginSettingsService.js'; @@ -59,7 +61,155 @@ function stubMeteredConnectionService(instantiationService: TestInstantiationSer } suite('PluginMarketplaceService', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + for (const [name, value] of [ + ['GitHub URL', 'https://github.com/microsoft/vscode/../../example/unapproved.git'], + ['GitHub URL with ref', 'https://github.com/microsoft/vscode/../../example/unapproved.git#marketplace'], + ['encoded parent', 'https://github.com/microsoft/vscode/%2e%2e/%2E%2E/example/unapproved.git'], + ['mixed encoded parent', 'https://github.com/microsoft/vscode/.%2e/%2e./example/unapproved.git'], + ['encoded separator', 'https://github.com/microsoft/vscode/..%2f..%2fexample/unapproved.git'], + ['encoded backslash', 'https://github.com/microsoft/vscode/..%5c..%5cexample/unapproved.git'], + ['backslash', String.raw`https://github.com/microsoft/vscode/..\..\example/unapproved.git`], + ['current directory', 'https://example.com/team/./repo.git'], + ['encoded current directory', 'https://example.com/team/%2e/repo.git'], + ['cache escape', 'http://example.com/a/../../../../outside'], + ['dot after removing git suffix', 'https://example.com/team/..git'], + ['parent after removing git suffix', 'https://example.com/team/...git'], + ['SSH URL', 'ssh://git@example.com/team/../../outside.git'], + ['SCP URL', 'git@example.com:team/../../outside.git'], + ['SCP git suffix', 'git@example.com:team/...git'], + ['shorthand owner', '../repo'], + ['shorthand repository', 'owner/..'], + ['shorthand current owner', './repo'], + ['shorthand current repository', 'owner/.'], + ]) { + test(`rejects marketplace traversal: ${name}`, () => { + assert.deepStrictEqual({ + invalid: parseMarketplaceReference(value), + allowed: parseMarketplaceReference('https://github.com/microsoft/vscode.git#marketplace')?.cloneUrl, + }, { + invalid: undefined, + allowed: 'https://github.com/microsoft/vscode.git', + }); + }); + } + + for (const path of ['/cache/agentPlugins/', '/cache/agentPlugins/host/../']) { + test(`rejects an equivalent plugin cache root: ${path}`, () => { + assert.throws(() => validatePluginCacheUri(URI.file('/cache/agentPlugins'), URI.file(path)), /Invalid plugin cache path/); + }); + } + + test('plugin cache validation discards a revived filesystem path', () => { + const expected = URI.file('/cache/agentPlugins/github.com/microsoft/vscode'); + const stored = { ...expected.toJSON(), fsPath: URI.file('/outside').fsPath, _sep: isWindows ? 1 : undefined }; + const directory = validatePluginCacheUri(URI.file('/cache/agentPlugins'), URI.revive(stored)); + assert.deepStrictEqual({ path: directory.path, fsPath: directory.fsPath }, { path: expected.path, fsPath: expected.fsPath }); + }); + + (isWindows ? test : test.skip)('plugin cache validation respects Windows path casing', () => { + const root = URI.file(String.raw`C:\cache\agentPlugins`); + assert.doesNotThrow(() => validatePluginCacheUri(root, URI.file(String.raw`c:\CACHE\agentPlugins\github.com\owner\repo`))); + assert.throws(() => validatePluginCacheUri(root, URI.file('c:\\CACHE\\agentPlugins\\')), /Invalid plugin cache path/); + }); + + for (const segment of ['.. ', 'repo.', 'NUL', 'data:stream', 'file\u0001']) { + (isWindows ? test : test.skip)(`rejects Windows-invalid plugin cache component ${JSON.stringify(segment)}`, () => { + const root = URI.file('/cache/agentPlugins'); + const segments = ['host', segment, 'repo']; + assert.throws(() => getPluginCacheUri(root, segments), /Invalid plugin cache path/); + assert.throws(() => validatePluginCacheUri(root, joinPath(root, ...segments)), /Invalid plugin cache path/); + const virtualRoot = root.with({ scheme: Schemas.inMemory }); + assert.strictEqual(getPluginCacheUri(virtualRoot, segments).path, `${virtualRoot.path}/host/${segment}/repo`); + }); + } + + for (const value of [ + 'https://example.com/a/%2e%2e%20/repo.git', + 'https://example.com/a/repo..git', + 'owner/NUL', + 'git@example.com:a/repo..git', + ]) { + (isWindows && !isWeb ? test : test.skip)(`rejects Windows-invalid marketplace cache path ${value}`, () => { + assert.deepStrictEqual({ + invalid: parseMarketplaceReference(value), + allowed: parseMarketplaceReference('owner/repo')?.cloneUrl, + }, { invalid: undefined, allowed: 'https://github.com/owner/repo.git' }); + }); + } + + for (const [kind, source] of [ + ['shorthand', 'owner/repo'], + ['host-only URL', 'https://example.com'], + ['GitHub URL', 'https://github.com/owner/repo.git'], + ['Git URL', 'https://example.com/owner/repo.git'], + ['SSH URL', 'ssh://git@example.com/owner/repo.git'], + ]) { + (isWindows && !isWeb ? test : test.skip)(`validates Windows marketplace ref cache segments for ${kind}`, () => { + const invalidRefs = ['branch.', 'r'.repeat(252), 'r'.repeat(256), `${'r'.repeat(249)}/`]; + const allowedRefs = ['release/v1', 'r'.repeat(251), `${'r'.repeat(248)}/`]; + assert.deepStrictEqual({ + invalid: invalidRefs.map(ref => parseMarketplaceReference(`${source}#${ref}`)?.ref), + allowed: allowedRefs.map(ref => { + const parsed = parseMarketplaceReference(`${source}#${ref}`); + return parsed && basename(getPluginCacheUri(URI.file('/cache/agentPlugins'), parsed.cacheSegments)); + }), + }, { + invalid: invalidRefs.map(() => undefined), + allowed: allowedRefs.map(ref => `ref_${encodeURIComponent(ref)}`), + }); + }); + } + + test('workspace marketplace traversal cannot replace an allowed clone target', async () => { + const allowed = 'https://github.com/microsoft/vscode.git'; + const workspaceReference = parseMarketplaceObjectEntry({ + name: 'workspace-tools', + source: { source: 'git', url: 'https://github.com/microsoft/vscode/../../example/unapproved.git' }, + }); + const clones: string[] = []; + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IConfigurationService, new TestConfigurationService({ + [ChatConfiguration.PluginMarketplaces]: [allowed], + [ChatConfiguration.PluginsEnabled]: true, + [ChatConfiguration.StrictMarketplaces]: [{ source: 'github', repo: 'microsoft/vscode' }], + })); + instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') }); + instantiationService.stub(IFileService, { + readFile: async (resource: URI) => { + const value = VSBuffer.fromString('{"plugins":[]}'); + return { resource, name: basename(resource), value, size: value.byteLength, mtime: 0, ctime: 0, etag: '', readonly: false, locked: false, executable: false }; + }, + createWatcher: () => ({ onDidChange: Event.None, dispose: () => { } }), + }); + instantiationService.stub(IAgentPluginRepositoryService, { + agentPluginsHome: URI.file('/agent-plugins'), + ensureRepository: async (reference: IMarketplaceReference) => { + clones.push(reference.cloneUrl); + return URI.file('/cache/marketplace'); + }, + }); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IRequestService, { + request: async () => { throw new Error('Unexpected HTTP request'); }, + }); + instantiationService.stub(IStorageService, store.add(new InMemoryStorageService())); + instantiationService.stub(IWorkspacePluginSettingsService, { + extraMarketplaces: observableValue('test.extraMarketplaces', workspaceReference ? [{ name: 'workspace-tools', reference: workspaceReference }] : []), + enabledPlugins: observableValue('test.enabledPlugins', new Map()), + }); + instantiationService.stub(IWorkspaceTrustManagementService, { + isWorkspaceTrusted: () => true, + onDidChangeTrust: Event.None, + }); + instantiationService.stub(IExtensionsWorkbenchService, { getAutoUpdateValue: () => 'off' }); + stubMeteredConnectionService(instantiationService); + + const service = store.add(instantiationService.createInstance(PluginMarketplaceService)); + await service.fetchMarketplacePlugins(CancellationToken.None); + assert.deepStrictEqual(clones, [allowed]); + }); test('parses GitHub shorthand marketplace', () => { const parsed = parseMarketplaceReference('microsoft/vscode'); @@ -615,6 +765,21 @@ suite('PluginMarketplaceService - Agent Plugin direct install probes', () => { })); } + test('skips invalid Git entries without hiding valid marketplace plugins', async () => { + const fileService = new ProbeFileService(); + const repoDir = URI.file('/repos/catalog'); + fileService.files.set(joinPath(repoDir, 'marketplace.json').toString(), JSON.stringify({ + plugins: [ + { name: 'invalid-github', source: { source: 'github', repo: 'owner/..' } }, + { name: 'invalid-url', source: { source: 'url', url: 'https://example.com/a/../b.git' } }, + { name: 'valid', source: { source: 'github', repo: 'owner/plugin' } }, + ], + })); + const service = createService(fileService); + const plugins = await service.readPluginsFromDirectory(repoDir, parseMarketplaceReference('owner/catalog')!); + assert.deepStrictEqual(plugins.map(plugin => plugin.name), ['valid']); + }); + test('reads a Git direct-source manifest with a compatible schema revision', async () => { const fileService = new ProbeFileService(); const repoDir = URI.file('/repos/compatible'); @@ -1299,8 +1464,8 @@ suite('PluginMarketplaceService - hydration after restart', () => { }; } - function storeMarketplaceCache(storageService: InMemoryStorageService, marketplaceReference: IMarketplaceReference, plugin: IMarketplacePlugin): void { - storageService.store('chat.plugins.marketplaces.githubCache.v1', JSON.stringify({ + function storeMarketplaceCache(storageService: InMemoryStorageService, marketplaceReference: IMarketplaceReference, plugin: IMarketplacePlugin, key = 'chat.plugins.marketplaces.githubCache.v2'): void { + storageService.store(key, JSON.stringify({ [marketplaceReference.canonicalId]: { plugins: [plugin], expiresAt: Date.now() + 60_000, @@ -1309,6 +1474,54 @@ suite('PluginMarketplaceService - hydration after restart', () => { }), StorageScope.APPLICATION, StorageTarget.MACHINE); } + test('refreshes marketplace catalogs cached before source validation', async () => { + const storageService = store.add(new InMemoryStorageService()); + const reference = parseMarketplaceReference('owner/catalog')!; + storeMarketplaceCache(storageService, reference, { + ...makeAzurePlugin(reference), + name: 'unsafe', + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/..' }, + }, 'chat.plugins.marketplaces.githubCache.v1'); + let requests = 0; + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IConfigurationService, new TestConfigurationService({ + [ChatConfiguration.PluginMarketplaces]: [reference.rawValue], + [ChatConfiguration.PluginsEnabled]: true, + })); + instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') }); + instantiationService.stub(IFileService, new TestFileService() as unknown as IFileService); + instantiationService.stub(IAgentPluginRepositoryService, createPluginRepositoryStub()); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IRequestService, { + request: async () => { + requests++; + return { + res: { headers: {}, statusCode: 200 }, + stream: bufferToStream(VSBuffer.fromString(JSON.stringify({ + plugins: [ + { name: 'unsafe', source: { source: 'github', repo: 'owner/..' } }, + { name: 'safe', source: { source: 'github', repo: 'owner/plugin' } }, + ], + }))), + }; + }, + }); + instantiationService.stub(IStorageService, storageService); + instantiationService.stub(IWorkspacePluginSettingsService, { + extraMarketplaces: observableValue('test.extraMarketplaces', []), + enabledPlugins: observableValue('test.enabledPlugins', new Map()), + }); + instantiationService.stub(IWorkspaceTrustManagementService, { + isWorkspaceTrusted: () => true, + onDidChangeTrust: Event.None, + }); + instantiationService.stub(IExtensionsWorkbenchService, { getAutoUpdateValue: () => 'off' }); + stubMeteredConnectionService(instantiationService); + const service = store.add(instantiationService.createInstance(PluginMarketplaceService)); + const plugins = await service.fetchMarketplacePlugins(CancellationToken.None); + assert.deepStrictEqual({ requests, plugins: plugins.map(plugin => plugin.name) }, { requests: 1, plugins: ['safe'] }); + }); + test('hydrates a github-sourced plugin from installed.json name and marketplace cache after restart', async () => { // Simulates: user installs the "azure" plugin from the // "github/awesome-copilot#marketplace" marketplace (fetched via HTTP, never @@ -1583,7 +1796,7 @@ suite('PluginMarketplaceService - hydration after restart', () => { }); suite('parsePluginSource', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); const logContext = { pluginName: 'test', @@ -1591,6 +1804,45 @@ suite('parsePluginSource', () => { logPrefix: '[test]', }; + for (const [name, source] of [ + ['GitHub parent', { source: 'github', repo: 'owner/..' }], + ['GitHub current directory', { source: 'github', repo: './repo' }], + ['Git URL parent', { source: 'url', url: 'https://example.com/a/../b.git' }], + ['encoded Git URL parent', { source: 'url', url: 'https://example.com/a/%2e%2e/b.git' }], + ['Git URL suffix', { source: 'url', url: 'https://example.com/...git' }], + ['SCP parent', { source: 'url', url: 'git@example.com:a/../../b.git' }], + ['git-subdir parent', { source: 'git-subdir', url: 'https://example.com/a/../b', path: 'plugins/tool' }], + ['backslash parent', { source: 'url', url: String.raw`https://example.com/a\..\b.git` }], + ['encoded backslash parent', { source: 'url', url: 'https://example.com/a%5c..%5cb.git' }], + ['encoded backslash and dots', { source: 'url', url: 'https://example.com/a%5c%2e%2e%5cb.git' }], + ['git-subdir backslash parent', { source: 'git-subdir', url: String.raw`https://example.com/a\..\b`, path: 'plugins/tool' }], + ] as const) { + test(`skips unsafe Git source with a warning: ${name}`, () => { + const warnings: (string | Error)[] = []; + const logService = store.add(new class extends NullLogService { + override warn(message: string | Error): void { warnings.push(message); } + }()); + const result = parsePluginSource(source, undefined, { ...logContext, logService }); + assert.deepStrictEqual({ result, warnings: warnings.length }, { result: undefined, warnings: 1 }); + }); + } + + for (const [name, source] of [ + ['GitHub reserved name', { source: 'github', repo: 'owner/NUL' }], + ['Git URL trailing dot', { source: 'url', url: 'https://example.com/a/repo..git' }], + ['GitHub revision', { source: 'github', repo: 'owner/repo', ref: 'branch.' }], + ['Git URL revision', { source: 'url', url: 'https://example.com/a/repo.git', ref: 'branch ' }], + ] as const) { + (isWindows && !isWeb ? test : test.skip)(`skips Windows-invalid Git source with a warning: ${name}`, () => { + const warnings: (string | Error)[] = []; + const logService = store.add(new class extends NullLogService { + override warn(message: string | Error): void { warnings.push(message); } + }()); + const result = parsePluginSource(source, undefined, { ...logContext, logService }); + assert.deepStrictEqual({ result, warnings: warnings.length }, { result: undefined, warnings: 1 }); + }); + } + test('parses string source as RelativePath', () => { const result = parsePluginSource('./my-plugin', undefined, logContext); assert.deepStrictEqual(result, { kind: PluginSourceKind.RelativePath, path: 'my-plugin' }); diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/strictKnownMarketplaces.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/strictKnownMarketplaces.test.ts index e29c4bb09aaca5..e72c21d9af1ca4 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/strictKnownMarketplaces.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/strictKnownMarketplaces.test.ts @@ -50,6 +50,27 @@ suite('strictKnownMarketplaces', () => { assert.strictEqual(isMarketplaceReferenceAllowed(allowlist, ref('microsoft/other-repo')), false); }); + for (const revision of [undefined, 'marketplace']) { + test(`marketplace traversal cannot inherit an allowed identity (${revision ?? 'default ref'})`, () => { + const suffix = revision ? `#${revision}` : ''; + const allowlist: IStrictMarketplaceSource[] = [{ source: 'github', repo: 'microsoft/vscode', ref: revision }]; + const candidate = parseMarketplaceReference(`https://github.com/microsoft/vscode/../../example/unapproved.git${suffix}`); + assert.deepStrictEqual({ + crafted: !!candidate && isMarketplaceReferenceAllowed(allowlist, candidate), + direct: isMarketplaceReferenceAllowed(allowlist, ref(`example/unapproved${suffix}`)), + approved: isMarketplaceReferenceAllowed(allowlist, ref(`microsoft/vscode${suffix}`)), + }, { crafted: false, direct: false, approved: true }); + }); + } + + test('a GitHub repository prefix is not a marketplace identity', () => { + const allowlist: IStrictMarketplaceSource[] = [{ source: 'github', repo: 'microsoft/vscode' }]; + assert.deepStrictEqual([ + 'https://github.com/microsoft/vscode/another.git', + 'git@github.com:microsoft/vscode/another.git', + ].map(value => isMarketplaceReferenceAllowed(allowlist, ref(value))), [false, false]); + }); + test('github entry ref must match exactly (tri-state)', () => { const pinned: IStrictMarketplaceSource[] = [{ source: 'github', repo: 'owner/repo', ref: 'main' }]; assert.strictEqual(isMarketplaceReferenceAllowed(pinned, ref('owner/repo#main')), true); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/customizationMigrationTelemetryService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/customizationMigrationTelemetryService.test.ts index bbb76919a6bba2..25ec1796c8ac81 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/customizationMigrationTelemetryService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/customizationMigrationTelemetryService.test.ts @@ -4,10 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { mock } from '../../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { IFileService } from '../../../../../../../platform/files/common/files.js'; +import { NullLogService } from '../../../../../../../platform/log/common/log.js'; import { NullTelemetryServiceShape } from '../../../../../../../platform/telemetry/common/telemetryUtils.js'; +import { IChatService } from '../../../../common/chatService/chatService.js'; import { CustomizationMigrationType, FileCustomizationMigrationFailureReason } from '../../../../common/promptSyntax/service/customizationMigrationService.js'; -import { CustomizationMigrationTelemetryService } from '../../../../common/promptSyntax/service/customizationMigrationTelemetryService.js'; +import { CustomizationMigrationTelemetryService, parseCustomizationMigrationAgentResult } from '../../../../common/promptSyntax/service/customizationMigrationTelemetryService.js'; class TestTelemetryService extends NullTelemetryServiceShape { readonly events: { readonly name: string; readonly data: unknown }[] = []; @@ -20,11 +24,11 @@ class TestTelemetryService extends NullTelemetryServiceShape { } suite('CustomizationMigrationTelemetryService', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); test('reports migration impressions, actions, and outcomes', () => { const telemetryService = new TestTelemetryService(); - const service = new CustomizationMigrationTelemetryService(telemetryService); + const service = store.add(new CustomizationMigrationTelemetryService(telemetryService, new class extends mock() { }, new NullLogService(), new class extends mock() { })); const hint = { migrationFlowId: 'migration-flow-id', message: 'Migration hint', @@ -38,12 +42,15 @@ suite('CustomizationMigrationTelemetryService', () => { service.pageShown(); service.pageShown(CustomizationMigrationType.PromptFiles); service.actionClicked('migrationCategoryClicked', CustomizationMigrationType.PromptFiles); + service.actionClicked('agentMigrationClicked'); service.migrationClicked(CustomizationMigrationType.PromptFiles, 3, hint.migrationFlowId); service.migrationCompleted(CustomizationMigrationType.PromptFiles, 3, 2, 1, [ FileCustomizationMigrationFailureReason.TargetWriteFailed, FileCustomizationMigrationFailureReason.TargetWriteFailed, FileCustomizationMigrationFailureReason.RollbackFailed, ], hint.migrationFlowId); + service.migrationCompleted(CustomizationMigrationType.McpServers, 0, 0, 0, [], hint.migrationFlowId, true); + service.migrationCompleted(CustomizationMigrationType.UserData, 0, 0, 0, [], hint.migrationFlowId, false, true); assert.deepStrictEqual(telemetryService.events, [ { name: 'chat.customizationMigrationAssessment', data: { migrationFlowId: 'migration-flow-id', category: 'promptFiles', count: 3 } }, @@ -53,8 +60,34 @@ suite('CustomizationMigrationTelemetryService', () => { { name: 'chat.customizationMigration', data: { action: 'migrationOverviewShown', category: undefined } }, { name: 'chat.customizationMigration', data: { action: 'migrationCategoryShown', category: 'promptFiles' } }, { name: 'chat.customizationMigration', data: { action: 'migrationCategoryClicked', category: 'promptFiles' } }, + { name: 'chat.customizationMigration', data: { action: 'agentMigrationClicked', category: undefined } }, { name: 'chat.customizationMigration', data: { action: 'migrationClicked', category: 'promptFiles', migrationFlowId: 'migration-flow-id', requestedCount: 3 } }, { name: 'chat.customizationMigration', data: { action: 'migrationCompleted', category: 'promptFiles', migrationFlowId: 'migration-flow-id', requestedCount: 3, migratedCount: 2, failedCount: 1, migrationFailedReasons: 'rollbackFailed;targetWriteFailed' } }, + { name: 'chat.customizationMigration', data: { action: 'migrationCompleted', category: 'mcpServers', migrationFlowId: 'migration-flow-id', requestedCount: 0, migratedCount: 0, failedCount: 0, cancelled: true } }, + { name: 'chat.customizationMigration', data: { action: 'migrationCompleted', category: 'userData', migrationFlowId: 'migration-flow-id', requestedCount: 0, migratedCount: 0, failedCount: 0, timedOut: true } }, ]); }); + + test('parses aggregate outcomes from an agent migration result', () => { + const report = parseCustomizationMigrationAgentResult(JSON.stringify({ + version: 1, + migrationFlowId: 'migration-flow-id', + cancelled: true, + results: [ + { category: 'promptFiles', scope: 'workspace', customizationType: 'skill', outcome: 'migrated', count: 2 }, + { category: 'promptFiles', scope: 'user', customizationType: 'skill', outcome: 'failed', count: 1 }, + { category: 'promptFiles', scope: 'user', customizationType: 'skill', outcome: 'skipped', count: 1 }, + ], + }), 'migration-flow-id', new Map([[CustomizationMigrationType.PromptFiles, 4]])); + + assert.deepStrictEqual(report, { + cancelled: true, + outcomes: [{ + category: CustomizationMigrationType.PromptFiles, + requestedCount: 3, + migratedCount: 2, + failedCount: 1, + }], + }); + }); }); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts index 16d8f1df752dab..aaecaf010848ca 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts @@ -53,7 +53,7 @@ import { IRemoteUserDataProfilesService } from '../../../services/userDataProfil import { IUserDataProfileService } from '../../../services/userDataProfile/common/userDataProfile.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; import { IChatWidgetService } from '../../chat/browser/chat.js'; -import { IAgentHostCustomizationService } from '../../chat/browser/agentSessions/agentHost/agentHostCustomizationService.js'; +import { getMcpServerDisplayLabel, IAgentHostCustomizationService } from '../../chat/browser/agentSessions/agentHost/agentHostCustomizationService.js'; import { setAgentHostPluginEnablement } from '../../chat/browser/agentPluginActions.js'; import { IAICustomizationWorkspaceService } from '../../chat/common/aiCustomizationWorkspaceService.js'; import { IAgentPluginService } from '../../chat/common/plugins/agentPluginService.js'; @@ -238,7 +238,7 @@ export class ListMcpServerCommand extends Action2 { } satisfies ItemType] : servers.map((server): ItemType => ({ id: server.id, server, - label: server.name, + label: getMcpServerDisplayLabel(server), description: server.enabled ? mcpServerStatusToLabel(server.status) : localize('mcp.disabled', 'Disabled'), @@ -373,9 +373,10 @@ async function runAgentHostMcpServerLifecycleAction(server: IAgentHostMcpServer, } catch (error) { services.logService.error(`Failed to ${action} MCP server '${server.name}'`, error); const message = error instanceof Error ? error.message : String(error); + const label = getMcpServerDisplayLabel(server); services.notificationService.error(action === 'start' - ? localize('mcp.agentHost.startError', "Failed to start MCP server '{0}': {1}", server.name, message) - : localize('mcp.agentHost.stopError', "Failed to stop MCP server '{0}': {1}", server.name, message)); + ? localize('mcp.agentHost.startError', "Failed to start MCP server '{0}': {1}", label, message) + : localize('mcp.agentHost.stopError', "Failed to stop MCP server '{0}': {1}", label, message)); } } @@ -546,7 +547,7 @@ export class McpAgentHostServerOptionsCommand extends Action2 { }); const picked = await quickInputService.pick(items, { - placeHolder: server.name, + placeHolder: getMcpServerDisplayLabel(server), }); if (!picked || !hasKey(picked, { action: true })) { diff --git a/src/vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts b/src/vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts index 0d5f4528f05b13..86dbc6b19e00c0 100644 --- a/src/vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts +++ b/src/vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts @@ -7,6 +7,7 @@ import { ValueWithChangeEvent } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { observableFromEvent, ValueWithChangeEventFromObservable, waitForState } from '../../../../base/common/observable.js'; import { basename } from '../../../../base/common/path.js'; +import { comparePaths } from '../../../../base/common/comparers.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; import { IMultiDiffEditorOptions } from '../../../../editor/common/multiDiffEditor.js'; import { localize2 } from '../../../../nls.js'; @@ -176,7 +177,9 @@ class ScmResolvedMultiDiffSource implements IResolvedMultiDiffSource { ) { this._resources = observableFromEvent( this._group.onDidChangeResources, - () => /** @description resources */ this._group.resources.map(e => new MultiDiffEditorItem(e.multiDiffEditorOriginalUri, e.multiDiffEditorModifiedUri, e.sourceUri)) + () => /** @description resources */[...this._group.resources] + .sort((a, b) => comparePaths(a.sourceUri.fsPath, b.sourceUri.fsPath)) + .map(e => new MultiDiffEditorItem(e.multiDiffEditorOriginalUri, e.multiDiffEditorModifiedUri, e.sourceUri)) ); this.resources = new ValueWithChangeEventFromObservable(this._resources); this.contextKeys = { diff --git a/src/vs/workbench/contrib/multiDiffEditor/test/browser/scmMultiDiffSourceResolver.test.ts b/src/vs/workbench/contrib/multiDiffEditor/test/browser/scmMultiDiffSourceResolver.test.ts index 313da4a454d097..7fa1520e0956fb 100644 --- a/src/vs/workbench/contrib/multiDiffEditor/test/browser/scmMultiDiffSourceResolver.test.ts +++ b/src/vs/workbench/contrib/multiDiffEditor/test/browser/scmMultiDiffSourceResolver.test.ts @@ -4,14 +4,82 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Emitter } from '../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IActivityService } from '../../../../services/activity/common/activity.js'; import { ISCMHistoryProvider } from '../../../scm/common/history.js'; -import { ISCMProvider, ISCMRepository, ISCMService } from '../../../scm/common/scm.js'; -import { ScmHistoryItemResolver } from '../../browser/scmMultiDiffSourceResolver.js'; +import { ISCMProvider, ISCMRepository, ISCMResource, ISCMResourceGroup, ISCMService } from '../../../scm/common/scm.js'; +import { ScmMultiDiffSourceResolver, ScmHistoryItemResolver } from '../../browser/scmMultiDiffSourceResolver.js'; + +suite('ScmMultiDiffSourceResolver', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('sorts resources by path', async () => { + const resources: ISCMResource[] = []; + const onDidChangeResources = disposables.add(new Emitter()); + const group = new class extends mock() { + override readonly id = 'changes'; + override readonly label = 'Changes'; + override readonly resources = resources; + override readonly onDidChangeResources = onDidChangeResources.event; + }(); + const provider = new class extends mock() { + override readonly id = 'scm0'; + override readonly providerId = 'scm0'; + override readonly rootUri = URI.file('/repository'); + override readonly groups = [group]; + override readonly onDidChangeResourceGroups = new Emitter().event; + }(); + const repository = new class extends mock() { + override readonly id = provider.id; + override readonly provider = provider; + }(); + const repositories = [repository]; + const onDidAddRepository = disposables.add(new Emitter()); + const scmService = new class extends mock() { + override readonly onDidAddRepository = onDidAddRepository.event; + override get repositories(): Iterable { return repositories; } + override get repositoryCount(): number { return repositories.length; } + override getRepository(idOrResource: string | URI): ISCMRepository | undefined { + return typeof idOrResource === 'string' ? repositories.find(r => r.id === idOrResource) : undefined; + } + }(); + + const activityService = new class extends mock() { + override readonly onDidChangeActivity = Event.None; + override getViewContainerActivities() { return []; } + }(); + const resolver = new ScmMultiDiffSourceResolver(scmService, activityService); + const sourceUri = ScmMultiDiffSourceResolver.getMultiDiffSourceUri(provider.rootUri.toString(), group.id); + const sourcePromise = resolver.resolveDiffSource(sourceUri); + + const createResource = (path: string) => new class extends mock() { + override readonly sourceUri = URI.file(path); + override readonly multiDiffEditorModifiedUri = this.sourceUri; + }(); + + resources.push( + createResource('/repository/githubServer.ts'), + createResource('/repository/env.ts'), + createResource('/repository/githubUri.ts'), + ); + onDidChangeResources.fire(); + + const source = await sourcePromise; + assert.deepStrictEqual( + source.resources.value.map(resource => resource.goToFileUri?.path), + [ + '/repository/env.ts', + '/repository/githubServer.ts', + '/repository/githubUri.ts', + ] + ); + }); +}); suite('ScmHistoryItemResolver', () => { diff --git a/src/vs/workbench/contrib/output/browser/outputServices.ts b/src/vs/workbench/contrib/output/browser/outputServices.ts index 0093c7efe79e22..01c80a09be6eec 100644 --- a/src/vs/workbench/contrib/output/browser/outputServices.ts +++ b/src/vs/workbench/contrib/output/browser/outputServices.ts @@ -45,6 +45,7 @@ class OutputChannel extends Disposable implements IOutputChannel { readonly outputChannelDescriptor: IOutputChannelDescriptor, private readonly outputLocation: URI, private readonly outputDirPromise: Promise, + onDispose: () => void, @ILanguageService private readonly languageService: ILanguageService, @IInstantiationService private readonly instantiationService: IInstantiationService, ) { @@ -53,6 +54,7 @@ class OutputChannel extends Disposable implements IOutputChannel { this.label = outputChannelDescriptor.label; this.uri = URI.from({ scheme: Schemas.outputChannel, path: this.id }); this.model = this._register(this.createOutputChannelModel(this.uri, outputChannelDescriptor)); + this._register(Event.once(this.model.onDispose)(onDispose)); } private createOutputChannelModel(uri: URI, outputChannelDescriptor: IOutputChannelDescriptor): IOutputChannelModel { @@ -565,8 +567,7 @@ export class OutputService extends Disposable implements IOutputService, ITextMo } private createChannel(id: string): OutputChannel { - const channel = this.instantiateChannel(id); - this._register(Event.once(channel.model.onDispose)(() => { + const channel = this.instantiateChannel(id, () => { if (this.activeChannel === channel) { const channels = this.getChannelDescriptors(); const channel = channels.length ? this.getChannel(channels[0].id) : undefined; @@ -577,13 +578,13 @@ export class OutputService extends Disposable implements IOutputService, ITextMo } } Registry.as(Extensions.OutputChannels).removeChannel(id); - })); + }); return channel; } private outputFolderCreationPromise: Promise | null = null; - private instantiateChannel(id: string): OutputChannel { + private instantiateChannel(id: string, onDispose: () => void): OutputChannel { const channelData = Registry.as(Extensions.OutputChannels).getChannel(id); if (!channelData) { this.logService.error(`Channel '${id}' is not registered yet`); @@ -592,7 +593,7 @@ export class OutputService extends Disposable implements IOutputService, ITextMo if (!this.outputFolderCreationPromise) { this.outputFolderCreationPromise = this.fileService.createFolder(this.outputLocation).then(() => undefined); } - return this.instantiationService.createInstance(OutputChannel, channelData, this.outputLocation, this.outputFolderCreationPromise); + return this.instantiationService.createInstance(OutputChannel, channelData, this.outputLocation, this.outputFolderCreationPromise, onDispose); } private setLevelContext(): void { diff --git a/src/vs/workbench/contrib/output/test/browser/outputServices.test.ts b/src/vs/workbench/contrib/output/test/browser/outputServices.test.ts new file mode 100644 index 00000000000000..c484e8beeaa4f0 --- /dev/null +++ b/src/vs/workbench/contrib/output/test/browser/outputServices.test.ts @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { timeout } from '../../../../../base/common/async.js'; +import { Event } from '../../../../../base/common/event.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { IWebWorkerService } from '../../../../../platform/webWorker/browser/webWorkerService.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { Workspace } from '../../../../../platform/workspace/test/common/testWorkspace.js'; +import { IDefaultLogLevelsService } from '../../../../services/log/common/defaultLogLevels.js'; +import { Extensions, IOutputChannel, IOutputChannelDescriptor, IOutputChannelRegistry } from '../../../../services/output/common/output.js'; +import { IViewsService } from '../../../../services/views/common/viewsService.js'; +import { TestViewsService, workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { TestContextService } from '../../../../test/common/workbenchTestServices.js'; +import { OutputService } from '../../browser/outputServices.js'; + +suite('OutputService channel disposal', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + const registry = Registry.as(Extensions.OutputChannels); + let originalChannels: IOutputChannelDescriptor[]; + let service: OutputService; + let nextId = 0; + + setup(() => { + // Earlier suites can leave source-less channels with asynchronous model initialization. + originalChannels = registry.getChannels(); + for (const channel of originalChannels) { + registry.removeChannel(channel.id); + } + + const instantiationService = workbenchInstantiationService({}, disposables); + instantiationService.stub(IViewsService, new TestViewsService()); + instantiationService.stub(IWorkspaceContextService, new TestContextService(new Workspace('output-test', []))); + instantiationService.stub(IWebWorkerService, new class extends mock() { }); + instantiationService.stub(IDefaultLogLevelsService, new class extends mock() { + override readonly onDidChangeDefaultLogLevels = Event.None; + }); + service = disposables.add(instantiationService.createInstance(OutputService)); + }); + + teardown(() => { + // The disposable tracker tears down the service before restoring the shared registry. + for (const channel of registry.getChannels()) { + registry.removeChannel(channel.id); + } + for (const channel of originalChannels) { + registry.registerChannel(channel); + } + }); + + function registerChannel() { + const id = `output-disposal-test-${nextId++}`; + registry.registerChannel({ id, label: id, log: false, source: { resource: URI.file(`/logs/${id}.log`) } }); + return service.getChannel(id)!; + } + + function disposeChannel(): WeakRef { + const channel = registerChannel(); + const ref = new WeakRef(channel); + channel.dispose(); + assert.strictEqual(service.getChannel(channel.id), undefined); + return ref; + } + + for (const removeFromRegistry of [false, true]) { + test(`does not retain disposal listeners after ${removeFromRegistry ? 'registry removal' : 'channel disposal'}`, () => { + registerChannel(); + // eslint-disable-next-line local/code-no-bracket-notation-for-identifiers -- Inspect service-owned registrations without exposing test-only API. + const registrations = service['_store']['_toDispose']; + const initialRegistrations = [...registrations]; + for (let i = 0; i < 3; i++) { + const channel = registerChannel(); + if (removeFromRegistry) { + registry.removeChannel(channel.id); + } else { + channel.dispose(); + } + } + assert.deepStrictEqual([...registrations], initialRegistrations); + }); + } + + test('disposed channels become collectible while the service remains alive', async function () { + if (typeof globalThis.gc !== 'function') { + this.skip(); // Run the Electron suite with --js-flags=--expose-gc. + } + registerChannel(); // Keep another channel open, as in the workbench. + const ref = disposeChannel(); + // A WeakRef keeps its target alive until the end of the current job. + await timeout(0); + await globalThis.gc!({ type: 'major', execution: 'async' }); + assert.strictEqual(ref.deref() === undefined, true, 'Disposed output channel is still strongly retained'); + }); + + test('disposing the active channel preserves another open channel', async () => { + const remaining = registerChannel(); + const closing = registerChannel(); + await service.showChannel(closing.id); + closing.dispose(); + assert.deepStrictEqual({ + active: service.getActiveChannel()?.id, + remaining: service.getChannel(remaining.id)?.id, + removed: registry.getChannel(closing.id) + }, { active: remaining.id, remaining: remaining.id, removed: undefined }); + }); + + test('registry removal releases its channel while the service remains alive', async function () { + if (typeof globalThis.gc !== 'function') { + this.skip(); + } + registerChannel(); + const ref = (() => { + const channel = registerChannel(); + const ref = new WeakRef(channel); + registry.removeChannel(channel.id); + assert.strictEqual(service.getChannel(channel.id), undefined); + return ref; + })(); + await timeout(0); + await globalThis.gc!({ type: 'major', execution: 'async' }); + assert.strictEqual(ref.deref() === undefined, true, 'Removed output channel is still strongly retained'); + }); + + test('service shutdown still disposes open channels once', () => { + const channel = registerChannel(); + let disposed = 0; + disposables.add(channel.model.onDispose(() => disposed++)); + service.dispose(); + service.dispose(); + assert.deepStrictEqual({ disposed, registered: registry.getChannel(channel.id) }, { disposed: 1, registered: undefined }); + }); +}); diff --git a/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts b/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts index b45ee54ff37cd7..1e87a26f500947 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts @@ -813,6 +813,12 @@ export function settingKeyToDisplayFormat(key: string, groupId: string = '', isL case AgentSandboxSettingId.AgentSandboxLspServers: displayLabel = localize('agentSandbox.lspServers.label', "Sandbox LSP Servers"); break; + case AgentSandboxSettingId.AgentSandboxAuthenticateGit: + displayLabel = localize('agentSandbox.authenticateGit.label', "Authenticate git"); + break; + case AgentSandboxSettingId.AgentSandboxAuthenticateGh: + displayLabel = localize('agentSandbox.authenticateGh.label', "Authenticate gh"); + break; case AgentSandboxSettingId.AgentSandboxUserConfiguredPaths: displayLabel = localize('agentSandbox.userConfiguredPaths.label', "User-Configured Paths"); break; diff --git a/src/vs/workbench/contrib/preferences/test/browser/settingsTreeModels.test.ts b/src/vs/workbench/contrib/preferences/test/browser/settingsTreeModels.test.ts index eeb0a0f6d6f6ea..54f0e206cf9be4 100644 --- a/src/vs/workbench/contrib/preferences/test/browser/settingsTreeModels.test.ts +++ b/src/vs/workbench/contrib/preferences/test/browser/settingsTreeModels.test.ts @@ -664,6 +664,22 @@ suite('SettingsTree', () => { }); }); + test('settingKeyToDisplayFormat - sandbox credential authentication', () => { + for (const [key, label] of [ + [AgentSandboxSettingId.AgentSandboxAuthenticateGit, 'Authenticate git'], + [AgentSandboxSettingId.AgentSandboxAuthenticateGh, 'Authenticate gh'], + ] as const) { + assert.deepStrictEqual( + ['', 'chat.agent.sandbox', 'chat.agent.sandbox.credentials'].map(group => settingKeyToDisplayFormat(key, group)), + [ + { category: 'Chat › Agent › Sandbox › Credentials', label }, + { category: 'Credentials', label }, + { category: '', label }, + ], + ); + } + }); + test('settingKeyToDisplayFormat - sandbox user-configured paths', () => { assert.deepStrictEqual( ['', 'chat.agent.sandbox', 'chat.agent.sandbox.fileSystem'].map(group => diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index 800274db038d9a..2c0de0dec1d7a0 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -785,11 +785,20 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach } clearBuffer(): void { + const isNormalBuffer = this.raw.buffer.active.type === 'normal'; + // Clearing the alternate buffer leaves the normal buffer's output and markers intact. + if (isNormalBuffer) { + this._decorationAddon.clearDecorations(); + this._capabilities.get(TerminalCapability.CommandDetection)?.clearCommands(); + this._capabilities.get(TerminalCapability.PartialCommandDetection)?.clearCommands(); + } this.raw.clear(); - // xterm.js does not clear the first prompt, so trigger these to simulate - // the prompt being written - this._capabilities.get(TerminalCapability.CommandDetection)?.handlePromptStart(); - this._capabilities.get(TerminalCapability.CommandDetection)?.handleCommandStart(); + if (isNormalBuffer) { + // xterm.js does not clear the first prompt, so trigger these to simulate + // the prompt being written + this._capabilities.get(TerminalCapability.CommandDetection)?.handlePromptStart(); + this._capabilities.get(TerminalCapability.CommandDetection)?.handleCommandStart(); + } this._accessibilitySignalService.playSignal(AccessibilitySignal.clear); } diff --git a/src/vs/workbench/contrib/terminal/test/browser/capabilities/commandDetectionCapability.test.ts b/src/vs/workbench/contrib/terminal/test/browser/capabilities/commandDetectionCapability.test.ts index ea011a736ade2a..bd383ff63d11a3 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/capabilities/commandDetectionCapability.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/capabilities/commandDetectionCapability.test.ts @@ -16,7 +16,7 @@ import { workbenchInstantiationService } from '../../../../../test/browser/workb type TestTerminalCommandMatch = Pick & { marker: { line: number } }; class TestCommandDetectionCapability extends CommandDetectionCapability { - clearCommands() { + clearCommandsForTest() { this._commands.length = 0; } } @@ -41,7 +41,7 @@ suite('CommandDetectionCapability', () => { deepStrictEqual(addEvents, capability.commands); // Clear the commands to avoid re-asserting past commands addEvents.length = 0; - capability.clearCommands(); + capability.clearCommandsForTest(); } async function printStandardCommand(prompt: string, command: string, output: string, cwd: string | undefined, exitCode: number) { @@ -93,6 +93,19 @@ suite('CommandDetectionCapability', () => { }]); }); + test('should invalidate all commands when cleared', async () => { + await printStandardCommand('$ ', 'echo foo', 'foo', undefined, 0); + await printStandardCommand('$ ', 'echo bar', 'bar', undefined, 0); + strictEqual(capability.commands.length, 2); + + const invalidatedCommands: ITerminalCommand[] = []; + store.add(capability.onCommandInvalidated(commands => invalidatedCommands.push(...commands))); + capability.clearCommands(); + + deepStrictEqual(capability.commands, []); + deepStrictEqual(invalidatedCommands.map(e => e.command), ['echo foo', 'echo bar']); + }); + test('should trim the command when command executed appears on the following line', async () => { await printStandardCommand('$ ', 'echo foo\r\n', 'foo', undefined, 0); await printCommandStart('$ '); diff --git a/src/vs/workbench/contrib/terminal/test/browser/capabilities/partialCommandDetectionCapability.test.ts b/src/vs/workbench/contrib/terminal/test/browser/capabilities/partialCommandDetectionCapability.test.ts index e506e1534958c2..3ffb8c546379a7 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/capabilities/partialCommandDetectionCapability.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/capabilities/partialCommandDetectionCapability.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { IMarker, Terminal } from '@xterm/xterm'; -import { deepEqual, deepStrictEqual } from 'assert'; +import { deepEqual, deepStrictEqual, strictEqual } from 'assert'; import { importAMDNodeModule } from '../../../../../../amdX.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { PartialCommandDetectionCapability } from '../../../../../../platform/terminal/common/capabilities/partialCommandDetectionCapability.js'; @@ -65,4 +65,20 @@ suite('PartialCommandDetectionCapability', () => { onDidExecuteTextEmitter.fire(); deepEqual(addEvents.length, 2); }); + + test('should clear all commands including scrollback', async () => { + await writeP(xterm, 'ab'); + xterm.input('\x0d'); + await writeP(xterm, '\r\n\r\n'); + await writeP(xterm, 'cd'); + xterm.input('\x0d'); + await writeP(xterm, '\r\n'); + deepStrictEqual(capability.commands.map(e => e.line), [0, 2]); + await writeP(xterm, 'line\r\n'.repeat(xterm.rows)); + strictEqual(xterm.buffer.active.baseY > 0, true); + + capability.clearCommands(); + + deepStrictEqual(capability.commands, []); + }); }); diff --git a/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts b/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts index f8ead87632a2d3..56557b621a8766 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { Terminal } from '@xterm/xterm'; +import type { IDecoration, IDecorationOptions, Terminal } from '@xterm/xterm'; import { deepStrictEqual, ok, strictEqual } from 'assert'; import { importAMDNodeModule } from '../../../../../../amdX.js'; import { timeout } from '../../../../../../base/common/async.js'; @@ -17,6 +17,9 @@ import { IEditorOptions } from '../../../../../../editor/common/config/editorOpt import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IConfigurationChangeEvent } from '../../../../../../platform/configuration/common/configuration.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ITerminalCommand, TerminalCapability } from '../../../../../../platform/terminal/common/capabilities/capabilities.js'; +import { CommandDetectionCapability } from '../../../../../../platform/terminal/common/capabilities/commandDetectionCapability.js'; +import { PartialCommandDetectionCapability } from '../../../../../../platform/terminal/common/capabilities/partialCommandDetectionCapability.js'; import { TerminalCapabilityStore } from '../../../../../../platform/terminal/common/capabilities/terminalCapabilityStore.js'; import { IThemeService } from '../../../../../../platform/theme/common/themeService.js'; import { TestColorTheme, TestThemeService } from '../../../../../../platform/theme/test/common/testThemeService.js'; @@ -63,7 +66,11 @@ const defaultTerminalConfig: Partial = { scrollback: 10, fastScrollSensitivity: 2, mouseWheelScrollSensitivity: 1, - unicodeVersion: '6' + unicodeVersion: '6', + shellIntegration: { + enabled: true, + decorationsEnabled: 'both' + } }; function listenerCount(emitter: Emitter): number { @@ -78,6 +85,7 @@ suite('XtermTerminal', () => { let themeService: TestThemeService; let xterm: XtermTerminal; let XTermBaseCtor: typeof Terminal; + let capabilityStore: TerminalCapabilityStore; let onWillShutdown: Emitter; let lifecycleListenerCountBeforeXterm: number; @@ -109,7 +117,7 @@ suite('XtermTerminal', () => { XTermBaseCtor = (await importAMDNodeModule('@xterm/xterm', 'lib/xterm.js')).Terminal; - const capabilityStore = store.add(new TerminalCapabilityStore()); + capabilityStore = store.add(new TerminalCapabilityStore()); xterm = store.add(instantiationService.createInstance(XtermTerminal, undefined, XTermBaseCtor, { cols: 80, rows: 30, @@ -129,6 +137,113 @@ suite('XtermTerminal', () => { strictEqual(xterm.raw.rows, 30); }); + for (const buffer of ['normal', 'alternate'] as const) { + test(`clearBuffer should ${buffer === 'normal' ? 'clear rich and partial command history including scrollback' : 'preserve normal-buffer command history and decorations when clearing the alternate buffer'}`, async () => { + class TestTerminal extends XTermBaseCtor { + override registerDecoration(options: IDecorationOptions): IDecoration | undefined { + const disposeListeners = new Set<() => unknown>(); + let isDisposed = false; + return { + marker: options.marker, + options, + get isDisposed() { return isDisposed; }, + dispose: () => { + isDisposed = true; + for (const listener of disposeListeners) { + listener(); + } + disposeListeners.clear(); + }, + onDispose: (listener: () => unknown) => { + disposeListeners.add(listener); + return { dispose: () => disposeListeners.delete(listener) }; + }, + onRender: (listener: (element: HTMLElement) => unknown) => { + listener(document.createElement('div')); + return { dispose() { } }; + } + } as unknown as IDecoration; + } + } + capabilityStore = store.add(new TerminalCapabilityStore()); + xterm = store.add(instantiationService.createInstance(XtermTerminal, undefined, TestTerminal, { + cols: 80, + rows: 30, + xtermColorProvider: { getBackgroundColor: () => undefined }, + capabilities: capabilityStore, + disableShellIntegrationReporting: true, + xtermAddonImporter: new TestXtermAddonImporter(), + }, undefined)); + const commandDetection = store.add(instantiationService.createInstance(CommandDetectionCapability, xterm.raw)); + const onDidExecuteText = store.add(new Emitter()); + const partialCommandDetection = store.add(new PartialCommandDetectionCapability(xterm.raw, onDidExecuteText.event)); + capabilityStore.add(TerminalCapability.CommandDetection, commandDetection); + capabilityStore.add(TerminalCapability.PartialCommandDetection, partialCommandDetection); + + xterm.raw.registerMarker(0); + commandDetection.handlePromptStart(); + await write('$ '); + commandDetection.handleCommandStart(); + await write('echo test'); + commandDetection.handleCommandExecuted(); + await write('\r\noutput\r\n'); + commandDetection.handleCommandFinished(0); + + await write('partial'); + xterm.raw.input('\r'); + await write('\r\n'); + await write('line\r\n'.repeat(xterm.raw.rows)); + commandDetection.handlePromptStart(); + await write('$ '); + commandDetection.handleCommandStart(); + + strictEqual(xterm.raw.buffer.active.baseY > 0, true); + strictEqual(commandDetection.commands.length, 1); + strictEqual(partialCommandDetection.commands.length, 1); + const decorations = (xterm.decorationAddon as unknown as { _decorations: Map })._decorations; + const clearedCommandMarkerId = commandDetection.commands[0].marker!.id; + strictEqual(decorations.has(clearedCommandMarkerId), true); + const invalidatedCommands: ITerminalCommand[] = []; + store.add(commandDetection.onCommandInvalidated(commands => invalidatedCommands.push(...commands))); + + const command = commandDetection.commands[0]; + const partialMarker = partialCommandDetection.commands[0]; + const decoration = decorations.get(clearedCommandMarkerId); + const normalBufferLines = Array.from({ length: xterm.raw.buffer.normal.length }, (_, i) => xterm.raw.buffer.normal.getLine(i)!.translateToString()); + const currentCommandStartMarker = commandDetection.currentCommand.commandStartMarker; + ok(currentCommandStartMarker); + if (buffer === 'alternate') { + await write('\x1b[?1049h'); + await write('alternate output\r\n'); + strictEqual(xterm.raw.buffer.active.type, 'alternate'); + } + + xterm.clearBuffer(); + + if (buffer === 'alternate') { + strictEqual(xterm.raw.buffer.active.cursorY, 0); + await write('\x1b[?1049l'); + strictEqual(xterm.raw.buffer.active.type, 'normal'); + deepStrictEqual(Array.from({ length: xterm.raw.buffer.normal.length }, (_, i) => xterm.raw.buffer.normal.getLine(i)!.translateToString()), normalBufferLines); + strictEqual(commandDetection.commands.length, 1); + strictEqual(commandDetection.commands[0], command); + strictEqual(partialCommandDetection.commands.length, 1); + strictEqual(partialCommandDetection.commands[0], partialMarker); + deepStrictEqual(invalidatedCommands, []); + strictEqual(command.marker!.isDisposed, false); + strictEqual(partialMarker.isDisposed, false); + strictEqual(decorations.get(clearedCommandMarkerId), decoration); + strictEqual(commandDetection.currentCommand.commandStartMarker, currentCommandStartMarker); + return; + } + + deepStrictEqual(commandDetection.commands, []); + deepStrictEqual(partialCommandDetection.commands, []); + deepStrictEqual(invalidatedCommands.map(e => e.command), ['echo test']); + strictEqual(decorations.has(clearedCommandMarkerId), false); + }); + } + suite('fontRendering', () => { async function setTerminalConfiguration(configuration: Partial): Promise { await configurationService.setUserConfiguration('terminal.integrated', { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/sandboxSettingsReader.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/sandboxSettingsReader.ts index 048b022e6606ce..965e7483519713 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/sandboxSettingsReader.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/sandboxSettingsReader.ts @@ -17,6 +17,8 @@ export const SANDBOX_SETTING_KEYS: readonly string[] = [ AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands, AgentSandboxSettingId.AgentSandboxMcpServers, AgentSandboxSettingId.AgentSandboxLspServers, + AgentSandboxSettingId.AgentSandboxAuthenticateGit, + AgentSandboxSettingId.AgentSandboxAuthenticateGh, AgentSandboxSettingId.AgentSandboxAllowDevToolAccess, AgentSandboxSettingId.AgentSandboxUserConfiguredPaths, AgentSandboxSettingId.AgentSandboxLinuxFileSystem, diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index 7ac8ed474a0607..f02cfabbb7edf3 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -9,7 +9,7 @@ import { localize } from '../../../../../nls.js'; import { type IConfigurationPropertySchema } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../../platform/sandbox/common/settings.js'; import { SandboxSettingsResolutionHelper } from '../../../../../platform/sandbox/common/sandboxSettingsResolutionHelper.js'; -import { COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ALLOW_DEV_TOOL_ACCESS_KEY, COPILOT_SANDBOX_ALLOW_LOCAL_NETWORK_KEY, COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY, COPILOT_SANDBOX_ENABLED_KEY, COPILOT_SANDBOX_LSP_SERVERS_KEY, COPILOT_SANDBOX_MCP_SERVERS_KEY } from '../../../../../platform/policy/common/copilotManagedSettings.js'; +import { COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ALLOW_DEV_TOOL_ACCESS_KEY, COPILOT_SANDBOX_ALLOW_LOCAL_NETWORK_KEY, COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY, COPILOT_SANDBOX_AUTH_GH_KEY, COPILOT_SANDBOX_AUTH_GIT_KEY, COPILOT_SANDBOX_ENABLED_KEY, COPILOT_SANDBOX_LSP_SERVERS_KEY, COPILOT_SANDBOX_MCP_SERVERS_KEY } from '../../../../../platform/policy/common/copilotManagedSettings.js'; import { gitAutoApproveRules } from '../../../../../platform/terminal/common/autoApprove/gitAutoApproveRules.js'; import { powershellAutoApproveRules } from '../../../../../platform/terminal/common/autoApprove/powershellAutoApproveRules.js'; import { sortAutoApproveRules } from '../../../../../platform/terminal/common/autoApprove/sortAutoApproveRules.js'; @@ -644,6 +644,24 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary SandboxSettingsResolutionHelper.resolveAllowAccess(undefined, read(COPILOT_SANDBOX_AUTH_GIT_KEY) !== false), + order: 51, + keywords: ['Sandbox', 'sandboxing'], + markdownDescription: localize('agentSandbox.authenticateGit', "Use your credentials for HTTPS git operations inside the sandbox. Applies only to the Copilot Agent Host harness."), + type: 'boolean', + default: true, + restricted: true, + }, + [AgentSandboxSettingId.AgentSandboxAuthenticateGh]: { + managedSettingsPresentation: read => SandboxSettingsResolutionHelper.resolveAllowAccess(undefined, read(COPILOT_SANDBOX_AUTH_GH_KEY) !== false), + order: 52, + keywords: ['Sandbox', 'sandboxing'], + markdownDescription: localize('agentSandbox.authenticateGh', "Use your GitHub account for gh commands inside the sandbox. Applies only to the Copilot Agent Host harness."), + type: 'boolean', + default: true, + restricted: true, + }, [AgentSandboxSettingId.AgentSandboxAllowDevToolAccess]: { managedSettingsPresentation: read => SandboxSettingsResolutionHelper.resolveAllowAccess(undefined, read(COPILOT_SANDBOX_ALLOW_DEV_TOOL_ACCESS_KEY) !== false), order: 60, diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/agentHostSandboxForwarder.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/agentHostSandboxForwarder.test.ts index 42a38a1185f7ef..76a1670f603c4d 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/agentHostSandboxForwarder.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/agentHostSandboxForwarder.test.ts @@ -354,6 +354,8 @@ suite('AgentHostSandboxForwarder', () => { for (const [key, hostKey] of [ [AgentSandboxSettingId.AgentSandboxMcpServers, AgentHostSandboxKey.SandboxMcpServers], [AgentSandboxSettingId.AgentSandboxLspServers, AgentHostSandboxKey.SandboxLspServers], + [AgentSandboxSettingId.AgentSandboxAuthenticateGit, AgentHostSandboxKey.AuthenticateGit], + [AgentSandboxSettingId.AgentSandboxAuthenticateGh, AgentHostSandboxKey.AuthenticateGh], [AgentSandboxSettingId.AgentSandboxAllowDevToolAccess, AgentHostSandboxKey.AllowDevToolAccess], [AgentSandboxSettingId.AgentSandboxAllowLocalNetwork, AgentHostSandboxKey.AllowLocalNetwork], ]) { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/sandboxSettingsReader.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/sandboxSettingsReader.test.ts index c8c8cf667ac124..33bb161d28c6ec 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/sandboxSettingsReader.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/sandboxSettingsReader.test.ts @@ -51,6 +51,8 @@ suite('sandboxSettingsReader', () => { [AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands, AgentHostSandboxKey.AllowUnsandboxedCommands, true], [AgentSandboxSettingId.AgentSandboxMcpServers, AgentHostSandboxKey.SandboxMcpServers, true], [AgentSandboxSettingId.AgentSandboxLspServers, AgentHostSandboxKey.SandboxLspServers, true], + [AgentSandboxSettingId.AgentSandboxAuthenticateGit, AgentHostSandboxKey.AuthenticateGit, true], + [AgentSandboxSettingId.AgentSandboxAuthenticateGh, AgentHostSandboxKey.AuthenticateGh, true], [AgentSandboxSettingId.AgentSandboxAllowDevToolAccess, AgentHostSandboxKey.AllowDevToolAccess, true], [AgentSandboxSettingId.AgentSandboxAllowLocalNetwork, AgentHostSandboxKey.AllowLocalNetwork, false], ] as const) { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/terminalChatAgentToolsConfiguration.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/terminalChatAgentToolsConfiguration.test.ts index cda70bf4c3c82d..46d9222c1b71fd 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/terminalChatAgentToolsConfiguration.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/terminalChatAgentToolsConfiguration.test.ts @@ -10,7 +10,7 @@ import { DefaultConfiguration } from '../../../../../../platform/configuration/c import { AgentSandboxSettingId } from '../../../../../../platform/sandbox/common/settings.js'; import { Extensions, IConfigurationNode, IConfigurationRegistry } from '../../../../../../platform/configuration/common/configurationRegistry.js'; import { NullLogService } from '../../../../../../platform/log/common/log.js'; -import { COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY, COPILOT_SANDBOX_ENABLED_KEY } from '../../../../../../platform/policy/common/copilotManagedSettings.js'; +import { COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY, COPILOT_SANDBOX_AUTH_GH_KEY, COPILOT_SANDBOX_AUTH_GIT_KEY, COPILOT_SANDBOX_ENABLED_KEY } from '../../../../../../platform/policy/common/copilotManagedSettings.js'; import { Registry } from '../../../../../../platform/registry/common/platform.js'; import { WorkspaceConfigurationModelParser } from '../../../../../services/configuration/common/configurationModels.js'; import { sandboxAllowNetworkMigration, terminalChatAgentToolsConfiguration, TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; @@ -59,6 +59,8 @@ suite('Terminal chat agent tools configuration', () => { [AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands, 30], [AgentSandboxSettingId.AgentSandboxMcpServers, 40], [AgentSandboxSettingId.AgentSandboxLspServers, 50], + [AgentSandboxSettingId.AgentSandboxAuthenticateGit, 51], + [AgentSandboxSettingId.AgentSandboxAuthenticateGh, 52], [AgentSandboxSettingId.AgentSandboxAllowDevToolAccess, 60], [AgentSandboxSettingId.AgentSandboxUserConfiguredPaths, 65], [AgentSandboxSettingId.AgentSandboxLinuxFileSystem, 70], @@ -86,6 +88,25 @@ suite('Terminal chat agent tools configuration', () => { assert.deepStrictEqual(values, [true, false, true]); }); + test('presents managed credential denial without locking allowed or unmanaged preferences', () => { + const settings = [ + [AgentSandboxSettingId.AgentSandboxAuthenticateGit, COPILOT_SANDBOX_AUTH_GIT_KEY, 'Use your credentials for HTTPS git operations inside the sandbox.'], + [AgentSandboxSettingId.AgentSandboxAuthenticateGh, COPILOT_SANDBOX_AUTH_GH_KEY, 'Use your GitHub account for gh commands inside the sandbox.'], + ] as const; + assert.deepStrictEqual(settings.map(([id, managedKey]) => { + const setting = terminalChatAgentToolsConfiguration[id]; + return { + description: setting.markdownDescription, + policy: setting.policy, + presentation: [undefined, false, true].map(value => setting.managedSettingsPresentation?.(key => key === managedKey ? value : undefined)), + }; + }), settings.map(([, , description]) => ({ + description: `${description} Applies only to the Copilot Agent Host harness.`, + policy: undefined, + presentation: [undefined, false, undefined], + }))); + }); + test('registers Copilot user-configured paths and warns about legacy filesystem settings', () => { const setting = terminalChatAgentToolsConfiguration[AgentSandboxSettingId.AgentSandboxUserConfiguredPaths]; assert.deepStrictEqual({ @@ -180,6 +201,8 @@ suite('Terminal chat agent tools configuration', () => { AgentSandboxSettingId.AgentSandboxAllowLocalNetwork, AgentSandboxSettingId.AgentSandboxMcpServers, AgentSandboxSettingId.AgentSandboxLspServers, + AgentSandboxSettingId.AgentSandboxAuthenticateGit, + AgentSandboxSettingId.AgentSandboxAuthenticateGh, AgentSandboxSettingId.AgentSandboxAllowDevToolAccess, AgentSandboxSettingId.AgentSandboxUserConfiguredPaths, ]; @@ -205,6 +228,8 @@ suite('Terminal chat agent tools configuration', () => { for (const [key, defaultValue] of [ [AgentSandboxSettingId.AgentSandboxMcpServers, true], [AgentSandboxSettingId.AgentSandboxLspServers, true], + [AgentSandboxSettingId.AgentSandboxAuthenticateGit, true], + [AgentSandboxSettingId.AgentSandboxAuthenticateGh, true], [AgentSandboxSettingId.AgentSandboxAllowDevToolAccess, true], [AgentSandboxSettingId.AgentSandboxAllowLocalNetwork, false], ] as const) { diff --git a/src/vs/workbench/test/browser/chatPills.test.ts b/src/vs/workbench/test/browser/chatPills.test.ts index 7f3a6b367be8b3..0161d753be20b9 100644 --- a/src/vs/workbench/test/browser/chatPills.test.ts +++ b/src/vs/workbench/test/browser/chatPills.test.ts @@ -493,6 +493,33 @@ suite('ChatPills', () => { }, { preserved: true, changed: true, copied: ['new'] }); }); + test('keeps hover content an updated entry still owns and releases it when the entry is removed', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + let released = 0; + // The entry's source keeps this content alive across updates, like the live output of a background shell. + const release = { dispose: () => { released++; } }; + const entry = (elapsed: string): IChatPillEntry => ({ + id: 'shell', label: 'Run tests', ariaDescription: elapsed, + hover: { ...getChatPillLocationHover('npm test'), disposable: release }, + open: () => { }, + }); + const sections = observableValue('shells', [{ title: 'Shells', entries: [entry('1s')] }]); + const viewItem = store.add(instantiationService.createInstance(ChatDropdownPillActionViewItem, store.add(new Action('shells', 'Background Shells')), {}, sections, { + widgetId: 'shells', icon: Codicon.terminal, title: 'Background Shells', + summaryLabel: count => `${count} Background Shells`, summaryAriaLabel: count => `Show ${count} background shells`, + singleEntry: ChatPillSingleEntry.Summary, + })); + viewItem.render(mainWindow.document.createElement('div')); + getDropdownPillItems.call(viewItem); + sections.set([{ title: 'Shells', entries: [entry('2s')] }], undefined); + getDropdownPillItems.call(viewItem); + // Nothing rebuilds the dropdown's items after this update, as when the dropdown is closed. + sections.set([{ title: 'Shells', entries: [entry('3s')] }], undefined); + const afterUpdates = released; + sections.set([], undefined); + assert.deepStrictEqual({ afterUpdates, afterRemoval: released }, { afterUpdates: 0, afterRemoval: 1 }); + }); + test('uses the main DOM realm and target auxiliary window', () => { const disposables = store.add(new DisposableStore()); const iframe = mainWindow.document.createElement('iframe'); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts index e4570a881bd9a7..af3e6e436c04cc 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts @@ -244,7 +244,7 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I }()); reg.defineInstance(IChatPetService, new class extends mock() { override readonly enabled = observableValue('chatPetEnabled', false); - override readonly variant = observableValue('chatPetVariant', 'stable' as const); + override readonly color = observableValue('chatPetColor', 'stable' as const); override readonly onTheRun = observableValue('chatPetOnTheRun', false); override readonly scale = observableValue('chatPetScale', 1); override readonly unlockedAchievements = observableValue('chatPetUnlockedAchievements', []); @@ -253,7 +253,7 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I override readonly onDidUnlockAchievement = Event.None; override readonly horizontalPosition = observableValue('chatPetHorizontalPosition', undefined); override toggle() { return false; } - override setVariant() { } + override setColor() { } override setOnTheRun() { } override setScale(scale: number) { this.scale.set(scale, undefined); } override resetScale() { this.scale.set(1, undefined); } diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatMcpAuthenticationContentPart.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatMcpAuthenticationContentPart.fixture.ts new file mode 100644 index 00000000000000..bc1f41169bb803 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatMcpAuthenticationContentPart.fixture.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { Event } from '../../../../../base/common/event.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { McpServerStatus } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IAgentHostCustomizationService } from '../../../../contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.js'; +import { ChatMcpAuthenticationContentPart } from '../../../../contrib/chat/browser/widget/chatContentParts/chatMcpAuthenticationContentPart.js'; +import { IChatMcpAuthenticationRequired } from '../../../../contrib/chat/common/chatService/chatService.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; + +import '../../../../contrib/chat/browser/widget/media/chat.css'; + +const sessionResource = URI.parse('chat-session://fixture/connector-authentication'); +const runtimeServerId = 'github-copilot-connector-94d26095770df60673dd'; + +function renderAuthenticationPart(context: ComponentFixtureContext): void { + type McpServer = ReturnType[number]; + + const server = new class extends mock() { + override readonly id = runtimeServerId; + override readonly name = runtimeServerId; + override readonly displayName = 'GitHub'; + override readonly enabled = true; + override readonly status = McpServerStatus.AuthRequired; + }(); + const customizationService = new class extends mock() { + override readonly onDidChangeCustomizations = Event.None; + override getMcpServers(): readonly McpServer[] { + return [server]; + } + }(); + const instantiationService = createEditorServices(context.disposableStore, { + colorTheme: context.theme, + additionalServices: registration => { + registration.define(IMarkdownRendererService, MarkdownRendererService); + registration.defineInstance(IAgentHostCustomizationService, customizationService); + }, + }); + const data: IChatMcpAuthenticationRequired = { + kind: 'mcpAuthenticationRequired', + sessionResource, + servers: observableValue('mcpAuthenticationServers', [{ + id: runtimeServerId, + name: runtimeServerId, + resource: 'https://api.github.com', + }]), + isUsed: false, + }; + const part = context.disposableStore.add(instantiationService.createInstance(ChatMcpAuthenticationContentPart, data, {})); + + context.container.style.width = '720px'; + context.container.style.padding = '16px'; + context.container.classList.add('interactive-session'); + const response = dom.$('.interactive-item-container.interactive-response'); + const value = dom.$('.value'); + value.appendChild(part.domNode); + response.appendChild(value); + context.container.appendChild(response); +} + +export default defineThemedFixtureGroup({ path: 'chat/' }, { + ConnectorAuthentication: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: renderAuthenticationPart, + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAccessoryRig.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAccessoryRig.fixture.ts index b7c13dc72aeee9..5dc00497107f5d 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAccessoryRig.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAccessoryRig.fixture.ts @@ -7,6 +7,7 @@ import * as DOM from '../../../../../base/browser/dom.js'; import { mainWindow } from '../../../../../base/browser/window.js'; import { FileAccess } from '../../../../../base/common/network.js'; import { allChatPetAccessories, chatPetAccessories, ChatPetAccessoryIds, getChatPetAccessory, type ChatPetAccessoryId, type IChatPetAccessory } from '../../../../contrib/chat/browser/chatPetAchievements.js'; +import { ChatPetColor, chatPetColorPresets, getChatPetColorVariant } from '../../../../contrib/chat/browser/chatPetColors.js'; import { drawChatPetComposite, drawChatPetEyeAccessory, getChatPetAccessoryImageSource, hasChatPetAccessoryImageDimensions, hasChatPetBodyImageDimensions } from '../../../../contrib/chat/browser/widget/chatPetAccessoryRenderer.js'; import { getChatPetFrameDurations, getChatPetSpriteName, doesChatPetStateTrackCursor, CHAT_PET_SING_FIXED_ORIENTATION_DECORATIONS, drawChatPetAchievementStar, type ChatPetState } from '../../../../contrib/chat/browser/widget/chatPetWidget.js'; import { getChatPetReducedMotionRigFrame } from '../../../../contrib/chat/browser/widget/chatPetAccessoryRig.js'; @@ -160,6 +161,14 @@ const allChatPetStates: readonly ChatPetState[] = [ ]; export default defineThemedFixtureGroup({ path: 'chat/chatPetAccessoryRig/' }, { + ColorPresets: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: renderColorPresets, + }), + CustomColorCriticalPoses: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderCriticalPoses(ctx, '#cc44aa'), + }), CriticalPoses: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: true }, render: renderCriticalPoses, @@ -186,7 +195,39 @@ export default defineThemedFixtureGroup({ path: 'chat/chatPetAccessoryRig/' }, { }), }); -async function renderCriticalPoses(ctx: ComponentFixtureContext): Promise { +async function renderColorPresets(ctx: ComponentFixtureContext): Promise { + configureChatPetFixtureFileRoot(ctx.disposableStore); + ctx.container.style.width = '760px'; + ctx.container.style.padding = 'var(--vscode-spacing-size240)'; + ctx.container.style.background = 'var(--vscode-editor-background)'; + ctx.container.style.color = 'var(--vscode-foreground)'; + const grid = DOM.append(ctx.container, DOM.$('div')); + grid.style.display = 'grid'; + grid.style.gridTemplateColumns = 'repeat(5, 1fr)'; + grid.style.gap = 'var(--vscode-spacing-size160)'; + const bodies = new Map(await Promise.all((['stable', 'insiders'] as const).map(async variant => [ + variant, + await loadImage(FileAccess.asBrowserUri(`vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-idle-${variant}-96.png`).toString(true)), + ] as const))); + for (const preset of chatPetColorPresets) { + const card = DOM.append(grid, DOM.$('div')); + DOM.append(card, DOM.$('div')).textContent = preset.label; + const canvas = DOM.append(card, DOM.$('canvas')); + canvas.width = 96; + canvas.height = 96; + canvas.style.imageRendering = 'pixelated'; + canvas.setAttribute('aria-hidden', 'true'); + const image = bodies.get(getChatPetColorVariant(preset.color)); + const context = canvas.getContext('2d'); + if (!image || !context) { + throw new Error('Unable to render the pet color fixture.'); + } + context.imageSmoothingEnabled = false; + drawChatPetComposite(context, image, undefined, 0, 0, 96, 96, 'right', 'idle', undefined, true, true, false, preset.color); + } +} + +async function renderCriticalPoses(ctx: ComponentFixtureContext, color?: ChatPetColor): Promise { configureChatPetFixtureFileRoot(ctx.disposableStore); ctx.container.style.width = '1100px'; @@ -279,6 +320,7 @@ async function renderCriticalPoses(ctx: ComponentFixtureContext): Promise true, accessory.eyeAccessoryMirrorsWithFacing !== false, accessory.coversAntennae === true, + color, ); } } diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAchievementsEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAchievementsEditor.fixture.ts index 02a2ce34bebc91..0e66284412c68b 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAchievementsEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatPetAchievementsEditor.fixture.ts @@ -8,16 +8,19 @@ import { mainWindow } from '../../../../../base/browser/window.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ChatPetAchievementsEditor } from '../../../../contrib/chat/browser/chatPetAchievementsEditor.js'; -import { ChatPetAchievementsEditorInput } from '../../../../contrib/chat/browser/chatPetAchievementsEditorInput.js'; +import { ChatPetAchievementsEditorInput, ChatPetCustomizationTab } from '../../../../contrib/chat/browser/chatPetAchievementsEditorInput.js'; import { chatPetAchievements, ChatPetAccessoryIds, ChatPetAchievementIds } from '../../../../contrib/chat/browser/chatPetAchievements.js'; import { IChatPetService } from '../../../../contrib/chat/browser/chatPetService.js'; import { IEditorGroup } from '../../../../services/editor/common/editorGroupsService.js'; +import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; +import { TestChatEntitlementService } from '../../../common/workbenchTestServices.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; import { configureChatPetFixtureFileRoot, FixtureChatPetService, IChatPetFixtureOptions } from './chatPetFixtureUtils.js'; interface IAchievementsEditorFixtureOptions extends IChatPetFixtureOptions { readonly width?: number; readonly height?: number; + readonly tab?: ChatPetCustomizationTab; } function createMockEditorGroup(): IEditorGroup { @@ -39,16 +42,62 @@ async function renderAchievementsEditor(context: ComponentFixtureContext, option additionalServices: registry => { registerWorkbenchServices(registry); registry.defineInstance(IChatPetService, chatPetService); + registry.defineInstance(IChatEntitlementService, new TestChatEntitlementService()); }, }); const editor = context.disposableStore.add(instantiationService.createInstance(ChatPetAchievementsEditor, createMockEditorGroup())); editor.create(context.container); editor.layout(new Dimension(width, height)); const input = context.disposableStore.add(ChatPetAchievementsEditorInput.getOrCreate()); - await editor.setInput(input, undefined, {}, CancellationToken.None); + await editor.setInput(input, { tab: options.tab }, {}, CancellationToken.None); + if (options.tab === 'color') { + await new Promise((resolve, reject) => { + const deadline = mainWindow.performance.now() + 5_000; + const check = () => { + const previews = Array.from(context.container.querySelectorAll('.chat-pet-color-preview')); + const scrollbars = Array.from(context.container.querySelectorAll('.scrollbar')); + const scrollbarsSettled = scrollbars.every(scrollbar => scrollbar.classList.contains('invisible') && mainWindow.getComputedStyle(scrollbar).opacity === '0'); + if (scrollbarsSettled && previews.length === 11 && previews.every(preview => preview.getContext('2d')?.getImageData(0, 0, preview.width, preview.height).data.some((value, index) => index % 4 === 3 && value !== 0))) { + resolve(); + } else if (mainWindow.performance.now() >= deadline) { + reject(new Error('Color customization previews and scrollbars did not finish rendering.')); + } else { + mainWindow.requestAnimationFrame(check); + } + }; + check(); + }); + } } export default defineThemedFixtureGroup({ path: 'chat/petAchievements/standaloneModal/' }, { + ColorLocked: defineComponentFixture({ + labels: { kind: 'screenshot' }, + virtualTime: { enabled: false }, + render: context => renderAchievementsEditor(context, { enabled: true, tab: 'color' }), + }), + ColorUnlocked: defineComponentFixture({ + labels: { kind: 'screenshot' }, + virtualTime: { enabled: false }, + render: context => renderAchievementsEditor(context, { + enabled: true, + tab: 'color', + unlockedAchievements: [ChatPetAchievementIds.Blobby], + color: '#a277e6', + }), + }), + ColorNarrow: defineComponentFixture({ + labels: { kind: 'screenshot' }, + virtualTime: { enabled: false }, + render: context => renderAchievementsEditor(context, { + enabled: true, + tab: 'color', + unlockedAchievements: [ChatPetAchievementIds.Blobby], + color: '#abc123', + width: 360, + height: 640, + }), + }), AllLocked: defineComponentFixture({ labels: { kind: 'screenshot' }, render: context => renderAchievementsEditor(context, { enabled: true }), diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatPetFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatPetFixtureUtils.ts index 7f6aa6f724c399..67f736b67c82b9 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatPetFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatPetFixtureUtils.ts @@ -9,6 +9,7 @@ import { Disposable, DisposableStore, toDisposable } from '../../../../../base/c import { IObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; import { ChatPetAccessoryId, ChatPetAchievementId } from '../../../../contrib/chat/browser/chatPetAchievements.js'; import { ChatPetVariant, IChatPetService } from '../../../../contrib/chat/browser/chatPetService.js'; +import { ChatPetColor } from '../../../../contrib/chat/browser/chatPetColors.js'; export interface IChatPetFixtureOptions { readonly enabled: boolean; @@ -16,6 +17,7 @@ export interface IChatPetFixtureOptions { readonly unseenAchievements?: readonly ChatPetAchievementId[]; readonly selectedAccessory?: ChatPetAccessoryId; readonly variant?: ChatPetVariant; + readonly color?: ChatPetColor; } export class FixtureChatPetService extends Disposable implements IChatPetService { @@ -24,8 +26,8 @@ export class FixtureChatPetService extends Disposable implements IChatPetService private readonly enabledValue: ISettableObservable; readonly enabled: IObservable; - private readonly variantValue: ISettableObservable; - readonly variant: IObservable; + private readonly colorValue: ISettableObservable; + readonly color: IObservable; private readonly onTheRunValue = observableValue(this, false); readonly onTheRun: IObservable = this.onTheRunValue; private readonly scaleValue = observableValue(this, 1); @@ -44,8 +46,8 @@ export class FixtureChatPetService extends Disposable implements IChatPetService super(); this.enabledValue = observableValue(this, options.enabled); this.enabled = this.enabledValue; - this.variantValue = observableValue(this, options.variant ?? 'stable'); - this.variant = this.variantValue; + this.colorValue = observableValue(this, options.color ?? options.variant ?? 'stable'); + this.color = this.colorValue; this.unlockedAchievementsValue = observableValue(this, options.unlockedAchievements ?? []); this.unlockedAchievements = this.unlockedAchievementsValue; this.unseenAchievementsValue = observableValue(this, options.unseenAchievements ?? []); @@ -60,8 +62,8 @@ export class FixtureChatPetService extends Disposable implements IChatPetService return enabled; } - setVariant(variant: ChatPetVariant): void { - this.variantValue.set(variant, undefined); + setColor(color: ChatPetColor): void { + this.colorValue.set(color, undefined); } setOnTheRun(onTheRun: boolean): void { diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/customizationMigrationDashboard.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/customizationMigrationDashboard.fixture.ts index 58f870121b8e75..096d6deb88134f 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/customizationMigrationDashboard.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/customizationMigrationDashboard.fixture.ts @@ -163,6 +163,7 @@ function renderDashboard(ctx: ComponentFixtureContext, width: number, state: 'ov const dashboard = disposableStore.add(instantiationService.createInstance(CustomizationMigrationDashboard, container, { actionClicked: () => { }, configureLocations: () => { }, + migrateWithAgent: () => { }, migrateCategory: () => { }, setItemSelected: () => { }, showItemActions: () => { }, diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 51ca17ddc9d6e1..b35241b11b16df 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -31,7 +31,7 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/e67ff22f55b85ba448486d56e6e7e01e060fa8f7503c72638ef1b999015d829f) #### chat/petAchievements/standaloneModal/chatPetAchievementsEditor/MixedSelected/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/b4e697f87416c5f384d7dd0338fcf76ca128bf695efa96ed885abc124a8736e1) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/25cedd0bedf186071991ad692608c4a88280cf720cfa89e1a93776659ab86bc1) #### comments/commentTree/CommentTreeMetadataZoomed/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/a81446b88a24acb43f7713d0d84c921bb9d79d4c06448e77b4baae5c1f5c9026) @@ -91,7 +91,7 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/2945563f28440c62c812e2c80ac40620807822ec8b691bac9b79a85610b4fc50) #### sessions/accountMenu/petAchievementBadges/chatPetAchievementBadges/AllBadges/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/fe4b95bf8348637bba9f8c0dda791924e6c67fd7b5d173398f9b2c0bfc9f7071) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a92e0c554f09d1e7d3b864d0b1cd5c70c057b2acbe4fb67daadeba0cb8351eb8) #### sessions/chat/newWidget/newChatWidget/MigrationsBackground/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/8e385d1dbb4f325dfd536c0965773398e726bd7868e01949afb2c740a9ef84af) diff --git a/test/smoke/README.md b/test/smoke/README.md index 05ab9798aa4e98..99bd9bd2306892 100644 --- a/test/smoke/README.md +++ b/test/smoke/README.md @@ -75,6 +75,8 @@ The Agents Window Dev Container suites require a reachable Linux Docker daemon. Each selected Dev Container suite checks Docker in its first setup hook, before starting fixture resources, with a fresh `docker info` probe and a 60-second timeout. The probe runs asynchronously and logs its elapsed time and failure reason. Filtered-out suites do not probe Docker. Missing Docker fails the suite on Linux and for an explicitly requested Tunnel test; optional local runs on other platforms are skipped. +The shared container fixture reads the selected desktop's product quality and commit. It first tries to install the exact commit's CLI into the product's commit-keyed server directory, including the runtime's `-dev` suffix when running from source. Unpublished commits fall back explicitly to the latest CLI in the same quality channel, installed in the quality-specific legacy directory so the production installer can discover it. Source runs without a product commit use latest directly. Downloads are checked before extraction, and installation failures fail container preparation. + Run the local and SSH Dev Container cases: ```bash diff --git a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts index 48a93c43801c7e..764f4e2e63f6d9 100644 --- a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts +++ b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts @@ -10,7 +10,7 @@ import * as path from 'path'; import { Application, ApplicationOptions, Logger, Quality } from '../../../../automation'; import { createApp, dumpFailureDiagnostics, getCopilotSmokeTestEnv, getMockLlmServerPath, getMockLlmServerUrl, installAppAfterHandler, installDiagnosticsHandler, MockLlmServer, suiteCrashPath, suiteLogsPath } from '../../utils'; import { shellEchoResponseMatcher, shellEchoScenario } from '../chat/shellScenarios'; -import { createRemoteDevContainerFixture, getTunnelSmokeTestAvailability, IRemoteDevContainerFixture, RemoteDevContainerTransport } from './remoteDevContainerFixtures'; +import { createRemoteDevContainerFixture, getDevContainerCliInstallCommand, getTunnelSmokeTestAvailability, IRemoteDevContainerFixture, RemoteDevContainerTransport } from './remoteDevContainerFixtures'; // Selector for the send button in the Agents Window new-session homepage. // Kept in sync with `SEND_BUTTON_ENABLED` in `test/automation/src/agentsWindow.ts` @@ -45,7 +45,7 @@ const AGENT_HOST_REPLACEMENT_SCENARIO_ID = 'smoke-agent-host-session-replacement const AGENT_HOST_REPLACEMENT_REPLY = 'MOCKED_AGENT_HOST_REPLACEMENT_RESPONSE'; const DEV_CONTAINER_SCENARIO_ID = 'smoke-dev-container-agent-host'; -function prepareDevContainerWorkspace(workspacePath: string, port: number): void { +function prepareDevContainerWorkspace(workspacePath: string, port: number, options: ApplicationOptions): void { const configDirectory = path.join(workspacePath, '.devcontainer'); const mockServerUrl = `http://vscode-smoke.test:${port}`; fs.mkdirSync(configDirectory, { recursive: true }); @@ -61,13 +61,7 @@ function prepareDevContainerWorkspace(workspacePath: string, port: number): void VSCODE_AGENT_HOST_CAPI_URL_OVERRIDE: mockServerUrl, VSCODE_SMOKE_TEST_PROXY_HEADER: process.env.VSCODE_SMOKE_TEST_PROXY_HEADER ?? 'dev-container', }, - postCreateCommand: [ - 'set -e', - 'case "$(uname -m)" in x86_64) cli_arch=x64 ;; aarch64|arm64) cli_arch=arm64 ;; *) exit 1 ;; esac', - 'mkdir -p ~/.vscode-cli-insider', - 'curl -fsSL "https://update.code.visualstudio.com/latest/cli-linux-${cli_arch}/insider" | tar xz -C ~/.vscode-cli-insider', - 'chmod +x ~/.vscode-cli-insider/code-insiders', - ].join(' && '), + postCreateCommand: getDevContainerCliInstallCommand(options.codePath), }, null, 2)); } @@ -225,7 +219,7 @@ export function setup(logger: Logger, quality: Quality) { 'chat.agentHost.devContainer.worktree.enabled': false, 'chat.remoteAgentHostsEnabled': true, }, - prepareWorkspace: workspacePath => prepareDevContainerWorkspace(workspacePath, devContainer.mockServer.port), + prepareWorkspace: (workspacePath, options) => prepareDevContainerWorkspace(workspacePath, devContainer.mockServer.port, options), cleanupWorkspace: cleanupDevContainerWorkspace, }); @@ -329,7 +323,7 @@ export function setup(logger: Logger, quality: Quality) { 'chat.agentHost.devContainer.worktree.enabled': false, 'chat.remoteAgentHostsEnabled': true, }, - prepareWorkspace: workspacePath => prepareDevContainerWorkspace(workspacePath, context.mockServer.port), + prepareWorkspace: (workspacePath, options) => prepareDevContainerWorkspace(workspacePath, context.mockServer.port, options), cleanupWorkspace: cleanupDevContainerWorkspace, }); @@ -715,7 +709,7 @@ function setupAgentHostSuite(logger: Logger, config: { readonly registerScenarios: (api: { ScenarioBuilder: any; registerScenario: (id: string, scenario: unknown) => void }) => void; readonly settings: Record; readonly remoteTransport?: RemoteDevContainerTransport; - readonly prepareWorkspace?: (workspacePath: string) => Promise | void; + readonly prepareWorkspace?: (workspacePath: string, options: ApplicationOptions) => Promise | void; readonly cleanupWorkspace?: (workspacePath: string) => Promise | void; }): IAgentHostSuiteContext { let mockServer: MockLlmServer; @@ -755,7 +749,7 @@ function setupAgentHostSuite(logger: Logger, config: { logsPath = defaultOptions.logsPath; workspacePath = defaultOptions.workspacePath; assert.ok(workspacePath, 'Expected an Agents Window smoke workspace'); - await config.prepareWorkspace?.(workspacePath); + await config.prepareWorkspace?.(workspacePath, defaultOptions); if (config.remoteTransport) { assert.ok(defaultOptions.userDataDir, 'Expected an isolated smoke user-data directory'); fixtureDataPath = fs.mkdtempSync(path.join(path.dirname(defaultOptions.userDataDir), 'remote-devcontainer-')); diff --git a/test/smoke/src/areas/agentsWindow/remoteDevContainerFixtures.ts b/test/smoke/src/areas/agentsWindow/remoteDevContainerFixtures.ts index 56a9c7f9094071..a88d2bebea9062 100644 --- a/test/smoke/src/areas/agentsWindow/remoteDevContainerFixtures.ts +++ b/test/smoke/src/areas/agentsWindow/remoteDevContainerFixtures.ts @@ -46,6 +46,61 @@ const startupTimeout = 90_000; const fakeModelToken = 'smoketest-fake-agent-host-token'; const tunnelTokenEnvironmentKey = 'VSCODE_SMOKE_TEST_TUNNEL_TOKEN'; +/** Provision a matching CLI when published, otherwise a same-quality fallback for unpublished builds. */ +export function getDevContainerCliInstallCommand(codePath?: string): string { + const productPath = codePath ? getBuildProductPath(codePath) : path.join(repositoryRoot, 'product.json'); + const product: { quality?: string; commit?: string; serverDataFolderName?: string } = JSON.parse(fs.readFileSync(productPath, 'utf8')); + const overridesPath = path.join(repositoryRoot, 'product.overrides.json'); + if (!codePath && fs.existsSync(overridesPath)) { + Object.assign(product, JSON.parse(fs.readFileSync(overridesPath, 'utf8'))); + } + if (!codePath && product.serverDataFolderName) { + product.serverDataFolderName += '-dev'; + } + const quality = product.quality ?? 'insider'; + assertCliQuality(quality); + const archive = quality === 'stable' ? 'code' : quality === 'exploration' ? 'code-exploration' : 'code-insiders'; + const legacyDirectory = quality === 'stable' ? '.vscode-cli' : `.vscode-cli-${quality}`; + const serverDirectory = product.serverDataFolderName ?? '.vscode-server-oss'; + if (!/^[\w.-]+$/.test(serverDirectory)) { + throw new Error(`Invalid CLI server data folder: ${serverDirectory}`); + } + if (product.commit !== undefined && !/^[0-9a-f]{40}$/i.test(product.commit)) { + throw new Error(`Invalid CLI commit: ${product.commit}`); + } + const commit = product.commit?.toLowerCase(); + const latestDownload = `curl -fsSL "https://update.code.visualstudio.com/latest/cli-linux-\${cli_arch}/${quality}" -o "$cli_tmp/cli.tar.gz"`; + return [ + 'set -e', + 'case "$(uname -m)" in x86_64) cli_arch=x64 ;; aarch64|arm64) cli_arch=arm64 ;; *) exit 1 ;; esac', + 'cli_tmp=$(mktemp -d)', + 'trap \'rm -rf "$cli_tmp"\' EXIT', + `cli_dir="$HOME/${legacyDirectory}"`, + `cli_bin="$cli_dir/${archive}"`, + ...(commit ? [ + `if curl -fsSL "https://update.code.visualstudio.com/commit:${commit}/cli-linux-\${cli_arch}/${quality}" -o "$cli_tmp/cli.tar.gz"; then`, + `cli_dir="$HOME/${serverDirectory}"`, + `cli_bin="$cli_dir/${archive}-${commit}"`, + `echo "Installing ${quality} CLI matching desktop commit ${commit}"`, + 'else', + `echo "CLI for desktop commit ${commit} unavailable; falling back to latest ${quality} CLI" >&2`, + latestDownload, + 'fi', + ] : [latestDownload]), + 'mkdir -p "$cli_dir"', + 'tar -xzf "$cli_tmp/cli.tar.gz" -C "$cli_tmp"', + `mv "$cli_tmp/${archive}" "$cli_bin"`, + 'chmod +x "$cli_bin"', + '"$cli_bin" --version', + ].join('\n'); +} + +function assertCliQuality(quality: string): void { + if (quality !== 'stable' && quality !== 'insider' && quality !== 'exploration') { + throw new Error(`Unsupported CLI quality: ${quality}`); + } +} + async function readConnectionLogTail(file: string): Promise { const handle = await fs.promises.open(file, 'r'); try { diff --git a/test/unit/README.md b/test/unit/README.md index 0a825b3c0b74cc..c630c4aa74ed06 100644 --- a/test/unit/README.md +++ b/test/unit/README.md @@ -29,6 +29,12 @@ Unit tests from layers `common` and `browser` are run inside `chromium`, `webkit npm run test-node -- --run src/vs/editor/test/browser/controller/cursor.test.ts +The default test timeout is 5 seconds locally and 30 seconds in CI, matching the Electron runner. CI is detected through `BUILD_ARTIFACTSTAGINGDIRECTORY` or `GITHUB_WORKSPACE`. + +Use `--timeout ` (or `-t `) to override the default, or `--timeout 0` to disable timeouts. Explicit suite and test timeouts still take precedence. + + npm run test-node -- --timeout 10000 --run src/vs/base/test/common/async.test.ts + ## Coverage The following command will create a `coverage` folder in the `.build` folder at the root of the workspace: diff --git a/test/unit/node/index.js b/test/unit/node/index.js index 2e80f5eb9d7cba..f01f27becd3f54 100644 --- a/test/unit/node/index.js +++ b/test/unit/node/index.js @@ -20,13 +20,14 @@ import { fileURLToPath, pathToFileURL } from 'url'; import semver from 'semver'; /** - * @type {{ build: boolean; run: string; runGlob: string; coverage: boolean; help: boolean; coverageFormats: string | string[]; coveragePath: string; }} + * @type {{ build: boolean; run: string; runGlob: string; coverage: boolean; help: boolean; coverageFormats: string | string[]; coveragePath: string; timeout?: string; }} */ const args = minimist(process.argv.slice(2), { boolean: ['build', 'coverage', 'help'], - string: ['run', 'coveragePath', 'coverageFormats'], + string: ['run', 'coveragePath', 'coverageFormats', 'timeout'], alias: { - h: 'help' + h: 'help', + t: 'timeout' }, default: { build: false, @@ -39,6 +40,7 @@ const args = minimist(process.argv.slice(2), { coverage: 'Generate a coverage report', coveragePath: 'Path to coverage report to generate', coverageFormats: 'Coverage formats to generate', + timeout: 'Timeout for tests', help: 'Show help' } }); @@ -50,11 +52,13 @@ Options: --build Run from out-build --run Run a single file --coverage Generate a coverage report +--timeout Test timeout (default: 5000 locally, 30000 in CI; alias: -t) --help Show help`); process.exit(0); } const TEST_GLOB = '**/test/**/*.test.js'; +const IS_CI = !!process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || !!process.env.GITHUB_WORKSPACE; const excludeGlobs = [ '**/{browser,electron-browser,electron-main,electron-utility}/**/*.test.js', @@ -148,7 +152,8 @@ function main() { const runner = new Mocha({ - ui: 'tdd' + ui: 'tdd', + timeout: args.timeout ?? (IS_CI ? 30000 : 5000) }); /**