From 3115829b38251279537b141c41470e83cf4bff00 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 23 Oct 2026 00:05:09 -0500 Subject: [PATCH] =?UTF-8?q?Reconstruct=20batch=20038/124:=202026-09-04=20?= =?UTF-8?q?=E2=86=92=202026-09-07=20(sessions,=20workbench,=20build,=20bas?= =?UTF-8?q?e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed reconstruction of 53 upstream commits (da68c9a29b9..aea1715032c). 228 files changed, 11684 insertions(+), 1490 deletions(-) sessions: - Hide session chat pills in subagent chats - Pin subagent pill gating across widget navigation and fork chats - Use standard primary color for agent feedback (#334643) - Share the sessions application badge default between registration and runtime (#334909) - Fix keyboard access to selected session actions (#334904) workbench: - Fix stuck "Close Others" tab action after focus change - Add regression test for stuck "Close Others" tab action - Revalidate Alt in capture phase so pressing the tab action is covered - Strengthen focus regression test to cover stale hover re-arming build: - build: omit web extension metadata from desktop bundles (#334780) - [cherry-pick] build: update product Copilot runtime canary (#334838) - build: restrict PR workflows to main and release branches (#334835) - build: check generated Monaco declarations base: - clarify comment on markAsSingleton - Fix primary side bar width reset when toggled (#334167) - Explain why the regression test resizes the side bar during setup - Condense comments to a single line agenthost: - agentHost: Fix sessions stuck in progress after subagent completion (#334781) - Keep Codex MCP tool progress out of the tool result (#334053) - agentHost: Fix Codex file links on Windows (#334911) - agentHost: Refine workspace transition boundaries (#334919) editor: - editor: preserve restored font measurements on short sessions (#334779) - Center full-width characters in two monospace cells (#334022) - editor: update Monaco declarations automations: - automations: refactor: make provider session templates canonical (#334521) - automations: feat: add starter templates and clarify target selection (#334836) - automations: fix: limit New badge to returning Agents users (#334898) misc: - Initial plan - Initial plan fix: - fix: avoid refocusing find input during IME composition (#320898) - fix: memory leak in markers table (#333241) eslint: - eslint: enable no bracket notation rule (#334756) - eslint: fix bracket notation in test infrastructure (#334773) extensions/copilot: - Recognize eagerness option in PatchBased02Unified (#333606) - Pause automatic inline completions on metered connections (#331691) titlebar: - titlebar: Defer overflow measurement from startup (#334751) windows: - windows: avoid destroyed window access in badge cleanup (#334782) fix tests: - fix tests: resolve ModifierKeyEmitter singleton at construction time engineering: - Create custom CodeQL workflow (#334739) mcp: - mcp: validate gallery server sources and package types (#334761) perf: - Perf: Register built-in notebook types declaratively (#331911) configuration: - configuration: batch initial policy definitions (#334790) test: - test: gate Copilot restored-parent replay (#334688) webviewview: - Fix webview view badge not clearing when set to undefined (#331019) contextkey: - Add isChromeOS to platforms.ts and context. (#309097) extensions: - Add feature flag setting for extensions in agents window capability (#334879) Co-Authored-By: Claude Opus 5.5 --- .eslint-allowed-bracket-notation-files | 534 ++++++++++++ .github/CODEOWNERS | 4 + .github/codeql/codeql-config.yml | 116 ++- .github/workflows/chat-lib-package.yml | 3 + .github/workflows/codeql.yml | 51 ++ .github/workflows/copilot-setup-steps.yml | 3 + .github/workflows/monaco-editor.yml | 3 + .github/workflows/telemetry.yml | 8 +- CodeQL.yml | 30 - build/azure-pipelines/dependencies-check.yml | 2 + .../azure-pipelines/product-build-ado-ci.yml | 2 +- build/azure-pipelines/product-build.yml | 2 +- .../publish-types/publish-types.yml | 4 + build/gulpfile.editor.ts | 14 +- build/next/index.ts | 17 +- eslint.config.js | 20 + extensions/copilot/CodeQL.yml | 5 - extensions/copilot/build/update-assets.yml | 4 + extensions/copilot/package.json | 1 + .../src/vscodeInlineCompletionItemProvider.ts | 5 + .../vscode-node/inlineCompletionProvider.ts | 6 +- .../mcp/test/vscode-node/nuget.stub.spec.ts | 62 +- .../src/extension/mcp/vscode-node/nuget.ts | 102 ++- .../common/dataTypes/xtabPromptOptions.ts | 1 + .../test/common/xtabPromptOptions.spec.ts | 9 + extensions/copilot/tsconfig.json | 3 +- .../markdown-language-features/.vscodeignore | 1 + .../src/task/taskProvider.ts | 12 +- src/vs/base/browser/dom.ts | 7 +- src/vs/base/browser/ui/findinput/findInput.ts | 12 +- src/vs/base/browser/ui/splitview/splitview.ts | 8 +- src/vs/base/common/platform.ts | 3 + .../base/parts/request/common/requestImpl.ts | 3 + .../test/electron-main/request.test.ts | 26 + .../browser/ui/splitview/splitview.test.ts | 41 + .../editor/browser/config/fontMeasurements.ts | 29 +- src/vs/editor/browser/gpu/viewGpuContext.ts | 20 +- .../browser/view/domLineBreaksComputer.ts | 136 +-- .../browser/viewParts/viewLines/rangeUtil.ts | 15 + .../browser/viewParts/viewLines/viewLine.ts | 24 +- .../viewParts/viewLines/viewLineOptions.ts | 3 + .../browser/viewParts/viewLines/viewLines.css | 12 + .../diffEditorViewZones/renderLines.ts | 8 +- src/vs/editor/common/config/editorOptions.ts | 43 +- .../editor/common/modelLineProjectionData.ts | 2 +- .../common/standalone/standaloneEnums.ts | 4 +- .../common/viewLayout/viewLineRenderer.ts | 52 +- .../viewModel/monospaceLineBreaksComputer.ts | 54 +- .../editor/common/viewModel/viewModelImpl.ts | 7 +- .../editor/common/viewModel/viewModelLines.ts | 17 +- .../browser/view/ghostText/ghostTextView.ts | 6 +- .../browser/stickyScrollController.ts | 8 +- .../browser/stickyScrollWidget.ts | 6 +- .../browser/config/fontMeasurements.test.ts | 87 ++ .../view/domLineBreaksComputer.test.ts | 66 ++ .../viewModel/modelLineProjection.test.ts | 8 +- .../viewLayout/viewLineRenderer.test.ts | 233 ++++- .../monospaceLineBreaksComputer.test.ts | 133 ++- src/vs/monaco.d.ts | 15 +- .../agentHostChatContributionsService.ts | 4 +- .../agentHost/common/automationMigration.ts | 57 ++ .../common/meta/agentToolCallMeta.ts | 3 + .../agentHost/common/sessionConfigKeys.ts | 35 + .../node/agentHostAutomationService.ts | 25 +- .../platform/agentHost/node/agentService.ts | 60 +- .../sessionWorkspaceConversionContribution.ts | 43 +- .../sessionWorkspaceConversionService.ts | 19 +- .../node/codex/codexClientCustomizations.ts | 16 +- .../node/codex/codexMapAppServerEvents.ts | 29 +- .../node/copilot/copilotAgentSession.ts | 24 +- .../node/copilot/prompts/promptRegistry.ts | 5 +- .../node/copilot/prompts/systemMessage.ts | 16 - .../agentHost/node/sessionPermissions.ts | 5 +- .../node/shared/fileLinkInstructions.ts | 19 + .../test/common/agentMetaReaders.test.ts | 9 + .../test/common/automationMigration.test.ts | 32 + .../node/agentHostAutomationService.test.ts | 118 +++ .../test/node/agentHostPromptRegistry.test.ts | 13 +- .../agentHost/test/node/agentService.test.ts | 95 +++ .../codex/codexClientCustomizations.test.ts | 23 +- .../codex/codexMapAppServerEvents.test.ts | 90 +- .../node/codex/codexPrewarmEviction.test.ts | 14 +- .../agentHost/test/node/copilotAgent.test.ts | 4 +- .../test/node/copilotAgentSession.test.ts | 152 +++- .../agentHost/test/node/e2e/KNOWN_ISSUES.md | 40 + ...mode-and-approvals-after-host-restart.yaml | 38 + .../node/e2e/suites/agentHostE2ESuites.ts | 3 +- .../test/node/e2e/suites/automationsSuite.ts | 82 +- .../test/node/e2e/suites/subagentSuite.ts | 5 +- .../test/node/sessionPermissions.test.ts | 14 +- .../node/sessionWorkspaceConversion.test.ts | 116 ++- .../configuration/common/configurations.ts | 6 +- .../test/common/policyConfiguration.test.ts | 33 +- .../platform/contextkey/common/contextkey.ts | 3 +- .../platform/contextkey/common/contextkeys.ts | 3 +- .../contextkey/test/common/contextkey.test.ts | 3 +- .../test/common/instantiationServiceMock.ts | 6 +- .../platform/mcp/common/mcpGalleryService.ts | 200 +++-- .../mcp/common/mcpManagementService.ts | 2 +- .../mcp/test/common/mcpGalleryService.test.ts | 232 ++++- .../test/common/mcpManagementService.test.ts | 15 + .../windows/electron-main/windowImpl.ts | 22 +- src/vs/sessions/AUTOMATIONS.md | 28 +- src/vs/sessions/SESSIONS.md | 4 + src/vs/sessions/browser/menus.ts | 2 + .../sessions/browser/parts/customViewNode.ts | 13 +- src/vs/sessions/browser/parts/titlebarPart.ts | 11 +- .../browser/agentFeedbackEditorWidget.ts | 6 +- .../media/agentFeedbackEditorWidget.css | 4 +- .../browser/agentFeedbackEditorWidget.test.ts | 15 + .../automations/browser/automationDialog.ts | 581 +++++++++---- .../browser/automationDialogService.ts | 271 ++++-- .../browser/automationModelConfiguration.ts | 97 +++ .../automations/browser/automationRunner.ts | 24 +- .../automations/browser/automationService.ts | 137 ++- .../automations/browser/automationTools.ts | 190 ++++- .../browser/automations.contribution.ts | 10 +- .../browser/media/automationDialog.css | 142 +++- .../browser/providerAutomationService.ts | 31 +- .../test/browser/automationDialog.test.ts | 392 ++++++++- .../test/browser/automationRunner.test.ts | 58 +- .../test/browser/automationService.test.ts | 288 ++++++- .../test/browser/automationTools.test.ts | 305 ++++++- .../browser/providerAutomationService.test.ts | 208 ++++- .../contrib/chat/browser/modelPicker.ts | 1 + .../contrib/chat/browser/newChatInput.ts | 19 +- .../chat/browser/newSessionConfigToolbars.ts | 33 + .../chat/browser/sessionChatInputToolbar.ts | 12 +- .../chat/browser/sessionModelSelection.ts | 37 +- .../chat/browser/sessionWorkspacePicker.ts | 2 +- .../browser/sessionChatInputToolbar.test.ts | 77 +- .../browser/sessionModelSelection.test.ts | 223 ++++- .../browser/sessionWorkspacePicker.test.ts | 85 ++ .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 6 + .../agentHost/browser/agentHostAgentPicker.ts | 14 +- .../browser/agentHostAutomationStore.ts | 136 ++- .../browser/baseAgentHostSessionsProvider.ts | 208 ++++- .../browser/localAgentHostSessionsProvider.ts | 4 + .../mobile/mobileChatInputConfigPicker.ts | 9 +- .../reconnectableAgentHostAutomationStore.ts | 57 +- .../browser/agentHostAutomationStore.test.ts | 794 +++++++++++++++++- .../localAgentHostSessionsProvider.test.ts | 425 +++++++++- .../browser/copilotChatSessionsActions.ts | 26 +- .../browser/copilotChatSessionsProvider.ts | 250 +++++- .../copilotChatSessions/browser/modePicker.ts | 67 +- .../copilotChatSessionsProvider.test.ts | 492 ++++++++++- .../test/browser/modePicker.test.ts | 135 ++- .../test/browser/sandboxPicker.test.ts | 1 + .../sessions/browser/automationsNewBadge.ts | 84 +- .../browser/media/automationsCards.css | 167 +++- .../sessions/browser/media/sessionsList.css | 3 +- .../browser/sessionsLifecycleTracker.ts | 12 +- .../browser/sessionsTelemetry.contribution.ts | 4 +- .../browser/views/automationTemplates.ts | 41 + .../browser/views/automationsAccessibility.ts | 48 +- .../sessions/browser/views/automationsView.ts | 317 ++++++- .../electron-browser/sessions.contribution.ts | 5 +- .../sessionsApplicationBadge.ts | 4 +- .../test/browser/automationsNewBadge.test.ts | 244 ++++-- .../test/browser/automationsView.fixture.ts | 45 +- .../test/browser/automationsView.test.ts | 557 +++++++++++- .../browser/sessionsLifecycleTracker.test.ts | 20 +- .../test/browser/sessionsList.test.ts | 86 +- .../test/browser/sessionsListTestUtils.ts | 5 + .../sessionsTelemetry.contribution.test.ts | 5 + .../sessionsApplicationBadge.test.ts | 17 +- .../browser/sessionsManagementService.ts | 43 +- .../browser/sessionsWindowUsageService.ts | 35 + .../sessions/common/sessionsManagement.ts | 19 +- .../sessions/common/sessionsProvider.ts | 27 +- .../test/browser/sessionNavigation.test.ts | 3 + .../browser/sessionsManagementService.test.ts | 102 ++- .../sessionsWindowUsageService.test.ts | 60 ++ src/vs/sessions/sessions.common.main.ts | 1 + src/vs/workbench/browser/contextkeys.ts | 3 +- .../browser/parts/editor/editorTabsControl.ts | 2 +- .../parts/editor/multiEditorTabsControl.ts | 44 +- .../browser/parts/titlebar/titlebarPart.ts | 12 +- .../browser/actions/chatExecuteActions.ts | 1 + .../agentHost/agentHostSessionInputPills.ts | 25 +- .../agentHost/stateToProgressAdapter.ts | 13 +- .../browser/widget/input/chatInputPart.ts | 47 +- .../modelPicker/modelPickerModelConfig.ts | 23 +- .../chat/common/automations/automation.ts | 43 +- .../automations/automationDialogService.ts | 6 +- .../common/automations/automationService.ts | 48 +- .../common/automations/automationTelemetry.ts | 4 +- .../contrib/chat/common/languageModels.ts | 9 + .../agentHostSessionInputPills.test.ts | 103 +++ .../agentHostChatContribution.test.ts | 18 +- .../stateToProgressAdapter.test.ts | 40 + .../chatMarkdownContentPart.test.ts | 19 + .../browser/find/simpleFindWidget.ts | 7 +- .../browser/extensions.contribution.ts | 11 +- .../browser/interactive.contribution.ts | 22 +- .../contrib/markers/browser/markersTable.ts | 4 +- .../browser/services/notebookServiceImpl.ts | 43 +- .../notebook/common/notebookTypeRegistry.ts | 47 ++ .../test/browser/notebookServiceImpl.test.ts | 48 +- .../test/browser/webviewFindWidget.test.ts | 140 +++ .../webviewView/browser/webviewViewPane.ts | 2 + .../test/browser/webviewViewPane.test.ts | 66 ++ .../extensionEnablementService.test.ts | 5 +- .../extensionManifestPropertiesService.ts | 3 +- .../extensions/common/extensionsRegistry.ts | 4 +- ...extensionManifestPropertiesService.test.ts | 14 +- .../chat/chatWidget.fixture.ts | 10 +- .../editor/fullwidthCharacterWidth.fixture.ts | 129 +++ .../multiDiffEditorScroll.fixture.ts | 26 +- .../resizeObserver.fixture.ts | 6 +- .../sessions/sessionsList.fixture.ts | 35 +- .../editor/multiEditorTabsControl.test.ts | 205 +++++ .../parts/titlebar/titlebarPart.test.ts | 59 ++ test/automation/src/electron.ts | 8 +- test/automation/src/scm.ts | 2 +- test/automation/src/search.ts | 4 +- .../blocks-ci-screenshots.md | 45 + .../tests/chatResizeObserver.spec.ts | 2 +- .../playwright/tests/utils.ts | 2 +- test/monaco/esm-check/esm-check.js | 2 +- test/sanity/src/context.ts | 8 +- test/sanity/src/main.ts | 2 +- .../areas/agentsWindow/agentsWindow.test.ts | 8 +- .../src/areas/preferences/preferences.test.ts | 4 +- test/smoke/test/index.js | 4 +- test/unit/electron/index.js | 2 +- test/unit/electron/preload.js | 2 +- test/unit/electron/renderer.js | 4 +- 228 files changed, 11684 insertions(+), 1490 deletions(-) create mode 100644 .eslint-allowed-bracket-notation-files create mode 100644 .github/workflows/codeql.yml delete mode 100644 CodeQL.yml delete mode 100644 extensions/copilot/CodeQL.yml create mode 100644 src/vs/editor/test/browser/config/fontMeasurements.test.ts create mode 100644 src/vs/editor/test/browser/view/domLineBreaksComputer.test.ts create mode 100644 src/vs/platform/agentHost/node/shared/fileLinkInstructions.ts create mode 100644 src/vs/platform/agentHost/test/common/automationMigration.test.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-an-automation-run-restores-mode-and-approvals-after-host-restart.yaml create mode 100644 src/vs/sessions/contrib/automations/browser/automationModelConfiguration.ts create mode 100644 src/vs/sessions/contrib/chat/browser/newSessionConfigToolbars.ts create mode 100644 src/vs/sessions/contrib/sessions/browser/views/automationTemplates.ts create mode 100644 src/vs/sessions/services/sessions/browser/sessionsWindowUsageService.ts create mode 100644 src/vs/sessions/services/sessions/test/browser/sessionsWindowUsageService.test.ts create mode 100644 src/vs/workbench/contrib/notebook/common/notebookTypeRegistry.ts create mode 100644 src/vs/workbench/contrib/webview/test/browser/webviewFindWidget.test.ts create mode 100644 src/vs/workbench/contrib/webviewView/test/browser/webviewViewPane.test.ts create mode 100644 src/vs/workbench/test/browser/componentFixtures/editor/fullwidthCharacterWidth.fixture.ts create mode 100644 src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts create mode 100644 src/vs/workbench/test/browser/parts/titlebar/titlebarPart.test.ts diff --git a/.eslint-allowed-bracket-notation-files b/.eslint-allowed-bracket-notation-files new file mode 100644 index 00000000000000..4f06ad658e78fe --- /dev/null +++ b/.eslint-allowed-bracket-notation-files @@ -0,0 +1,534 @@ +# Allowlist of files containing pre-existing bracket notation for identifier properties. +# +# This file is consumed by eslint.config.js and gates the +# `local/code-no-bracket-notation-for-identifiers` lint rule. +# +# DO NOT ADD NEW ENTRIES. Use dot notation for identifier properties instead. +# Modifications to this file require sign-off from the CODEOWNERS. +# +# Lines starting with `#` are comments. Blank lines are ignored. + +# Agent Host platform (89 files) +src/vs/platform/agentHost/browser/webPubSubRelayTransport.ts +src/vs/platform/agentHost/common/automationMigration.ts +src/vs/platform/agentHost/common/copilotHome.ts +src/vs/platform/agentHost/common/meta/agentChangesetFileMeta.ts +src/vs/platform/agentHost/common/meta/agentChatSurfaceMeta.ts +src/vs/platform/agentHost/common/meta/agentCompletionAttachmentMeta.ts +src/vs/platform/agentHost/common/meta/agentCustomizationMeta.ts +src/vs/platform/agentHost/common/meta/agentErrorMeta.ts +src/vs/platform/agentHost/common/meta/agentFeedbackAnnotations.ts +src/vs/platform/agentHost/common/meta/agentMessageDelegationMeta.ts +src/vs/platform/agentHost/common/meta/agentPermissionRequestMeta.ts +src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts +src/vs/platform/agentHost/common/meta/agentToolCallMeta.ts +src/vs/platform/agentHost/common/sessionArtifactCollection.ts +src/vs/platform/agentHost/common/sessionArtifacts.ts +src/vs/platform/agentHost/common/state/protocolUpgrade.ts +src/vs/platform/agentHost/common/state/sessionState.ts +src/vs/platform/agentHost/common/taskEventReplay.ts +src/vs/platform/agentHost/common/webPubSub/framing.ts +src/vs/platform/agentHost/node/agentHostAutomationService.ts +src/vs/platform/agentHost/node/agentHostCustomizationEnablementService.ts +src/vs/platform/agentHost/node/agentHostMain.ts +src/vs/platform/agentHost/node/agentHostMicrosoftTelemetry.ts +src/vs/platform/agentHost/node/agentHostRepoInfoTelemetry.ts +src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts +src/vs/platform/agentHost/node/agentHostServerMain.ts +src/vs/platform/agentHost/node/agentHostTerminalManager.ts +src/vs/platform/agentHost/node/appNodeModules.ts +src/vs/platform/agentHost/node/claude/claudeProxyAuth.ts +src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts +src/vs/platform/agentHost/node/claude/claudeToolDisplay.ts +src/vs/platform/agentHost/node/claude/customizations/scan/claudeNativePluginScan.ts +src/vs/platform/agentHost/node/codex/codexAgent.ts +src/vs/platform/agentHost/node/codex/codexDelegation.ts +src/vs/platform/agentHost/node/codex/codexProxyService.ts +src/vs/platform/agentHost/node/codex/codexThreadCoordination.ts +src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts +src/vs/platform/agentHost/node/copilot/copilotAgent.ts +src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +src/vs/platform/agentHost/node/copilot/copilotCliEnvironment.ts +src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +src/vs/platform/agentHost/node/nodeAgentHostStarter.ts +src/vs/platform/agentHost/node/protocolServerHandler.ts +src/vs/platform/agentHost/node/shared/worktreeIsolation.ts +src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts +src/vs/platform/agentHost/node/wslRemoteAgentHostHelpers.ts +src/vs/platform/agentHost/test/browser/webPubSubRelayTransport.test.ts +src/vs/platform/agentHost/test/common/partialToolInput.test.ts +src/vs/platform/agentHost/test/common/sessionFolderPickerMeta.test.ts +src/vs/platform/agentHost/test/common/sessionWorkspacelessMeta.test.ts +src/vs/platform/agentHost/test/common/state/hostBuildInfoMeta.test.ts +src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +src/vs/platform/agentHost/test/node/agentHostInternalTelemetry.test.ts +src/vs/platform/agentHost/test/node/agentHostRequestService.test.ts +src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts +src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +src/vs/platform/agentHost/test/node/ahpSurfaceCoverage.ts +src/vs/platform/agentHost/test/node/byokResponsesTranslation.test.ts +src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts +src/vs/platform/agentHost/test/node/claudeAgent.test.ts +src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts +src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts +src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts +src/vs/platform/agentHost/test/node/codex/codexProfileImage.test.ts +src/vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test.ts +src/vs/platform/agentHost/test/node/codex/codexSessionTitleSpans.test.ts +src/vs/platform/agentHost/test/node/copilotAgent.test.ts +src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts +src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts +src/vs/platform/agentHost/test/node/e2e/harness/capiReplayProxy.ts +src/vs/platform/agentHost/test/node/e2e/harness/capiWireCodec.ts +src/vs/platform/agentHost/test/node/e2e/providers/codexAgentHostE2E.integrationTest.ts +src/vs/platform/agentHost/test/node/e2e/providers/codexAgentHostLive.integrationTest.ts +src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts +src/vs/platform/agentHost/test/node/e2e/providers/copilotOtelAgentHostE2E.integrationTest.ts +src/vs/platform/agentHost/test/node/e2e/providers/copilotPromptsE2E.integrationTest.ts +src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts +src/vs/platform/agentHost/test/node/e2e/suites/copilotCoverageSuite.ts +src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts +src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts +src/vs/platform/agentHost/test/node/mapSessionEvents.perf.test.ts +src/vs/platform/agentHost/test/node/mockAgent.ts +src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts +src/vs/platform/agentHost/test/node/shared/agentHostOctoKitService.test.ts +src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts +src/vs/platform/agentHost/test/node/sshHostKeyVerification.test.ts +src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts + +# Copilot extension (90 files) +extensions/copilot/.esbuild.mts +extensions/copilot/script/alternativeAction/index.ts +extensions/copilot/script/setup/getEnv.mts +extensions/copilot/script/testGeneration/editFromPatchTests.ts +extensions/copilot/src/extension/byok/common/test/geminiFunctionDeclarationConverter.spec.ts +extensions/copilot/src/extension/byok/node/azureOpenAIEndpoint.ts +extensions/copilot/src/extension/byok/node/openAIEndpoint.ts +extensions/copilot/src/extension/byok/node/test/azureOpenAIEndpoint.spec.ts +extensions/copilot/src/extension/byok/vscode-node/customEndpointProvider.ts +extensions/copilot/src/extension/byok/vscode-node/test/customEndpointProvider.spec.ts +extensions/copilot/src/extension/chat/test/vscode-node/chatHookService.spec.ts +extensions/copilot/src/extension/chat/vscode-node/chatHookService.ts +extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLIPrompt.ts +extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts +extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliBridgeSpanProcessor.spec.ts +extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/chatSessionMetadataStoreImpl.spec.ts +extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLITerminalLinkProvider.ts +extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts +extensions/copilot/src/extension/completions-core/vscode-node/extension/src/lib/panelShared/common.ts +extensions/copilot/src/extension/completions-core/vscode-node/lib/src/ghostText/completionsFromNetwork.ts +extensions/copilot/src/extension/completions-core/vscode-node/lib/src/ghostText/telemetry.ts +extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/fetch.ts +extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/test/fetch.test.ts +extensions/copilot/src/extension/completions-core/vscode-node/lib/src/prompt/completionsPromptFactory/test/completionsPromptFactory.test.tsx +extensions/copilot/src/extension/completions-core/vscode-node/lib/src/prompt/test/contextProviderRegistryTs.test.ts +extensions/copilot/src/extension/completions-core/vscode-node/lib/src/telemetry.ts +extensions/copilot/src/extension/completions-core/vscode-node/lib/src/test/inlineCompletion.test.ts +extensions/copilot/src/extension/completions-core/vscode-node/lib/src/test/textDocument.ts +extensions/copilot/src/extension/completions-core/vscode-node/lib/src/util/runtimeMode.ts +extensions/copilot/src/extension/context/node/resolvers/promptWorkspaceLabels.ts +extensions/copilot/src/extension/conversation/common/languageModelAccess.ts +extensions/copilot/src/extension/conversation/vscode-node/remoteAgents.ts +extensions/copilot/src/extension/conversation/vscode-node/userActions.ts +extensions/copilot/src/extension/githubMcp/common/githubMcpDefinitionProvider.ts +extensions/copilot/src/extension/githubMcp/test/node/githubMcpDefinitionProvider.spec.ts +extensions/copilot/src/extension/inlineChat/node/rendererVisualization.ts +extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderTelemetry.spec.ts +extensions/copilot/src/extension/intents/node/agentIntent.ts +extensions/copilot/src/extension/linkify/common/filePathLinkifier.ts +extensions/copilot/src/extension/linkify/common/modelFilePathLinkifier.ts +extensions/copilot/src/extension/log/vscode-node/loggingActions.ts +extensions/copilot/src/extension/mcp/test/vscode-node/nuget.integration.spec.ts +extensions/copilot/src/extension/mcp/test/vscode-node/nuget.mapping.spec.ts +extensions/copilot/src/extension/mcp/vscode-node/nuget.ts +extensions/copilot/src/extension/onboardDebug/node/parseLaunchConfigFromResponse.ts +extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts +extensions/copilot/src/extension/prompt/test/node/conversation.spec.ts +extensions/copilot/src/extension/prompts/node/inline/summarizedDocument/implementation.ts +extensions/copilot/src/extension/prompts/node/panel/vscode.tsx +extensions/copilot/src/extension/telemetry/vscode/githubTelemetryForwardingContrib.ts +extensions/copilot/src/extension/tools/node/test/searchSubagentTool.spec.ts +extensions/copilot/src/extension/tools/test/node/virtualTools/virtualToolGrouper.spec.ts +extensions/copilot/src/extension/tools/test/node/virtualTools/virtualToolGrouping.spec.ts +extensions/copilot/src/platform/authentication/test/node/simulationTestCopilotTokenManager.ts +extensions/copilot/src/platform/chat/common/chatQuotaServiceImpl.ts +extensions/copilot/src/platform/customInstructions/common/customInstructionsService.ts +extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts +extensions/copilot/src/platform/endpoint/test/node/azureEndpoint.ts +extensions/copilot/src/platform/endpoint/test/node/openaiCompatibleEndpoint.ts +extensions/copilot/src/platform/env/common/envService.ts +extensions/copilot/src/platform/git/common/utils.ts +extensions/copilot/src/platform/github/common/githubAPI.ts +extensions/copilot/src/platform/networking/test/node/networking.spec.ts +extensions/copilot/src/platform/otel/common/agentOTelEnv.ts +extensions/copilot/src/platform/otel/common/otelConfig.ts +extensions/copilot/src/platform/otel/common/test/agentOTelEnv.spec.ts +extensions/copilot/src/platform/otel/common/test/genAiEvents.spec.ts +extensions/copilot/src/platform/telemetry/common/telemetryData.ts +extensions/copilot/src/platform/telemetry/node/azureInsightsReporter.ts +extensions/copilot/src/platform/telemetry/test/node/telemetry2.spec.ts +extensions/copilot/src/platform/telemetry/vscode-node/microsoftExperimentationService.ts +extensions/copilot/src/shared-fetch-utils/common/middleware/authBlockedMiddleware.ts +extensions/copilot/test/base/cachingChatMLFetcher.ts +extensions/copilot/test/base/simulationOptions.ts +extensions/copilot/test/cacheSalt.ts +extensions/copilot/test/codeMapper/codeMapper.stest.ts +extensions/copilot/test/inline/multiFileEdit.stest.ts +extensions/copilot/test/intent/inlineChatIntent.stest.ts +extensions/copilot/test/intent/intentTest.ts +extensions/copilot/test/intent/panelChatIntent.stest.ts +extensions/copilot/test/pipeline/parseInput.ts +extensions/copilot/test/pipeline/scoredEditsOutput.ts +extensions/copilot/test/pipeline/test/pipeline.spec.ts +extensions/copilot/test/pipeline/workspaceRecording/workspaceRecording.ts +extensions/copilot/test/simulation/workbench/initArgs.ts +extensions/copilot/test/simulationMain.ts +extensions/copilot/test/testExecutor.ts +extensions/copilot/test/testVisualizationRunner.ts +extensions/copilot/test/testVisualizationRunnerSTest.ts + +# Workbench chat and agents (40 files) +src/vs/platform/agentPlugins/common/agentPluginParser.ts +src/vs/platform/agentPlugins/common/pluginParsers.ts +src/vs/platform/mcp/test/common/mcpManagementService.test.ts +src/vs/workbench/contrib/chat/browser/actions/createPluginAction.ts +src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts +src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedAgentPluginDetail.ts +src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugCacheExplorerView.ts +src/vs/workbench/contrib/chat/browser/githubRepoFetcher.ts +src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts +src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +src/vs/workbench/contrib/chat/browser/voiceClient/voiceToolDispatchService.ts +src/vs/workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.ts +src/vs/workbench/contrib/chat/common/chatArtifactExtraction.ts +src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts +src/vs/workbench/contrib/chat/common/tools/builtinTools/resolveDebugEventDetailsTool.ts +src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts +src/vs/workbench/contrib/chat/test/browser/actions/createPluginAction.test.ts +src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts +src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSettingsFileSystemProvider.test.ts +src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts +src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionSettingsFileSystemProvider.test.ts +src/vs/workbench/contrib/chat/test/browser/agentSessions/convertCarouselAnswers.test.ts +src/vs/workbench/contrib/chat/test/browser/chatSessions/chatSessionsService.test.ts +src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts +src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceToolDispatchService.test.ts +src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelConfigurationStore.test.ts +src/vs/workbench/contrib/chat/test/common/plugins/agentPluginFormatDetection.test.ts +src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts +src/vs/workbench/contrib/chat/test/common/tools/builtinTools/manageTodoListTool.test.ts +src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts +src/vs/workbench/contrib/inlineChat/browser/inlineChatWidget.ts +src/vs/workbench/contrib/mcp/common/mcpSandboxService.ts +src/vs/workbench/contrib/mcp/common/mcpServer.ts +src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcomeInput.ts +src/vs/workbench/services/agentHost/test/common/agentHostResourceService.test.ts +src/vs/workbench/services/chat/common/chatEntitlementService.ts + +# Agents Window (11 files) +src/vs/sessions/contrib/automations/browser/automationService.ts +src/vs/sessions/contrib/github/browser/githubApiClient.ts +src/vs/sessions/contrib/github/common/utils.ts +src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts +src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts +src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSettingsFileSystemProvider.test.ts +src/vs/sessions/contrib/providers/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts +src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts +src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxApiService.ts +src/vs/sessions/electron-browser/sessions.ts +src/vs/sessions/test/browser/chatGroupsView.test.ts + +# Build, CI, and scripts (48 files) +build/azure-pipelines/common/apply-sdk-canary-override.ts +build/azure-pipelines/common/createBuild.ts +build/azure-pipelines/common/downloadNotice.ts +build/azure-pipelines/common/getPublishAuthTokens.ts +build/azure-pipelines/common/publish.ts +build/azure-pipelines/common/releaseBuild.ts +build/azure-pipelines/common/sign-win32.ts +build/azure-pipelines/common/sign.ts +build/azure-pipelines/distro/mixin-quality.ts +build/azure-pipelines/oss/audit-notices.ts +build/azure-pipelines/oss/merge-notices.ts +build/azure-pipelines/oss/scan-licenses.ts +build/azure-pipelines/upload-cdn.ts +build/azure-pipelines/upload-nlsmetadata.ts +build/azure-pipelines/upload-sourcemaps.ts +build/azure-pipelines/win32/codesign.ts +build/darwin/create-dmg.ts +build/darwin/create-universal-app.ts +build/darwin/sign-server.ts +build/darwin/sign.ts +build/darwin/verify-macho.ts +build/gulpfile.reh.ts +build/gulpfile.vscode.linux.ts +build/gulpfile.vscode.ts +build/gulpfile.vscode.win32.ts +build/hygiene.ts +build/lib/builtInExtensions.ts +build/lib/compilation.ts +build/lib/electron.ts +build/lib/fetch.ts +build/lib/getVersion.ts +build/lib/i18n.ts +build/lib/policies/exportPolicyData.ts +build/lib/preLaunch.ts +build/lib/test/render.test.ts +build/linux/debian/install-sysroot.ts +build/linux/libcxx-fetcher.ts +build/npm/postinstall.ts +build/npm/preinstall.ts +build/npm/update-localization-extension.ts +build/rspack/rspack.serve-out.config.mts +build/win32/explorer-dll-fetcher.ts +scripts/code-agent-host.js +scripts/code-server.js +scripts/code-sessions-web.js +scripts/code-web.js +scripts/sync-agent-host-protocol.ts +scripts/test-agent-host-e2e.ts + +# Application bootstrap, server, and environment (41 files) +src/bootstrap-cli.ts +src/bootstrap-esm.ts +src/bootstrap-fork.ts +src/bootstrap-meta.ts +src/bootstrap-node.ts +src/bootstrap-server.ts +src/cli.ts +src/main.ts +src/server-cli.ts +src/server-main.ts +src/vs/code/electron-browser/workbench/workbench.ts +src/vs/code/electron-main/app.ts +src/vs/code/electron-main/main.ts +src/vs/code/node/cli.ts +src/vs/code/node/cliProcessMain.ts +src/vs/code/test/node/bootstrapESM.test.ts +src/vs/platform/diagnostics/node/diagnosticsService.ts +src/vs/platform/dialogs/electron-browser/dialog.ts +src/vs/platform/environment/common/environmentService.ts +src/vs/platform/environment/electron-main/environmentMainService.ts +src/vs/platform/environment/node/argvHelper.ts +src/vs/platform/environment/node/userDataPath.ts +src/vs/platform/environment/test/electron-main/environmentMainService.test.ts +src/vs/platform/environment/test/node/userDataPath.test.ts +src/vs/platform/launch/electron-main/launchMainService.ts +src/vs/platform/native/electron-main/auth.ts +src/vs/platform/product/common/product.ts +src/vs/platform/shell/node/shellEnv.ts +src/vs/platform/utilityProcess/electron-main/utilityProcess.ts +src/vs/platform/windows/electron-main/windowImpl.ts +src/vs/platform/windows/electron-main/windowsMainService.ts +src/vs/server/node/remoteAgentEnvironmentImpl.ts +src/vs/server/node/remoteExtensionHostAgentCli.ts +src/vs/server/node/remoteExtensionHostAgentServer.ts +src/vs/server/node/remoteExtensionsScanner.ts +src/vs/server/node/remoteTerminalChannel.ts +src/vs/server/node/server.cli.ts +src/vs/server/node/server.cliAgent.ts +src/vs/server/node/server.main.ts +src/vs/server/node/serverEnvironmentService.ts +src/vs/server/node/webClientServer.ts + +# Base and editor (27 files) +src/vs/base/browser/dom.ts +src/vs/base/browser/markdownRenderer.ts +src/vs/base/common/desktopEnvironmentInfo.ts +src/vs/base/common/event.ts +src/vs/base/common/observableInternal/index.ts +src/vs/base/common/platform.ts +src/vs/base/common/process.ts +src/vs/base/common/strings.ts +src/vs/base/node/nls.ts +src/vs/base/node/powershell.ts +src/vs/base/node/processes.ts +src/vs/base/node/shell.ts +src/vs/base/node/terminalEncoding.ts +src/vs/base/parts/ipc/node/ipc.net.ts +src/vs/base/parts/ipc/test/node/testApp.ts +src/vs/base/parts/sandbox/electron-browser/preload.ts +src/vs/base/test/browser/markdownRenderer.test.ts +src/vs/base/test/common/decorators.test.ts +src/vs/base/test/common/keyCodes.test.ts +src/vs/base/test/common/oauth.test.ts +src/vs/base/test/common/processes.test.ts +src/vs/base/test/node/port.test.ts +src/vs/base/test/node/processes/processes.integrationTest.ts +src/vs/editor/common/languages/supports/languageBracketsConfiguration.ts +src/vs/editor/common/services/findSectionHeaders.ts +src/vs/editor/contrib/colorPicker/browser/colorDetector.ts +src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/debugVisualization.ts + +# Terminal and tasks (22 files) +extensions/terminal-suggest/src/completions/npm.ts +extensions/terminal-suggest/src/completions/yarn.ts +src/vs/platform/terminal/node/ptyService.ts +src/vs/platform/terminal/node/terminalEnvironment.ts +src/vs/platform/terminal/node/terminalProcess.ts +src/vs/platform/terminal/node/terminalProfiles.ts +src/vs/platform/terminal/test/node/terminalEnvironment.test.ts +src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts +src/vs/workbench/contrib/tasks/browser/taskQuickPick.ts +src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts +src/vs/workbench/contrib/tasks/common/jsonSchema_v1.ts +src/vs/workbench/contrib/tasks/common/jsonSchema_v2.ts +src/vs/workbench/contrib/tasks/common/problemMatcher.ts +src/vs/workbench/contrib/terminal/browser/terminalActions.ts +src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts +src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts +src/vs/workbench/contrib/terminal/test/common/terminalEnvironment.test.ts +src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts +src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +src/vs/workbench/contrib/terminalContrib/history/common/history.ts +src/vs/workbench/contrib/terminalContrib/history/test/common/history.test.ts +src/vs/workbench/contrib/terminalContrib/suggest/test/browser/terminalCompletionService.test.ts + +# Debug (7 files) +src/vs/workbench/contrib/debug/browser/debugAdapterManager.ts +src/vs/workbench/contrib/debug/browser/debugConfigurationManager.ts +src/vs/workbench/contrib/debug/common/debugger.ts +src/vs/workbench/contrib/debug/node/debugAdapter.ts +src/vs/workbench/contrib/debug/test/browser/repl.test.ts +src/vs/workbench/contrib/debug/test/node/debugAdapter.test.ts +src/vs/workbench/contrib/debug/test/node/debugger.test.ts + +# Notebooks (6 files) +extensions/ipynb/src/deserializers.ts +src/vs/workbench/contrib/notebook/browser/contrib/cellCommands/cellCommands.ts +src/vs/workbench/contrib/notebook/browser/contrib/layout/layoutActions.ts +src/vs/workbench/contrib/notebook/browser/notebookEditor.ts +src/vs/workbench/contrib/notebook/browser/notebookOptions.ts +src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts + +# Extension host, API, and extension management (20 files) +extensions/vscode-api-tests/src/singlefolder-tests/browser.cdp.test.ts +extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts +extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts +extensions/vscode-api-tests/src/singlefolder-tests/configuration.test.ts +extensions/vscode-api-tests/src/singlefolder-tests/window.test.ts +extensions/vscode-test-resolver/src/extension.ts +src/vs/platform/extensionManagement/common/extensionGalleryService.ts +src/vs/platform/extensionManagement/common/extensionTipsService.ts +src/vs/platform/extensionManagement/node/extensionLifecycle.ts +src/vs/workbench/api/common/extHostDebugService.ts +src/vs/workbench/api/common/extHostMcp.ts +src/vs/workbench/api/common/extHostVariableResolverService.ts +src/vs/workbench/api/node/extensionHostProcess.ts +src/vs/workbench/api/node/extHostExtensionService.ts +src/vs/workbench/api/test/browser/extHostConfiguration.test.ts +src/vs/workbench/api/test/browser/extHostTreeViews.test.ts +src/vs/workbench/api/worker/extensionHostWorker.ts +src/vs/workbench/contrib/extensions/browser/extensionsActions.ts +src/vs/workbench/services/extensions/common/extensionsRegistry.ts +src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts + +# Configuration, profiles, and sync (14 files) +extensions/configuration-editing/src/configurationEditingMain.ts +src/vs/platform/configuration/test/common/configurationModels.test.ts +src/vs/platform/configuration/test/common/configurationRegistry.test.ts +src/vs/platform/configuration/test/common/policyConfiguration.test.ts +src/vs/platform/contextkey/browser/contextKeyService.ts +src/vs/platform/policy/test/common/policy.test.ts +src/vs/platform/userDataProfile/electron-main/userDataProfile.ts +src/vs/platform/userDataSync/common/abstractSynchronizer.ts +src/vs/platform/userDataSync/common/userDataSyncStoreService.ts +src/vs/workbench/services/configuration/common/configurationModels.ts +src/vs/workbench/services/configuration/test/browser/configurationEditing.test.ts +src/vs/workbench/services/configuration/test/common/configurationModels.test.ts +src/vs/workbench/services/configurationResolver/test/electron-browser/configurationResolverService.test.ts +src/vs/workbench/services/keybinding/common/keybindingEditing.ts + +# Telemetry, OTel, and performance (14 files) +src/vs/platform/otel/test/node/otlp/otlpJsonDecode.test.ts +src/vs/platform/otel/test/node/otlp/outboundForwarder.test.ts +src/vs/platform/telemetry/common/1dsAppender.ts +src/vs/platform/telemetry/common/commonProperties.ts +src/vs/platform/telemetry/common/telemetryService.ts +src/vs/platform/telemetry/test/browser/1dsAppender.test.ts +src/vs/platform/telemetry/test/browser/telemetryService.test.ts +src/vs/workbench/contrib/performance/browser/performance.contribution.ts +src/vs/workbench/contrib/performance/test/browser/perfviewEditor.test.ts +src/vs/workbench/contrib/telemetry/browser/telemetry.contribution.ts +src/vs/workbench/services/telemetry/browser/workbenchCommonProperties.ts +src/vs/workbench/services/telemetry/common/workbenchCommonProperties.ts +src/vs/workbench/services/telemetry/test/browser/commonProperties.test.ts +src/vs/workbench/services/telemetry/test/node/commonProperties.test.ts + +# Remote, tunnels, networking, and authentication (11 files) +extensions/github-authentication/src/test/github.test.ts +extensions/microsoft-authentication/src/common/experimentation.ts +src/vs/platform/github/common/githubTransport.ts +src/vs/platform/remote/node/wsl.ts +src/vs/platform/request/node/requestService.ts +src/vs/platform/tunnel/node/tunnelProxy.ts +src/vs/platform/tunnel/test/node/tunnelProxy.test.ts +src/vs/platform/webContentExtractor/electron-main/webPageLoader.ts +src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts +src/vs/workbench/contrib/remote/browser/remoteStartEntry.ts +src/vs/workbench/contrib/remoteTunnel/test/electron-browser/remoteTunnel.contribution.test.ts + +# Language feature extensions (22 files) +extensions/css-language-features/client/src/node/cssClientMain.ts +extensions/css-language-features/server/src/cssServer.ts +extensions/css-language-features/server/src/node/cssServerNodeMain.ts +extensions/emmet/src/abbreviationActions.ts +extensions/emmet/src/defaultCompletionProvider.ts +extensions/emmet/src/splitJoinTag.ts +extensions/emmet/src/util.ts +extensions/html-language-features/client/src/autoInsertion.ts +extensions/html-language-features/client/src/node/htmlClientMain.ts +extensions/html-language-features/server/src/modes/languageModes.ts +extensions/html-language-features/server/src/node/htmlServerNodeMain.ts +extensions/json-language-features/client/src/node/jsonClientMain.ts +extensions/json-language-features/server/src/node/jsonServerNodeMain.ts +extensions/markdown-language-features/src/extension.ts +extensions/markdown-language-features/src/languageFeatures/copyFiles/copyFiles.ts +extensions/markdown-language-features/src/languageFeatures/copyFiles/snippets.ts +extensions/markdown-language-features/src/markdownEngine.ts +extensions/merge-conflict/src/mergeDecorator.ts +extensions/typescript-language-features/src/logging/telemetry.ts +extensions/typescript-language-features/src/tsServer/serverProcess.electron.ts +extensions/typescript-language-features/src/typescriptServiceClient.ts +extensions/typescript-language-features/src/utils/platform.ts + +# Git extension (6 files) +extensions/git/src/askpass-main.ts +extensions/git/src/askpassManager.ts +extensions/git/src/git.ts +extensions/git/src/ipc/ipcClient.ts +extensions/git/src/ipc/ipcServer.ts +extensions/git/src/util.ts + +# Workbench UI and remaining services (23 files) +src/vs/workbench/browser/parts/editor/editor.ts +src/vs/workbench/browser/parts/editor/editorGroupView.ts +src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts +src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPane.ts +src/vs/workbench/contrib/callHierarchy/browser/callHierarchyPeek.ts +src/vs/workbench/contrib/issue/electron-browser/nativeGitHubUploadService.ts +src/vs/workbench/contrib/localization/electron-browser/localization.contribution.ts +src/vs/workbench/contrib/policyExport/electron-browser/policyExport.contribution.ts +src/vs/workbench/contrib/policyExport/test/node/policyExport.integrationTest.ts +src/vs/workbench/contrib/search/browser/replaceService.ts +src/vs/workbench/contrib/tags/electron-browser/workspaceTags.ts +src/vs/workbench/contrib/tags/electron-browser/workspaceTagsService.ts +src/vs/workbench/contrib/themes/browser/themes.contribution.ts +src/vs/workbench/contrib/themes/test/node/colorRegistry.releaseTest.ts +src/vs/workbench/contrib/typeHierarchy/browser/typeHierarchyPeek.ts +src/vs/workbench/contrib/update/common/updateInfoParser.ts +src/vs/workbench/contrib/webview/browser/pre/service-worker.js +src/vs/workbench/contrib/welcomeWalkthrough/browser/walkThroughInput.ts +src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts +src/vs/workbench/services/actions/common/menusExtensionPoint.ts +src/vs/workbench/services/extensionRecommendations/common/workspaceExtensionsConfig.ts +src/vs/workbench/services/textfile/test/node/encoding/encoding.integrationTest.ts +src/vs/workbench/services/themes/common/colorThemeData.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 78e7f13c6f9754..9598b45a749365 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -19,3 +19,7 @@ src/vs/workbench/services/extensions/common/extensionPoints.json @TylerLeonhardt # review is required to make sure TypeScript is not a better choice. .eslint-allowed-javascript-files @alexr00 @alexdima @sbatten @TylerLeonhardt +# Allowlist for the `local/code-no-bracket-notation-for-identifiers` lint rule. +# Adding entries here permits bracket notation for identifier properties; +# review is required to make sure dot notation cannot be used instead. +.eslint-allowed-bracket-notation-files @alexdima @dmitrivMS diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index f9316cdcd7dba3..e95ecc8404be4a 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -1,21 +1,115 @@ -# Apply this file to CodeQL default setup with the repository property: -# github-codeql-config-file: ./.github/codeql/codeql-config.yml +# Applied by .github/workflows/codeql.yml. paths-ignore: - # Keep directory exclusions scoped: product code imports helpers from some - # extension src/test directories. - - 'test/**' - - 'src/**/test/**' - - 'build/**/test/**' + # Development, build, packaging, and CI tooling, not shipped product code. + - '.agents/**' + - '.eslint-plugin-local/**' + - '.github/**' + - '.vscode-test.js' + - '.vscode/**' + - 'build/**' + - 'cli/build.rs' + - 'eslint.config.js' + - 'extensions/**/.esbuild.*' + - 'extensions/*/build/**' + - 'extensions/*/esbuild*.mts' + - 'extensions/*/scripts/**' + - 'extensions/copilot/.eslintplugin/**' + - 'extensions/copilot/.mocha-multi-reporters.js' + - 'extensions/copilot/.mocharc.js' + - 'extensions/copilot/.vscode-test.mjs' + - 'extensions/copilot/.vscode/**' + - 'extensions/copilot/chat-lib/vitest.config.ts' + - 'extensions/copilot/script/**' + - 'extensions/copilot/vite.config.ts' + - 'extensions/esbuild*.mts' + - 'extensions/postinstall.mjs' + - 'extensions/search-result/syntaxes/generateTMLanguage.js' + - 'gulpfile.mjs' + - 'scripts/**' + + # Generated build output and dependencies; analyze the product sources instead. + - '**/node_modules/**' + - '**/out/**' + - '.build/**' + - 'cli/target/**' + - 'coverage/**' + - 'extensions/**/dist/**' + - 'out*/**' + + # Test directories, fixtures, and helpers. - 'cli/tests/**' - - '.eslint-plugin-local/tests/**' + - 'extensions/*/server/**/test/**' + - 'extensions/*/test-workspace/**' - 'extensions/*/test/**' - 'extensions/*/tests/**' + - 'extensions/configuration-editing/src/test/**' + - 'extensions/copilot/src/extension/**/test/**' + - 'extensions/copilot/src/extension/agents/node/adapters/openaiAdapterForSTests.ts' + - 'extensions/copilot/src/extension/typescriptContext/serverPlugin/fixtures/**' + - 'extensions/copilot/src/lib/**/test/**' + - 'extensions/copilot/src/platform/**/test/**' + - 'extensions/copilot/src/platform/commands/common/mockRunCommandExecutionService.ts' + - 'extensions/copilot/src/platform/notebook/common/mockAlternativeContentService.ts' + - 'extensions/copilot/src/platform/tasks/common/testTasksService.ts' + - 'extensions/copilot/src/sanity-test-extension.ts' + - 'extensions/copilot/src/shared-fetch-utils/**/test/**' + - 'extensions/copilot/src/test-extension.ts' + # Standalone chat-lib rewrites runtime VS Code API imports to util/common/test/shims. + # These are required runtime dependencies, not tests; exclude only their test-only siblings. + - 'extensions/copilot/src/util/common/test/annotatedSrc.ts' + - 'extensions/copilot/src/util/common/test/mockChatResponseStream.ts' + - 'extensions/copilot/src/util/common/test/simpleMock.ts' + - 'extensions/copilot/src/util/common/test/testUtils.ts' + - 'extensions/copilot/src/util/node/test/**' + - 'extensions/copilot/src/util/test/**' + - 'extensions/emmet/src/test/**' + - 'extensions/git-base/src/test/**' + - 'extensions/git/src/test/**' + - 'extensions/github-authentication/src/test/**' + - 'extensions/github/src/test/**' + - 'extensions/ipynb/src/test/**' + - 'extensions/markdown-language-features/src/test/**' + - 'extensions/microsoft-authentication/src/**/test/**' + - 'extensions/notebook-renderers/src/test/**' + - 'extensions/npm/src/test/**' + - 'extensions/terminal-suggest/fixtures/**' + - 'extensions/terminal-suggest/src/**/test/**' + - 'extensions/typescript-language-features/src/test-all.ts' + - 'extensions/typescript-language-features/src/test/**' + - 'extensions/vscode-api-tests/**' + - 'extensions/vscode-colorize-perf-tests/**' + - 'extensions/vscode-colorize-tests/**' + - 'extensions/vscode-test-resolver/**' + - 'src/**/test/**' + - 'src/vs/workbench/contrib/terminal/browser/terminalTestHelpers.ts' + - 'test/**' + # Keep exact suffixes: themes.test.contribution.ts ships in the product. + - '**/*.fixture.cjs' + - '**/*.fixture.cts' + - '**/*.fixture.js' + - '**/*.fixture.jsx' + - '**/*.fixture.mjs' + - '**/*.fixture.mts' + - '**/*.fixture.ts' + - '**/*.fixture.tsx' + - '**/*.sanity-test.ts' + - '**/*.sanity-test.tsx' + - '**/*.spec.cjs' + - '**/*.spec.cts' + - '**/*.spec.js' + - '**/*.spec.jsx' + - '**/*.spec.mjs' + - '**/*.spec.mts' + - '**/*.spec.ts' + - '**/*.spec.tsx' + - '**/*.stest.ts' + - '**/*.stest.tsx' + - '**/*.test.cjs' + - '**/*.test.cts' - '**/*.test.js' - '**/*.test.jsx' - '**/*.test.mjs' - - '**/*.test.cjs' + - '**/*.test.mts' - '**/*.test.ts' - '**/*.test.tsx' - - '**/*.test.mts' - - '**/*.test.cts' diff --git a/.github/workflows/chat-lib-package.yml b/.github/workflows/chat-lib-package.yml index ff4987a8c7e3b5..cb8c1c8192ef50 100644 --- a/.github/workflows/chat-lib-package.yml +++ b/.github/workflows/chat-lib-package.yml @@ -2,6 +2,9 @@ name: chat-lib tests on: pull_request: + branches: + - main + - 'release/*' workflow_dispatch: permissions: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000000000..b662810c09cf71 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,51 @@ +name: CodeQL + +on: + push: + branches: + - main + - 'release/**/*' + pull_request: + branches: + - main + - 'release/**/*' + schedule: + - cron: '17 19 * * 6' + +permissions: {} + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + packages: read + security-events: write + strategy: + fail-fast: false + matrix: + # Other detected languages occur only in test fixtures or build tooling. + include: + - language: javascript-typescript + build-mode: none + - language: rust + build-mode: none + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Initialize CodeQL + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + dependency-caching: true + config-file: ./.github/codeql/codeql-config.yml + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + with: + category: /language:${{ matrix.language }} diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 3daeb39c938ee0..2e4c5c356c40ce 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -8,6 +8,9 @@ on: paths: - .github/workflows/copilot-setup-steps.yml pull_request: + branches: + - main + - 'release/*' paths: - .github/workflows/copilot-setup-steps.yml diff --git a/.github/workflows/monaco-editor.yml b/.github/workflows/monaco-editor.yml index a3653ee3621663..63b54c88c02fc8 100644 --- a/.github/workflows/monaco-editor.yml +++ b/.github/workflows/monaco-editor.yml @@ -68,6 +68,9 @@ jobs: - name: Run Monaco Editor Checks run: npm run monaco-compile-check + - name: Check generated Monaco declarations + run: npm run gulp monacodts-check + - name: Editor Distro & ESM run: npm run gulp editor-distro diff --git a/.github/workflows/telemetry.yml b/.github/workflows/telemetry.yml index b0017798e3ba50..9fe20ab0e968d1 100644 --- a/.github/workflows/telemetry.yml +++ b/.github/workflows/telemetry.yml @@ -1,5 +1,11 @@ name: 'Telemetry' -on: pull_request + +on: + pull_request: + branches: + - main + - 'release/*' + permissions: {} jobs: check-metadata: diff --git a/CodeQL.yml b/CodeQL.yml deleted file mode 100644 index ca99d172935b66..00000000000000 --- a/CodeQL.yml +++ /dev/null @@ -1,30 +0,0 @@ -path_classifiers: - test: - # Classify all files in the top-level directories test/ and testsuites/ as test code. - - test - # Classify all files with suffix `.test` as test code. - # Note: use only forward slash / as a path separator. - # * Matches any sequence of characters except a forward slash. - # ** Matches any sequence of characters, including a forward slash. - # This wildcard must either be surrounded by forward slash symbols, or used as the first segment of a path. - # It matches zero or more whole directory segments. There is no need to use a wildcard at the end of a directory path because all sub-directories are automatically matched. - # That is, /anything/ matches the anything directory and all its subdirectories. - # Always enclose the expression in double quotes if it includes *. - - "**/*.test.ts" - - # The default behavior is to tag all files created during the - # build as `generated`. Results are hidden for generated code. You can tag - # further files as being generated by adding them to the `generated` section. - generated: - # generated code. - - out - - "out-build" - - "out-vscode" - - "**/out/**" - - ".build/distro/cli-patches/index.js" - - # The default behavior is to tag library code as `library`. Results are hidden - # for library code. You can tag further files as being library code by adding them - # to the `library` section. - library: - - "**/node_modules/**" diff --git a/build/azure-pipelines/dependencies-check.yml b/build/azure-pipelines/dependencies-check.yml index af659e714bdc77..804aedc22042ad 100644 --- a/build/azure-pipelines/dependencies-check.yml +++ b/build/azure-pipelines/dependencies-check.yml @@ -7,6 +7,8 @@ parameters: type: string variables: + - name: Codeql.SkipTaskAutoInjection + value: true - name: NPM_REGISTRY value: "https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/npm/registry/" - name: VSCODE_QUALITY diff --git a/build/azure-pipelines/product-build-ado-ci.yml b/build/azure-pipelines/product-build-ado-ci.yml index 2594f4e5be8454..00c4e6df388517 100644 --- a/build/azure-pipelines/product-build-ado-ci.yml +++ b/build/azure-pipelines/product-build-ado-ci.yml @@ -83,7 +83,7 @@ parameters: - name: VSCODE_CLI_CANARY_VERSION displayName: "Product @github/copilot runtime version ('auto' = infer from an SDK override)" type: string - default: 1.0.84-canary.67.g6496fad.unsigned + default: 1.0.84-canary.70.gdb75d0d.unsigned variables: - name: VSCODE_PRIVATE_BUILD diff --git a/build/azure-pipelines/product-build.yml b/build/azure-pipelines/product-build.yml index 392b82420e5972..27c62f47e3b805 100644 --- a/build/azure-pipelines/product-build.yml +++ b/build/azure-pipelines/product-build.yml @@ -136,7 +136,7 @@ parameters: - name: VSCODE_CLI_CANARY_VERSION displayName: "Product @github/copilot runtime version ('auto' = infer from an SDK override)" type: string - default: 1.0.84-canary.67.g6496fad.unsigned + default: 1.0.84-canary.70.gdb75d0d.unsigned variables: - name: VSCODE_BUILD_TITLE_PREFIX diff --git a/build/azure-pipelines/publish-types/publish-types.yml b/build/azure-pipelines/publish-types/publish-types.yml index 25dbf1f185a419..28e0c44a9b6d3a 100644 --- a/build/azure-pipelines/publish-types/publish-types.yml +++ b/build/azure-pipelines/publish-types/publish-types.yml @@ -6,6 +6,10 @@ trigger: pr: none +variables: + - name: Codeql.SkipTaskAutoInjection + value: true + pool: vmImage: ubuntu-latest diff --git a/build/gulpfile.editor.ts b/build/gulpfile.editor.ts index f9a3e91df611b5..e3f70854d4b3d2 100644 --- a/build/gulpfile.editor.ts +++ b/build/gulpfile.editor.ts @@ -20,6 +20,7 @@ import { createReporter } from './lib/reporter.ts'; import monacoPackage from './monaco/package.json' with { type: 'json' }; const root = path.dirname(import.meta.dirname); +const standaloneEnumsPath = path.join(root, 'src/vs/editor/common/standalone/standaloneEnums.ts'); const sha1 = getVersion(root); const semver = monacoPackage.version; const headerVersion = semver + '(' + sha1 + ')'; @@ -226,7 +227,18 @@ task.task('editor-distro', task.task('monacodts', task.define('monacodts', () => { const result = monacoapi.execute(); fs.writeFileSync(result.filePath, result.content); - fs.writeFileSync(path.join(root, 'src/vs/editor/common/standalone/standaloneEnums.ts'), result.enums); + fs.writeFileSync(standaloneEnumsPath, result.enums); + return Promise.resolve(true); +})); + +task.task('monacodts-check', task.define('monacodts-check', () => { + const result = monacoapi.execute(); + const currentEnums = fs.readFileSync(standaloneEnumsPath).toString(); + const normalizedCurrentEnums = currentEnums.replace(/\r\n/g, '\n'); + const normalizedGeneratedEnums = result.enums.replace(/\r\n/g, '\n'); + if (!result.isTheSame || normalizedCurrentEnums !== normalizedGeneratedEnums) { + throw new Error('Monaco declarations are no longer up to date. Run `npm run gulp monacodts` and commit the generated files.'); + } return Promise.resolve(true); })); diff --git a/build/next/index.ts b/build/next/index.ts index defbaa1b9dd18a..60597b7b05106c 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -662,12 +662,17 @@ function fileContentMapperPlugin(outDir: string, target: BuildTarget): esbuild.P // Inject built-in extensions list if (contents.includes('/*BUILD->INSERT_BUILTIN_EXTENSIONS*/')) { if (builtinExtensionsReplacement === undefined) { - // Web target uses .build/web/extensions (from compileWebExtensionsBuildTask) - // Other targets use .build/extensions - const extensionsRoot = target === 'web' ? '.build/web/extensions' : '.build/extensions'; - const builtinExtensions = JSON.stringify(scanBuiltinExtensions(extensionsRoot)); - // Remove the outer brackets since the placeholder is inside an array literal - builtinExtensionsReplacement = builtinExtensions.substring(1, builtinExtensions.length - 1); + if (target === 'web' || target === 'server-web') { + // Web target uses .build/web/extensions (from compileWebExtensionsBuildTask) + // while server-web uses .build/extensions. + const extensionsRoot = target === 'web' ? '.build/web/extensions' : '.build/extensions'; + const builtinExtensions = JSON.stringify(scanBuiltinExtensions(extensionsRoot)); + // Remove the outer brackets since the placeholder is inside an array literal + builtinExtensionsReplacement = builtinExtensions.substring(1, builtinExtensions.length - 1); + } else { + // Native targets never consume the web-only bundled extension list. + builtinExtensionsReplacement = ''; + } } contents = contents.replace('/*BUILD->INSERT_BUILTIN_EXTENSIONS*/', () => builtinExtensionsReplacement!); modified = true; diff --git a/eslint.config.js b/eslint.config.js index b9221655462a09..6b65c3d8f1d911 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -30,6 +30,12 @@ const allowedJavaScriptFiles = fs.readFileSync(path.join(import.meta.dirname, '. .map(line => line.trim()) .filter(line => line && !line.startsWith('#')); +const allowedBracketNotationFiles = fs.readFileSync(path.join(import.meta.dirname, '.eslint-allowed-bracket-notation-files'), 'utf8') + .toString() + .split(/\r\n|\n/) + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')); + export default defineConfig( // Global ignores { @@ -144,6 +150,20 @@ export default defineConfig( ] }, }, + // Disallow bracket notation for property names that can use dot notation. + { + files: [ + '**/*.{js,cjs,mjs,ts,tsx,mts,cts}', + '.eslint-plugin-local/**/*.ts', + ], + ignores: allowedBracketNotationFiles, + plugins: { + 'local': pluginLocal, + }, + rules: { + 'local/code-no-bracket-notation-for-identifiers': 'warn', + }, + }, // TS { files: [ diff --git a/extensions/copilot/CodeQL.yml b/extensions/copilot/CodeQL.yml deleted file mode 100644 index 8862580602e063..00000000000000 --- a/extensions/copilot/CodeQL.yml +++ /dev/null @@ -1,5 +0,0 @@ -path_classifiers: - tests: - - "test/simulation/fixtures/edit-asyncawait-4151/*.ts" - - "test/simulation/fixtures/edit-slice-4149/*.ts" - - src/platform/parser/test/node/fixtures diff --git a/extensions/copilot/build/update-assets.yml b/extensions/copilot/build/update-assets.yml index ca729aef0dd3ca..a76c72f27d98f5 100644 --- a/extensions/copilot/build/update-assets.yml +++ b/extensions/copilot/build/update-assets.yml @@ -1,6 +1,10 @@ trigger: none pr: none +variables: + - name: Codeql.SkipTaskAutoInjection + value: true + resources: repositories: - repository: 1esPipelines diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index c014c84ad103e0..647efa0778ad64 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -92,6 +92,7 @@ "agentsWindowConfiguration", "chatDebug", "chatHooks", + "envIsConnectionMetered", "extensionsAny", "newSymbolNamesProvider", "interactive", diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/vscodeInlineCompletionItemProvider.ts b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/vscodeInlineCompletionItemProvider.ts index e9349e5fc81a3c..d7ffbc268716e7 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/vscodeInlineCompletionItemProvider.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/vscodeInlineCompletionItemProvider.ts @@ -6,6 +6,7 @@ import { basename } from 'path'; import { CancellationToken, + env, InlineCompletionContext, InlineCompletionEndOfLifeReason, InlineCompletionItemProvider, @@ -102,6 +103,10 @@ export class CopilotInlineCompletionItemProvider extends Disposable implements I token: CancellationToken ): Promise { + if (context.triggerKind === InlineCompletionTriggerKind.Automatic && env.isMeteredConnection) { + return; + } + // it's ok to return an undefined here because we don't want telemetry for when automatic completions are disabled if (context.triggerKind === InlineCompletionTriggerKind.Automatic) { if (!this.instantiationService.invokeFunction(isCompletionEnabledForDocument, doc)) { diff --git a/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts b/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts index a68f034a4c468b..c780c00227f704 100644 --- a/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts +++ b/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as l10n from '@vscode/l10n'; -import { CancellationToken, Command, EndOfLine, InlineCompletionContext, InlineCompletionDisplayLocation, InlineCompletionDisplayLocationKind, InlineCompletionEndOfLifeReason, InlineCompletionEndOfLifeReasonKind, InlineCompletionItem, InlineCompletionItemProvider, InlineCompletionList, InlineCompletionModelInfo, InlineCompletionProviderOption, InlineCompletionsDisposeReason, InlineCompletionsDisposeReasonKind, NotebookCell, NotebookCellKind, Position, Range, TextDocument, TextDocumentShowOptions, Uri, window, workspace } from 'vscode'; +import { CancellationToken, Command, EndOfLine, env, InlineCompletionContext, InlineCompletionDisplayLocation, InlineCompletionDisplayLocationKind, InlineCompletionEndOfLifeReason, InlineCompletionEndOfLifeReasonKind, InlineCompletionItem, InlineCompletionItemProvider, InlineCompletionList, InlineCompletionModelInfo, InlineCompletionProviderOption, InlineCompletionsDisposeReason, InlineCompletionsDisposeReasonKind, InlineCompletionTriggerKind, NotebookCell, NotebookCellKind, Position, Range, TextDocument, TextDocumentShowOptions, Uri, window, workspace } from 'vscode'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { IDiffService } from '../../../platform/diff/common/diffService'; import { stringEditFromDiff } from '../../../platform/editing/common/edit'; @@ -233,6 +233,10 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo context: InlineCompletionContext | NESInlineCompletionContext, token: CancellationToken ): Promise { + if (context.triggerKind === InlineCompletionTriggerKind.Automatic && env.isMeteredConnection) { + return undefined; + } + const label = `NES | ${basename(document.uri.fsPath)} (v${document.version})`; const capturingToken = new CapturingToken(label, undefined); diff --git a/extensions/copilot/src/extension/mcp/test/vscode-node/nuget.stub.spec.ts b/extensions/copilot/src/extension/mcp/test/vscode-node/nuget.stub.spec.ts index d7f48a1d541276..9316370f325901 100644 --- a/extensions/copilot/src/extension/mcp/test/vscode-node/nuget.stub.spec.ts +++ b/extensions/copilot/src/extension/mcp/test/vscode-node/nuget.stub.spec.ts @@ -7,7 +7,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { ILogService } from '../../../../platform/log/common/logService'; import { ITestingServicesAccessor, TestingServiceCollection } from '../../../../platform/test/node/services'; import { createExtensionUnitTestingServices } from '../../../test/node/services'; -import { IInstallableMcpServer, mapServerJsonToMcpServer, McpServerType, NuGetMcpSetup, RegistryType } from '../../vscode-node/nuget'; +import { IGalleryMcpServerConfiguration, IInstallableMcpServer, mapServerJsonToMcpServer, McpMappingUtility, McpServerType, NuGetMcpSetup, RegistryType, TransportType } from '../../vscode-node/nuget'; import { FixtureCommandExecutor, FixtureFetcherService } from './util'; describe('get nuget MCP server info using fake CLI', { timeout: 30_000 }, () => { @@ -198,4 +198,64 @@ describe('mapServerJsonToMcpServer', () => { expect(actual).toEqual(expected); }); + + it('rejects unsupported 2025-07-09 package registry types', () => { + const manifest = { + '$schema': 'https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json', + name: 'test', + description: 'test', + version: '1.0.0', + packages: [{ registry_type: 'unsupported', name: 'SomeId', version: '0.1.0' }] + }; + + expect(mapServerJsonToMcpServer(manifest, RegistryType.NUGET)).toBeUndefined(); + }); + + it('rejects unsupported 2025-09-29 package registry types', () => { + const manifest = { + '$schema': 'https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json', + name: 'test', + description: 'test', + version: '1.0.0', + packages: [{ registryType: 'unsupported', identifier: 'SomeId', version: '0.1.0' }] + }; + + expect(mapServerJsonToMcpServer(manifest, RegistryType.NUGET)).toBeUndefined(); + }); + + it('filters unsupported packages when a supported package remains', () => { + const manifest = { + '$schema': 'https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json', + name: 'test', + description: 'test', + version: '1.0.0', + packages: [ + { registryType: 'mcpb', identifier: 'UnsupportedId', version: '0.1.0' }, + { registryType: 'nuget', identifier: 'SomeId', version: '0.1.0' } + ] + }; + + expect(mapServerJsonToMcpServer(manifest, RegistryType.NUGET)).toEqual({ + config: { + type: McpServerType.LOCAL, + command: 'dnx', + args: ['SomeId@0.1.0', '--yes'] + } + }); + }); + + it('fails closed when mapping an unsupported package registry type', () => { + const registryType = 'unsupported' as RegistryType; + const manifest: IGalleryMcpServerConfiguration = { + packages: [{ + registryType, + identifier: 'SomeId', + version: '0.1.0', + transport: { type: TransportType.STDIO } + }] + }; + + expect(() => new McpMappingUtility().getMcpServerConfigurationFromManifest(manifest, registryType)) + .toThrow('Unsupported MCP server package registry type: unsupported'); + }); }); diff --git a/extensions/copilot/src/extension/mcp/vscode-node/nuget.ts b/extensions/copilot/src/extension/mcp/vscode-node/nuget.ts index 46ced94d04e395..b7c9b1075d1027 100644 --- a/extensions/copilot/src/extension/mcp/vscode-node/nuget.ts +++ b/extensions/copilot/src/extension/mcp/vscode-node/nuget.ts @@ -376,6 +376,42 @@ interface IRawGalleryMcpServer { readonly remotes?: ReadonlyArray; } +function toSupportedPackageRegistryType(input: unknown): RegistryType | undefined { + switch (input) { + case 'npm': + return RegistryType.NODE; + case 'docker': + case 'docker-hub': + case 'oci': + return RegistryType.DOCKER; + case 'pypi': + return RegistryType.PYTHON; + case 'nuget': + return RegistryType.NUGET; + default: + return undefined; + } +} + +function filterMcpPackages( + packages: readonly T[] | undefined, + getRegistryType: (serverPackage: T) => unknown, + convert: (serverPackage: T, registryType: RegistryType) => IMcpServerPackage, +): IMcpServerPackage[] | undefined { + if (!packages) { + return undefined; + } + + const result: IMcpServerPackage[] = []; + for (const serverPackage of packages) { + const registryType = toSupportedPackageRegistryType(getRegistryType(serverPackage)); + if (registryType) { + result.push(convert(serverPackage, registryType)); + } + } + return result; +} + export namespace McpServerSchemaVersion_v2025_07_09 { export const VERSION = 'v0-2025-07-09'; @@ -545,38 +581,28 @@ export namespace McpServerSchemaVersion_v2025_07_09 { } } - function convertRegistryType(input: string): RegistryType { - switch (input) { - case 'npm': - return RegistryType.NODE; - case 'docker': - case 'docker-hub': - case 'oci': - return RegistryType.DOCKER; - case 'pypi': - return RegistryType.PYTHON; - case 'nuget': - return RegistryType.NUGET; - case 'mcpb': - return RegistryType.MCPB; - default: - return RegistryType.NODE; - } + const packages = filterMcpPackages( + from.packages, + serverPackage => serverPackage.registry_type ?? serverPackage.registry_name, + (serverPackage, registryType) => ({ + identifier: serverPackage.identifier ?? serverPackage.name, + registryType, + version: serverPackage.version, + fileSha256: serverPackage.file_sha256, + registryBaseUrl: serverPackage.registry_base_url, + transport: serverPackage.transport ? convertTransport(serverPackage.transport) : { type: TransportType.STDIO }, + packageArguments: serverPackage.package_arguments?.map(convertServerArgument), + runtimeHint: serverPackage.runtime_hint, + runtimeArguments: serverPackage.runtime_arguments?.map(convertServerArgument), + environmentVariables: serverPackage.environment_variables?.map(convertKeyValueInput), + }), + ); + if (!packages?.length && !from.remotes?.length) { + return undefined; } return { - packages: from.packages?.map(p => ({ - identifier: p.identifier ?? p.name, - registryType: convertRegistryType(p.registry_type ?? p.registry_name), - version: p.version, - fileSha256: p.file_sha256, - registryBaseUrl: p.registry_base_url, - transport: p.transport ? convertTransport(p.transport) : { type: TransportType.STDIO }, - packageArguments: p.package_arguments?.map(convertServerArgument), - runtimeHint: p.runtime_hint, - runtimeArguments: p.runtime_arguments?.map(convertServerArgument), - environmentVariables: p.environment_variables?.map(convertKeyValueInput), - })), + packages, remotes: from.remotes?.map(remote => { const type = (remote).type ?? (remote).transport_type ?? (remote).transport; return { @@ -651,7 +677,7 @@ namespace McpServerSchemaVersion_v0_1 { } interface RawGalleryMcpServerPackage { - readonly registryType: RegistryType; + readonly registryType: string; readonly identifier: string; readonly version: string; readonly transport: RawGalleryTransport; @@ -692,8 +718,17 @@ namespace McpServerSchemaVersion_v0_1 { return undefined; } + const packages = filterMcpPackages( + from.server.packages, + serverPackage => serverPackage.registryType, + (serverPackage, registryType) => ({ ...serverPackage, registryType }), + ); + if (!packages?.length && !from.server.remotes?.length) { + return undefined; + } + return { - packages: from.server.packages, + packages, remotes: from.server.remotes, }; } @@ -716,6 +751,9 @@ export namespace McpServerSchemaVersion_v0 { } public toRawGalleryMcpServer(input: unknown): IRawGalleryMcpServer | undefined { + if (isObject(input) && isObject((input as { readonly server?: unknown }).server)) { + return McpServerSchemaVersion_v0_1.SERIALIZER.toRawGalleryMcpServer(input); + } for (const serializer of this.galleryMcpServerDataSerializers) { const result = serializer.toRawGalleryMcpServer(input); if (result) { @@ -936,8 +974,8 @@ export class McpMappingUtility { case RegistryType.DOCKER: return 'docker'; case RegistryType.PYTHON: return 'uvx'; case RegistryType.NUGET: return 'dnx'; + default: throw new Error(`Unsupported MCP server package registry type: ${packageType}`); } - return packageType; } protected getVariables(variableInputs: Record): IMcpServerVariable[] { diff --git a/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts index 7c4412b5e52535..972f76475d3567 100644 --- a/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts +++ b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts @@ -574,6 +574,7 @@ export function isEagernessPrompt(options: PromptOptions): boolean { return (options.eagernessPrompt !== undefined && [ PromptingStrategy.PatchBased02, PromptingStrategy.PatchBased02WithRecentLineNumbers, + PromptingStrategy.PatchBased02Unified, PromptingStrategy.PatchBased02WithoutRecentLineNumbers, ].includes(options.promptingStrategy)) // eagerness prompt option is only supported for patch-based strategies || [PromptingStrategy.XtabAggressiveness, diff --git a/extensions/copilot/src/platform/inlineEdits/test/common/xtabPromptOptions.spec.ts b/extensions/copilot/src/platform/inlineEdits/test/common/xtabPromptOptions.spec.ts index 5393db445f9fb0..6543d82e4b50b0 100644 --- a/extensions/copilot/src/platform/inlineEdits/test/common/xtabPromptOptions.spec.ts +++ b/extensions/copilot/src/platform/inlineEdits/test/common/xtabPromptOptions.spec.ts @@ -159,6 +159,15 @@ describe('isEagernessPrompt', () => { expect(isEagernessPrompt({ ...DEFAULT_OPTIONS, promptingStrategy: PromptingStrategy.PatchBased02, eagernessPrompt: 'aggressionHighLow' })).toBe(true); expect(isEagernessPrompt({ ...DEFAULT_OPTIONS, promptingStrategy: PromptingStrategy.PatchBased02 })).toBe(false); }); + + it('recognizes the optimized PatchBased02 aggression prompt option', () => { + expect(isEagernessPrompt({ ...DEFAULT_OPTIONS, promptingStrategy: PromptingStrategy.PatchBased02Unified, eagernessPrompt: 'aggressionHighLow' })).toBe(true); + expect(isEagernessPrompt({ ...DEFAULT_OPTIONS, promptingStrategy: PromptingStrategy.PatchBased02Unified })).toBe(false); + }); + + it('does not recognize eagerness for an unrelated strategy', () => { + expect(isEagernessPrompt({ ...DEFAULT_OPTIONS, promptingStrategy: PromptingStrategy.CopilotNesXtab, eagernessPrompt: 'aggressionHighLow' })).toBe(false); + }); }); describe('GlobalBudgetOptions', () => { diff --git a/extensions/copilot/tsconfig.json b/extensions/copilot/tsconfig.json index 7743f80b4b0db7..9e7d73a4ffff96 100644 --- a/extensions/copilot/tsconfig.json +++ b/extensions/copilot/tsconfig.json @@ -23,7 +23,8 @@ "include": [ "./src", "./test", - ".vscode/extensions/test-extension" + ".vscode/extensions/test-extension", + "../../src/vscode-dts/vscode.proposed.envIsConnectionMetered.d.ts" ], "exclude": [ "test/scenarios/**/*", diff --git a/extensions/markdown-language-features/.vscodeignore b/extensions/markdown-language-features/.vscodeignore index 31ae4c0eb0bb93..0875889991d70c 100644 --- a/extensions/markdown-language-features/.vscodeignore +++ b/extensions/markdown-language-features/.vscodeignore @@ -1,5 +1,6 @@ test/** test-workspace/** +scripts/** src/** notebook/** tsconfig*.json diff --git a/extensions/typescript-language-features/src/task/taskProvider.ts b/extensions/typescript-language-features/src/task/taskProvider.ts index b38469ba58d73d..c951206d071431 100644 --- a/extensions/typescript-language-features/src/task/taskProvider.ts +++ b/extensions/typescript-language-features/src/task/taskProvider.ts @@ -6,9 +6,9 @@ import * as jsonc from 'jsonc-parser'; import * as path from 'path'; import * as vscode from 'vscode'; -import { wait } from '../test/testUtils'; import { ITypeScriptServiceClient, ServerResponse } from '../typescriptService'; import { coalesce } from '../utils/arrays'; +import { raceTimeout } from '../utils/async'; import { readUnifiedConfig } from '../utils/configuration'; import { Disposable } from '../utils/dispose'; import { exists } from '../utils/fs'; @@ -157,13 +157,11 @@ class TscTaskProvider extends Disposable implements vscode.TaskProvider { const getConfigsTimeout = new vscode.CancellationTokenSource(); token.onCancellationRequested(() => getConfigsTimeout.cancel()); - return Promise.race([ + return (await raceTimeout( this.tsconfigProvider.getConfigsForWorkspace(getConfigsTimeout.token).then(x => Array.from(x)), - wait(this.findConfigFilesTimeout).then(() => { - getConfigsTimeout.cancel(); - return []; - }), - ]); + this.findConfigFilesTimeout, + () => getConfigsTimeout.cancel(), + )) ?? []; } private static async getCommand(project: TSConfig): Promise { diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index ac3f08fdc23bf1..6d7ea0fbd8ee3e 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -11,7 +11,7 @@ import { AbstractIdleValue, IntervalTimer, TimeoutTimer, _runWhenIdle, IdleDeadl import { BugIndicatingError, onUnexpectedError } from '../common/errors.js'; import * as event from '../common/event.js'; import { KeyCode } from '../common/keyCodes.js'; -import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, markAsSingleton, MutableDisposable, toDisposable } from '../common/lifecycle.js'; import { RemoteAuthorities } from '../common/network.js'; import * as platform from '../common/platform.js'; import { URI } from '../common/uri.js'; @@ -1890,7 +1890,10 @@ export interface IModifierKeyStatus { export class ModifierKeyEmitter extends event.Emitter { - private readonly _subscriptions = new DisposableStore(); + // This emitter is a lazily created singleton (see `getInstance`) that is allowed + // to outlive the test that happens to create it first. `Emitter` itself is not + // tracked, so the store has to be marked to keep it out of leak detection. + private readonly _subscriptions = markAsSingleton(new DisposableStore()); private _keyStatus: IModifierKeyStatus; private static instance: ModifierKeyEmitter | undefined; diff --git a/src/vs/base/browser/ui/findinput/findInput.ts b/src/vs/base/browser/ui/findinput/findInput.ts index e12672c7f61556..46ecbf4e769d92 100644 --- a/src/vs/base/browser/ui/findinput/findInput.ts +++ b/src/vs/base/browser/ui/findinput/findInput.ts @@ -45,6 +45,10 @@ export interface IFindInputOptions { readonly hideHoverOnValueChange?: boolean; } +export interface IFindInputEvent { + readonly fromCompositionEnd: boolean; +} + const NLS_DEFAULT_LABEL = nls.localize('defaultLabel', "input"); export class FindInput extends Widget { @@ -76,8 +80,8 @@ export class FindInput extends Widget { private readonly _onMouseDown = this._register(new Emitter()); public get onMouseDown(): Event { return this._onMouseDown.event; } - private readonly _onInput = this._register(new Emitter()); - public get onInput(): Event { return this._onInput.event; } + private readonly _onInput = this._register(new Emitter()); + public get onInput(): Event { return this._onInput.event; } private readonly _onKeyUp = this._register(new Emitter()); public get onKeyUp(): Event { return this._onKeyUp.event; } @@ -204,12 +208,12 @@ export class FindInput extends Widget { })); this._register(dom.addDisposableListener(this.inputBox.inputElement, 'compositionend', (e: CompositionEvent) => { this.imeSessionInProgress = false; - this._onInput.fire(); + this._onInput.fire({ fromCompositionEnd: true }); })); this.onkeydown(this.inputBox.inputElement, (e) => this._onKeyDown.fire(e)); this.onkeyup(this.inputBox.inputElement, (e) => this._onKeyUp.fire(e)); - this.oninput(this.inputBox.inputElement, (e) => this._onInput.fire()); + this.oninput(this.inputBox.inputElement, (e) => this._onInput.fire({ fromCompositionEnd: false })); this.onmousedown(this.inputBox.inputElement, (e) => this._onMouseDown.fire(e)); } diff --git a/src/vs/base/browser/ui/splitview/splitview.ts b/src/vs/base/browser/ui/splitview/splitview.ts index 35f2724c1a8e48..00e413644ed44d 100644 --- a/src/vs/base/browser/ui/splitview/splitview.ts +++ b/src/vs/base/browser/ui/splitview/splitview.ts @@ -1017,7 +1017,13 @@ export class SplitView i !== index); + const lowPriorityIndexes = [...indexes.filter(i => this.viewItems[i].priority === LayoutPriority.Low), index]; + const highPriorityIndexes = indexes.filter(i => this.viewItems[i].priority === LayoutPriority.High); + + this.relayout(lowPriorityIndexes, highPriorityIndexes); } } diff --git a/src/vs/base/common/platform.ts b/src/vs/base/common/platform.ts index d2072dea83c8b4..bc542baa21ac6e 100644 --- a/src/vs/base/common/platform.ts +++ b/src/vs/base/common/platform.ts @@ -11,6 +11,7 @@ let _isWindows = false; let _isMacintosh = false; let _isLinux = false; let _isLinuxSnap = false; +let _isChromeOS = false; let _isNative = false; let _isWeb = false; let _isElectron = false; @@ -101,6 +102,7 @@ else if (typeof navigator === 'object' && !isElectronRenderer) { _isMacintosh = _userAgent.indexOf('Macintosh') >= 0; _isIOS = (_userAgent.indexOf('Macintosh') >= 0 || _userAgent.indexOf('iPad') >= 0 || _userAgent.indexOf('iPhone') >= 0) && !!navigator.maxTouchPoints && navigator.maxTouchPoints > 0; _isLinux = _userAgent.indexOf('Linux') >= 0; + _isChromeOS = _userAgent.indexOf('CrOS') >= 0; _isMobile = _userAgent?.indexOf('Mobi') >= 0; _isWeb = true; _language = nls.getNLSLanguage() || LANGUAGE_DEFAULT; @@ -143,6 +145,7 @@ export const isWindows = _isWindows; export const isMacintosh = _isMacintosh; export const isLinux = _isLinux; export const isLinuxSnap = _isLinuxSnap; +export const isChromeOS = _isChromeOS; export const isNative = _isNative; export const isElectron = _isElectron; export const isWeb = _isWeb; diff --git a/src/vs/base/parts/request/common/requestImpl.ts b/src/vs/base/parts/request/common/requestImpl.ts index 17f80575ef5e23..d895d436e1b25d 100644 --- a/src/vs/base/parts/request/common/requestImpl.ts +++ b/src/vs/base/parts/request/common/requestImpl.ts @@ -30,6 +30,9 @@ export async function request(options: IRequestOptions, token: CancellationToken if (options.disableCache) { fetchInit.cache = 'no-store'; } + if (options.followRedirects === 0) { + fetchInit.redirect = 'manual'; + } const res = await fetch(options.url || '', fetchInit); return { res: { diff --git a/src/vs/base/parts/request/test/electron-main/request.test.ts b/src/vs/base/parts/request/test/electron-main/request.test.ts index e1d88f1583e8ef..09a7b84f192fe2 100644 --- a/src/vs/base/parts/request/test/electron-main/request.test.ts +++ b/src/vs/base/parts/request/test/electron-main/request.test.ts @@ -17,14 +17,24 @@ suite('Request', () => { let port: number; let server: http.Server; + let redirectTargetRequests: number; setup(async () => { const http = await import('http'); + redirectTargetRequests = 0; port = await new Promise((resolvePort, rejectPort) => { server = http.createServer((req, res) => { if (req.url === '/noreply') { return; // never respond } + if (req.url === '/redirect') { + res.writeHead(302, { location: `http://127.0.0.1:${port}/redirect-target` }); + res.end(); + return; + } + if (req.url === '/redirect-target') { + redirectTargetRequests++; + } res.setHeader('Content-Type', 'application/json'); if (req.headers['echo-header']) { res.setHeader('echo-header', req.headers['echo-header']); @@ -87,6 +97,22 @@ suite('Request', () => { assert.strictEqual(body.data, 'Some data'); }); + test('does not follow redirects when disabled', async () => { + const context = await request({ + url: `http://127.0.0.1:${port}/redirect`, + followRedirects: 0, + callSite: 'request.test.noRedirects' + }, CancellationToken.None); + + assert.deepStrictEqual({ + statusCode: context.res.statusCode, + redirectTargetRequests + }, { + statusCode: 0, + redirectTargetRequests: 0 + }); + }); + test('timeout', async () => { return runWithFakedTimers({}, async () => { try { diff --git a/src/vs/base/test/browser/ui/splitview/splitview.test.ts b/src/vs/base/test/browser/ui/splitview/splitview.test.ts index 76d7639b04ce6e..f1bf4fba88f7ca 100644 --- a/src/vs/base/test/browser/ui/splitview/splitview.test.ts +++ b/src/vs/base/test/browser/ui/splitview/splitview.test.ts @@ -36,6 +36,9 @@ class TestView implements IView { private readonly _onDidFocus = new Emitter(); readonly onDidFocus = this._onDidFocus.event; + private readonly _onDidSetVisible = new Emitter(); + readonly onDidSetVisible = this._onDidSetVisible.event; + constructor( private _minimumSize: number, private _maximumSize: number, @@ -44,6 +47,17 @@ class TestView implements IView { assert(_minimumSize <= _maximumSize, 'splitview view minimum size must be <= maximum size'); } + /** Change both size constraints at once, as a view with a fixed width does when that width changes. */ + setSizeConstraints(minimumSize: number, maximumSize: number): void { + this._minimumSize = minimumSize; + this._maximumSize = maximumSize; + this._onDidChange.fire(undefined); + } + + setVisible(visible: boolean): void { + this._onDidSetVisible.fire(visible); + } + layout(size: number, _offset: number, orthogonalSize: number | undefined): void { this._size = size; this._orthogonalSize = orthogonalSize; @@ -59,6 +73,7 @@ class TestView implements IView { this._onDidGetElement.dispose(); this._onDidLayout.dispose(); this._onDidFocus.dispose(); + this._onDidSetVisible.dispose(); } } @@ -459,6 +474,32 @@ suite('Splitview', () => { assert.deepStrictEqual([view1.size, view2.size, view3.size], [20, 160, 20]); }); + test('view changing its size constraints while a sibling is toggled does not squeeze that sibling (#334167)', () => { + + // Mirrors the workbench with the side bar on the right: the activity bar reports its new fixed width from within the visibility change of the side bar + const editor = store.add(new TestView(20, Number.POSITIVE_INFINITY, LayoutPriority.High)); + const sideBar = store.add(new TestView(20, Number.POSITIVE_INFINITY, LayoutPriority.Low)); + const activityBar = store.add(new TestView(10, 10)); + const splitview = store.add(new SplitView(container, { proportionalLayout: false })); + splitview.layout(200); + + splitview.addView(editor, 120); + splitview.addView(sideBar, 70); + splitview.addView(activityBar, 10); + + // `addView` distributes in index order too, so restore the side bar to a known starting point + splitview.resizeView(1, 70); + assert.deepStrictEqual([editor.size, sideBar.size, activityBar.size], [120, 70, 10]); + + store.add(sideBar.onDidSetVisible(visible => activityBar.setSizeConstraints(visible ? 10 : 12, visible ? 10 : 12))); + + splitview.setViewVisible(1, false); + assert.deepStrictEqual([editor.size, sideBar.size, activityBar.size], [188, 0, 12]); + + splitview.setViewVisible(1, true); + assert.deepStrictEqual([editor.size, sideBar.size, activityBar.size], [120, 70, 10]); + }); + test('context propagates to views', () => { const view1 = store.add(new TestView(20, Number.POSITIVE_INFINITY)); const view2 = store.add(new TestView(20, Number.POSITIVE_INFINITY)); diff --git a/src/vs/editor/browser/config/fontMeasurements.ts b/src/vs/editor/browser/config/fontMeasurements.ts index 759add4ccc843c..2df57fcc6368f9 100644 --- a/src/vs/editor/browser/config/fontMeasurements.ts +++ b/src/vs/editor/browser/config/fontMeasurements.ts @@ -69,9 +69,9 @@ export class FontMeasurementsImpl extends Disposable { return cache; } - private _writeToCache(targetWindow: Window, item: BareFontInfo, value: FontInfo): void { + private _writeToCache(targetWindow: Window, item: BareFontInfo, value: FontInfo, isRestored: boolean): void { const cache = this._ensureCache(targetWindow); - cache.put(item, value); + cache.put(item, value, isRestored); if (!value.isTrusted && this._evictUntrustedReadingsTimeout === -1) { // Try reading again after some time @@ -98,11 +98,13 @@ export class FontMeasurementsImpl extends Disposable { } /** - * Serialized currently cached font information. + * Returns trusted cached font information, or undefined when the cache contains only restored readings. */ - public serializeFontInfo(targetWindow: Window): ISerializedFontInfo[] { - // Only save trusted font info (that has been measured in this running instance) + public serializeFontInfo(targetWindow: Window): ISerializedFontInfo[] | undefined { const cache = this._ensureCache(targetWindow); + if (cache.shouldPreservePersistedValues()) { + return undefined; + } return cache.getValues().filter(item => item.isTrusted); } @@ -118,7 +120,7 @@ export class FontMeasurementsImpl extends Disposable { continue; } const fontInfo = new FontInfo(savedFontInfo, false); - this._writeToCache(targetWindow, fontInfo, fontInfo); + this._writeToCache(targetWindow, fontInfo, fontInfo, true); } } @@ -152,7 +154,7 @@ export class FontMeasurementsImpl extends Disposable { }, false); } - this._writeToCache(targetWindow, bareFontInfo, readConfig); + this._writeToCache(targetWindow, bareFontInfo, readConfig, false); } return cache.get(bareFontInfo); } @@ -249,6 +251,8 @@ class FontMeasurementsCache { private readonly _keys: { [key: string]: BareFontInfo }; private readonly _values: { [key: string]: FontInfo }; + private _wasPopulatedWithRestoredValues = false; + private _wasPopulatedWithCurrentSessionValues = false; constructor() { this._keys = Object.create(null); @@ -265,10 +269,15 @@ class FontMeasurementsCache { return this._values[itemId]; } - public put(item: BareFontInfo, value: FontInfo): void { + public put(item: BareFontInfo, value: FontInfo, isRestored: boolean): void { const itemId = item.getId(); this._keys[itemId] = item; this._values[itemId] = value; + if (isRestored) { + this._wasPopulatedWithRestoredValues = true; + } else { + this._wasPopulatedWithCurrentSessionValues = true; + } } public remove(item: FontInfo): void { @@ -280,6 +289,10 @@ class FontMeasurementsCache { public getValues(): FontInfo[] { return Object.keys(this._keys).map(id => this._values[id]); } + + public shouldPreservePersistedValues(): boolean { + return this._wasPopulatedWithRestoredValues && !this._wasPopulatedWithCurrentSessionValues; + } } export const FontMeasurements = new FontMeasurementsImpl(); diff --git a/src/vs/editor/browser/gpu/viewGpuContext.ts b/src/vs/editor/browser/gpu/viewGpuContext.ts index 9d520abdb3b077..2fd57051960f9e 100644 --- a/src/vs/editor/browser/gpu/viewGpuContext.ts +++ b/src/vs/editor/browser/gpu/viewGpuContext.ts @@ -12,6 +12,7 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import type { ViewportData } from '../../common/viewLayout/viewLinesViewportData.js'; import type { ViewLineOptions } from '../viewParts/viewLines/viewLineOptions.js'; import { observableValue, runOnChange, type IObservable } from '../../../base/common/observable.js'; +import * as strings from '../../../base/common/strings.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { TextureAtlas } from './atlas/textureAtlas.js'; import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; @@ -164,7 +165,8 @@ export class ViewGpuContext extends Disposable { // Check if the line has simple attributes that aren't supported if ( data.containsRTL || - data.maxColumn > this.maxGpuCols + data.maxColumn > this.maxGpuCols || + requiresDomFullwidthCharacterRendering(options, data.content) ) { return false; } @@ -212,6 +214,9 @@ export class ViewGpuContext extends Disposable { if (data.maxColumn > this.maxGpuCols) { reasons.push('maxColumn > maxGpuCols'); } + if (requiresDomFullwidthCharacterRendering(options, data.content)) { + reasons.push('useTwoCellFullwidthCharacters'); + } if (data.inlineDecorations.length > 0) { let supported = true; const problemTypes: InlineDecorationType[] = []; @@ -257,6 +262,19 @@ export class ViewGpuContext extends Disposable { } } +function requiresDomFullwidthCharacterRendering(options: ViewLineOptions, content: string): boolean { + if (!options.useTwoCellFullwidthCharacters) { + return false; + } + const limit = options.stopRenderingLineAfter === -1 ? content.length : Math.min(options.stopRenderingLineAfter, content.length); + for (let offset = 0; offset < limit; offset++) { + if (strings.isFullWidthCharacter(content.charCodeAt(offset))) { + return true; + } + } + return false; +} + /** * A list of supported decoration CSS rules that can be used in the GPU renderer. */ diff --git a/src/vs/editor/browser/view/domLineBreaksComputer.ts b/src/vs/editor/browser/view/domLineBreaksComputer.ts index abac9d8a123d1f..af958c54b93afe 100644 --- a/src/vs/editor/browser/view/domLineBreaksComputer.ts +++ b/src/vs/editor/browser/view/domLineBreaksComputer.ts @@ -26,20 +26,20 @@ export class DOMLineBreaksComputerFactory implements ILineBreaksComputerFactory constructor(private targetWindow: WeakRef) { } - public createLineBreaksComputer(context: ILineBreaksComputerContext, fontInfo: FontInfo, tabSize: number, wrappingColumn: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', wrapOnEscapedLineFeeds: boolean): ILineBreaksComputer { + public createLineBreaksComputer(context: ILineBreaksComputerContext, fontInfo: FontInfo, tabSize: number, wrappingColumn: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', wrapOnEscapedLineFeeds: boolean, useTwoCellFullwidthCharacters: boolean): ILineBreaksComputer { const lineNumbers: number[] = []; return { addRequest: (lineNumber: number, previousLineBreakData: ModelLineProjectionData | null) => { lineNumbers.push(lineNumber); }, finalize: () => { - return createLineBreaks(assertReturnsDefined(this.targetWindow.deref()), context, lineNumbers, fontInfo, tabSize, wrappingColumn, wrappingIndent, wordBreak); + return createLineBreaks(assertReturnsDefined(this.targetWindow.deref()), context, lineNumbers, fontInfo, tabSize, wrappingColumn, wrappingIndent, wordBreak, useTwoCellFullwidthCharacters); } }; } } -function createLineBreaks(targetWindow: Window, context: ILineBreaksComputerContext, lineNumbers: number[], fontInfo: FontInfo, tabSize: number, firstLineBreakColumn: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll'): (ModelLineProjectionData | null)[] { +function createLineBreaks(targetWindow: Window, context: ILineBreaksComputerContext, lineNumbers: number[], fontInfo: FontInfo, tabSize: number, firstLineBreakColumn: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', useTwoCellFullwidthCharacters: boolean): (ModelLineProjectionData | null)[] { function createEmptyLineBreakWithPossiblyInjectedText(lineNumber: number): ModelLineProjectionData | null { const injectedTexts = context.getLineInjectedText(lineNumber); if (injectedTexts) { @@ -69,6 +69,9 @@ function createLineBreaks(targetWindow: Window, context: ILineBreaksComputerCont const additionalIndent = (wrappingIndent === WrappingIndent.DeepIndent ? 2 : wrappingIndent === WrappingIndent.Indent ? 1 : 0); const additionalIndentSize = Math.round(tabSize * additionalIndent); const additionalIndentLength = Math.ceil(fontInfo.spaceWidth * additionalIndentSize); + // Stretching full-width characters to two cells makes their width independent of the font, so the + // width the font reports for them must not be used anywhere below. + const fullwidthCharacterWidth = useTwoCellFullwidthCharacters ? 2 * fontInfo.spaceWidth : undefined; const containerDomNode = document.createElement('div'); applyFontInfo(containerDomNode, fontInfo); @@ -117,7 +120,7 @@ function createLineBreaks(targetWindow: Window, context: ILineBreaksComputerCont const indentWidth = Math.ceil(fontInfo.spaceWidth * wrappedTextIndentLength); // Force sticking to beginning of line if no character would fit except for the indentation - if (indentWidth + fontInfo.typicalFullwidthCharacterWidth > overallWidth) { + if (indentWidth + (fullwidthCharacterWidth ?? fontInfo.typicalFullwidthCharacterWidth) > overallWidth) { firstNonWhitespaceIndex = 0; wrappedTextIndentLength = 0; } else { @@ -134,7 +137,7 @@ function createLineBreaks(targetWindow: Window, context: ILineBreaksComputerCont endOffset: range.endOffset - firstNonWhitespaceIndex, widthInEm: range.widthInEm })); - const tmp = renderLine(renderLineContent, wrappedTextIndentLength, tabSize, width, sb, additionalIndentLength, shiftedFixedWidthRanges); + const tmp = renderLine(renderLineContent, wrappedTextIndentLength, tabSize, width, sb, additionalIndentLength, shiftedFixedWidthRanges, fullwidthCharacterWidth); firstNonWhitespaceIndices[i] = firstNonWhitespaceIndex; wrappedTextIndentLengths[i] = wrappedTextIndentLength; renderLineContents[i] = renderLineContent; @@ -210,7 +213,7 @@ const enum Constants { SPAN_MODULO_LIMIT = 16384 } -function renderLine(lineContent: string, initialVisibleColumn: number, tabSize: number, width: number, sb: StringBuilder, wrappingIndentLength: number, fixedWidthRanges: readonly FixedWidthInjectedTextRange[]): [number[], number[], number[]] { +function renderLine(lineContent: string, initialVisibleColumn: number, tabSize: number, width: number, sb: StringBuilder, wrappingIndentLength: number, fixedWidthRanges: readonly FixedWidthInjectedTextRange[], fullwidthCharacterWidth: number | undefined): [number[], number[], number[]] { if (wrappingIndentLength !== 0) { const hangingOffset = String(wrappingIndentLength); @@ -235,13 +238,14 @@ function renderLine(lineContent: string, initialVisibleColumn: number, tabSize: const charOffsets: number[] = []; const spanStartOffsets: number[] = [0]; const visibleColumns: number[] = []; - let nextCharCode = (0 < len ? lineContent.charCodeAt(0) : CharCode.Null); let spanOpen = true; sb.appendString(''); for (let charIndex = 0; charIndex < len; charIndex++) { let fixedWidthRange = fixedWidthRanges[fixedWidthRangeIndex]; const startsFixedWidth = fixedWidthRange && fixedWidthRange.startOffset === charIndex; + const charCode = lineContent.charCodeAt(charIndex); + const isFullWidthCharacter = fullwidthCharacterWidth !== undefined && strings.isFullWidthCharacter(charCode); if (startsFixedWidth) { if (spanOpen) { sb.appendString(''); @@ -269,6 +273,15 @@ function renderLine(lineContent: string, initialVisibleColumn: number, tabSize: } spanStartOffsets.push(charOffset); spanOpen = true; + } else if (isFullWidthCharacter) { + if (spanOpen) { + sb.appendString(''); + } + sb.appendString(''); + spanStartOffsets.push(charOffset); + spanOpen = true; } else if (!spanOpen) { sb.appendString(''); spanStartOffsets.push(charOffset); @@ -277,65 +290,72 @@ function renderLine(lineContent: string, initialVisibleColumn: number, tabSize: sb.appendString(''); spanStartOffsets.push(charOffset); } + charOffsets[charIndex] = charOffset; visibleColumns[charIndex] = visibleColumn; - const charCode = nextCharCode; - nextCharCode = (charIndex + 1 < len ? lineContent.charCodeAt(charIndex + 1) : CharCode.Null); let producedCharacters = 1; let charWidth = 1; - switch (charCode) { - case CharCode.Tab: - producedCharacters = (tabSize - (visibleColumn % tabSize)); - charWidth = producedCharacters; - for (let space = 1; space <= producedCharacters; space++) { - if (space < producedCharacters) { + if (isFullWidthCharacter) { + sb.appendCharCode(charCode); + charWidth = 2; + sb.appendString(''); + spanOpen = false; + } else { + switch (charCode) { + case CharCode.Tab: + producedCharacters = (tabSize - (visibleColumn % tabSize)); + charWidth = producedCharacters; + for (let space = 1; space <= producedCharacters; space++) { + if (space < producedCharacters) { + sb.appendCharCode(0xA0); //   + } else { + sb.appendASCIICharCode(CharCode.Space); + } + } + break; + + case CharCode.Space: + // `charCodeAt` past the end of the line yields `NaN`, which is not a space either. + if (lineContent.charCodeAt(charIndex + 1) === CharCode.Space) { sb.appendCharCode(0xA0); //   } else { sb.appendASCIICharCode(CharCode.Space); } - } - break; - - case CharCode.Space: - if (nextCharCode === CharCode.Space) { - sb.appendCharCode(0xA0); //   - } else { - sb.appendASCIICharCode(CharCode.Space); - } - break; - - case CharCode.LessThan: - sb.appendString('<'); - break; - - case CharCode.GreaterThan: - sb.appendString('>'); - break; - - case CharCode.Ampersand: - sb.appendString('&'); - break; - - case CharCode.Null: - sb.appendString('�'); - break; - - case CharCode.UTF8_BOM: - case CharCode.LINE_SEPARATOR: - case CharCode.PARAGRAPH_SEPARATOR: - case CharCode.NEXT_LINE: - sb.appendCharCode(0xFFFD); - break; - - default: - if (strings.isFullWidthCharacter(charCode)) { - charWidth++; - } - if (charCode < 32) { - sb.appendCharCode(9216 + charCode); - } else { - sb.appendCharCode(charCode); - } + break; + + case CharCode.LessThan: + sb.appendString('<'); + break; + + case CharCode.GreaterThan: + sb.appendString('>'); + break; + + case CharCode.Ampersand: + sb.appendString('&'); + break; + + case CharCode.Null: + sb.appendString('�'); + break; + + case CharCode.UTF8_BOM: + case CharCode.LINE_SEPARATOR: + case CharCode.PARAGRAPH_SEPARATOR: + case CharCode.NEXT_LINE: + sb.appendCharCode(0xFFFD); + break; + + default: + if (strings.isFullWidthCharacter(charCode)) { + charWidth++; + } + if (charCode < 32) { + sb.appendCharCode(9216 + charCode); + } else { + sb.appendCharCode(charCode); + } + } } charOffset += producedCharacters; diff --git a/src/vs/editor/browser/viewParts/viewLines/rangeUtil.ts b/src/vs/editor/browser/viewParts/viewLines/rangeUtil.ts index 3c0a4ba39644db..50e3c4bfced2bf 100644 --- a/src/vs/editor/browser/viewParts/viewLines/rangeUtil.ts +++ b/src/vs/editor/browser/viewParts/viewLines/rangeUtil.ts @@ -88,6 +88,21 @@ export class RangeUtil { return this._mergeAdjacentRanges(result); } + /** + * Reads the horizontal range occupied by a single child element. Unlike a collapsed text + * range, this reports the edges of the element's box rather than the edges of its glyphs. + */ + public static readHorizontalRangeForElement(domNode: HTMLElement, childIndex: number, context: DomReadingContext): FloatHorizontalRange | null { + const child = domNode.children[childIndex]; + if (!child) { + return null; + } + const clientRects = child.getClientRects(); + context.markDidDomLayout(); + const ranges = this._createHorizontalRangesFromClientRects(clientRects, context.clientRectDeltaLeft, context.clientRectScale); + return ranges && ranges.length > 0 ? ranges[0] : null; + } + public static readHorizontalRanges(domNode: HTMLElement, startChildIndex: number, startOffset: number, endChildIndex: number, endOffset: number, context: DomReadingContext): FloatHorizontalRange[] | null { // Panic check const min = 0; diff --git a/src/vs/editor/browser/viewParts/viewLines/viewLine.ts b/src/vs/editor/browser/viewParts/viewLines/viewLine.ts index 3bf79d8b679259..c11421a08de6a3 100644 --- a/src/vs/editor/browser/viewParts/viewLines/viewLine.ts +++ b/src/vs/editor/browser/viewParts/viewLines/viewLine.ts @@ -6,6 +6,7 @@ import * as browser from '../../../../base/browser/browser.js'; import { FastDomNode, createFastDomNode } from '../../../../base/browser/fastDomNode.js'; import * as platform from '../../../../base/common/platform.js'; +import * as strings from '../../../../base/common/strings.js'; import { IVisibleLine } from '../../view/viewLayer.js'; import { RangeUtil } from './rangeUtil.js'; import { StringBuilder } from '../../../common/core/stringBuilder.js'; @@ -169,7 +170,9 @@ export class ViewLine implements IVisibleLine { options.fontLigatures !== EditorFontLigatures.OFF, selectionsOnLine, lineData.textDirection, - options.verticalScrollbarSize + options.verticalScrollbarSize, + false, + options.useTwoCellFullwidthCharacters ); if (this._renderedViewLine && this._renderedViewLine.input.equals(renderLineInput)) { @@ -642,6 +645,25 @@ class RenderedViewLine implements IRenderedViewLine { const domPosition = this._characterMapping.getDomPosition(column); + if (this.input.useTwoCellFullwidthCharacters) { + const target = this._getReadingTarget(domNode); + // The first branch needs the left edge of the character to the right + if (strings.isFullWidthCharacter(this.input.lineContent.charCodeAt(column - 1))) { + const r = RangeUtil.readHorizontalRangeForElement(target, domPosition.partIndex, context); + if (r) { + return r.left; + } + } + // The second branch needs the right edge of the character to the left. + else if (strings.isFullWidthCharacter(this.input.lineContent.charCodeAt(column - 2))) { + const previousDomPosition = this._characterMapping.getDomPosition(column - 1); + const r = RangeUtil.readHorizontalRangeForElement(target, previousDomPosition.partIndex, context); + if (r) { + return r.left + r.width; + } + } + } + const r = RangeUtil.readHorizontalRanges(this._getReadingTarget(domNode), domPosition.partIndex, domPosition.charIndex, domPosition.partIndex, domPosition.charIndex, context); if (!r || r.length === 0) { return -1; diff --git a/src/vs/editor/browser/viewParts/viewLines/viewLineOptions.ts b/src/vs/editor/browser/viewParts/viewLines/viewLineOptions.ts index 0fe2a72d1671c8..a2bbdab9ae3f84 100644 --- a/src/vs/editor/browser/viewParts/viewLines/viewLineOptions.ts +++ b/src/vs/editor/browser/viewParts/viewLines/viewLineOptions.ts @@ -22,6 +22,7 @@ export class ViewLineOptions { public readonly fontLigatures: string; public readonly verticalScrollbarSize: number; public readonly useGpu: boolean; + public readonly useTwoCellFullwidthCharacters: boolean; constructor(config: IEditorConfiguration, themeType: ColorScheme) { this.themeType = themeType; @@ -43,6 +44,7 @@ export class ViewLineOptions { this.fontLigatures = options.get(EditorOption.fontLigatures); this.verticalScrollbarSize = options.get(EditorOption.scrollbar).verticalScrollbarSize; this.useGpu = options.get(EditorOption.experimentalGpuAcceleration) === 'on'; + this.useTwoCellFullwidthCharacters = options.get(EditorOption.effectiveFullwidthCharacterWidth) === 'twoCells'; } public equals(other: ViewLineOptions): boolean { @@ -61,6 +63,7 @@ export class ViewLineOptions { && this.fontLigatures === other.fontLigatures && this.verticalScrollbarSize === other.verticalScrollbarSize && this.useGpu === other.useGpu + && this.useTwoCellFullwidthCharacters === other.useTwoCellFullwidthCharacters ); } } diff --git a/src/vs/editor/browser/viewParts/viewLines/viewLines.css b/src/vs/editor/browser/viewParts/viewLines/viewLines.css index 2e2e717307bf2e..300603f6ca2bdf 100644 --- a/src/vs/editor/browser/viewParts/viewLines/viewLines.css +++ b/src/vs/editor/browser/viewParts/viewLines/viewLines.css @@ -19,6 +19,18 @@ background: rgb(150, 0, 0) !important; } +/* + * A full-width character centered in exactly two character cells. The cell width is editor + * specific and stays inline. Deliberately not scoped to `.monaco-editor`, so that the lines + * `DOMLineBreaksComputer` measures in a container attached to the document body are styled + * the same way as the rendered ones. + */ +.mtkfullwidth { + display: inline-block; + box-sizing: border-box; + text-align: center; +} + .mtkoverflow { background-color: var(--vscode-button-background, var(--vscode-editor-background)); color: var(--vscode-button-foreground, var(--vscode-editor-foreground)); diff --git a/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.ts b/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.ts index fc04faf71044e1..87b6bf5c49051f 100644 --- a/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.ts +++ b/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.ts @@ -122,6 +122,7 @@ export class RenderOptions { modifiedEditorOptions.get(EditorOption.renderControlCharacters), modifiedEditorOptions.get(EditorOption.fontLigatures), modifiedEditorOptions.get(EditorOption.scrollbar).verticalScrollbarSize, + modifiedEditorOptions.get(EditorOption.effectiveFullwidthCharacterWidth) === 'twoCells', ); } @@ -138,6 +139,7 @@ export class RenderOptions { public readonly renderControlCharacters: boolean, public readonly fontLigatures: FindComputedEditorOptionValueById, public readonly verticalScrollbarSize: number, + public readonly useTwoCellFullwidthCharacters: boolean = false, public readonly setWidth = true, ) { } @@ -155,6 +157,7 @@ export class RenderOptions { this.renderControlCharacters, this.fontLigatures, this.verticalScrollbarSize, + this.useTwoCellFullwidthCharacters, setWidth, ); } @@ -173,6 +176,7 @@ export class RenderOptions { this.renderControlCharacters, this.fontLigatures, this.verticalScrollbarSize, + this.useTwoCellFullwidthCharacters, this.setWidth, ); } @@ -306,7 +310,9 @@ function renderOriginalLine( options.fontLigatures !== EditorFontLigatures.OFF, null, // Send no selections, original line cannot be selected null, - options.verticalScrollbarSize + options.verticalScrollbarSize, + false, + options.useTwoCellFullwidthCharacters ), sb); sb.appendString(''); diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index 0a283d7abc78a0..22376e41155950 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -566,6 +566,11 @@ export interface IEditorOptions { * Defaults to false. */ formatOnPaste?: boolean; + /** + * Controls the width used to render full-width characters. + * Defaults to 'font'. + */ + fullwidthCharacterWidth?: 'font' | 'twoCells'; /** * Controls whether double-clicking next to a bracket or quote selects the content inside. * Defaults to true. @@ -2091,6 +2096,24 @@ class EffectiveAllowVariableFonts extends ComputedEditorOption { + + constructor() { + super(EditorOption.effectiveFullwidthCharacterWidth, 'font'); + } + + public compute(env: IEnvironmentalOptions, options: IComputedEditorOptions): 'font' | 'twoCells' { + if (options.get(EditorOption.fullwidthCharacterWidth) === 'twoCells' && env.fontInfo.isMonospace) { + return 'twoCells'; + } + return 'font'; + } +} + +//#endregion + //#region fontSize class EditorFontSize extends SimpleEditorOption { @@ -5977,7 +6000,10 @@ export const enum EditorOption { effectiveEditContext, scrollOnMiddleClick, effectiveAllowVariableFonts, - doubleClickSelectsBlock + doubleClickSelectsBlock, + fullwidthCharacterWidth, + // Must come after `fullwidthCharacterWidth`, which it is computed from. + effectiveFullwidthCharacterWidth } export const EditorOptions = { @@ -6382,6 +6408,18 @@ export const EditorOptions = { EditorOption.formatOnType, 'formatOnType', false, { description: nls.localize('formatOnType', "Controls whether the editor should automatically format the line after typing.") } )), + fullwidthCharacterWidth: register(new EditorStringEnumOption( + EditorOption.fullwidthCharacterWidth, 'fullwidthCharacterWidth', + 'font' as 'font' | 'twoCells', + ['font', 'twoCells'] as const, + { + enumDescriptions: [ + nls.localize('fullwidthCharacterWidth.font', "Render full-width characters using the width defined by the font."), + nls.localize('fullwidthCharacterWidth.twoCells', "Render full-width characters centered in exactly two character cells. Only applies to monospace fonts. Does not apply to multi codepoint full-width characters or to GPU rendering. This has a performance impact on line rendering."), + ], + description: nls.localize('fullwidthCharacterWidth', "Controls the width used to render full-width characters.") + } + )), glyphMargin: register(new EditorBooleanOption( EditorOption.glyphMargin, 'glyphMargin', true, { description: nls.localize('glyphMargin', "Controls whether the editor should render the vertical glyph margin. Glyph margin is mostly used for debugging.") } @@ -6889,7 +6927,8 @@ export const EditorOptions = { wrappingIndent: register(new WrappingIndentOption()), wrappingStrategy: register(new WrappingStrategy()), effectiveEditContextEnabled: register(new EffectiveEditContextEnabled()), - effectiveAllowVariableFonts: register(new EffectiveAllowVariableFonts()) + effectiveAllowVariableFonts: register(new EffectiveAllowVariableFonts()), + effectiveFullwidthCharacterWidth: register(new EffectiveFullwidthCharacterWidth()) }; type EditorOptionsType = typeof EditorOptions; diff --git a/src/vs/editor/common/modelLineProjectionData.ts b/src/vs/editor/common/modelLineProjectionData.ts index 948214355ef3f8..357ca6ee3c7fe8 100644 --- a/src/vs/editor/common/modelLineProjectionData.ts +++ b/src/vs/editor/common/modelLineProjectionData.ts @@ -334,7 +334,7 @@ export interface ILineBreaksComputerContext { } export interface ILineBreaksComputerFactory { - createLineBreaksComputer(context: ILineBreaksComputerContext, fontInfo: FontInfo, tabSize: number, wrappingColumn: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', wrapOnEscapedLineFeeds: boolean): ILineBreaksComputer; + createLineBreaksComputer(context: ILineBreaksComputerContext, fontInfo: FontInfo, tabSize: number, wrappingColumn: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', wrapOnEscapedLineFeeds: boolean, useTwoCellFullwidthCharacters: boolean): ILineBreaksComputer; } export interface ILineBreaksComputer { diff --git a/src/vs/editor/common/standalone/standaloneEnums.ts b/src/vs/editor/common/standalone/standaloneEnums.ts index cb0db8b268d0ef..e9c95c79715f35 100644 --- a/src/vs/editor/common/standalone/standaloneEnums.ts +++ b/src/vs/editor/common/standalone/standaloneEnums.ts @@ -347,7 +347,9 @@ export enum EditorOption { effectiveEditContext = 170, scrollOnMiddleClick = 171, effectiveAllowVariableFonts = 172, - doubleClickSelectsBlock = 173 + doubleClickSelectsBlock = 173, + fullwidthCharacterWidth = 174, + effectiveFullwidthCharacterWidth = 175 } /** diff --git a/src/vs/editor/common/viewLayout/viewLineRenderer.ts b/src/vs/editor/common/viewLayout/viewLineRenderer.ts index 3616af6c04d186..a30ba1658c2bd0 100644 --- a/src/vs/editor/common/viewLayout/viewLineRenderer.ts +++ b/src/vs/editor/common/viewLayout/viewLineRenderer.ts @@ -45,6 +45,7 @@ export interface IRenderLineInputOptions { textDirection: TextDirection | null; verticalScrollbarSize: number; renderNewLineWhenEmpty: boolean; + useTwoCellFullwidthCharacters: boolean; } export class RenderLineInput { @@ -79,6 +80,10 @@ export class RenderLineInput { * When rendering an empty line, whether to render a new line instead */ public readonly renderNewLineWhenEmpty: boolean; + /** + * Whether full-width characters should be rendered centered in exactly two character cells. + */ + public readonly useTwoCellFullwidthCharacters: boolean; public get isLTR(): boolean { return !this.containsRTL && this.textDirection !== TextDirection.RTL; @@ -107,6 +112,7 @@ export class RenderLineInput { textDirection: TextDirection | null, verticalScrollbarSize: number, renderNewLineWhenEmpty: boolean = false, + useTwoCellFullwidthCharacters = false, ) { this.useMonospaceOptimizations = useMonospaceOptimizations; this.canUseHalfwidthRightwardsArrow = canUseHalfwidthRightwardsArrow; @@ -136,6 +142,7 @@ export class RenderLineInput { this.fontLigatures = fontLigatures; this.selectionsOnLine = selectionsOnLine && selectionsOnLine.sort((a, b) => a.start < b.start ? -1 : 1); this.renderNewLineWhenEmpty = renderNewLineWhenEmpty; + this.useTwoCellFullwidthCharacters = useTwoCellFullwidthCharacters; this.textDirection = textDirection; this.verticalScrollbarSize = verticalScrollbarSize; @@ -196,6 +203,7 @@ export class RenderLineInput { && this.textDirection === other.textDirection && this.verticalScrollbarSize === other.verticalScrollbarSize && this.renderNewLineWhenEmpty === other.renderNewLineWhenEmpty + && this.useTwoCellFullwidthCharacters === other.useTwoCellFullwidthCharacters ); } } @@ -457,6 +465,7 @@ class ResolvedRenderLineInput { public readonly renderSpaceCharCode: number, public readonly renderWhitespace: RenderWhitespace, public readonly renderControlCharacters: boolean, + public readonly useTwoCellFullwidthCharacters: boolean ) { // } @@ -517,6 +526,9 @@ function resolveRenderLineInput(input: RenderLineInput): ResolvedRenderLineInput // Split the first token if it contains both leading whitespace and RTL text tokens = splitLeadingWhitespaceFromRTL(lineContent, tokens); } + if (input.useTwoCellFullwidthCharacters) { + tokens = splitFullWidthCharacters(lineContent, tokens); + } return new ResolvedRenderLineInput( input.useMonospaceOptimizations, @@ -533,10 +545,36 @@ function resolveRenderLineInput(input: RenderLineInput): ResolvedRenderLineInput input.spaceWidth, input.renderSpaceCharCode, input.renderWhitespace, - input.renderControlCharacters + input.renderControlCharacters, + input.useTwoCellFullwidthCharacters ); } +/** Splits parts such that every full-width character ends up in a part of its own. */ +function splitFullWidthCharacters(lineContent: string, parts: LinePart[]): LinePart[] { + const result: LinePart[] = []; + let startOffset = 0; + for (const part of parts) { + let didSplit = false; + for (let offset = startOffset; offset < part.endIndex; offset++) { + if (!strings.isFullWidthCharacter(lineContent.charCodeAt(offset))) { + continue; + } + if (offset > startOffset) { + result.push(new LinePart(offset, part.type, part.metadata, part.containsRTL)); + } + result.push(new LinePart(offset + 1, part.type, part.metadata, part.containsRTL)); + startOffset = offset + 1; + didSplit = true; + } + if (!didSplit || part.endIndex > startOffset) { + result.push(part); + } + startOffset = part.endIndex; + } + return result; +} + /** * In the rendering phase, characters are always looped until token.endIndex. * Ensure that all tokens end before `len` and the last one ends precisely at `len`. @@ -996,6 +1034,8 @@ function _renderLine(input: ResolvedRenderLineInput, sb: StringBuilder): RenderL const renderSpaceCharCode = input.renderSpaceCharCode; const renderWhitespace = input.renderWhitespace; const renderControlCharacters = input.renderControlCharacters; + const useTwoCellFullwidthCharacters = input.useTwoCellFullwidthCharacters; + const fullWidthCharacterWidth = 2 * input.spaceWidth; const characterMapping = new CharacterMapping(len + 1, parts.length); let lastCharacterMappingDefined = false; @@ -1018,14 +1058,24 @@ function _renderLine(input: ResolvedRenderLineInput, sb: StringBuilder): RenderL const partRendersWhitespace = (renderWhitespace !== RenderWhitespace.None && part.isWhitespace()); const partRendersWhitespaceWithWidth = partRendersWhitespace && !fontIsMonospace && (partType === 'mtkw'/*only whitespace*/ || !containsForeignElements); const partIsEmptyAndHasPseudoAfter = (charIndex === partEndIndex && part.isPseudoAfter()); + // A full-width character is centered in two cells, unless the part already needs its + // style attribute for bidi isolation. + const partIsCenteredFullWidth = !partContainsRTL && useTwoCellFullwidthCharacters && strings.isFullWidthCharacter(lineContent.charCodeAt(charIndex)); charOffsetInPart = 0; sb.appendString(' { let arrPool1: number[] = []; let arrPool2: number[] = []; -function createLineBreaksFromPreviousLineBreaks(classifier: WrappingCharacterClassifier, previousBreakingData: ModelLineProjectionData, lineText: string, tabSize: number, firstLineBreakColumn: number, columnsForFullWidthChar: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll'): ModelLineProjectionData | null { +function createLineBreaksFromPreviousLineBreaks(classifier: WrappingCharacterClassifier, previousBreakingData: ModelLineProjectionData, lineText: string, tabSize: number, firstLineBreakColumn: number, columnsForFullWidthChar: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', useTwoCellFullwidthCharacters: boolean): ModelLineProjectionData | null { if (firstLineBreakColumn === -1) { return null; } @@ -117,7 +117,7 @@ function createLineBreaksFromPreviousLineBreaks(classifier: WrappingCharacterCla const prevBreakingOffsets = previousBreakingData.breakOffsets; const prevBreakingOffsetsVisibleColumn = previousBreakingData.breakOffsetsVisibleColumn; - const wrappedTextIndentLength = computeWrappedTextIndentLength(lineText, tabSize, firstLineBreakColumn, columnsForFullWidthChar, wrappingIndent); + const wrappedTextIndentLength = computeWrappedTextIndentLength(lineText, tabSize, firstLineBreakColumn, fullWidthCharacterColumns(columnsForFullWidthChar, useTwoCellFullwidthCharacters), wrappingIndent); const wrappedLineBreakColumn = firstLineBreakColumn - wrappedTextIndentLength; const breakingOffsets: number[] = arrPool1; @@ -176,7 +176,7 @@ function createLineBreaksFromPreviousLineBreaks(classifier: WrappingCharacterCla charWidth = 2; } else { charCodeClass = classifier.get(charCode); - charWidth = computeCharWidth(charCode, visibleColumn, tabSize, columnsForFullWidthChar); + charWidth = computeCharWidth(charCode, visibleColumn, tabSize, columnsForFullWidthChar, useTwoCellFullwidthCharacters); } if (charStartOffset > lastBreakingOffset && canBreak(prevCharCode, prevCharCodeClass, charCode, charCodeClass, isKeepAll)) { @@ -249,7 +249,7 @@ function createLineBreaksFromPreviousLineBreaks(classifier: WrappingCharacterCla prevCharWidth = 2; } else { prevCharCodeClass = classifier.get(prevCharCode); - prevCharWidth = (strings.isFullWidthCharacter(prevCharCode) ? columnsForFullWidthChar : 1); + prevCharWidth = (strings.isFullWidthCharacter(prevCharCode) ? fullWidthCharacterColumns(columnsForFullWidthChar, useTwoCellFullwidthCharacters) : 1); } if (visibleColumn <= breakingColumn) { @@ -284,7 +284,7 @@ function createLineBreaksFromPreviousLineBreaks(classifier: WrappingCharacterCla // A surrogate pair must always be considered as a single unit, so it is never to be broken charWidth = 2; } else { - charWidth = computeCharWidth(charCodeAtForcedBreakOffset, forcedBreakOffsetVisibleColumn, tabSize, columnsForFullWidthChar); + charWidth = computeCharWidth(charCodeAtForcedBreakOffset, forcedBreakOffsetVisibleColumn, tabSize, columnsForFullWidthChar, useTwoCellFullwidthCharacters); } if (remainingWidthOfNextLine - charWidth < 0) { // it is not worth it to break at breakOffset, it just introduces an extra needless line! @@ -315,7 +315,7 @@ function createLineBreaksFromPreviousLineBreaks(classifier: WrappingCharacterCla breakOffsetVisibleColumn = lastBreakingOffsetVisibleColumn + 2; } else { breakOffset = lastBreakingOffset + 1; - breakOffsetVisibleColumn = lastBreakingOffsetVisibleColumn + computeCharWidth(charCode, lastBreakingOffsetVisibleColumn, tabSize, columnsForFullWidthChar); + breakOffsetVisibleColumn = lastBreakingOffsetVisibleColumn + computeCharWidth(charCode, lastBreakingOffsetVisibleColumn, tabSize, columnsForFullWidthChar, useTwoCellFullwidthCharacters); } } @@ -356,7 +356,7 @@ function createLineBreaksFromPreviousLineBreaks(classifier: WrappingCharacterCla return previousBreakingData; } -function createLineBreaks(classifier: WrappingCharacterClassifier, _lineText: string, injectedTexts: LineInjectedText[] | null, tabSize: number, firstLineBreakColumn: number, columnsForFullWidthChar: number, fontInfo: FontInfo, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', wrapOnEscapedLineFeeds: boolean): ModelLineProjectionData | null { +function createLineBreaks(classifier: WrappingCharacterClassifier, _lineText: string, injectedTexts: LineInjectedText[] | null, tabSize: number, firstLineBreakColumn: number, columnsForFullWidthChar: number, fontInfo: FontInfo, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', wrapOnEscapedLineFeeds: boolean, useTwoCellFullwidthCharacters: boolean): ModelLineProjectionData | null { const lineText = LineInjectedText.applyInjectedText(_lineText, injectedTexts); const fixedWidthRanges = LineInjectedText.getFixedWidthInjectedTextRanges(injectedTexts); @@ -390,7 +390,7 @@ function createLineBreaks(classifier: WrappingCharacterClassifier, _lineText: st } const isKeepAll = (wordBreak === 'keepAll'); - const wrappedTextIndentLength = computeWrappedTextIndentLength(lineText, tabSize, firstLineBreakColumn, columnsForFullWidthChar, wrappingIndent, fixedWidthRanges); + const wrappedTextIndentLength = computeWrappedTextIndentLength(lineText, tabSize, firstLineBreakColumn, fullWidthCharacterColumns(columnsForFullWidthChar, useTwoCellFullwidthCharacters), wrappingIndent, fixedWidthRanges); // The wrapping decision is taken in pixels, because injected text can request an arbitrary // width via `widthInEm` which does not map to a whole number of columns. @@ -417,14 +417,14 @@ function createLineBreaks(classifier: WrappingCharacterClassifier, _lineText: st if (startsWithFixedWidth) { prevCharCode = CharCode.Null; prevCharCodeClass = CharacterClass.NONE; - visibleColumn = computeFixedWidthRangeColumnWidth(lineText, firstFixedWidthRange, 0, tabSize, columnsForFullWidthChar); + visibleColumn = computeFixedWidthRangeColumnWidth(lineText, firstFixedWidthRange, 0, tabSize, columnsForFullWidthChar, useTwoCellFullwidthCharacters); currentLinePixelWidth = firstFixedWidthRange.widthInEm * fontInfo.fontSize; startOffset = firstFixedWidthRange.endOffset; fixedWidthRangeIndex++; } else { prevCharCode = lineText.charCodeAt(0); - visibleColumn = computeCharWidth(prevCharCode, 0, tabSize, columnsForFullWidthChar); - currentLinePixelWidth = computeCharPixelWidth(prevCharCode, 0, tabSize, fontInfo); + visibleColumn = computeCharWidth(prevCharCode, 0, tabSize, columnsForFullWidthChar, useTwoCellFullwidthCharacters); + currentLinePixelWidth = computeCharPixelWidth(prevCharCode, 0, tabSize, fontInfo, useTwoCellFullwidthCharacters); startOffset = 1; if (strings.isHighSurrogate(prevCharCode)) { // A surrogate pair must always be considered as a single unit, so it is never to be broken @@ -448,7 +448,7 @@ function createLineBreaks(classifier: WrappingCharacterClassifier, _lineText: st if (fixedWidthRange && fixedWidthRange.startOffset === i) { charCode = CharCode.Null; charCodeClass = CharacterClass.NONE; - charWidth = computeFixedWidthRangeColumnWidth(lineText, fixedWidthRange, visibleColumn, tabSize, columnsForFullWidthChar); + charWidth = computeFixedWidthRangeColumnWidth(lineText, fixedWidthRange, visibleColumn, tabSize, columnsForFullWidthChar, useTwoCellFullwidthCharacters); charPixelWidth = fixedWidthRange.widthInEm * fontInfo.fontSize; i = fixedWidthRange.endOffset - 1; fixedWidthRangeIndex++; @@ -460,8 +460,8 @@ function createLineBreaks(classifier: WrappingCharacterClassifier, _lineText: st charPixelWidth = 2 * typicalHalfwidthCharacterWidth; } else { charCodeClass = classifier.get(charCode); - charWidth = computeCharWidth(charCode, visibleColumn, tabSize, columnsForFullWidthChar); - charPixelWidth = computeCharPixelWidth(charCode, visibleColumn, tabSize, fontInfo); + charWidth = computeCharWidth(charCode, visibleColumn, tabSize, columnsForFullWidthChar, useTwoCellFullwidthCharacters); + charPixelWidth = computeCharPixelWidth(charCode, visibleColumn, tabSize, fontInfo, useTwoCellFullwidthCharacters); } // literal \n shall trigger a softwrap @@ -518,12 +518,12 @@ function createLineBreaks(classifier: WrappingCharacterClassifier, _lineText: st return new ModelLineProjectionData(injectionOffsets, injectionOptions, breakingOffsets, breakingOffsetsVisibleColumn, wrappedTextIndentLength); } -function computeCharWidth(charCode: number, visibleColumn: number, tabSize: number, columnsForFullWidthChar: number): number { +function computeCharWidth(charCode: number, visibleColumn: number, tabSize: number, columnsForFullWidthChar: number, useTwoCellFullwidthCharacters: boolean): number { if (charCode === CharCode.Tab) { return (tabSize - (visibleColumn % tabSize)); } if (strings.isFullWidthCharacter(charCode)) { - return columnsForFullWidthChar; + return fullWidthCharacterColumns(columnsForFullWidthChar, useTwoCellFullwidthCharacters); } if (charCode < 32) { // when using `editor.renderControlCharacters`, the substitutions are often wide @@ -532,6 +532,14 @@ function computeCharWidth(charCode: number, visibleColumn: number, tabSize: numb return 1; } +/** + * The number of columns a full-width character occupies: exactly two cells when full-width + * characters are forced into two monospace cells, otherwise the ratio measured from the font. + */ +function fullWidthCharacterColumns(columnsForFullWidthChar: number, useTwoCellFullwidthCharacters: boolean): number { + return useTwoCellFullwidthCharacters ? 2 : columnsForFullWidthChar; +} + function tabCharacterWidth(visibleColumn: number, tabSize: number): number { return (tabSize - (visibleColumn % tabSize)); } @@ -540,12 +548,12 @@ function tabCharacterWidth(visibleColumn: number, tabSize: number): number { * The width in pixels a character occupies. Used for the wrapping decision, which must reason * in real widths because injected text can request an arbitrary width via `widthInEm`. */ -function computeCharPixelWidth(charCode: number, visibleColumn: number, tabSize: number, fontInfo: FontInfo): number { +function computeCharPixelWidth(charCode: number, visibleColumn: number, tabSize: number, fontInfo: FontInfo, useTwoCellFullwidthCharacters: boolean): number { if (charCode === CharCode.Tab) { return tabCharacterWidth(visibleColumn, tabSize) * fontInfo.typicalHalfwidthCharacterWidth; } if (strings.isFullWidthCharacter(charCode)) { - return fontInfo.typicalFullwidthCharacterWidth; + return useTwoCellFullwidthCharacters ? 2 * fontInfo.typicalHalfwidthCharacterWidth : fontInfo.typicalFullwidthCharacterWidth; } if (charCode < 32) { // when using `editor.renderControlCharacters`, the substitutions are often wide @@ -560,10 +568,10 @@ function computeCharPixelWidth(charCode: number, visibleColumn: number, tabSize: * expansion, which must stay in sync with the line rendering, and the rendering does not know about * `widthInEm`. */ -function computeFixedWidthRangeColumnWidth(lineText: string, range: FixedWidthInjectedTextRange, visibleColumn: number, tabSize: number, columnsForFullWidthChar: number): number { +function computeFixedWidthRangeColumnWidth(lineText: string, range: FixedWidthInjectedTextRange, visibleColumn: number, tabSize: number, columnsForFullWidthChar: number, useTwoCellFullwidthCharacters: boolean): number { let width = 0; for (let i = range.startOffset; i < range.endOffset; i++) { - width += computeCharWidth(lineText.charCodeAt(i), visibleColumn + width, tabSize, columnsForFullWidthChar); + width += computeCharWidth(lineText.charCodeAt(i), visibleColumn + width, tabSize, columnsForFullWidthChar, useTwoCellFullwidthCharacters); } return width; } diff --git a/src/vs/editor/common/viewModel/viewModelImpl.ts b/src/vs/editor/common/viewModel/viewModelImpl.ts index abbd44aa3966cb..c29dfc521f3044 100644 --- a/src/vs/editor/common/viewModel/viewModelImpl.ts +++ b/src/vs/editor/common/viewModel/viewModelImpl.ts @@ -103,6 +103,7 @@ export class ViewModel extends Disposable implements IViewModel { const wrappingIndent = options.get(EditorOption.wrappingIndent); const wordBreak = options.get(EditorOption.wordBreak); const wrapOnEscapedLineFeeds = options.get(EditorOption.wrapOnEscapedLineFeeds); + const useTwoCellFullwidthCharacters = options.get(EditorOption.effectiveFullwidthCharacterWidth) === 'twoCells'; this._lines = new ViewModelLinesFromProjectedModel( this._editorId, @@ -115,7 +116,8 @@ export class ViewModel extends Disposable implements IViewModel { wrappingInfo.wrappingColumn, wrappingIndent, wordBreak, - wrapOnEscapedLineFeeds + wrapOnEscapedLineFeeds, + useTwoCellFullwidthCharacters ); } @@ -279,8 +281,9 @@ export class ViewModel extends Disposable implements IViewModel { const wrappingInfo = options.get(EditorOption.wrappingInfo); const wrappingIndent = options.get(EditorOption.wrappingIndent); const wordBreak = options.get(EditorOption.wordBreak); + const useTwoCellFullwidthCharacters = options.get(EditorOption.effectiveFullwidthCharacterWidth) === 'twoCells'; - if (this._lines.setWrappingSettings(fontInfo, wrappingStrategy, wrappingInfo.wrappingColumn, wrappingIndent, wordBreak)) { + if (this._lines.setWrappingSettings(fontInfo, wrappingStrategy, wrappingInfo.wrappingColumn, wrappingIndent, wordBreak, useTwoCellFullwidthCharacters)) { eventsCollector.emitViewEvent(new viewEvents.ViewFlushedEvent()); eventsCollector.emitViewEvent(new viewEvents.ViewLineMappingChangedEvent()); eventsCollector.emitViewEvent(new viewEvents.ViewDecorationsChangedEvent(null)); diff --git a/src/vs/editor/common/viewModel/viewModelLines.ts b/src/vs/editor/common/viewModel/viewModelLines.ts index 199e787f88235f..20f6af9ae85389 100644 --- a/src/vs/editor/common/viewModel/viewModelLines.ts +++ b/src/vs/editor/common/viewModel/viewModelLines.ts @@ -22,7 +22,7 @@ import { LineInjectedText } from '../textModelEvents.js'; export interface IViewModelLines extends IDisposable { createCoordinatesConverter(): ICoordinatesConverter; - setWrappingSettings(fontInfo: FontInfo, wrappingStrategy: 'simple' | 'advanced', wrappingColumn: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll'): boolean; + setWrappingSettings(fontInfo: FontInfo, wrappingStrategy: 'simple' | 'advanced', wrappingColumn: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', useTwoCellFullwidthCharacters: boolean): boolean; setTabSize(newTabSize: number): boolean; getHiddenAreas(): Range[]; setHiddenAreas(_ranges: readonly Range[]): boolean; @@ -72,6 +72,7 @@ export class ViewModelLinesFromProjectedModel implements IViewModelLines { private wordBreak: 'normal' | 'keepAll'; private wrappingStrategy: 'simple' | 'advanced'; private wrapOnEscapedLineFeeds: boolean; + private useTwoCellFullwidthCharacters: boolean; private modelLineProjections!: IModelLineProjection[]; @@ -93,7 +94,8 @@ export class ViewModelLinesFromProjectedModel implements IViewModelLines { wrappingColumn: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', - wrapOnEscapedLineFeeds: boolean + wrapOnEscapedLineFeeds: boolean, + useTwoCellFullwidthCharacters: boolean ) { this._editorId = editorId; this.model = model; @@ -107,6 +109,7 @@ export class ViewModelLinesFromProjectedModel implements IViewModelLines { this.wrappingIndent = wrappingIndent; this.wordBreak = wordBreak; this.wrapOnEscapedLineFeeds = wrapOnEscapedLineFeeds; + this.useTwoCellFullwidthCharacters = useTwoCellFullwidthCharacters; this._constructLines(/*resetHiddenAreas*/true, null); } @@ -274,23 +277,25 @@ export class ViewModelLinesFromProjectedModel implements IViewModelLines { return true; } - public setWrappingSettings(fontInfo: FontInfo, wrappingStrategy: 'simple' | 'advanced', wrappingColumn: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll'): boolean { + public setWrappingSettings(fontInfo: FontInfo, wrappingStrategy: 'simple' | 'advanced', wrappingColumn: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', useTwoCellFullwidthCharacters: boolean): boolean { const equalFontInfo = this.fontInfo.equals(fontInfo); const equalWrappingStrategy = (this.wrappingStrategy === wrappingStrategy); const equalWrappingColumn = (this.wrappingColumn === wrappingColumn); const equalWrappingIndent = (this.wrappingIndent === wrappingIndent); const equalWordBreak = (this.wordBreak === wordBreak); - if (equalFontInfo && equalWrappingStrategy && equalWrappingColumn && equalWrappingIndent && equalWordBreak) { + const equalUseTwoCellFullwidthCharacters = (this.useTwoCellFullwidthCharacters === useTwoCellFullwidthCharacters); + if (equalFontInfo && equalWrappingStrategy && equalWrappingColumn && equalWrappingIndent && equalWordBreak && equalUseTwoCellFullwidthCharacters) { return false; } - const onlyWrappingColumnChanged = (equalFontInfo && equalWrappingStrategy && !equalWrappingColumn && equalWrappingIndent && equalWordBreak); + const onlyWrappingColumnChanged = (equalFontInfo && equalWrappingStrategy && !equalWrappingColumn && equalWrappingIndent && equalWordBreak && equalUseTwoCellFullwidthCharacters); this.fontInfo = fontInfo; this.wrappingStrategy = wrappingStrategy; this.wrappingColumn = wrappingColumn; this.wrappingIndent = wrappingIndent; this.wordBreak = wordBreak; + this.useTwoCellFullwidthCharacters = useTwoCellFullwidthCharacters; let previousLineBreaks: ((ModelLineProjectionData | null)[]) | null = null; if (onlyWrappingColumnChanged) { @@ -319,7 +324,7 @@ export class ViewModelLinesFromProjectedModel implements IViewModelLines { return this.model.getLineInjectedText(lineNumber, this._editorId); } }; - return lineBreaksComputerFactory.createLineBreaksComputer(context, this.fontInfo, this.tabSize, this.wrappingColumn, this.wrappingIndent, this.wordBreak, this.wrapOnEscapedLineFeeds); + return lineBreaksComputerFactory.createLineBreaksComputer(context, this.fontInfo, this.tabSize, this.wrappingColumn, this.wrappingIndent, this.wordBreak, this.wrapOnEscapedLineFeeds, this.useTwoCellFullwidthCharacters); } public onModelFlushed(): void { diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/ghostText/ghostTextView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/ghostText/ghostTextView.ts index 96053b4e19aa33..ee0c2ae26a455d 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/ghostText/ghostTextView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/ghostText/ghostTextView.ts @@ -493,6 +493,7 @@ export class AdditionalLinesWidget extends Disposable { || e.hasChanged(EditorOption.fontLigatures) || e.hasChanged(EditorOption.fontInfo) || e.hasChanged(EditorOption.lineHeight) + || e.hasChanged(EditorOption.effectiveFullwidthCharacterWidth) )); this._onDidClick = this._register(new Emitter()); this.onDidClick = this._onDidClick.event; @@ -640,6 +641,7 @@ function renderLines(domNode: HTMLElement, tabSize: number, lines: readonly Line const fontLigatures = opts.get(EditorOption.fontLigatures); const fontInfo = opts.get(EditorOption.fontInfo); const lineHeight = opts.get(EditorOption.lineHeight); + const useTwoCellFullwidthCharacters = opts.get(EditorOption.effectiveFullwidthCharacterWidth) === 'twoCells'; let classNames = 'suggest-preview-text'; if (isClickable) { @@ -682,7 +684,9 @@ function renderLines(domNode: HTMLElement, tabSize: number, lines: readonly Line fontLigatures !== EditorFontLigatures.OFF, null, null, - 0 + 0, + false, + useTwoCellFullwidthCharacters ), sb); sb.appendString(''); diff --git a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollController.ts b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollController.ts index e998043590356d..232eb1ff972334 100644 --- a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollController.ts +++ b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollController.ts @@ -507,7 +507,13 @@ export class StickyScrollController extends Disposable implements IEditorContrib this._readConfiguration(); } - if (event.hasChanged(EditorOption.lineNumbers) || event.hasChanged(EditorOption.folding) || event.hasChanged(EditorOption.showFoldingControls)) { + if ( + event.hasChanged(EditorOption.lineNumbers) + || event.hasChanged(EditorOption.folding) + || event.hasChanged(EditorOption.showFoldingControls) + || event.hasChanged(EditorOption.fontInfo) + || event.hasChanged(EditorOption.effectiveFullwidthCharacterWidth) + ) { this._renderStickyScroll(0); } } diff --git a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollWidget.ts b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollWidget.ts index ddc342fa97ec1d..5d3894e79aef9a 100644 --- a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollWidget.ts +++ b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollWidget.ts @@ -462,13 +462,15 @@ class RenderedStickyLine { const lineHeight = editor.getLineHeightForPosition(new Position(lineNumber, 1)); const textDirection = viewModel.getTextDirection(lineNumber); + const fontInfo = editor.getOption(EditorOption.fontInfo); const renderLineInput: RenderLineInput = new RenderLineInput(true, true, lineRenderingData.content, lineRenderingData.continuesWithWrappedLine, lineRenderingData.isBasicASCII, lineRenderingData.containsRTL, 0, lineRenderingData.tokens, actualInlineDecorations, lineRenderingData.tabSize, lineRenderingData.startVisibleColumn, - 1, 1, 1, 500, 'none', true, true, null, - textDirection, verticalScrollbarSize + fontInfo.spaceWidth, 1, 1, 500, 'none', true, true, null, + textDirection, verticalScrollbarSize, false, + editor.getOption(EditorOption.effectiveFullwidthCharacterWidth) === 'twoCells' ); const sb = new StringBuilder(2000); diff --git a/src/vs/editor/test/browser/config/fontMeasurements.test.ts b/src/vs/editor/test/browser/config/fontMeasurements.test.ts new file mode 100644 index 00000000000000..bac8f253854274 --- /dev/null +++ b/src/vs/editor/test/browser/config/fontMeasurements.test.ts @@ -0,0 +1,87 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import sinon from 'sinon'; +import { mainWindow } from '../../../../base/browser/window.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { FontMeasurementsImpl, ISerializedFontInfo } from '../../../browser/config/fontMeasurements.js'; +import { FontInfo, SERIALIZED_FONT_INFO_VERSION } from '../../../common/config/fontInfo.js'; + +suite('FontMeasurements', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + teardown(() => { + sinon.restore(); + }); + + const restoredFontInfo: ISerializedFontInfo = { + version: SERIALIZED_FONT_INFO_VERSION, + pixelRatio: 1, + fontFamily: 'monospace', + fontWeight: 'normal', + fontSize: 14, + fontFeatureSettings: '"liga" off, "calt" off', + fontVariationSettings: 'normal', + lineHeight: 19, + letterSpacing: 0, + isMonospace: true, + typicalHalfwidthCharacterWidth: 8, + typicalFullwidthCharacterWidth: 14, + canUseHalfwidthRightwardsArrow: true, + spaceWidth: 8, + middotWidth: 8, + wsmiddotWidth: 14, + maxDigitWidth: 8, + }; + + test('preserves restored untrusted font information through eviction', () => { + const clock = sinon.useFakeTimers(); + const fontMeasurements = store.add(new FontMeasurementsImpl()); + const initiallySerialized = fontMeasurements.serializeFontInfo(mainWindow); + let changeCount = 0; + store.add(fontMeasurements.onDidChange(() => changeCount++)); + + fontMeasurements.restoreFontInfo(mainWindow, [restoredFontInfo]); + const isTrusted = fontMeasurements.readFontInfo(mainWindow, new FontInfo(restoredFontInfo, false)).isTrusted; + const serializedBeforeEviction = fontMeasurements.serializeFontInfo(mainWindow); + + clock.tick(5000); + + const serializedAfterEviction = fontMeasurements.serializeFontInfo(mainWindow); + const changeCountAfterEviction = changeCount; + + fontMeasurements.clearAllFontInfos(); + + assert.deepStrictEqual({ + initiallySerialized, + isTrusted, + serializedBeforeEviction, + serializedAfterEviction, + changeCountAfterEviction, + serializedAfterClear: fontMeasurements.serializeFontInfo(mainWindow), + }, { + initiallySerialized: [], + isTrusted: false, + serializedBeforeEviction: undefined, + serializedAfterEviction: undefined, + changeCountAfterEviction: 1, + serializedAfterClear: [], + }); + }); + + test('serializes empty current-session failed font measurements', () => { + const fontMeasurements = store.add(new FontMeasurementsImpl()); + const fontInfo = fontMeasurements.readFontInfo(mainWindow, new FontInfo({ ...restoredFontInfo, fontSize: 0 }, false)); + + assert.deepStrictEqual({ + isTrusted: fontInfo.isTrusted, + serialized: fontMeasurements.serializeFontInfo(mainWindow), + }, { + isTrusted: false, + serialized: [], + }); + }); +}); diff --git a/src/vs/editor/test/browser/view/domLineBreaksComputer.test.ts b/src/vs/editor/test/browser/view/domLineBreaksComputer.test.ts new file mode 100644 index 00000000000000..d261dcf7f31ce9 --- /dev/null +++ b/src/vs/editor/test/browser/view/domLineBreaksComputer.test.ts @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { mainWindow } from '../../../../base/browser/window.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { DOMLineBreaksComputerFactory } from '../../../browser/view/domLineBreaksComputer.js'; +import { WrappingIndent } from '../../../common/config/editorOptions.js'; +import { FontInfo } from '../../../common/config/fontInfo.js'; +import { ILineBreaksComputerContext } from '../../../common/modelLineProjectionData.js'; + +/** + * Measures `text` the way advanced wrapping does, in a container one pixel wide. The browser then + * puts every character on a line of its own whichever font it ends up picking, so the break + * offsets, and with them the visible column each character sits at, do not depend on the machine + * running the test. + */ +function measure(text: string, useTwoCellFullwidthCharacters: boolean): { breakOffsets: number[]; visibleColumns: number[] } { + const fontInfo = new FontInfo({ + pixelRatio: 1, + fontFamily: 'monospace', + fontWeight: 'normal', + fontSize: 14, + fontFeatureSettings: '', + fontVariationSettings: '', + lineHeight: 19, + letterSpacing: 0, + isMonospace: true, + typicalHalfwidthCharacterWidth: 1, + typicalFullwidthCharacterWidth: 2, + canUseHalfwidthRightwardsArrow: true, + spaceWidth: 1, + middotWidth: 1, + wsmiddotWidth: 1, + maxDigitWidth: 1 + }, false); + const context: ILineBreaksComputerContext = { + getLineContent: () => text, + getLineInjectedText: () => null + }; + const computer = DOMLineBreaksComputerFactory.create(mainWindow).createLineBreaksComputer(context, fontInfo, 4, 1, WrappingIndent.None, 'normal', false, useTwoCellFullwidthCharacters); + computer.addRequest(1, null); + const lineBreakData = computer.finalize()[0]; + assert.ok(lineBreakData, 'the line should have wrapped'); + return { breakOffsets: lineBreakData.breakOffsets, visibleColumns: lineBreakData.breakOffsetsVisibleColumn }; +} + +suite('Editor View - DOMLineBreaksComputer', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('a full-width character does not shift the narrow characters that follow it', () => { + // Stretching full-width characters to two cells only changes how they are drawn: both + // policies count them as two columns, so the two must agree on where every character sits. + const text = '漢abc漢d'; + assert.deepStrictEqual({ + twoCells: measure(text, true), + naturalWidth: measure(text, false) + }, { + twoCells: { breakOffsets: [1, 2, 3, 4, 5, 6], visibleColumns: [2, 3, 4, 5, 7, 8] }, + naturalWidth: { breakOffsets: [1, 2, 3, 4, 5, 6], visibleColumns: [2, 3, 4, 5, 7, 8] } + }); + }); +}); diff --git a/src/vs/editor/test/browser/viewModel/modelLineProjection.test.ts b/src/vs/editor/test/browser/viewModel/modelLineProjection.test.ts index 05f1deb2aa0982..6e2ae99c2012f6 100644 --- a/src/vs/editor/test/browser/viewModel/modelLineProjection.test.ts +++ b/src/vs/editor/test/browser/viewModel/modelLineProjection.test.ts @@ -102,6 +102,7 @@ suite('Editor ViewModel - SplitLinesCollection', () => { const wrappingIndent = config.options.get(EditorOption.wrappingIndent); const wordBreak = config.options.get(EditorOption.wordBreak); const wrapOnEscapedLineFeeds = config.options.get(EditorOption.wrapOnEscapedLineFeeds); + const useTwoCellFullwidthCharacters = config.options.get(EditorOption.effectiveFullwidthCharacterWidth) === 'twoCells'; const lineBreaksComputerFactory = new MonospaceLineBreaksComputerFactory(wordWrapBreakBeforeCharacters, wordWrapBreakAfterCharacters); const model = createTextModel(text); @@ -117,7 +118,8 @@ suite('Editor ViewModel - SplitLinesCollection', () => { wrappingInfo.wrappingColumn, wrappingIndent, wordBreak, - wrapOnEscapedLineFeeds + wrapOnEscapedLineFeeds, + useTwoCellFullwidthCharacters ); callback(model, linesCollection); @@ -951,6 +953,7 @@ suite('SplitLinesCollection', () => { const wordWrapBreakBeforeCharacters = configuration.options.get(EditorOption.wordWrapBreakBeforeCharacters); const wrappingIndent = configuration.options.get(EditorOption.wrappingIndent); const wordBreak = configuration.options.get(EditorOption.wordBreak); + const useTwoCellFullwidthCharacters = configuration.options.get(EditorOption.effectiveFullwidthCharacterWidth) === 'twoCells'; const lineBreaksComputerFactory = new MonospaceLineBreaksComputerFactory(wordWrapBreakBeforeCharacters, wordWrapBreakAfterCharacters); @@ -965,7 +968,8 @@ suite('SplitLinesCollection', () => { wrappingInfo.wrappingColumn, wrappingIndent, wordBreak, - wrapOnEscapedLineFeeds + wrapOnEscapedLineFeeds, + useTwoCellFullwidthCharacters ); callback(linesCollection); diff --git a/src/vs/editor/test/common/viewLayout/viewLineRenderer.test.ts b/src/vs/editor/test/common/viewLayout/viewLineRenderer.test.ts index 475ba0463ffde8..467244df834035 100644 --- a/src/vs/editor/test/common/viewLayout/viewLineRenderer.test.ts +++ b/src/vs/editor/test/common/viewLayout/viewLineRenderer.test.ts @@ -77,7 +77,8 @@ const defaultRenderLineInputOptions: IRenderLineInputOptions = { selectionsOnLine: null, textDirection: null, verticalScrollbarSize: 14, - renderNewLineWhenEmpty: false + renderNewLineWhenEmpty: false, + useTwoCellFullwidthCharacters: false }; function createRenderLineInputOptions(opts: IRelaxedRenderLineInputOptions): IRenderLineInputOptions { @@ -111,7 +112,8 @@ function createRenderLineInput(opts: IRelaxedRenderLineInputOptions): RenderLine options.selectionsOnLine, options.textDirection, options.verticalScrollbarSize, - options.renderNewLineWhenEmpty + options.renderNewLineWhenEmpty, + options.useTwoCellFullwidthCharacters ); } @@ -1647,3 +1649,230 @@ suite('renderViewLine2', () => { testGetColumnOfLinePartOffset(1, 8, 8, 11); }); }); + +suite('renderViewLine - useTwoCellFullwidthCharacters', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + /** + * The style attribute emitted for a character that is centered in two character cells. The + * declarations it shares with every other such character live in the `mtkfullwidth` class. + */ + function cell(width: number): string { + return ` style="width:${width}px"`; + } + + function render(lineContent: string, opts: IRelaxedRenderLineInputOptions = {}): string[] { + const actual = renderViewLine(createRenderLineInput({ + lineContent, + isBasicASCII: strings.isBasicASCII(lineContent), + containsRTL: strings.containsRTL(lineContent), + lineTokens: createViewLineTokens([createPart(lineContent.length, 1)]), + useTwoCellFullwidthCharacters: true, + ...opts + })); + return inflateRenderLineOutput(actual).html; + } + + test('a lone full-width character is centered in two cells', () => { + assert.deepStrictEqual(render('漢'), [ + `漢` + ]); + }); + + test('every full-width character of a run gets its own cell', () => { + assert.deepStrictEqual(render('漢字'), [ + `漢`, + `字` + ]); + }); + + test('narrow characters around a full-width character stay in shared parts', () => { + assert.deepStrictEqual(render('ab漢cd'), [ + `ab`, + `漢`, + `cd` + ]); + }); + + test('a full-width character at either end of the line is centered', () => { + assert.deepStrictEqual(render('漢a漢'), [ + `漢`, + `a`, + `漢` + ]); + }); + + test('full-width forms of ASCII characters are centered', () => { + assert.deepStrictEqual(render('A1!'), [ + `A`, + `1`, + `!` + ]); + }); + + test('CJK punctuation is centered', () => { + assert.deepStrictEqual(render('あ、い。'), [ + `あ`, + `、`, + `い`, + `。` + ]); + }); + + test('Hangul Jamo outside the shared full-width classification is left alone', () => { + const hangulJamo = '\u1100'; + assert.deepStrictEqual(render(hangulJamo), [ + `${hangulJamo}` + ]); + }); + + test('half-width katakana is left alone', () => { + assert.deepStrictEqual(render('アイウ'), [ + `アイウ` + ]); + }); + + test('non-ASCII narrow characters are left alone', () => { + assert.deepStrictEqual(render('Ünïcödé'), [ + `Ünïcödé` + ]); + }); + + test('characters included by the shared full-width classification are centered', () => { + const vaiSyllable = '\uA500'; + assert.deepStrictEqual(render(vaiSyllable), [ + `${vaiSyllable}` + ]); + }); + + test('the cell width follows the space width', () => { + assert.deepStrictEqual(render('漢', { spaceWidth: 7.5 }), [ + `漢` + ]); + }); + + test('nothing changes when the setting is off', () => { + assert.deepStrictEqual(render('ab漢cd', { useTwoCellFullwidthCharacters: false }), [ + `ab漢cd` + ]); + }); + + test('basic ASCII lines are untouched', () => { + assert.deepStrictEqual(render('abcd'), [ + `abcd` + ]); + }); + + test('RTL parts preserve bidi isolation without centering', () => { + assert.deepStrictEqual(render('漢عربى'), [ + `漢`, + `عربى` + ]); + }); + + test('a full-width character carrying a combining mark is centered by code point', () => { + assert.deepStrictEqual(render('あ́い'), [ + `あ`, + `́`, + `い` + ]); + }); + + test('a full-width character followed by a variation selector is centered by code point', () => { + assert.deepStrictEqual(render('神︀社'), [ + `神`, + `︀`, + `社` + ]); + }); + + test('characters outside the shared full-width classification are not centered', () => { + const ideograph = '\u{2000B}'; + assert.deepStrictEqual(render(`${ideograph}漢`), [ + `${ideograph}`, + `漢` + ]); + }); + + test('supplementary characters with variation selectors are left intact', () => { + const ideographWithVariationSelector = '\u{2000B}\uFE00'; + assert.deepStrictEqual(render(ideographWithVariationSelector), [ + `${ideographWithVariationSelector}` + ]); + }); + + test('emoji modifier and ZWJ sequences are left intact', () => { + const emojiWithModifier = '\u{1F469}\u{1F3FD}'; + const emojiWithZwj = '\u{1F469}\u200D\u{1F4BB}'; + assert.deepStrictEqual({ + modifier: render(emojiWithModifier), + zwj: render(emojiWithZwj), + }, { + modifier: [`${emojiWithModifier}`], + zwj: [`${emojiWithZwj}`], + }); + }); + + test('token boundaries are preserved', () => { + assert.deepStrictEqual(render('ab漢cd', { + lineTokens: createViewLineTokens([createPart(3, 1), createPart(5, 2)]) + }), [ + `ab`, + `漢`, + `cd` + ]); + }); + + test('a token boundary in the middle of a run of full-width characters is preserved', () => { + assert.deepStrictEqual(render('漢字', { + lineTokens: createViewLineTokens([createPart(1, 1), createPart(2, 2)]) + }), [ + `漢`, + `字` + ]); + }); + + test('inline decorations are preserved', () => { + assert.deepStrictEqual(render('a漢b', { + lineDecorations: [new LineDecoration(1, 4, 'link', InlineDecorationType.Regular)] + }), [ + `a`, + `漢`, + `b` + ]); + }); + + test('rendered whitespace next to a full-width character is preserved', () => { + assert.deepStrictEqual(render(' 漢', { useMonospaceOptimizations: true, renderWhitespace: 'all' }), [ + `·‌·‌`, + `漢` + ]); + }); + + test('stopRenderingLineAfter truncates before centering', () => { + assert.deepStrictEqual(render('漢字漢字', { stopRenderingLineAfter: 2 }), [ + `漢`, + `字`, + `Show more (2 chars)` + ]); + }); + + // The character mapping already treats a full-width character as two columns wide; centering + // the glyph is what makes the rendered line agree with it. + test('the character mapping is unaffected by the extra parts', () => { + const actual = renderViewLine(createRenderLineInput({ + lineContent: 'a漢b', + isBasicASCII: false, + lineTokens: createViewLineTokens([createPart(3, 1)]), + useTwoCellFullwidthCharacters: true + })); + + assertCharacterMapping3(actual.characterMapping, [ + [0, [0, 0]], + [1, [1, 0]], + [3, [2, 0]], + [4, [2, 1]] + ]); + }); +}); diff --git a/src/vs/editor/test/common/viewModel/monospaceLineBreaksComputer.test.ts b/src/vs/editor/test/common/viewModel/monospaceLineBreaksComputer.test.ts index 70019f302fea89..7b91839eee6e9f 100644 --- a/src/vs/editor/test/common/viewModel/monospaceLineBreaksComputer.test.ts +++ b/src/vs/editor/test/common/viewModel/monospaceLineBreaksComputer.test.ts @@ -45,7 +45,7 @@ function toAnnotatedText(text: string, lineBreakData: ModelLineProjectionData | return actualAnnotatedText; } -function getLineBreakData(factory: ILineBreaksComputerFactory, tabSize: number, breakAfter: number, columnsForFullWidthChar: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', wrapOnEscapedLineFeeds: boolean, text: string, previousLineBreakData: ModelLineProjectionData | null, injectedText: LineInjectedText[] | null = null): ModelLineProjectionData | null { +function getLineBreakData(factory: ILineBreaksComputerFactory, tabSize: number, breakAfter: number, columnsForFullWidthChar: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', wrapOnEscapedLineFeeds: boolean, text: string, previousLineBreakData: ModelLineProjectionData | null, injectedText: LineInjectedText[] | null = null, useTwoCellFullwidthCharacters: boolean = false): ModelLineProjectionData | null { const fontInfo = new FontInfo({ pixelRatio: 1, fontFamily: 'testFontFamily', @@ -72,7 +72,7 @@ function getLineBreakData(factory: ILineBreaksComputerFactory, tabSize: number, return injectedText; } }; - const lineBreaksComputer = factory.createLineBreaksComputer(context, fontInfo, tabSize, breakAfter, wrappingIndent, wordBreak, wrapOnEscapedLineFeeds); + const lineBreaksComputer = factory.createLineBreaksComputer(context, fontInfo, tabSize, breakAfter, wrappingIndent, wordBreak, wrapOnEscapedLineFeeds, useTwoCellFullwidthCharacters); const previousLineBreakDataClone = previousLineBreakData ? new ModelLineProjectionData(null, null, previousLineBreakData.breakOffsets.slice(0), previousLineBreakData.breakOffsetsVisibleColumn.slice(0), previousLineBreakData.wrappedTextIndentLength) : null; lineBreaksComputer.addRequest(1, previousLineBreakDataClone); return lineBreaksComputer.finalize()[0]; @@ -353,26 +353,26 @@ suite('Editor ViewModel - MonospaceLineBreaksComputer', () => { } } - function assertIncrementalLineBreaks(factory: ILineBreaksComputerFactory, text: string, tabSize: number, breakAfter1: number, annotatedText1: string, breakAfter2: number, annotatedText2: string, wrappingIndent = WrappingIndent.None, columnsForFullWidthChar: number = 2): void { + function assertIncrementalLineBreaks(factory: ILineBreaksComputerFactory, text: string, tabSize: number, breakAfter1: number, annotatedText1: string, breakAfter2: number, annotatedText2: string, wrappingIndent = WrappingIndent.None, columnsForFullWidthChar: number = 2, useTwoCellFullwidthCharacters: boolean = false): void { // sanity check the test assert.strictEqual(text, parseAnnotatedText(annotatedText1).text); assert.strictEqual(text, parseAnnotatedText(annotatedText2).text); // check that the direct mapping is ok for 1 - const directLineBreakData1 = getLineBreakData(factory, tabSize, breakAfter1, columnsForFullWidthChar, wrappingIndent, 'normal', false, text, null); + const directLineBreakData1 = getLineBreakData(factory, tabSize, breakAfter1, columnsForFullWidthChar, wrappingIndent, 'normal', false, text, null, null, useTwoCellFullwidthCharacters); assert.strictEqual(toAnnotatedText(text, directLineBreakData1), annotatedText1); // check that the direct mapping is ok for 2 - const directLineBreakData2 = getLineBreakData(factory, tabSize, breakAfter2, columnsForFullWidthChar, wrappingIndent, 'normal', false, text, null); + const directLineBreakData2 = getLineBreakData(factory, tabSize, breakAfter2, columnsForFullWidthChar, wrappingIndent, 'normal', false, text, null, null, useTwoCellFullwidthCharacters); assert.strictEqual(toAnnotatedText(text, directLineBreakData2), annotatedText2); // check that going from 1 to 2 is ok - const lineBreakData2from1 = getLineBreakData(factory, tabSize, breakAfter2, columnsForFullWidthChar, wrappingIndent, 'normal', false, text, directLineBreakData1); + const lineBreakData2from1 = getLineBreakData(factory, tabSize, breakAfter2, columnsForFullWidthChar, wrappingIndent, 'normal', false, text, directLineBreakData1, null, useTwoCellFullwidthCharacters); assert.strictEqual(toAnnotatedText(text, lineBreakData2from1), annotatedText2); assertLineBreakDataEqual(lineBreakData2from1, directLineBreakData2); // check that going from 2 to 1 is ok - const lineBreakData1from2 = getLineBreakData(factory, tabSize, breakAfter1, columnsForFullWidthChar, wrappingIndent, 'normal', false, text, directLineBreakData2); + const lineBreakData1from2 = getLineBreakData(factory, tabSize, breakAfter1, columnsForFullWidthChar, wrappingIndent, 'normal', false, text, directLineBreakData2, null, useTwoCellFullwidthCharacters); assert.strictEqual(toAnnotatedText(text, lineBreakData1from2), annotatedText1); assertLineBreakDataEqual(lineBreakData1from2, directLineBreakData1); } @@ -470,6 +470,125 @@ suite('Editor ViewModel - MonospaceLineBreaksComputer', () => { assertLineBreaks(factory, 4, 5, 'aa |(\u5b89aa|\u5b89'); }); + test('MonospaceLineBreaksComputer - two-cell full-width character width', () => { + const factory = new MonospaceLineBreaksComputerFactory('(', '\t)'); + const text = '\u3042\u3042\u3042\u3042'; + const wrapWith = (columnsForFullWidthChar: number, useTwoCellFullwidthCharacters: boolean) => toAnnotatedText(text, getLineBreakData(factory, 4, 5, columnsForFullWidthChar, WrappingIndent.None, 'normal', false, text, null, null, useTwoCellFullwidthCharacters)); + + assert.deepStrictEqual({ + lyingFont: wrapWith(3, false), + lyingFontTwoCells: wrapWith(3, true), + gridAlignedFont: wrapWith(2, false) + }, { + lyingFont: '\u3042|\u3042|\u3042|\u3042', + lyingFontTwoCells: '\u3042\u3042|\u3042\u3042', + gridAlignedFont: '\u3042\u3042|\u3042\u3042' + }); + }); + + test('MonospaceLineBreaksComputer - two-cell width determines whether continuation indentation fits', () => { + const factory = new MonospaceLineBreaksComputerFactory('(', '\t)'); + const text = ' \u3042\u3042\u3042'; + const wrappedIndentWith = (lineText: string, breakAfter: number, columnsForFullWidthChar: number, useTwoCellFullwidthCharacters: boolean) => + getLineBreakData(factory, 4, breakAfter, columnsForFullWidthChar, WrappingIndent.Same, 'normal', false, lineText, null, null, useTwoCellFullwidthCharacters)?.wrappedTextIndentLength; + + assert.deepStrictEqual({ + wideNatural: wrappedIndentWith(text, 6, 3, false), + wideTwoCells: wrappedIndentWith(text, 6, 3, true), + narrowNatural: wrappedIndentWith(text, 5, 1, false), + narrowTwoCells: wrappedIndentWith(text, 5, 1, true), + }, { + wideNatural: 0, + wideTwoCells: 4, + narrowNatural: 4, + narrowTwoCells: 0, + }); + }); + + test('MonospaceLineBreaksComputer - two-cell width uses shared full-width classification', () => { + const factory = new MonospaceLineBreaksComputerFactory('(', '\t)'); + const wrapWith = (text: string, useTwoCellFullwidthCharacters: boolean) => toAnnotatedText(text, getLineBreakData(factory, 4, 5, 3, WrappingIndent.None, 'normal', false, text, null, null, useTwoCellFullwidthCharacters)); + const fullwidthAscii = '\uFF21'.repeat(4); + const vaiSyllables = '\uA500'.repeat(4); + + assert.deepStrictEqual({ + fullwidthAsciiNatural: wrapWith(fullwidthAscii, false), + fullwidthAsciiTwoCells: wrapWith(fullwidthAscii, true), + vaiNatural: wrapWith(vaiSyllables, false), + vaiTwoCells: wrapWith(vaiSyllables, true), + }, { + fullwidthAsciiNatural: '\uFF21|\uFF21|\uFF21|\uFF21', + fullwidthAsciiTwoCells: '\uFF21\uFF21|\uFF21\uFF21', + vaiNatural: '\uA500|\uA500|\uA500|\uA500', + vaiTwoCells: '\uA500\uA500|\uA500\uA500', + }); + }); + + test('MonospaceLineBreaksComputer - two-cell width preserves natural control-character metrics', () => { + const factory = new MonospaceLineBreaksComputerFactory('(', '\t)'); + const wrapWith = (text: string, useTwoCellFullwidthCharacters: boolean) => toAnnotatedText(text, getLineBreakData(factory, 4, 4, 3, WrappingIndent.None, 'normal', false, text, null, null, useTwoCellFullwidthCharacters)); + const controlCharacters = '\u0001\u0001'; + const controlCharacterWithTab = '\u0001\t'; + + assert.deepStrictEqual({ + controlNatural: wrapWith(controlCharacters, false), + controlTwoCells: wrapWith(controlCharacters, true), + controlWithTabNaturalColumns: getLineBreakData(factory, 4, 3, 3, WrappingIndent.None, 'normal', false, controlCharacterWithTab, null, null, false)?.breakOffsetsVisibleColumn, + controlWithTabTwoCellsColumns: getLineBreakData(factory, 4, 3, 3, WrappingIndent.None, 'normal', false, controlCharacterWithTab, null, null, true)?.breakOffsetsVisibleColumn, + }, { + controlNatural: '\u0001|\u0001', + controlTwoCells: '\u0001|\u0001', + controlWithTabNaturalColumns: [3, 4], + controlWithTabTwoCellsColumns: [3, 4], + }); + }); + + test('MonospaceLineBreaksComputer - two-cell width treats combining marks independently', () => { + const factory = new MonospaceLineBreaksComputerFactory('(', '\t)'); + const wrapWith = (text: string, useTwoCellFullwidthCharacters: boolean) => toAnnotatedText(text, getLineBreakData(factory, 4, 4, 3, WrappingIndent.None, 'normal', false, text, null, null, useTwoCellFullwidthCharacters)); + const combinedCharacter = '\u3042\u0301aa'; + + assert.deepStrictEqual({ + combinedNatural: wrapWith(combinedCharacter, false), + combinedTwoCells: wrapWith(combinedCharacter, true), + }, { + combinedNatural: '\u3042|\u0301aa', + combinedTwoCells: '\u3042|\u0301aa', + }); + }); + + test('MonospaceLineBreaksComputer - two-cell width supports incremental line breaks', () => { + const factory = new MonospaceLineBreaksComputerFactory(EditorOptions.wordWrapBreakBeforeCharacters.defaultValue, EditorOptions.wordWrapBreakAfterCharacters.defaultValue); + + // A font that lies about its full-width ratio: each ideograph still occupies exactly two cells. + assertIncrementalLineBreaks( + factory, 'ああああああ', 4, + 5, 'ああ|ああ|ああ', + 7, 'あああ|あああ', + WrappingIndent.None, + 3, + true + ); + + assertIncrementalLineBreaks( + factory, ' ああああ', 4, + 8, ' ああ|ああ', + 10, ' あああ|あ', + WrappingIndent.Same, + 3, + true + ); + + assertIncrementalLineBreaks( + factory, '你好 **hello** **hello** **hello-world** hey there!', 4, + 15, '你好 **hello** |**hello** |**hello-world**| hey there!', + 1, '你|好| |*|*|h|e|l|l|o|*|*| |*|*|h|e|l|l|o|*|*| |*|*|h|e|l|l|o|-|w|o|r|l|d|*|*| |h|e|y| |t|h|e|r|e|!', + WrappingIndent.Same, + 1.6605405405405405, + true + ); + }); + test('MonospaceLineBreaksComputer - WrappingIndent.Same', () => { const factory = new MonospaceLineBreaksComputerFactory('', '\t '); assertLineBreaks(factory, 4, 38, ' *123456789012345678901234567890123456|7890', WrappingIndent.Same); diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index 09915a982eb45f..58b1546a520f6e 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -3721,6 +3721,11 @@ declare namespace monaco.editor { * Defaults to false. */ formatOnPaste?: boolean; + /** + * Controls the width used to render full-width characters. + * Defaults to 'font'. + */ + fullwidthCharacterWidth?: 'font' | 'twoCells'; /** * Controls whether double-clicking next to a bracket or quote selects the content inside. * Defaults to true. @@ -4002,6 +4007,8 @@ declare namespace monaco.editor { inlineCompletionsAccessibilityVerbose?: boolean; } + export type DiffEditorViewMode = 'inline' | 'sideBySide' | 'automatic'; + export interface IDiffEditorBaseOptions { /** * Allow the user to resize the diff editor split view. @@ -5257,7 +5264,9 @@ declare namespace monaco.editor { effectiveEditContext = 170, scrollOnMiddleClick = 171, effectiveAllowVariableFonts = 172, - doubleClickSelectsBlock = 173 + doubleClickSelectsBlock = 173, + fullwidthCharacterWidth = 174, + effectiveFullwidthCharacterWidth = 175 } export const EditorOptions: { @@ -5314,7 +5323,7 @@ declare namespace monaco.editor { renderRichScreenReaderContent: IEditorOption; stickyScroll: IEditorOption>>; experimentalGpuAcceleration: IEditorOption; - experimentalWhitespaceRendering: IEditorOption; + experimentalWhitespaceRendering: IEditorOption; extraEditorClassName: IEditorOption; fastScrollSensitivity: IEditorOption; find: IEditorOption>>; @@ -5333,6 +5342,7 @@ declare namespace monaco.editor { fontVariations: IEditorOption; formatOnPaste: IEditorOption; formatOnType: IEditorOption; + fullwidthCharacterWidth: IEditorOption; glyphMargin: IEditorOption; gotoLocation: IEditorOption>>; hideCursorInOverviewRuler: IEditorOption; @@ -5435,6 +5445,7 @@ declare namespace monaco.editor { wrappingStrategy: IEditorOption; effectiveEditContextEnabled: IEditorOption; effectiveAllowVariableFonts: IEditorOption; + effectiveFullwidthCharacterWidth: IEditorOption; }; type EditorOptionsType = typeof EditorOptions; diff --git a/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts b/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts index fc3cad83b1918e..0105a6ebc01189 100644 --- a/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts +++ b/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts @@ -108,8 +108,8 @@ export type IncomingRequestDisposition = export interface IHydrationContext { readonly session: ProtocolURI; readonly chat: ProtocolURI; - /** Authoritative value from already-loaded host session metadata, when available. */ - readonly hasWorkspaceTransitions?: boolean; + /** Workspace transitions preloaded from this chat's backing storage, when present. */ + readonly workspaceTransitions?: ReadonlyMap; } /** diff --git a/src/vs/platform/agentHost/common/automationMigration.ts b/src/vs/platform/agentHost/common/automationMigration.ts index 16b4683b825852..3808594887c0d3 100644 --- a/src/vs/platform/agentHost/common/automationMigration.ts +++ b/src/vs/platform/agentHost/common/automationMigration.ts @@ -3,6 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { migrateLegacyAutopilotConfig } from './agentHostSchema.js'; +import { KNOWN_MODE_VALUES, SessionConfigKey } from './sessionConfigKeys.js'; + export const AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY = 'vscode.automationMigration'; export const AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY = 'automationsEnabled'; export const AGENT_HOST_AUTOMATION_RUN_TIMEOUT_MINUTES_CONFIG_KEY = 'automationRunTimeoutMinutes'; @@ -10,6 +13,8 @@ export const AGENT_HOST_LEGACY_AUTOMATION_IMPORT_META_KEY = 'vscode.legacyAutoma export const AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY = 'vscode.legacyAutomationImportPending'; export const AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY = 'vscode.migrationCompleted'; +const LEGACY_AUTOPILOT_PROVIDER = 'copilotcli'; + export interface IAgentHostAutomationMigrationCompletion { readonly version: 1; readonly status: 'complete'; @@ -27,3 +32,55 @@ export function isAgentHostAutomationMigrationCompletion(value: unknown): value const resources = candidate['resources']; return resources.every(resource => typeof resource === 'string') && new Set(resources).size === resources.length; } + +/** Whether the provider used the legacy flattened Automation mode and permission fields. */ +export function supportsLegacyAutomationSessionConfig(provider: string | undefined): boolean { + return provider === undefined || provider === LEGACY_AUTOPILOT_PROVIDER; +} + +/** Migrates the legacy combined Autopilot value into the Copilot Automation's current two-axis configuration. */ +export function migrateLegacyAutomationSessionConfig(provider: string | undefined, config: undefined): undefined; +export function migrateLegacyAutomationSessionConfig(provider: string | undefined, config: Record): Record; +export function migrateLegacyAutomationSessionConfig(provider: string | undefined, config: Record | undefined): Record | undefined; +export function migrateLegacyAutomationSessionConfig(provider: string | undefined, config: Record | undefined): Record | undefined { + if (!supportsLegacyAutomationSessionConfig(provider) || !config) { + return config; + } + if (config[SessionConfigKey.AutoApprove] === 'assisted' + && typeof config[SessionConfigKey.Mode] === 'string' + && !KNOWN_MODE_VALUES.has(config[SessionConfigKey.Mode])) { + return { ...config, [SessionConfigKey.Mode]: 'autopilot' }; + } + return migrateCombinedAutopilotConfig(config); +} + +function migrateCombinedAutopilotConfig(config: Record): Record { + if (config[SessionConfigKey.AutoApprove] !== 'autopilot') { + return config; + } + const migrated = migrateLegacyAutopilotConfig(config); + migrated[SessionConfigKey.AutoApprove] = 'assisted'; + return migrated; +} + +/** Applies the legacy flattened Automation values to provider configuration. */ +export function applyLegacyAutomationSessionConfig(provider: string | undefined, config: Readonly> | undefined, mode: string | undefined, permissionLevel: string | undefined): Record { + const result = { ...config }; + if (!supportsLegacyAutomationSessionConfig(provider)) { + if (permissionLevel === undefined || permissionLevel === 'default') { + delete result[SessionConfigKey.AutoApprove]; + } + return result; + } + if (mode === undefined) { + delete result[SessionConfigKey.Mode]; + } else if (KNOWN_MODE_VALUES.has(mode)) { + result[SessionConfigKey.Mode] = mode; + } + if (permissionLevel === undefined) { + delete result[SessionConfigKey.AutoApprove]; + } else { + result[SessionConfigKey.AutoApprove] = permissionLevel; + } + return migrateCombinedAutopilotConfig(result); +} diff --git a/src/vs/platform/agentHost/common/meta/agentToolCallMeta.ts b/src/vs/platform/agentHost/common/meta/agentToolCallMeta.ts index 81a5d56e083d0f..b6a598cc8a5bf1 100644 --- a/src/vs/platform/agentHost/common/meta/agentToolCallMeta.ts +++ b/src/vs/platform/agentHost/common/meta/agentToolCallMeta.ts @@ -49,6 +49,8 @@ export interface IToolCallMeta { readonly autoApproveRuleResolvable?: boolean; /** Transient runtime corpus for the local client tool-search invocation. */ readonly toolSearchCandidates?: readonly IToolSearchCandidate[]; + /** Latest progress message from a running tool; transient, meaningful only while Running. */ + readonly progressMessage?: string; } /** Minimal metadata needed to embed and rank a deferred tool. */ @@ -132,6 +134,7 @@ export function readToolCallMeta(source: IHasToolCallMeta): IToolCallMeta { if (typeof meta['subagentChatUri'] === 'string') { result.subagentChatUri = meta['subagentChatUri']; } if (typeof meta['mcpServerName'] === 'string') { result.mcpServerName = meta['mcpServerName']; } if (typeof meta['mcpToolName'] === 'string') { result.mcpToolName = meta['mcpToolName']; } + if (typeof meta['progressMessage'] === 'string') { result.progressMessage = meta['progressMessage']; } if (typeof meta['autoApproveBySetting'] === 'boolean') { result.autoApproveBySetting = meta['autoApproveBySetting']; } if (typeof meta['autoApproveRuleResolvable'] === 'boolean') { result.autoApproveRuleResolvable = meta['autoApproveRuleResolvable']; } const toolSearchCandidates = readToolSearchCandidates(meta['toolSearchCandidates']); diff --git a/src/vs/platform/agentHost/common/sessionConfigKeys.ts b/src/vs/platform/agentHost/common/sessionConfigKeys.ts index 55d4f98656a99b..c291bd91a15fbe 100644 --- a/src/vs/platform/agentHost/common/sessionConfigKeys.ts +++ b/src/vs/platform/agentHost/common/sessionConfigKeys.ts @@ -70,3 +70,38 @@ export function omitTransientSessionConfigValues(values: Record): delete result[SessionConfigKey.ShellInitScripts]; return result; } + +const automationDefinitionOwnedConfigKeys = [ + SessionConfigKey.Permissions, + SessionConfigKey.Isolation, + SessionConfigKey.Branch, + SessionConfigKey.WorktreeBranchPrefix, + SessionConfigKey.WorktreeIncludeFiles, + SessionConfigKey.WorktreeBranchTrack, + SessionConfigKey.WorktreeCreateNewBranch, + SessionConfigKey.AgentMerge, + SessionConfigKey.AgentMergeController, +] as const; + +/** Removes values owned by a concrete session or target rather than a reusable Automation template. */ +export function omitAutomationSessionTemplateConfigValues(values: Record): Record { + const result = omitTransientSessionConfigValues(values); + for (const key of automationDefinitionOwnedConfigKeys) { + delete result[key]; + } + return result; +} + +/** Retains definition-owned values while an editor-facing Automation template is written back. */ +export function pickAutomationDefinitionOwnedConfigValues(values: Readonly> | undefined): Record { + const result: Record = {}; + if (!values) { + return result; + } + for (const key of automationDefinitionOwnedConfigKeys) { + if (Object.hasOwn(values, key)) { + result[key] = values[key]; + } + } + return result; +} diff --git a/src/vs/platform/agentHost/node/agentHostAutomationService.ts b/src/vs/platform/agentHost/node/agentHostAutomationService.ts index 6e98e27f32ebff..ab3cdf63d77526 100644 --- a/src/vs/platform/agentHost/node/agentHostAutomationService.ts +++ b/src/vs/platform/agentHost/node/agentHostAutomationService.ts @@ -23,7 +23,7 @@ import { MessageKind } from '../common/state/protocol/channels-chat/state.js'; import { IAgentHostStateManager, type AgentHostStateManager } from './agentHostStateManager.js'; import { IAgentHostStorageService } from './agentHostStorageService.js'; import { nextAutomationCronOccurrence, validateAutomationCron } from './automationCron.js'; -import { AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY, AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY, AGENT_HOST_AUTOMATION_RUN_TIMEOUT_MINUTES_CONFIG_KEY } from '../common/automationMigration.js'; +import { AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY, AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY, AGENT_HOST_AUTOMATION_RUN_TIMEOUT_MINUTES_CONFIG_KEY, migrateLegacyAutomationSessionConfig } from '../common/automationMigration.js'; import { isAgentHostLegacyAutomationImportPending } from '../common/meta/automationMeta.js'; const STORAGE_KEY = 'automations'; @@ -107,7 +107,7 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost this._migrationCompletedAt = stored?.migration?.completedAt; this._runs = new Map(stored?.runs?.map(run => [run.resource, run])); this._catalog = stored?.catalog ? { - entries: stored.catalog.automations.map(automation => withRunWindow(automation, this._runs, RUN_HISTORY_PAGE_SIZE)), + entries: stored.catalog.automations.map(automation => withRunWindow(migrateStoredAutomation(automation), this._runs, RUN_HISTORY_PAGE_SIZE)), ...(stored.catalog._meta || this._migrationCompletedAt ? { _meta: { ...stored.catalog._meta, @@ -701,7 +701,11 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost await this._execution.cancelSession(session); return; } - await this._execution.startSession(session, definition.message); + // Clients restore the last turn's model configuration, not the SDK's creation defaults. + const message: Message = definition.message.model === undefined && definition.session.model !== undefined + ? { ...definition.message, model: definition.session.model } + : definition.message; + await this._execution.startSession(session, message); } catch (error) { try { await this._enqueueMutation(() => this._failRun(initialRun.resource, error)); @@ -960,6 +964,21 @@ function isStoredAutomationCatalog(value: unknown): value is IStoredAutomationCa && (meta === undefined || !!meta && typeof meta === 'object' && !Array.isArray(meta)); } +function migrateStoredAutomation(automation: AutomationEntry): AutomationEntry { + const session = automation.definition.session; + const config = migrateLegacyAutomationSessionConfig(session.provider, session.config); + if (config === session.config) { + return automation; + } + return { + ...automation, + definition: { + ...automation.definition, + session: { ...session, config }, + }, + }; +} + function isStoredAutomations(value: unknown): value is IStoredAutomations { if (!value || typeof value !== 'object' || Array.isArray(value)) { return false; diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index d0318b8694ac62..36c53366234f12 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -8,7 +8,7 @@ import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffe import { Barrier, DeferredPromise, disposableTimeout, Limiter, ResourceQueue } from '../../../base/common/async.js'; import { toErrorMessage } from '../../../base/common/errorMessage.js'; import { Emitter } from '../../../base/common/event.js'; -import { Disposable, DisposableMap, DisposableResourceMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableResourceMap, DisposableStore, IDisposable, IReference, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { getExtensionForMimeType, getMediaMime, getMediaOrTextMime } from '../../../base/common/mime.js'; import { Schemas } from '../../../base/common/network.js'; import { dirname as resourcesDirname, extname as resourcesExtname, extUriBiasedIgnorePathCase, isEqual, isEqualOrParent, joinPath } from '../../../base/common/resources.js'; @@ -21,7 +21,7 @@ import { IInstantiationService } from '../../instantiation/common/instantiation. import { ILogService } from '../../log/common/log.js'; import { AgentChatMigrationDeferred, AgentProvider, AgentSession, AgentSignal, IAgent, type IAgentAdoptedWorktree, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentCreateChatOptions, IAgentCreateChatRequestOptions, IAgentCreateChatResult, IAgentCreateChatSideChatSelection, IAgentCreateChatSideChatSource, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDiscoveredChat, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentChatAdoptionResult, type AgentChatAdoptionReason, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, SubagentChatSignal, subagentChatTitle } from '../common/agent.js'; import { type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentService } from '../common/agentService.js'; -import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; +import { ISessionDatabase, ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; import { IAgentEditAttributionService, ICancelEditAttributionFlushParams, ICommitEditAttributionFlushParams, IEditAttributionFlushResult, IPrepareEditAttributionFlushParams, IPreparedEditAttributionFlush, parseEditAttributionResource } from '../common/fileEditAttribution.js'; import { omitTransientSessionConfigValues, SessionConfigKey } from '../common/sessionConfigKeys.js'; import type { IAgentCustomizationSettingsRegistration } from '../common/agentCustomizationSettings.js'; @@ -37,7 +37,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type Annotation, type AnnotationEntry, type AnnotationOrigin, type AnnotationsState, type ChatOrigin, type Customization, type Message, type MessageAttachment, type MessageResourceAttachment, type TextRange } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js'; -import { isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, getErrorResponsePart, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionHasWorkspaceTransitions, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withMessageRequestHiddenFromTranscript, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionHasWorkspaceTransitions, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionEhcliLastMigratedTurn, AH_META_EHCLI_LAST_TURN_DB_KEY, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; +import { isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, chatStorageUri, getErrorResponsePart, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withMessageRequestHiddenFromTranscript, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionHasWorkspaceTransitions, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionEhcliLastMigratedTurn, AH_META_EHCLI_LAST_TURN_DB_KEY, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../common/meta/agentSnapshotAttachmentMeta.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js'; @@ -3492,12 +3492,44 @@ export class AgentService extends Disposable implements IAgentService { * exactly that window; every other caller relies on the exhaustive origin * {@link _chatContext} stamps. */ - private async _getChatMessages(provider: IAgent, chat: URI, session: URI, origin?: ChatOrigin, hasWorkspaceTransitions?: boolean): Promise { + private async _getChatMessages(provider: IAgent, chat: URI, session: URI, origin?: ChatOrigin, workspaceTransitionsPromise?: Promise | undefined>): Promise { const context = { ...this._chatContext(session, chat), ...(origin ? { origin } : {}) }; + const transitionsPromise = workspaceTransitionsPromise ?? this._loadWorkspaceTransitions(chat); this._logService.trace(`[AgentService] getChatMessages start: chat=${chat.toString()}`); - const providerTurns = await provider.chats.getMessages(chat, context); + const [providerTurns, workspaceTransitions] = await Promise.all([ + provider.chats.getMessages(chat, context), + transitionsPromise, + ]); this._logService.trace(`[AgentService] getChatMessages: provider returned ${providerTurns.length} turn(s) for chat=${chat.toString()}`); - return this._chatContributions.hydrateTurns({ session: session.toString(), chat: chat.toString(), hasWorkspaceTransitions }, providerTurns); + return this._chatContributions.hydrateTurns({ session: session.toString(), chat: chat.toString(), workspaceTransitions }, providerTurns); + } + + private async _loadWorkspaceTransitions(chat: URI): Promise | undefined> { + const storage = chatStorageUri(chat); + if (!storage) { + return undefined; + } + try { + const database = await this._sessionDataService.tryOpenDatabase(storage); + return database ? this._readWorkspaceTransitions(database, storage, true) : undefined; + } catch (error) { + this._logService.warn(`[AgentService] Failed to open workspace transition storage for ${storage.toString()}`, error); + return undefined; + } + } + + private async _readWorkspaceTransitions(database: IReference, storage: URI, requireMarker = false): Promise | undefined> { + try { + if (requireMarker && await database.object.getMetadata(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY) !== 'true') { + return undefined; + } + return await database.object.getTurnWorkspaceTransitions(); + } catch (error) { + this._logService.warn(`[AgentService] Failed to restore workspace transitions for ${storage.toString()}`, error); + return undefined; + } finally { + database.dispose(); + } } /** @@ -5509,12 +5541,13 @@ export class AgentService extends Disposable implements IAgentService { let gitMetadata: Record | undefined; let changesetMetadata: Record | undefined; let sessionMetadata: Record | undefined; - let sessionMetadataRead = false; + let workspaceTransitionsPromise: Promise | undefined> = Promise.resolve(undefined); const ref = this._sessionDataService.tryOpenDatabase?.(session); if (ref) { try { const db = await ref; if (db) { + let retainedForWorkspaceTransitions = false; try { const m = await db.object.getMetadataObject({ customTitle: true, @@ -5534,7 +5567,10 @@ export class AgentService extends Disposable implements IAgentService { ...GIT_DB_METADATA_KEYS, ...CHANGESET_DB_METADATA_KEYS, }); - sessionMetadataRead = true; + if (m[AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY] === 'true') { + retainedForWorkspaceTransitions = true; + workspaceTransitionsPromise = this._readWorkspaceTransitions(db, session); + } if (m.customTitle) { title = m.customTitle; } @@ -5626,10 +5662,10 @@ export class AgentService extends Disposable implements IAgentService { } } } finally { - db.dispose(); + if (!retainedForWorkspaceTransitions) { + db.dispose(); + } } - } else { - sessionMetadataRead = true; } } catch { // Best-effort: fall back to agent-provided metadata @@ -5639,7 +5675,7 @@ export class AgentService extends Disposable implements IAgentService { let turns: readonly Turn[]; try { - turns = await this._getChatMessages(agent, defaultChatUri, session, undefined, sessionMetadataRead ? readSessionHasWorkspaceTransitions(sessionMetadata) : undefined); + turns = await this._getChatMessages(agent, defaultChatUri, session, undefined, workspaceTransitionsPromise); } catch (err) { if (err instanceof ProtocolError) { throw err; diff --git a/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts index 70e2c57a3529da..bb43f8443dbab4 100644 --- a/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts @@ -4,15 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../../base/common/lifecycle.js'; -import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; -import { ILogService } from '../../../../log/common/log.js'; import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IHydrationContext, IIncomingRequest, IncomingRequestDisposition, ITurnEnd } from '../../../common/agentHostChatContributionsService.js'; import { parseAgentWorkspaceTransition, AgentSystemNotificationKind, readAgentSystemNotificationMeta, toAgentSystemNotificationMeta } from '../../../common/meta/agentSystemNotificationMeta.js'; import { toAgentWorkspaceContinuationMessageMeta } from '../../../common/meta/agentWorkspaceContinuationMeta.js'; -import { ISessionDataService } from '../../../common/sessionDataService.js'; -import { chatStorageUri, readSessionHasWorkspaceTransitions, ResponsePartKind, withMessageRequestHiddenFromTranscript, type Turn } from '../../../common/state/sessionState.js'; -import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js'; +import { ResponsePartKind, withMessageRequestHiddenFromTranscript, type Turn } from '../../../common/state/sessionState.js'; import { ISessionWorkspaceConversionService } from './sessionWorkspaceConversionService.js'; /** Finalizes requested workspace conversions after a turn and blocks new turns while conversion is pending. */ @@ -24,9 +20,6 @@ export class SessionWorkspaceConversionContribution extends Disposable implement constructor( protected readonly _context: IAgentHostChatContributionContext, @ISessionWorkspaceConversionService private readonly _conversionService: ISessionWorkspaceConversionService, - @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, - @ISessionDataService private readonly _sessionDataService: ISessionDataService, - @ILogService private readonly _logService: ILogService, ) { super(); } @@ -53,37 +46,9 @@ export class SessionWorkspaceConversionContribution extends Disposable implement }; } - async onHydrateTurns(context: IHydrationContext, turns: readonly Turn[]): Promise { - if (turns.length === 0) { - return turns; - } - if (context.hasWorkspaceTransitions === false) { - return turns; - } - if (context.hasWorkspaceTransitions !== true) { - const summary = this._stateManager.getSessionSummary(context.session); - if (summary && !readSessionHasWorkspaceTransitions(summary._meta)) { - return turns; - } - } - const storage = chatStorageUri(URI.parse(context.chat)); - if (!storage) { - return turns; - } - const database = await this._sessionDataService.tryOpenDatabase(storage); - if (!database) { - return turns; - } - let transitions: Map; - try { - transitions = await database.object.getTurnWorkspaceTransitions(); - } catch (error) { - this._logService.warn(`[SessionWorkspaceConversionContribution] Failed to restore workspace transitions for ${storage.toString()}`, error); - return turns; - } finally { - database.dispose(); - } - if (transitions.size === 0) { + onHydrateTurns(context: IHydrationContext, turns: readonly Turn[]): readonly Turn[] { + const transitions = context.workspaceTransitions; + if (turns.length === 0 || !transitions?.size) { return turns; } return turns.map(turn => { diff --git a/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts index 5502497dbc6b64..1870eba5319080 100644 --- a/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts +++ b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts @@ -35,6 +35,7 @@ interface IPendingSessionWorkspaceConversion { readonly isolation: boolean; readonly initiatingClientId: string; readonly prompt: string | undefined; + readonly showTransition: boolean; phase: 'requested' | 'converting'; resolvedWorkingDirectory?: URI; transition?: IAgentWorkspaceTransitionRecord; @@ -99,12 +100,13 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe if (activeTurnId !== turnId) { throw new Error('Session workspace conversion must be requested from the active turn.'); } - const prompt = this._stateManager.getChatState(chat.toString())?.activeTurn?.message.text; + const chatState = this._stateManager.getChatState(chat.toString()); + const prompt = chatState?.activeTurn?.message.text; const key = chat.toString(); if (this.isPending(key)) { throw new Error('A workspace conversion is already pending for this session.'); } - this._pending.set(key, { chat, turnId, workspaceFolder, isolation, initiatingClientId, prompt, phase: 'requested' }); + this._pending.set(key, { chat, turnId, workspaceFolder, isolation, initiatingClientId, prompt, showTransition: !!chatState?.turns.length, phase: 'requested' }); } isPending(chat: ProtocolURI): boolean { @@ -128,7 +130,9 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe let continuation: IDeferredAgentHostTurn | undefined; try { continuation = this._beginContinuation(pending); - pending.transition = this._createWorkspaceTransition(pending); + if (pending.showTransition) { + pending.transition = this._createWorkspaceTransition(pending); + } pending.resolvedWorkingDirectory = await this._convert(pending, continuation); this._pending.delete(chat); await this._continueConversion(continuation, pending, true); @@ -155,9 +159,6 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe private async _convert(pending: IPendingSessionWorkspaceConversion, continuation: IDeferredAgentHostTurn): Promise { const { chat, workspaceFolder, isolation, initiatingClientId, prompt, transition } = pending; - if (!transition) { - throw new Error('Cannot convert a session without a workspace transition.'); - } const { session, state, previousWorkingDirectory } = this._validateConversion(chat, workspaceFolder); const provider = this._providerService.getProviderForSession(session); if (!provider?.agentHostCapabilities.workspaceConversion) { @@ -224,14 +225,14 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe ? { ...convertedState.config?.values, ...configPatch } : undefined; let persistenceError: unknown; - const persistTransition = this._stateManager.getActiveTurnId(chat.toString()) === continuation.turnId; + const persistTransition = !!transition && this._stateManager.getActiveTurnId(chat.toString()) === continuation.turnId; const database = this._sessionDataService.openDatabase(session); try { const metadata = { [AH_META_WORKSPACELESS_DB_KEY]: 'false' }; if (configValues) { Object.assign(metadata, { configValues: JSON.stringify(configValues) }); } - if (persistTransition) { + if (persistTransition && transition) { await database.object.setWorkspaceConversion(continuation.turnId, serializeAgentWorkspaceTransition(transition), metadata); pending.transitionPersisted = true; } else { @@ -536,7 +537,7 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe workspaceName: pending.transition.workspaceName, }, ); - } else { + } else if (!converted || pending.showTransition) { this._publishConversionOutcome(pending.chat, continuation, label); } try { diff --git a/src/vs/platform/agentHost/node/codex/codexClientCustomizations.ts b/src/vs/platform/agentHost/node/codex/codexClientCustomizations.ts index b1bf4f58d67b25..3b1a74ee4c8b6d 100644 --- a/src/vs/platform/agentHost/node/codex/codexClientCustomizations.ts +++ b/src/vs/platform/agentHost/node/codex/codexClientCustomizations.ts @@ -15,8 +15,19 @@ import { CustomizationEnablementKind, type AgentSelection } from '../../common/s import { CustomizationType, type ChildCustomization, type ClientPluginCustomization, type McpServerCustomization, type PluginCustomization } from '../../common/state/sessionState.js'; import { readClientPluginMcpDefaultCwd } from '../../common/meta/clientPluginCustomizationMeta.js'; import { isCustomizationEnabled } from '../../common/customizationEnablement.js'; +import { AGENT_HOST_FILE_LINK_INSTRUCTIONS } from '../shared/fileLinkInstructions.js'; import { toCodexMcpServerJson, type ICodexMcpServerConfigJson } from './codexMcpServers.js'; +export const CODEX_FILE_LINK_INSTRUCTIONS = [ + AGENT_HOST_FILE_LINK_INSTRUCTIONS, + '', + '', + '- Use the actual filesystem path. Example prefixes such as `/path/to` or `/abs/path` are placeholders, not text to prepend to a path.', + '- On Windows, drive-letter paths are already absolute: [foo.ts](C:/project/foo.ts). Do not add a leading `/` or prepend the working directory.', + '- For UNC paths, preserve the server and share: [foo.ts](//server/share/foo.ts).', + '', +].join('\n'); + /** * Codex ingests **client-pushed** plugin customizations (the "Open Plugins" * the workbench syncs via {@link IActiveClient.customizations}) differently @@ -53,7 +64,7 @@ export interface ICodexAgentRoleSource { export interface ICodexCustomizationConfig { readonly agentRoles: readonly ICodexAgentRoleSource[]; - readonly developerInstructions?: string; + readonly developerInstructions: string; } /** @@ -302,11 +313,12 @@ export async function codexCustomizationConfig( const developerInstructions = [ ...pluginInstructions, ...(selectedAgentInstructions ? [selectedAgentInstructions.trim()] : []), + CODEX_FILE_LINK_INSTRUCTIONS, ].filter(Boolean).join('\n\n'); return { agentRoles: [...agentRoles.values()], - ...(developerInstructions ? { developerInstructions } : {}), + developerInstructions, }; } diff --git a/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts b/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts index fdd306689525bd..9785588ab526e4 100644 --- a/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts +++ b/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts @@ -137,6 +137,10 @@ export interface ICodexToolCallEntry { readonly turnId: string; readonly toolName: string; output: string; + /** Latest `mcpToolCall/progress` message, kept out of {@link output} so it never becomes the result. */ + progressMessage?: string; + /** The `_meta` emitted at start, re-spread by later actions since the reducer replaces the whole bag. */ + meta?: Record; } export function createCodexSessionMapState(serverToolNames: ReadonlySet = new Set(), clientToolSet: ActiveClientToolSet = new ActiveClientToolSet()): ICodexSessionMapState { @@ -596,11 +600,13 @@ function mapItemStartedBody( // fresh toolCallId; the `commandExecution` item id only // disambiguates the codex side. const toolCallId = generateUuid(); + const meta = toToolCallMeta({ toolKind: 'terminal' }); state.itemToToolCall.set(params.item.id, { toolCallId, turnId: params.turnId, toolName: 'shell', output: '', + meta, }); const command = unwrapShellInvocation(params.item.command ?? ''); return [ @@ -610,7 +616,7 @@ function mapItemStartedBody( toolCallId, toolName: 'shell', displayName: 'Run shell command', - _meta: toToolCallMeta({ toolKind: 'terminal' }), + _meta: meta, }, { type: ActionType.ChatToolCallDelta, @@ -625,17 +631,19 @@ function mapItemStartedBody( invocationMessage: command, toolInput: command, confirmed: ToolCallConfirmationReason.NotNeeded, - _meta: toToolCallMeta({ toolKind: 'terminal' }), + _meta: meta, }, ]; } if (params.item.type === 'webSearch') { const toolCallId = generateUuid(); + const meta = toToolCallMeta({ toolKind: 'search' }); state.itemToToolCall.set(params.item.id, { toolCallId, turnId: params.turnId, toolName: 'web_search', output: '', + meta, }); const query = describeWebSearch(params.item.query, params.item.action); return [ @@ -645,7 +653,7 @@ function mapItemStartedBody( toolCallId, toolName: 'web_search', displayName: 'Web search', - _meta: toToolCallMeta({ toolKind: 'search' }), + _meta: meta, }, { type: ActionType.ChatToolCallDelta, @@ -660,7 +668,7 @@ function mapItemStartedBody( invocationMessage: webSearchInvocationMessage(query), toolInput: query, confirmed: ToolCallConfirmationReason.NotNeeded, - _meta: toToolCallMeta({ toolKind: 'search' }), + _meta: meta, }, ]; } @@ -962,20 +970,25 @@ export function mapFileChangeOutputDelta( }]; } +/** + * Progress is a transient status line rather than output, so it travels as + * `_meta.progressMessage` (see {@link IToolCallMeta}) and never joins the result. + */ export function mapMcpToolCallProgress( state: ICodexSessionMapState, params: McpToolCallProgressNotification, ): (SessionAction | ChatAction)[] { const entry = state.itemToToolCall.get(params.itemId); - if (!entry) { + if (!entry || params.message === entry.progressMessage) { return []; } - entry.output = [entry.output, params.message].filter(Boolean).join('\n'); + entry.progressMessage = params.message; return [{ type: ActionType.ChatToolCallContentChanged, turnId: entry.turnId, toolCallId: entry.toolCallId, - content: [{ type: ToolResultContentType.Text, text: entry.output }], + content: entry.output ? [{ type: ToolResultContentType.Text, text: entry.output }] : [], + _meta: { ...entry.meta, ...toToolCallMeta({ progressMessage: params.message }) }, }]; } @@ -1136,7 +1149,7 @@ export function mapItemCompleted( } if (params.item.type === 'mcpToolCall') { const success = params.item.status === 'completed' && !params.item.error; - const output = mcpToolOutput(params.item.result, params.item.error?.message) || entry.output; + const output = mcpToolOutput(params.item.result, params.item.error?.message); const content = output ? [{ type: ToolResultContentType.Text as const, text: output }] : undefined; return [{ type: ActionType.ChatToolCallComplete, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index f0ee8e65822f62..e0c12556e82a18 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -38,7 +38,7 @@ import type { ChatInputRequestWithPlanReview, IAgentHostPlanReviewAction } from import { ChatInputRequestPurpose, withChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js'; import { gitHubMcpServerUrl } from '../../common/githubEndpoints.js'; import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; -import { AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyAnswer, AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js'; +import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyAnswer, AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js'; import { createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { AgentSession, AgentSignal, AgentWorkingDirectoryChangedError, AuthenticateParams, IMcpNotification, type AgentTurnProviderCallState, type IAgentToolPendingConfirmationSignal, type IAgentTurnDiagnosticSnapshot } from '../../common/agent.js'; import { META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js'; @@ -3973,6 +3973,9 @@ export class CopilotAgentSession extends Disposable { * level. Agent mode is an orthogonal axis and does not affect approvals. */ private _isBypassApprovals(): boolean { + if (this._configurationService.getRootValue(platformRootSchema, AgentHostAutoApprovePolicyRestrictedConfigKey) === true) { + return false; + } if (this._configurationService.getRootValue(platformRootSchema, AgentHostGlobalAutoApproveEnabledConfigKey) === true) { return true; } @@ -3989,6 +3992,9 @@ export class CopilotAgentSession extends Disposable { } private _getConfiguredApprovalLevel(): string { + if (this._configurationService.getRootValue(platformRootSchema, AgentHostAutoApprovePolicyRestrictedConfigKey) === true) { + return 'default'; + } return this._configurationService.getEffectiveValue(this._ownerSessionUri.toString(), platformSessionSchema, SessionConfigKey.AutoApprove) ?? 'default'; } @@ -6567,14 +6573,26 @@ export class CopilotAgentSession extends Disposable { this._logService.trace(`[Copilot:${sessionId}] Subagent selected: ${e.data.agentName}`); })); + const subagentIdsByStopHook = new Map(); this._register(wrapper.onHookStart(e => { this._logService.trace(`[Copilot:${sessionId}] Hook started: ${e.data.hookType} (${e.data.hookInvocationId})`); + if (e.data.hookType === 'subagentStop') { + // Some SDK stop hooks identify the subagent only in the start event's input. + const input = e.data.input; + const inputAgentId = input !== null && typeof input === 'object' && !Array.isArray(input) ? input.agentId : undefined; + const agentId = e.agentId ?? (isString(inputAgentId) ? inputAgentId : undefined); + if (agentId) { + subagentIdsByStopHook.set(e.data.hookInvocationId, agentId); + } + } })); this._register(wrapper.onHookEnd(e => { this._logService.trace(`[Copilot:${sessionId}] Hook ended: ${e.data.hookType} (${e.data.hookInvocationId}), success=${e.data.success}`); - if (e.data.hookType === 'agentStop') { - this._completeSubagentTurn(e.agentId); + const agentId = e.agentId ?? subagentIdsByStopHook.get(e.data.hookInvocationId); + subagentIdsByStopHook.delete(e.data.hookInvocationId); + if (e.data.hookType === 'agentStop' || e.data.hookType === 'subagentStop') { + this._completeSubagentTurn(agentId); } })); diff --git a/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts b/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts index 56f2e8010c90f5..682d092c859aa9 100644 --- a/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts +++ b/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts @@ -7,7 +7,8 @@ import type { SectionOverride, SystemMessageConfig, SystemMessageSection } from import { copilotCliConfigSchema } from '../../../common/copilotCliConfig.js'; import type { SchemaValue } from '../../../common/agentHostSchema.js'; import type { ModelSelection } from '../../../common/state/protocol/state.js'; -import { appendSystemMessageContent, COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS, COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS, COPILOT_AGENT_HOST_SYSTEM_MESSAGE, fullSystemPrompt, sectionOverrides, withDefaultSections } from './systemMessage.js'; +import { AGENT_HOST_FILE_LINK_INSTRUCTIONS } from '../../shared/fileLinkInstructions.js'; +import { appendSystemMessageContent, COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS, COPILOT_AGENT_HOST_SYSTEM_MESSAGE, fullSystemPrompt, sectionOverrides, withDefaultSections } from './systemMessage.js'; import { resolveToolInstructionsOverride, toolSearchInstructionLines, universalToolInstructions, type IToolInstructionContext } from './toolInstructions.js'; type CopilotCliConfigDefinition = typeof copilotCliConfigSchema.definition; @@ -156,7 +157,7 @@ export class AgentHostPromptRegistry { resolveSystemMessageConfig(model: ModelSelection | undefined, context: IAgentHostPromptContext): SystemMessageConfig { const config = this._withUniversalSections(this._resolveModelConfig(model, context), context); const withWorkspacelessScratch = this._withWorkspacelessScratch(config, context); - return appendSystemMessageContent(withWorkspacelessScratch, COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS); + return appendSystemMessageContent(withWorkspacelessScratch, AGENT_HOST_FILE_LINK_INSTRUCTIONS); } /** diff --git a/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts b/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts index e8957e3ccb4788..238ceef97c6bfd 100644 --- a/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts +++ b/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts @@ -12,22 +12,6 @@ import type { SectionOverride, SystemMessageConfig, SystemMessageSection } from */ export const COPILOT_AGENT_HOST_IDENTITY = 'You are an AI assistant using Copilot SDK in VS Code. You help users with software engineering tasks. When asked about your identity, you must state that you are an AI assistant using Copilot SDK in VS Code.'; -/** Response-formatting contract for workspace links emitted by Agent Host models. */ -export const COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS = [ - '', - 'Always use Markdown links when referring to existing files, folders, or symbols in the workspace. This is very important for helping the user understand your responses.', - '- File: use the file name as the link text and the absolute filesystem path as the target, for example [foo.ts](/path/to/foo.ts).', - '- Folder: links to folders are also supported, with an absolute path to the folder as the target, for example [src/](/path/to/src).', - '- Symbol: link to symbols by using the containing file path with a 1-based line number as the target, for example [myMethod](/path/to/foo.ts:42).', - '- Use `/` path separators in link targets, including on Windows (`C:/path/to/foo.ts`).', - '- If a file path has spaces, wrap the target in angle brackets: [foo bar.ts]().', - '- Use absolute filesystem paths rather than `file://` URIs.', - '- These rules are only for links in your responses. When writing a Markdown file, prefer paths relative to that Markdown file, for example [foo](./foo.md).', - '- Do not provide line ranges.', - '- Use a markdown link format every time you refer to a file, folder, or symbol, not just the first time.', - '', -].join('\n'); - /** * Used as-is when no per-model override matches, and composed UNDER a matching * override's sections. `customize` mode keeps the CLI/SDK foundation prompt and diff --git a/src/vs/platform/agentHost/node/sessionPermissions.ts b/src/vs/platform/agentHost/node/sessionPermissions.ts index 5fa2c94ff88d45..f673188efc6f1b 100644 --- a/src/vs/platform/agentHost/node/sessionPermissions.ts +++ b/src/vs/platform/agentHost/node/sessionPermissions.ts @@ -20,7 +20,7 @@ import { localize } from '../../../nls.js'; import { ALWAYS_CHECKED_EDIT_PATTERNS, DEFAULT_EDIT_AUTO_APPROVE_PATTERNS } from '../../chat/common/chatSettings.js'; import { ILogService } from '../../log/common/log.js'; import { containsCmdDelayedExpansion } from '../../terminal/common/autoApprove/cmdDelayedExpansion.js'; -import { AgentHostEditAutoApprovePatternsConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, platformRootSchema, platformSessionSchema } from '../common/agentHostSchema.js'; +import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostEditAutoApprovePatternsConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, platformRootSchema, platformSessionSchema } from '../common/agentHostSchema.js'; import type { IAgentToolPendingConfirmationSignal } from '../common/agent.js'; import { ISessionDataService, isSessionAttachmentPath } from '../common/sessionDataService.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; @@ -424,6 +424,9 @@ export class SessionPermissionManager extends Disposable { } getEffectiveApprovalLevel(sessionKey: ProtocolURI): string { + if (this._configService.getRootValue(platformRootSchema, AgentHostAutoApprovePolicyRestrictedConfigKey) === true) { + return 'default'; + } return this._configService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.AutoApprove) ?? 'default'; } diff --git a/src/vs/platform/agentHost/node/shared/fileLinkInstructions.ts b/src/vs/platform/agentHost/node/shared/fileLinkInstructions.ts new file mode 100644 index 00000000000000..51bd84f2985de9 --- /dev/null +++ b/src/vs/platform/agentHost/node/shared/fileLinkInstructions.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const AGENT_HOST_FILE_LINK_INSTRUCTIONS = [ + '', + 'Always use Markdown links when referring to existing files, folders, or symbols in the workspace. This is very important for helping the user understand your responses.', + '- File: use the file name as the link text and the absolute filesystem path as the target, for example [foo.ts](/path/to/foo.ts).', + '- Folder: links to folders are also supported, with an absolute path to the folder as the target, for example [src/](/path/to/src).', + '- Symbol: link to symbols by using the containing file path with a 1-based line number as the target, for example [myMethod](/path/to/foo.ts:42).', + '- Use `/` path separators in link targets, including on Windows (`C:/path/to/foo.ts`).', + '- If a file path has spaces, wrap the target in angle brackets: [foo bar.ts]().', + '- Use absolute filesystem paths rather than `file://` URIs.', + '- These rules are only for links in your responses. When writing a Markdown file, prefer paths relative to that Markdown file, for example [foo](./foo.md).', + '- Do not provide line ranges.', + '- Use a markdown link format every time you refer to a file, folder, or symbol, not just the first time.', + '', +].join('\n'); diff --git a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts index 43717b1f0c9745..5d6a0de38fe51a 100644 --- a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts +++ b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts @@ -84,6 +84,15 @@ suite('Agent host _meta readers', () => { assert.deepStrictEqual(wire, { toolKind: 'search' }); assert.deepStrictEqual(readToolCallMeta(toolCall(wire)), { toolKind: 'search' }); }); + + test('reads a progress message and drops a non-string one', () => { + const wire = toToolCallMeta({ progressMessage: 'Searching' }); + assert.deepStrictEqual({ wire, read: readToolCallMeta(toolCall(wire)), dropped: readToolCallMeta(toolCall({ progressMessage: 42 })) }, { + wire: { progressMessage: 'Searching' }, + read: { progressMessage: 'Searching' }, + dropped: {}, + }); + }); }); suite('readEphemeralSessionMeta', () => { diff --git a/src/vs/platform/agentHost/test/common/automationMigration.test.ts b/src/vs/platform/agentHost/test/common/automationMigration.test.ts new file mode 100644 index 00000000000000..e25b5b1713fdc5 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/automationMigration.test.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { applyLegacyAutomationSessionConfig, migrateLegacyAutomationSessionConfig } from '../../common/automationMigration.js'; + +suite('Automation migration', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('steady-state writes do not reinterpret generic or custom modes as Autopilot', () => { + assert.deepStrictEqual([ + applyLegacyAutomationSessionConfig('copilotcli', { mode: 'agent', autoApprove: 'assisted' }, 'agent', 'assisted'), + applyLegacyAutomationSessionConfig('copilotcli', { mode: 'reviewer', autoApprove: 'assisted' }, 'reviewer', 'assisted'), + ], [ + { mode: 'agent', autoApprove: 'assisted' }, + { mode: 'reviewer', autoApprove: 'assisted' }, + ]); + }); + + test('load-time migration still repairs transitional and combined Autopilot rows', () => { + assert.deepStrictEqual([ + migrateLegacyAutomationSessionConfig('copilotcli', { mode: 'agent', autoApprove: 'assisted' }), + migrateLegacyAutomationSessionConfig('copilotcli', { autoApprove: 'autopilot' }), + ], [ + { mode: 'autopilot', autoApprove: 'assisted' }, + { mode: 'autopilot', autoApprove: 'assisted' }, + ]); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts b/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts index 49033b78af4cea..c42239cd0ed482 100644 --- a/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts @@ -13,6 +13,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { NullLogService } from '../../../log/common/log.js'; import { AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY, AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY, AGENT_HOST_AUTOMATION_RUN_TIMEOUT_MINUTES_CONFIG_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY } from '../../common/automationMigration.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { AutomationMisfirePolicy, AutomationOperation, AutomationTriggerKind, type AutomationDefinition } from '../../common/state/protocol/channels-automation/state.js'; import { AutomationRunOriginKind, AutomationRunStatus, type AutomationRunState } from '../../common/state/protocol/channels-automation-run/state.js'; @@ -164,6 +165,57 @@ suite('AgentHostAutomationService', () => { }); }); + test('migrates stored Copilot Autopilot configurations into the current Automation shape', async () => { + storageService.set('automations', { + version: 1, + catalog: { + automations: [ + { + resource: 'ahp-automation:/legacy-autopilot', + definition: { + ...definition(), + session: { + provider: 'copilotcli', + config: { [SessionConfigKey.AutoApprove]: 'autopilot' }, + }, + }, + runs: [], + operations: [AutomationOperation.Update, AutomationOperation.Remove], + createdAt: '2026-01-01T00:00:00.000Z', + modifiedAt: '2026-01-01T00:00:00.000Z', + }, + { + resource: 'ahp-automation:/hotfix-window', + definition: { + ...definition(), + session: { + config: { + [SessionConfigKey.Mode]: 'agent', + [SessionConfigKey.AutoApprove]: 'assisted', + }, + }, + }, + runs: [], + operations: [AutomationOperation.Update, AutomationOperation.Remove], + createdAt: '2026-01-01T00:00:00.000Z', + modifiedAt: '2026-01-01T00:00:00.000Z', + }, + ], + }, + }); + await storageService.whenIdle(); + + createService(); + + assert.deepStrictEqual( + stateManager.getAutomationCatalogState()?.entries.map(automation => automation.definition.session.config), + [ + { mode: 'autopilot', autoApprove: 'assisted' }, + { mode: 'autopilot', autoApprove: 'assisted' }, + ], + ); + }); + test('failed catalogue persistence publishes nothing and a retry creates one entry', async () => { const service = createService(); await service.completeMigration(); @@ -336,6 +388,72 @@ suite('AgentHostAutomationService', () => { }); }); + for (const hasMessageModel of [false, true]) { + test(hasMessageModel ? 'preserves an explicit Automation message model' : 'records the Automation model configuration on its first turn', async () => { + const session = URI.parse('mock:/model-configuration-run'); + const model = { id: 'mock-model', config: { thinkingLevel: 'low', contextSize: 272_000 } }; + const messageModel = hasMessageModel ? { id: 'other-model', config: { thinkingLevel: 'high' } } : undefined; + const completed = new DeferredPromise(); + let createdModel: AutomationDefinition['session']['model']; + disposables.add(stateManager.onDidEmitEnvelope(envelope => { + if (envelope.action.type === ActionType.AutomationRunLifecycleChanged && envelope.action.lifecycle.status === AutomationRunStatus.Completed) { + void completed.complete(); + } + })); + const service = createService({ + createSession: async template => { + createdModel = template.model; + stateManager.createSession({ + resource: session.toString(), + provider: 'mock', + title: '', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }); + return session; + }, + startSession: async (createdSession, message) => { + const chat = buildDefaultChatUri(createdSession); + stateManager.dispatchServerAction(chat, { + type: ActionType.ChatTurnStarted, + turnId: 'model-configuration-turn', + startedAt: new Date().toISOString(), + message, + }); + stateManager.dispatchServerAction(chat, { + type: ActionType.ChatTurnComplete, + turnId: 'model-configuration-turn', + duration: 0, + }); + }, + }); + const automation = definition(); + automation.session.model = model; + if (messageModel) { + automation.message.model = messageModel; + } + await service.completeMigration(); + await service.handleCreate({ ...createAction(), definition: automation }); + await service.runAutomation({ + channel: 'ahp-automations://', + automation: 'ahp-automation:/review-changes', + requestId: 'model-configuration-request', + }); + await completed.p; + + assert.deepStrictEqual({ + createdModel, + recordedModel: stateManager.getChatState(buildDefaultChatUri(session))?.turns[0]?.message.model, + savedModel: stateManager.getAutomationCatalogState()?.entries[0].definition.session.model, + }, { + createdModel: model, + recordedModel: messageModel ?? model, + savedModel: model, + }); + }); + } + test('run persistence failure prevents session side effects', async () => { let createCalls = 0; const service = createService({ diff --git a/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts b/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts index 3b297e5914c412..58c3a9e0d0ff74 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts @@ -9,7 +9,8 @@ import { CopilotCliConfigKey, copilotCliConfigSchema, normalizeModelFamilyAlias, import type { SchemaValues } from '../../common/agentHostSchema.js'; import type { ModelSelection } from '../../common/state/protocol/state.js'; import { AgentHostPromptRegistry, agentHostPromptRegistry, type IAgentHostPromptContext } from '../../node/copilot/prompts/promptRegistry.js'; -import { COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS, COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS, COPILOT_AGENT_HOST_SYSTEM_MESSAGE } from '../../node/copilot/prompts/systemMessage.js'; +import { COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS, COPILOT_AGENT_HOST_SYSTEM_MESSAGE } from '../../node/copilot/prompts/systemMessage.js'; +import { AGENT_HOST_FILE_LINK_INSTRUCTIONS } from '../../node/shared/fileLinkInstructions.js'; import { COPILOT_AGENT_HOST_LARGE_OUTPUT_TOOL_INSTRUCTION, COPILOT_AGENT_HOST_SUBAGENT_TOOL_INSTRUCTIONS } from '../../node/copilot/prompts/toolInstructions.js'; import { BrowserChatToolReferenceName } from '../../../browserView/common/browserChatToolReferenceNames.js'; import { CLIENT_TOOL_SEARCH_REFERENCE_NAME } from '../../common/toolSearchConstants.js'; @@ -41,7 +42,7 @@ suite('AgentHostPromptRegistry', () => { const configWithToolInstructions = config.mode === 'replace' ? { ...config, content: `${config.content}\n\n${UNCONDITIONAL_TOOL_INSTRUCTIONS}` } : config; - const content = configWithToolInstructions.content ? `${configWithToolInstructions.content}\n\n${COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS}` : COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS; + const content = configWithToolInstructions.content ? `${configWithToolInstructions.content}\n\n${AGENT_HOST_FILE_LINK_INSTRUCTIONS}` : AGENT_HOST_FILE_LINK_INSTRUCTIONS; if (configWithToolInstructions.mode !== 'customize' || configWithToolInstructions.sections?.tool_instructions) { return { ...configWithToolInstructions, content }; } @@ -235,7 +236,7 @@ suite('AgentHostPromptRegistry', () => { ...COPILOT_AGENT_HOST_SYSTEM_MESSAGE.sections, tool_instructions: { action: 'append', content: `\n${UNCONDITIONAL_TOOL_INSTRUCTIONS}` }, }, - content: `${COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS}\n\n${COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS}`, + content: `${COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS}\n\n${AGENT_HOST_FILE_LINK_INSTRUCTIONS}`, } ); }); @@ -265,7 +266,7 @@ suite('AgentHostPromptRegistry', () => { guidelines: { action: 'append', content: 'Be concise.' }, tool_instructions: { action: 'append', content: `\n${UNCONDITIONAL_TOOL_INSTRUCTIONS}` }, }, - content: `${COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS}\n\n${COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS}`, + content: `${COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS}\n\n${AGENT_HOST_FILE_LINK_INSTRUCTIONS}`, } ); }); @@ -280,7 +281,7 @@ suite('AgentHostPromptRegistry', () => { }); assert.deepStrictEqual( registry.resolveSystemMessageConfig({ id: 'gpt-5-mini' }, context({}, [], true)), - { mode: 'replace', content: `FULL PROMPT\n\n${UNCONDITIONAL_TOOL_INSTRUCTIONS}\n\n${COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS}\n\n${COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS}` } + { mode: 'replace', content: `FULL PROMPT\n\n${UNCONDITIONAL_TOOL_INSTRUCTIONS}\n\n${COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS}\n\n${AGENT_HOST_FILE_LINK_INSTRUCTIONS}` } ); }); }); @@ -380,7 +381,7 @@ suite('AgentHostPromptRegistry', () => { }); assert.deepStrictEqual( registry.resolveSystemMessageConfig({ id: 'gpt-5-mini' }, context({}, browserTools)), - { mode: 'replace', content: `FULL PROMPT\n\n${UNCONDITIONAL_TOOL_INSTRUCTIONS}\n${BROWSER_LINE}\n\n${COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS}` } + { mode: 'replace', content: `FULL PROMPT\n\n${UNCONDITIONAL_TOOL_INSTRUCTIONS}\n${BROWSER_LINE}\n\n${AGENT_HOST_FILE_LINK_INSTRUCTIONS}` } ); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index a0d1b7b94fe90f..89cb67b5aee93a 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -45,6 +45,7 @@ import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_EHCL import { ChatInteractivity, type MessageAttachment } from '../../common/state/protocol/state.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { readAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; +import { AgentSystemNotificationWorkspaceKind, serializeAgentWorkspaceTransition } from '../../common/meta/agentSystemNotificationMeta.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; import { AgentHostDatabase, IAgentHostDatabase, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionOptions } from '../../node/agentHostDatabase.js'; @@ -7627,6 +7628,61 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(getStateManager(svc).getSessionState(session.toString()), undefined); }); + test('loads workspace transitions alongside provider history from the existing restore database', async () => { + class DelayedTransitionDatabase extends TestSessionDatabase { + private readonly _releaseTransitionRead = new DeferredPromise(); + transitionReadPending = false; + + override async getTurnWorkspaceTransitions(): Promise> { + this.transitionReadPending = true; + await this._releaseTransitionRead.p; + this.transitionReadPending = false; + return super.getTurnWorkspaceTransitions(); + } + + releaseTransitionRead(): void { + this._releaseTransitionRead.complete(); + } + } + + const database = new DelayedTransitionDatabase(); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(database), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MockAgent('copilot')); + registerTestAgentProvider(svc, agent); + const session = await svc.createSession({ provider: agent.id }); + await database.setTurnWorkspaceTransition('provider-turn', serializeAgentWorkspaceTransition({ + content: 'Now working in project', + workspaceKind: AgentSystemNotificationWorkspaceKind.Folder, + workspaceName: 'project', + })); + let providerSawTransitionRead = false; + agent.chats.getMessages = async () => { + providerSawTransitionRead = database.transitionReadPending; + database.releaseTransitionRead(); + return [{ + id: 'provider-turn', + message: { text: 'Continue work', origin: { kind: MessageKind.SystemNotification } }, + responseParts: [{ kind: ResponsePartKind.Markdown, id: 'response-1', content: 'Provider output' }], + usage: undefined, + state: TurnState.Complete, + }]; + }; + getStateManager(svc).deleteSession(session.toString()); + + await svc.restoreSession(session); + + const restoredTurn = getStateManager(svc).getChatState(buildDefaultChatUri(session))?.turns[0]; + assert.deepStrictEqual({ + providerSawTransitionRead, + transitionQueryCalls: database.getTurnWorkspaceTransitionsCalls, + responseParts: restoredTurn?.responseParts.map(part => part.kind === ResponsePartKind.SystemNotification ? part.content : part.kind), + }, { + providerSawTransitionRead: true, + transitionQueryCalls: 1, + responseParts: ['Now working in project', ResponsePartKind.Markdown], + }); + }); + test('marks only an explicit restore as an activating metadata read', async () => { class LazyMetadataAgent extends MockAgent { ambientReads = 0; @@ -11570,6 +11626,45 @@ suite('AgentService (node dispatcher)', () => { assert.ok(!registered.includes(AgentSession.uri('copilot', 'restored-peer-backing-sdk-id').toString()), 'the backing session must not leak into the registered session list'); }); + test('restores workspace transitions from a peer chat database', async () => { + const sessionData = createPerSessionDataService(); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MockAgent('copilot')); + registerTestAgentProvider(localService, agent); + const session = await localService.createSession({ provider: agent.id }); + const peer = URI.parse(buildChatUri(session, 'peer-with-transition')); + const sessionDatabase = sessionData.database(session); + const peerDatabase = sessionData.database(peer); + await sessionDatabase.setMetadata('peerChats', JSON.stringify([{ uri: peer.toString(), providerData: 'peer-backing' }])); + await peerDatabase.setTurnWorkspaceTransition('peer-turn', serializeAgentWorkspaceTransition({ + content: 'Now working in peer workspace', + workspaceKind: AgentSystemNotificationWorkspaceKind.Folder, + workspaceName: 'peer workspace', + })); + agent.chats.getMessages = async chat => isDefaultChatUri(chat) ? [] : [{ + id: 'peer-turn', + message: { text: 'Continue peer work', origin: { kind: MessageKind.SystemNotification } }, + responseParts: [{ kind: ResponsePartKind.Markdown, id: 'peer-response', content: 'Peer output' }], + usage: undefined, + state: TurnState.Complete, + }]; + getStateManager(localService).deleteSession(session.toString()); + await localService.restoreSession(session); + + await localService.subscribe(peer, 'peer-reader'); + + const restoredTurn = getStateManager(localService).getChatState(peer.toString())?.turns[0]; + assert.deepStrictEqual({ + sessionTransitionQueries: sessionDatabase.getTurnWorkspaceTransitionsCalls, + peerTransitionQueries: peerDatabase.getTurnWorkspaceTransitionsCalls, + responseParts: restoredTurn?.responseParts.map(part => part.kind === ResponsePartKind.SystemNotification ? part.content : part.kind), + }, { + sessionTransitionQueries: 0, + peerTransitionQueries: 1, + responseParts: ['Now working in peer workspace', ResponsePartKind.Markdown], + }); + }); + test('persists a replacement backing reported after peer chat materialization', async () => { class RematerializingPeerAgent extends MockAgent { private readonly _materialized = new Emitter(); diff --git a/src/vs/platform/agentHost/test/node/codex/codexClientCustomizations.test.ts b/src/vs/platform/agentHost/test/node/codex/codexClientCustomizations.test.ts index d8e6711440254a..ef23d6c6fd95b9 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexClientCustomizations.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexClientCustomizations.test.ts @@ -19,7 +19,7 @@ import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../common/agentHostFileSystem import { toClientPluginMcpDefaultCwdsMeta } from '../../../common/meta/clientPluginCustomizationMeta.js'; import type { ISyncedCustomization } from '../../../common/agentPluginManager.js'; import { CustomizationType, McpServerStatus, type PluginCustomization } from '../../../common/state/protocol/channels-session/state.js'; -import { CodexClientCustomizationStore, codexAgentRoleToml, codexCustomizationConfig, codexMcpServersFromPlugins, codexSkillCapabilityRoots, codexSkillRootsFromPlugins, type ICodexClientPlugin } from '../../../node/codex/codexClientCustomizations.js'; +import { CODEX_FILE_LINK_INSTRUCTIONS, CodexClientCustomizationStore, codexAgentRoleToml, codexCustomizationConfig, codexMcpServersFromPlugins, codexSkillCapabilityRoots, codexSkillRootsFromPlugins, type ICodexClientPlugin } from '../../../node/codex/codexClientCustomizations.js'; suite('codexClientCustomizations', () => { const disposables = new DisposableStore(); @@ -143,6 +143,15 @@ suite('codexClientCustomizations', () => { assert.deepStrictEqual(codexSkillCapabilityRoots(plugins).map(root => root.fsPath), [skillsRoot('/plugins/p'), skillsRoot('/plugins/q')]); }); + test('includes host file-link instructions without client customizations', async () => { + const config = await codexCustomizationConfig([], [], undefined, fileService); + + assert.deepStrictEqual(config, { + agentRoles: [], + developerInstructions: CODEX_FILE_LINK_INSTRUCTIONS, + }); + }); + test('converts agent markdown and plugin instructions into codex launch configuration', async () => { const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/plugin/agents/reviewer.agent.md' }); const instructionUri = URI.from({ scheme: Schemas.inMemory, path: '/plugin/rules/repo.instructions.md' }); @@ -162,7 +171,7 @@ suite('codexClientCustomizations', () => { instructions: 'Review the change and report risks.', model: 'gpt-test', }], - developerInstructions: 'Always run focused tests.\n\nReview the change and report risks.', + developerInstructions: `Always run focused tests.\n\nReview the change and report risks.\n\n${CODEX_FILE_LINK_INSTRUCTIONS}`, }); assert.strictEqual(codexAgentRoleToml(config.agentRoles[0]), [ 'name = "Reviewer"', @@ -201,7 +210,7 @@ suite('codexClientCustomizations', () => { instructions: 'Review the workspace change.', model: 'gpt-first', }], - developerInstructions: 'Review the workspace change.', + developerInstructions: `Review the workspace change.\n\n${CODEX_FILE_LINK_INSTRUCTIONS}`, }); }); @@ -219,7 +228,7 @@ suite('codexClientCustomizations', () => { const config = await codexCustomizationConfig([], plugins, undefined, fileService); - assert.strictEqual(config.developerInstructions, 'Apply globally.'); + assert.strictEqual(config.developerInstructions, `Apply globally.\n\n${CODEX_FILE_LINK_INSTRUCTIONS}`); }); test('matches a selected source agent to its host-synced plugin copy', async () => { @@ -244,7 +253,7 @@ suite('codexClientCustomizations', () => { const config = await codexCustomizationConfig([], plugins, { uri: sourceAgentUri.toString() }, fileService); - assert.strictEqual(config.developerInstructions, 'Apply synced reviewer instructions.'); + assert.strictEqual(config.developerInstructions, `Apply synced reviewer instructions.\n\n${CODEX_FILE_LINK_INSTRUCTIONS}`); }); test('matches an original loose-agent URI to its synthetic bundle copy', async () => { @@ -268,7 +277,7 @@ suite('codexClientCustomizations', () => { const config = await codexCustomizationConfig([], plugins, { uri: sourceAgentUri.toString() }, fileService); - assert.strictEqual(config.developerInstructions, 'Apply loose reviewer instructions.'); + assert.strictEqual(config.developerInstructions, `Apply loose reviewer instructions.\n\n${CODEX_FILE_LINK_INSTRUCTIONS}`); }); test('prefers an exact selected agent over a synthetic filename fallback', async () => { @@ -299,7 +308,7 @@ suite('codexClientCustomizations', () => { const config = await codexCustomizationConfig([], plugins, { uri: selectedAgentUri.toString() }, fileService); - assert.strictEqual(config.developerInstructions, 'Apply exact reviewer instructions.'); + assert.strictEqual(config.developerInstructions, `Apply exact reviewer instructions.\n\n${CODEX_FILE_LINK_INSTRUCTIONS}`); }); test('removeClient drops a client and setEnabled reports whether it changed', () => { diff --git a/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts b/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts index 9bdee45db918c0..5082de08b94c4d 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { readAgentMessageDelegationMeta } from '../../../common/meta/agentMessageDelegationMeta.js'; -import { createCodexSessionMapState, extractUserInputText, finalizeCodexTurnMapState, mapAgentMessageDelta, mapCommandExecutionOutputDelta, mapFileChangePatchUpdated, mapItemCompleted, mapItemStarted, mapMcpToolCallProgress, mapReasoningSummaryPartAdded, mapReasoningSummaryTextDelta, mapReasoningTextDelta, mapTokenUsageModelCallCompleted, mapTokenUsageUpdated, mapTurnCompleted, mapTurnStarted, resetCodexTurnMapState, turnStateFromStatus } from '../../../node/codex/codexMapAppServerEvents.js'; +import { createCodexSessionMapState, extractUserInputText, finalizeCodexTurnMapState, mapAgentMessageDelta, mapCommandExecutionOutputDelta, mapFileChangePatchUpdated, mapItemCompleted, mapItemStarted, mapMcpToolCallProgress, mapReasoningSummaryPartAdded, mapReasoningSummaryTextDelta, mapReasoningTextDelta, mapTokenUsageModelCallCompleted, mapTokenUsageUpdated, mapTurnCompleted, mapTurnStarted, resetCodexTurnMapState, turnStateFromStatus, type ICodexSessionMapState } from '../../../node/codex/codexMapAppServerEvents.js'; import { ActionType, type ChatAction, type SessionAction } from '../../../common/state/sessionActions.js'; import { chatReducer } from '../../../common/state/protocol/reducers.js'; import { ChatOriginKind, MessageKind, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolResultContentType, TurnState, type ChatState } from '../../../common/state/sessionState.js'; @@ -20,6 +20,23 @@ function markdownPartContent(action: SessionAction | ChatAction | undefined): st : undefined; } +/** Starts an in-progress `github.search` mcpToolCall item and returns its tool call id. */ +function startMcpToolCall(state: ICodexSessionMapState, itemId: string): string { + mapItemStarted(state, { + item: { type: 'mcpToolCall', id: itemId, server: 'github', tool: 'search', status: 'inProgress', arguments: { query: 'vscode' }, mcpAppResourceUri: undefined, pluginId: null, result: null, error: null, durationMs: null } as never, + threadId: 'thr_1', turnId: 'turn_a', startedAtMs: 0, + }); + return state.itemToToolCall.get(itemId)!.toolCallId; +} + +/** Completes a started mcpToolCall item with the given MCP result. */ +function completeMcpToolCall(state: ICodexSessionMapState, itemId: string, result: { content: unknown[]; structuredContent: unknown }): (SessionAction | ChatAction)[] { + return mapItemCompleted(state, { + item: { type: 'mcpToolCall', id: itemId, server: 'github', tool: 'search', status: 'completed', arguments: { query: 'vscode' }, mcpAppResourceUri: undefined, pluginId: null, result: { ...result, _meta: null }, error: null, durationMs: 5 } as never, + threadId: 'thr_1', turnId: 'turn_a', completedAtMs: 0, + }); +} + suite('codexMapAppServerEvents', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -769,12 +786,81 @@ suite('codexMapAppServerEvents', () => { startTypes: [ActionType.ChatToolCallStart, ActionType.ChatToolCallDelta, ActionType.ChatToolCallReady], delta: { type: ActionType.ChatToolCallDelta, turnId: 'turn_a', toolCallId, content: '{\n "query": "vscode"\n}' }, ready: { type: ActionType.ChatToolCallReady, turnId: 'turn_a', toolCallId, invocationMessage: 'Calling github.search', toolInput: '{\n "query": "vscode"\n}', confirmed: ToolCallConfirmationReason.NotNeeded }, - progressActions: [{ type: ActionType.ChatToolCallContentChanged, turnId: 'turn_a', toolCallId, content: [{ type: ToolResultContentType.Text, text: 'Searching' }] }], + progressActions: [{ type: ActionType.ChatToolCallContentChanged, turnId: 'turn_a', toolCallId, content: [], _meta: { progressMessage: 'Searching' } }], completeActions: [{ type: ActionType.ChatToolCallComplete, turnId: 'turn_a', toolCallId, result: { success: true, pastTenseMessage: 'Called github.search', content: [{ type: ToolResultContentType.Text, text: 'done\n{\n "count": 1\n}' }] } }], remainingToolCalls: 0, }); }); + test('mcpToolCall progress is not persisted as the result when the MCP result is empty', () => { + const state = createCodexSessionMapState(); + const toolCallId = startMcpToolCall(state, 'mcp_1'); + mapMcpToolCallProgress(state, { threadId: 'thr_1', turnId: 'turn_a', itemId: 'mcp_1', message: 'Searching' }); + assert.deepStrictEqual(completeMcpToolCall(state, 'mcp_1', { content: [], structuredContent: null }), [{ + type: ActionType.ChatToolCallComplete, turnId: 'turn_a', toolCallId, + result: { success: true, pastTenseMessage: 'Called github.search', content: undefined }, + }]); + }); + + test('mcpToolCall progress is not carried into an orphaned completion', () => { + const state = createCodexSessionMapState(); + const toolCallId = startMcpToolCall(state, 'mcp_1'); + mapMcpToolCallProgress(state, { threadId: 'thr_1', turnId: 'turn_a', itemId: 'mcp_1', message: 'Searching' }); + assert.deepStrictEqual(finalizeCodexTurnMapState(state, 'Turn interrupted before the tool completed'), [{ + type: ActionType.ChatToolCallComplete, turnId: 'turn_a', toolCallId, + result: { success: false, pastTenseMessage: 'Stopped github.search', content: undefined, error: { message: 'Turn interrupted before the tool completed' } }, + }]); + }); + + test('mcpToolCall progress after item/completed is dropped', () => { + const state = createCodexSessionMapState(); + startMcpToolCall(state, 'mcp_1'); + completeMcpToolCall(state, 'mcp_1', { content: ['done'], structuredContent: null }); + assert.deepStrictEqual(mapMcpToolCallProgress(state, { threadId: 'thr_1', turnId: 'turn_a', itemId: 'mcp_1', message: 'Late' }), []); + }); + + test('identical consecutive mcpToolCall progress messages are coalesced', () => { + const state = createCodexSessionMapState(); + const toolCallId = startMcpToolCall(state, 'mcp_1'); + const progress = (message: string) => mapMcpToolCallProgress(state, { threadId: 'thr_1', turnId: 'turn_a', itemId: 'mcp_1', message }); + assert.deepStrictEqual([progress('Searching'), progress('Searching'), progress('Ranking')], [ + [{ type: ActionType.ChatToolCallContentChanged, turnId: 'turn_a', toolCallId, content: [], _meta: { progressMessage: 'Searching' } }], + [], + [{ type: ActionType.ChatToolCallContentChanged, turnId: 'turn_a', toolCallId, content: [], _meta: { progressMessage: 'Ranking' } }], + ]); + }); + + test('an empty mcpToolCall progress message clears the previous one', () => { + const state = createCodexSessionMapState(); + const toolCallId = startMcpToolCall(state, 'mcp_1'); + const progress = (message: string) => mapMcpToolCallProgress(state, { threadId: 'thr_1', turnId: 'turn_a', itemId: 'mcp_1', message }); + const emitted = (progressMessage: string) => [{ type: ActionType.ChatToolCallContentChanged, turnId: 'turn_a', toolCallId, content: [], _meta: { progressMessage } }]; + assert.deepStrictEqual([progress(''), progress('Searching'), progress(''), progress('')], [emitted(''), emitted('Searching'), emitted(''), []]); + }); + + test('mcpToolCall progress does not clobber start-time _meta', () => { + const state = createCodexSessionMapState(); + state.itemToToolCall.set('mcp_1', { toolCallId: 'tc_1', turnId: 'turn_a', toolName: 'github.search', output: '', meta: { ui: { resourceUri: 'ui://github/app' } } }); + assert.deepStrictEqual(mapMcpToolCallProgress(state, { threadId: 'thr_1', turnId: 'turn_a', itemId: 'mcp_1', message: 'Searching' }), [{ + type: ActionType.ChatToolCallContentChanged, turnId: 'turn_a', toolCallId: 'tc_1', content: [], + _meta: { ui: { resourceUri: 'ui://github/app' }, progressMessage: 'Searching' }, + }]); + }); + + test('tool call entries record the _meta emitted at start', () => { + const state = createCodexSessionMapState(); + mapItemStarted(state, { + item: { type: 'commandExecution', id: 'cmd_1', command: 'ls', cwd: '/tmp', processId: null, source: 'agent', status: 'inProgress', commandActions: [], aggregatedOutput: null, exitCode: null, durationMs: null } as never, + threadId: 'thr_1', turnId: 'turn_a', startedAtMs: 0, + }); + mapItemStarted(state, { + item: { type: 'webSearch', id: 'web_1', query: 'vscode tests', action: { type: 'search', query: 'vscode tests', queries: null } } as never, + threadId: 'thr_1', turnId: 'turn_a', startedAtMs: 0, + }); + startMcpToolCall(state, 'mcp_1'); + assert.deepStrictEqual([...state.itemToToolCall.values()].map(entry => entry.meta), [{ toolKind: 'terminal' }, { toolKind: 'search' }, undefined]); + }); + test('mcpToolCall start carries an MCP contributor when the server has a customization', () => { const state = createCodexSessionMapState(); state.mcpCustomizationIds.set('github', 'cust-gh'); diff --git a/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts b/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts index d7075670036f88..4a0baff4d81fe8 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts @@ -45,7 +45,7 @@ import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../../c import { IAgentHostOTelService } from '../../../common/otel/agentHostOTelService.js'; import { CodexAgent, toCodexModelSelectionId } from '../../../node/codex/codexAgent.js'; import { CodexAppServerClient, type ICodexAppServerTransport } from '../../../node/codex/codexAppServerClient.js'; -import type { ICodexClientPlugin } from '../../../node/codex/codexClientCustomizations.js'; +import { CODEX_FILE_LINK_INSTRUCTIONS, type ICodexClientPlugin } from '../../../node/codex/codexClientCustomizations.js'; import { ICodexProxyService } from '../../../node/codex/codexProxyService.js'; import { ICopilotApiService } from '../../../node/shared/copilotApiService.js'; import { buildMcpChannel } from '../../../node/shared/mcpCustomizationController.js'; @@ -1866,8 +1866,8 @@ suite('CodexAgent prewarm eviction', () => { }, { mcp: { local: { command: 'node', args: ['server.js'] } }, agentDescription: 'Reviews changes', - developerInstructions: 'Run focused tests.\n\nReview carefully.', - turnDeveloperInstructions: 'Run focused tests.\n\nReview carefully.', + developerInstructions: `Run focused tests.\n\nReview carefully.\n\n${CODEX_FILE_LINK_INSTRUCTIONS}`, + turnDeveloperInstructions: `Run focused tests.\n\nReview carefully.\n\n${CODEX_FILE_LINK_INSTRUCTIONS}`, capabilityPaths: [URI.file('/plugin/skills').fsPath], roleFile: 'name = "Reviewer"\ndescription = "Reviews changes"\ndeveloper_instructions = "Review carefully."\n', roleFileUsesHostGeneratedRoot: true, @@ -1928,11 +1928,11 @@ suite('CodexAgent prewarm eviction', () => { resumedRoleFile, needsResume: agent['_sessions'].get(AgentSession.id(session))?.needsResume, }, { - start: { method: 'thread/start', developerInstructions: 'Use the original instructions.' }, - firstTurn: { method: 'turn/start', developerInstructions: 'Use the original instructions.' }, + start: { method: 'thread/start', developerInstructions: `Use the original instructions.\n\n${CODEX_FILE_LINK_INSTRUCTIONS}` }, + firstTurn: { method: 'turn/start', developerInstructions: `Use the original instructions.\n\n${CODEX_FILE_LINK_INSTRUCTIONS}` }, unsubscribe: { method: 'thread/unsubscribe', threadId: 'thread-workspace-agent' }, - resume: { method: 'thread/resume', developerInstructions: 'Use the updated instructions.' }, - secondTurn: { method: 'turn/start', developerInstructions: 'Use the updated instructions.' }, + resume: { method: 'thread/resume', developerInstructions: `Use the updated instructions.\n\n${CODEX_FILE_LINK_INSTRUCTIONS}` }, + secondTurn: { method: 'turn/start', developerInstructions: `Use the updated instructions.\n\n${CODEX_FILE_LINK_INSTRUCTIONS}` }, resumedRoleFile: 'name = "Reviewer"\ndescription = "Reviews changes"\ndeveloper_instructions = "Use the updated instructions."\n', needsResume: false, }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 76ac7faf33b797..ace035bd3ee300 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -61,7 +61,7 @@ import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js import { AgentHostCompletions, IAgentHostCompletions } from '../../node/agentHostCompletions.js'; import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE, CopilotAgent, getCopilotManagedSettingsDiagnostics, rebaseUnder, REFRESH_DEBOUNCE_MS, resolveCopilotOtlpMetricsEndpoint } from '../../node/copilot/copilotAgent.js'; import { GITHUB_MCP_SERVER_NAME } from '../../node/shared/githubMcpServer.js'; -import { COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS } from '../../node/copilot/prompts/systemMessage.js'; +import { AGENT_HOST_FILE_LINK_INSTRUCTIONS } from '../../node/shared/fileLinkInstructions.js'; import { COPILOT_AGENT_HOST_LARGE_OUTPUT_TOOL_INSTRUCTION } from '../../node/copilot/prompts/toolInstructions.js'; import { NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; import { IAgentHostReviewService, NULL_REVIEW_SERVICE } from '../../common/agentHostReviewService.js'; @@ -9549,7 +9549,7 @@ suite('CopilotAgent', () => { content: `\n${COPILOT_AGENT_HOST_LARGE_OUTPUT_TOOL_INSTRUCTION}`, }, }, - content: COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS, + content: AGENT_HOST_FILE_LINK_INSTRUCTIONS, }); if (!systemMessage || systemMessage.mode !== 'customize') { assert.fail('Expected customize-mode system message'); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 5b46ad04d277bc..32d48af0c94680 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -61,7 +61,7 @@ import { TestAgentHostTerminalManager } from './testAgentHostTerminalManager.js' import { buildCopilotSystemNotification } from '../../node/copilot/copilotSystemNotification.js'; import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; -import { AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey } from '../../common/agentHostSchema.js'; +import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey } from '../../common/agentHostSchema.js'; import { CopilotCliConfigKey } from '../../common/copilotCliConfig.js'; import { SEMANTIC_SEARCH_TOOL_NAME } from '../../common/semanticSearchConstants.js'; import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../common/toolSearchConstants.js'; @@ -5657,6 +5657,27 @@ suite('CopilotAgentSession', () => { assert.deepStrictEqual(mockSession.permissionModeSetCalls, ['manual', 'allow-all']); }); + test('revokes elevated permission modes when policy changes', async () => { + const results: PermissionMode[][] = []; + for (const autoApprove of ['assisted', 'autoApprove']) { + const { session, mockSession, setRootValue, fireRootConfigChange } = await createAgentSession(disposables, { + configValues: { [SessionConfigKey.AutoApprove]: autoApprove }, + }); + await session.syncPermissionMode('turn-start'); + session.resetTurnState('active-turn'); + setRootValue(AgentHostAutoApprovePolicyRestrictedConfigKey, true); + + fireRootConfigChange(); + await timeout(0); + results.push([...mockSession.permissionModeSetCalls]); + } + + assert.deepStrictEqual(results, [ + ['assisted', 'manual'], + ['allow-all', 'manual'], + ]); + }); + test('aborts when a live permission mode update fails', async () => { const { session, mockSession, setConfigValue, fireSessionConfigChange } = await createAgentSession(disposables, { configValues: { [SessionConfigKey.AutoApprove]: 'assisted' }, @@ -9318,6 +9339,135 @@ Use the attached image as context. ]); }); + test('completes a resumed subagent when its stop hook identifies the agent only in the input', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + session.resetTurnState('turn-parent'); + + mockSession.fire('subagent.started', { + toolCallId: 'tc-subagent', + agentName: 'explore', + agentDisplayName: 'Explore', + agentDescription: 'Explore tests', + }, { agentId: 'agent-1' }); + const completion = { + toolCallId: 'tc-subagent', + agentName: 'explore', + agentDisplayName: 'Explore', + durationMs: 1, + totalTokens: 0, + totalToolCalls: 0, + }; + mockSession.fire('subagent.completed', completion, { agentId: 'agent-1' }); + mockSession.fire('user.message', { + content: 'Review the follow-up', + source: 'agent-parent', + }, { agentId: 'agent-1' }); + mockSession.fire('hook.end', { + hookInvocationId: 'agent-stop', + hookType: 'agentStop', + success: true, + }); + mockSession.fire('hook.start', { + hookInvocationId: 'subagent-stop', + hookType: 'subagentStop', + input: { agentId: 'agent-1' }, + }); + + const completions = () => signals + .filter(signal => signal.kind === 'subagent_completed') + .map(signal => signal.toolCallId); + const beforeHookEnd = completions(); + + mockSession.fire('hook.end', { + hookInvocationId: 'subagent-stop', + hookType: 'subagentStop', + success: true, + }); + const afterHookEnd = completions(); + mockSession.fire('subagent.completed', completion, { agentId: 'agent-1' }); + + assert.deepStrictEqual({ + beforeHookEnd, + afterHookEnd, + afterDuplicateCompletion: completions(), + resumed: signals.filter(signal => signal.kind === 'subagent_resumed').map(signal => signal.toolCallId), + parentCompleted: signals.some(signal => isAction(signal, ActionType.ChatTurnComplete)), + }, { + beforeHookEnd: ['tc-subagent'], + afterHookEnd: ['tc-subagent', 'tc-subagent'], + afterDuplicateCompletion: ['tc-subagent', 'tc-subagent'], + resumed: ['tc-subagent'], + parentCompleted: false, + }); + }); + + test('ignores subagent stop hook inputs without a string agent ID', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + session.resetTurnState('turn-parent'); + mockSession.fire('subagent.started', { + toolCallId: 'tc-subagent', + agentName: 'explore', + agentDisplayName: 'Explore', + agentDescription: 'Explore tests', + }, { agentId: 'agent-1' }); + + const inputs: SessionEventPayload<'hook.start'>['data']['input'][] = [ + undefined, null, true, 1, 'agent-1', [], {}, { agentId: 1 }, { agentId: null }, { agentId: ['agent-1'] }, + ]; + for (const input of inputs) { + mockSession.fire('hook.start', { + hookInvocationId: 'subagent-stop', + hookType: 'subagentStop', + input, + }); + mockSession.fire('hook.end', { + hookInvocationId: 'subagent-stop', + hookType: 'subagentStop', + success: true, + }); + } + + assert.deepStrictEqual(signals.filter(signal => signal.kind === 'subagent_completed'), []); + }); + + test('matches overlapping subagent stop hooks to their own agents', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + session.resetTurnState('turn-parent'); + + for (const id of ['first', 'second']) { + mockSession.fire('subagent.started', { + toolCallId: `tc-${id}`, + agentName: 'explore', + agentDisplayName: 'Explore', + agentDescription: 'Explore tests', + }, { agentId: `agent-${id}` }); + mockSession.fire('hook.start', { + hookInvocationId: `stop-${id}`, + hookType: 'subagentStop', + input: { agentId: `agent-${id}` }, + }); + } + + for (const id of ['second', 'first']) { + mockSession.fire('hook.end', { + hookInvocationId: `stop-${id}`, + hookType: 'subagentStop', + success: true, + }); + } + mockSession.fire('user.message', { content: 'Another turn' }, { agentId: 'agent-first' }); + mockSession.fire('hook.end', { + hookInvocationId: 'stop-first', + hookType: 'subagentStop', + success: true, + }); + + assert.deepStrictEqual( + signals.filter(signal => signal.kind === 'subagent_completed').map(signal => signal.toolCallId), + ['tc-second', 'tc-first'], + ); + }); + test('history replay seeds turn id from the SDK envelope id, matching `turns.event_id`', async () => { // Regression test: fork / truncate look up the SDK boundary // event id via `getNextTurnEventId(turnId)`, which keys on 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 e5cd4e99dcc043..aefe33d8d614c1 100644 --- a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md +++ b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md @@ -314,6 +314,29 @@ A user can reopen an Agent Host session and ask Copilot to edit a file. The file in `mcpPluginSuite.ts`, record one scenario at a time, and review the resulting Responses fixtures before enabling the group. +### Automation restart execution coverage is Copilot-scoped + +Users can save independent Mode and Approvals choices on an Automation and expect every later run to use them, including after the Agent Host restarts. The black-box restart scenario currently covers Copilot only, so equivalent Claude and Codex configuration persistence could regress without this suite detecting it. + +- Test: `an automation run restores Mode and Approvals after host restart`. +- Scope: Claude and Codex. +- Expected: after the host restarts, the Automation definition retains its provider Mode and Approvals and the next manual run creates a session with those same effective values. +- Observed: Copilot has deterministic model-backed replay coverage. Claude and Codex variants are not registered and have no captures. +- Gate: `automationsSuite.ts` registers the model-backed scenario only when `config.provider === 'copilotcli'`. +- Reproduce: + + Extend the provider gate and adapt the provider-specific Mode and Approvals values, then record one provider at a time: + + ```bash + AGENT_HOST_REPLAY_RECORD=1 ./scripts/test-integration.sh --run \ + src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts \ + --grep "an automation run restores Mode and Approvals after host restart" + + AGENT_HOST_REPLAY_RECORD=1 ./scripts/test-integration.sh --run \ + src/vs/platform/agentHost/test/node/e2e/providers/codexAgentHostE2E.integrationTest.ts \ + --grep "an automation run restores Mode and Approvals after host restart" + ``` + ### Claude paused-turn cancellation is not replay-stable - Tests: @@ -824,6 +847,23 @@ A client-contributed custom agent can specify its stable name, description, and --grep "custom agent without a display name completes as a subagent" ``` +### Copilot restored-parent replay differs between OSS and product runtimes + +A parent session should remain usable after restoration when a custom subagent has no available transcript. The public runtime used by OSS CI creates that state by rejecting a custom agent without `displayName`, while the product runtime override fixes that validation error and makes an additional child-model request. One sequence-based model fixture cannot replay both request paths, so enabling the test makes either OSS CI or product-build CI fail despite the restored parent accepting another turn in live recording. + +- Test: `restored parent accepts a new turn after a custom subagent has no transcript`. +- Scope: Copilot deterministic replay while OSS and product builds use different runtime versions. +- Expected: the parent is reconstructed and accepts a new turn under both runtimes. +- Observed: the OSS fixture records the failed child launch, while the product runtime launches the child successfully and consumes the fixture's next response. +- Gate: the scenario is disabled until OSS and product builds use the same fixed runtime. +- Reproduce after locally installing the product runtime: + + ```bash + ./scripts/test-integration.sh --run \ + src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts \ + --grep "restored parent accepts a new turn after a custom subagent has no transcript" + ``` + ### Mid-turn abort is record-only - Tests: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-an-automation-run-restores-mode-and-approvals-after-host-restart.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-an-automation-run-restores-mode-and-approvals-after-host-restart.yaml new file mode 100644 index 00000000000000..95ea61e602080e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-an-automation-run-restores-mode-and-approvals-after-host-restart.yaml @@ -0,0 +1,38 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "ran". + response: + content: ran + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "ran". + - role: assistant + content: ran + - role: user + content: |- + You have not yet marked the task as complete using the task_complete tool. If you were planning, stop planning and start implementing. You aren't done until you have fully completed the task. + + IMPORTANT: Do NOT call task_complete if: + - You have open questions or ambiguities - make good decisions and keep working + - You encountered an error - try to resolve it or find an alternative approach + - There are remaining steps - complete them first + + Keep working autonomously until the task is truly finished, then call task_complete. + response: + content: + - type: tool_use + id: toolcall_0 + name: task_complete + input: + summary: Replied "ran" as requested. There is no further task to implement. + stopReason: tool_use diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts b/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts index 3aefe4bf299509..dbe01da65c3f9a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts @@ -146,9 +146,10 @@ function defineSuite(config: IAgentHostE2EProviderConfig, options: IDefineOption } }); + defineAutomationsTests(context); + // Suites that contain only conformance-tier scenarios. if (options.tier === 'conformance') { - defineAutomationsTests(context); defineHostFeaturesTests(context); defineStateOperationsTests(context); defineClientFilesystemTests(context); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/automationsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/automationsSuite.ts index f5e065f088b381..6f3ba93db86c6c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/automationsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/automationsSuite.ts @@ -4,16 +4,24 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { mkdtemp } from 'fs/promises'; +import { tmpdir } from 'os'; +import { retry } from '../../../../../../base/common/async.js'; import { equals } from '../../../../../../base/common/objects.js'; +import { join } from '../../../../../../base/common/path.js'; +import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../../../common/agent.js'; import { AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY, AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY } from '../../../../common/automationMigration.js'; -import type { FetchAutomationRunsResult, InitializeResult, ListAutomationTriggerDefinitionsResult, SubscribeResult } from '../../../../common/state/protocol/commands.js'; +import { SessionConfigKey } from '../../../../common/sessionConfigKeys.js'; +import type { FetchAutomationRunsResult, InitializeResult, ListAutomationTriggerDefinitionsResult, RunAutomationResult, SubscribeResult } from '../../../../common/state/protocol/commands.js'; import { AutomationOperation, type AutomationDefinition, type AutomationEntry } from '../../../../common/state/protocol/state.js'; import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; -import { ActionType, type AutomationRemovedAction, type AutomationSetAction } from '../../../../common/state/sessionActions.js'; +import { ActionType, type AutomationRemovedAction, type AutomationRunPrimarySessionChangedAction, type AutomationSetAction } from '../../../../common/state/sessionActions.js'; import type { AhpNotification } from '../../../../common/state/sessionProtocol.js'; -import { AUTOMATION_CATALOG_URI, MessageKind, ROOT_STATE_URI, type AutomationState, type RootState } from '../../../../common/state/sessionState.js'; -import { getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; +import { AUTOMATION_CATALOG_URI, MessageKind, ROOT_STATE_URI, type AutomationRunState, type AutomationState, type RootState } from '../../../../common/state/sessionState.js'; +import { resolveGitHubToken } from '../harness/agentHostE2ETestHarness.js'; +import { fetchSessionWithChat, getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; import { conformanceTest, type IAgentHostE2ETestContext } from './e2eTestContext.js'; /** The migration gate's message, checked before the enablement gate's. */ @@ -382,4 +390,70 @@ export function defineAutomationsTests(context: IAgentHostE2ETestContext): void restored: { title: 'Survives restart', operations: GATED_OPERATIONS }, }); }); + + if (context.tier === 'parity' && config.provider === 'copilotcli') { + test('an automation run restores Mode and Approvals after host restart', async function () { + this.timeout(240_000); + const workspace = await mkdtemp(join(tmpdir(), 'ahp-automation-session-config-')); + context.tempDirs.push(workspace); + await initializeRoot('automations-session-config'); + await openAutomationGates(); + await subscribeCatalog(); + const resource = automationResource('session-config'); + const requestedConfig = { + [SessionConfigKey.Mode]: 'autopilot', + [SessionConfigKey.AutoApprove]: 'assisted', + }; + await createAutomation(resource, { + ...buildDefinition('Configured run'), + session: { + provider: config.provider, + workingDirectories: [URI.file(workspace).toString()], + config: requestedConfig, + }, + }); + + await context.restartServer(); + await initializeRoot('automations-session-config-verify'); + await context.client.call('authenticate', { + channel: ROOT_STATE_URI, + resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, + token: config.githubToken ?? resolveGitHubToken(), + }, 30_000); + await openAutomationGates(); + const restored = entryFor(await subscribeCatalog(), resource); + const run = await context.client.call('runAutomation', { + channel: AUTOMATION_CATALOG_URI, + automation: resource, + requestId: `request-${generateUuid()}`, + }, 30_000); + const runSnapshot = await context.client.call('subscribe', { channel: run.resource }); + let primarySession = (runSnapshot.snapshot?.state as AutomationRunState | undefined)?.primarySession; + if (!primarySession) { + const notification = await context.client.waitForNotification(candidate => + isActionNotification(candidate, ActionType.AutomationRunPrimarySessionChanged) + && getActionEnvelope(candidate).channel === run.resource, + ); + primarySession = (getActionEnvelope(notification).action as AutomationRunPrimarySessionChangedAction).primarySession; + } + assert.ok(primarySession); + context.createdSessions.push(primarySession); + let createdSession = await fetchSessionWithChat(context.client, primarySession); + await retry(async () => { + createdSession = await fetchSessionWithChat(context.client, primarySession); + assert.strictEqual(createdSession.turns.at(-1)?.state, 'complete'); + }, 100, 300); + + assert.deepStrictEqual({ + restoredConfig: restored?.definition.session.config, + sessionConfig: { + mode: createdSession.config?.values[SessionConfigKey.Mode], + autoApprove: createdSession.config?.values[SessionConfigKey.AutoApprove], + }, + }, { + restoredConfig: requestedConfig, + sessionConfig: requestedConfig, + }); + }); + } } diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/subagentSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/subagentSuite.ts index 65685e4e0fe09e..020cd278098d08 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/subagentSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/subagentSuite.ts @@ -94,7 +94,7 @@ export function defineSubagentTests(context: IAgentHostE2ETestContext): void { const copilotCustomAgentTest = config.provider === 'copilotcli' && config.supportsSubagents; - // The bundled runtime currently rejects the SDK's optional displayName field; keep this executable in known-issue recording until that runtime fix ships. + // The bundled OSS runtime currently rejects the SDK's optional displayName field; keep this executable in known-issue recording until that runtime fix ships. (context.runKnownIssueTests && copilotCustomAgentTest ? test : test.skip)('custom agent without a display name completes as a subagent', async function () { this.timeout(180_000); @@ -114,7 +114,8 @@ export function defineSubagentTests(context: IAgentHostE2ETestContext): void { assert.match(markdownText(snapshot.snapshot?.state as ChatState | undefined), /CUSTOM_AGENT_CHILD_OK/); }); - (copilotCustomAgentTest ? test : test.skip)('restored parent accepts a new turn after a custom subagent has no transcript', async function () { + // OSS and product runtimes take different model paths for this scenario; see KNOWN_ISSUES.md. + test.skip('restored parent accepts a new turn after a custom subagent has no transcript', async function () { this.timeout(240_000); const sessionUri = await createCustomAgentSession('ahp-missing-custom-agent-transcript-'); diff --git a/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts b/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts index b7dc09d9fea123..15aa9228094d40 100644 --- a/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts @@ -14,7 +14,7 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; -import { AgentHostEditAutoApprovePatternsConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, platformSessionSchema } from '../../common/agentHostSchema.js'; +import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostEditAutoApprovePatternsConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, platformSessionSchema } from '../../common/agentHostSchema.js'; import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../../common/sessionDataService.js'; import { DEFAULT_EDIT_AUTO_APPROVE_PATTERNS, mergeChatEditAutoApprovePatterns } from '../../../chat/common/chatSettings.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; @@ -586,6 +586,18 @@ suite('SessionPermissionManager', () => { assert.strictEqual(permissions.isSessionAutoApproveEnabled(sessionUri), false); }); + test('managed policy disables a persisted session auto-approve level', () => { + manager.setSessionConfig(sessionUri, { + schema: platformSessionSchema.toProtocol(), + values: { [SessionConfigKey.AutoApprove]: 'autoApprove' }, + }); + assert.strictEqual(permissions.isSessionAutoApproveEnabled(sessionUri), true); + + configService.updateRootConfig({ [AgentHostAutoApprovePolicyRestrictedConfigKey]: true }); + + assert.strictEqual(permissions.isSessionAutoApproveEnabled(sessionUri), false); + }); + // ---- Multi-root auto-approval ------------------------------------------ // A session with multiple working directories auto-approves a read/write/ // shell destination when it is contained by *any* root (index 0 = primary). diff --git a/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts b/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts index 803bb7e115e203..2212440c657462 100644 --- a/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts @@ -21,7 +21,7 @@ import { isAgentWorkspaceContinuationMessage } from '../../common/meta/agentWork import type { ISessionDatabase } from '../../common/sessionDataService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, buildChatUri, buildDefaultChatUri, createErrorResponsePart, customizationId, CustomizationLoadStatus, CustomizationType, isHostNoticeTurn, isMessageHiddenFromTranscript, isMessageRequestHiddenFromTranscript, MessageKind, readMessageSystemInitiatedLabel, readSessionHasWorkspaceTransitions, readSessionWorkspaceless, ResponsePartKind, SessionStatus, TurnState, withSessionHasWorkspaceTransitions, withSessionWorkspaceless, type ErrorInfo, type Message, type Turn } from '../../common/state/sessionState.js'; +import { AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, buildChatUri, buildDefaultChatUri, createErrorResponsePart, customizationId, CustomizationLoadStatus, CustomizationType, isHostNoticeTurn, isMessageHiddenFromTranscript, isMessageRequestHiddenFromTranscript, MessageKind, readMessageSystemInitiatedLabel, readSessionHasWorkspaceTransitions, readSessionWorkspaceless, ResponsePartKind, SessionStatus, TurnState, withSessionWorkspaceless, type ErrorInfo, type Message, type Turn } from '../../common/state/sessionState.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import type { IAgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; import type { IAgentHostTurnService, IDeferredAgentHostTurn } from '../../node/agentHostTurnService.js'; @@ -234,22 +234,13 @@ suite('SessionWorkspaceConversionService', () => { }); } - function updateSessionWorkspace(harness: ReturnType): Promise { - return harness.service.updateSessionWorkspace(harness.chat.toString(), 'turn-1'); + function completePriorTurn(stateManager: AgentHostStateManager, chat: URI): void { + startTurn(stateManager, chat, 'turn-0'); + completeTurn(stateManager, chat, 'turn-0'); } - function createHydrationStateManager(session: URI, hasWorkspaceTransitions: boolean): AgentHostStateManager { - const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); - stateManager.createSession({ - resource: session.toString(), - provider: 'copilot', - title: hasWorkspaceTransitions ? 'Converted Session' : 'Normal Session', - status: SessionStatus.Idle, - createdAt: new Date(0).toISOString(), - modifiedAt: new Date(0).toISOString(), - _meta: withSessionHasWorkspaceTransitions(undefined, hasWorkspaceTransitions), - }); - return stateManager; + function updateSessionWorkspace(harness: ReturnType): Promise { + return harness.service.updateSessionWorkspace(harness.chat.toString(), 'turn-1'); } test('keeps a visible continuation in progress while converting after the invoking turn', async () => { @@ -283,6 +274,7 @@ suite('SessionWorkspaceConversionService', () => { }); await providerMutation.p; }; + completePriorTurn(harness.stateManager, harness.chat); startTurn(harness.stateManager, harness.chat); await harness.database.setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'true'); harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', workspaceFolder, false, 'client-1'); @@ -410,24 +402,45 @@ suite('SessionWorkspaceConversionService', () => { }); }); - test('skips transition storage for a normal loaded session', async () => { + test('does not show or persist a workspace transition during the first turn', async () => { + const harness = createHarness(); + harness.agent.setWorkingDirectory = async () => { }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + const state = harness.stateManager.getSessionState(harness.session.toString()); + const activeTurn = harness.stateManager.getChatState(harness.chat.toString())?.activeTurn; + assert.deepStrictEqual({ + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + hasWorkspaceTransitions: readSessionHasWorkspaceTransitions(state?._meta), + persistedWorkspaceless: await harness.database.getMetadata(AH_META_WORKSPACELESS_DB_KEY), + persistedHasWorkspaceTransitions: await harness.database.getMetadata(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY), + persistedTransitions: [...(await harness.database.getTurnWorkspaceTransitions()).entries()], + outcomeNotifications: activeTurn?.responseParts.filter(part => part.kind === ResponsePartKind.SystemNotification), + outcomeKindsAtContinuation: harness.outcomeKindsAtContinuation, + continuations: harness.continuations.length, + }, { + workingDirectories: ['file:///workspace/project'], + workspaceless: false, + hasWorkspaceTransitions: false, + persistedWorkspaceless: 'false', + persistedHasWorkspaceTransitions: undefined, + persistedTransitions: [], + outcomeNotifications: [], + outcomeKindsAtContinuation: [[]], + continuations: 1, + }); + }); + + test('does not hydrate workspace transitions when none were loaded', () => { const session = URI.parse('copilot:/normal-session'); - const database = new TestSessionDatabase(); - const baseSessionDataService = createSessionDataService(database); - let databaseOpenCalls = 0; - const sessionDataService = { - ...baseSessionDataService, - tryOpenDatabase: async (resource: URI) => { - databaseOpenCalls++; - return baseSessionDataService.tryOpenDatabase(resource); - }, - }; const contribution = disposables.add(new SessionWorkspaceConversionContribution( new class extends mock() { }(), new class extends mock() { }(), - createHydrationStateManager(session, false), - sessionDataService, - new NullLogService(), )); const turns: Turn[] = [{ id: 'turn-1', @@ -437,20 +450,12 @@ suite('SessionWorkspaceConversionService', () => { state: TurnState.Complete, }]; - const hydrated = await contribution.onHydrateTurns({ + const hydrated = contribution.onHydrateTurns({ session: session.toString(), chat: buildDefaultChatUri(session), }, turns); - assert.deepStrictEqual({ - sameTurns: hydrated === turns, - databaseOpenCalls, - transitionQueryCalls: database.getTurnWorkspaceTransitionsCalls, - }, { - sameTurns: true, - databaseOpenCalls: 0, - transitionQueryCalls: 0, - }); + assert.strictEqual(hydrated, turns); }); test('restores one durable transition before provider output after service restart', async () => { @@ -463,6 +468,7 @@ suite('SessionWorkspaceConversionService', () => { const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => true, conversionDatabase); const workspaceFolder = URI.file('/workspace/project'); harness.agent.setWorkingDirectory = async () => { }; + completePriorTurn(harness.stateManager, harness.chat); startTurn(harness.stateManager, harness.chat); await harness.database.setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'true'); harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', workspaceFolder, false, 'client-1'); @@ -476,13 +482,9 @@ suite('SessionWorkspaceConversionService', () => { conversionDatabase = undefined; restoredDatabase = await SessionDatabase.open(databasePath); - const restoredStateManager = createHydrationStateManager(harness.session, true); const restoredContribution = disposables.add(new SessionWorkspaceConversionContribution( new class extends mock() { }(), new class extends mock() { }(), - restoredStateManager, - createSessionDataService(restoredDatabase), - new NullLogService(), )); const providerTurns: Turn[] = [{ id: 'provider-continuation', @@ -498,13 +500,16 @@ suite('SessionWorkspaceConversionService', () => { usage: undefined, state: TurnState.Complete, }]; + const workspaceTransitions = await restoredDatabase.getTurnWorkspaceTransitions(); const restoredOnce = await restoredContribution.onHydrateTurns({ session: harness.session.toString(), chat: harness.chat.toString(), + workspaceTransitions, }, providerTurns); const restoredTwice = await restoredContribution.onHydrateTurns({ session: harness.session.toString(), chat: harness.chat.toString(), + workspaceTransitions, }, restoredOnce); const restoredTurn = restoredTwice[0]; @@ -519,7 +524,7 @@ suite('SessionWorkspaceConversionService', () => { kind: part.kind, content: part.kind === ResponsePartKind.Markdown ? part.content : undefined, }), - persistedTransitions: [...(await restoredDatabase.getTurnWorkspaceTransitions()).keys()], + persistedTransitions: [...workspaceTransitions.keys()], }, { requestHidden: true, workspaceContinuation: true, @@ -546,15 +551,6 @@ suite('SessionWorkspaceConversionService', () => { test('restores every persisted workspace conversion at its own turn boundary', async () => { const database = new TestSessionDatabase(); - const baseSessionDataService = createSessionDataService(database); - let databaseOpenCalls = 0; - const sessionDataService = { - ...baseSessionDataService, - tryOpenDatabase: async (resource: URI) => { - databaseOpenCalls++; - return baseSessionDataService.tryOpenDatabase(resource); - }, - }; await database.setTurnWorkspaceTransition('turn-1', serializeAgentWorkspaceTransition({ content: 'Now working in first', workspaceKind: AgentSystemNotificationWorkspaceKind.Folder, @@ -568,9 +564,6 @@ suite('SessionWorkspaceConversionService', () => { const contribution = disposables.add(new SessionWorkspaceConversionContribution( new class extends mock() { }(), new class extends mock() { }(), - createHydrationStateManager(URI.parse('copilot:/workspace-less'), true), - sessionDataService, - new NullLogService(), )); const turns = ['turn-1', 'turn-2'].map((id): Turn => ({ id, @@ -583,20 +576,19 @@ suite('SessionWorkspaceConversionService', () => { const restored = await contribution.onHydrateTurns({ session: 'copilot:/workspace-less', chat: buildDefaultChatUri('copilot:/workspace-less'), + workspaceTransitions: await database.getTurnWorkspaceTransitions(), }, turns); assert.deepStrictEqual({ responseParts: restored.map(turn => turn.responseParts.map(part => part.kind === ResponsePartKind.SystemNotification ? part.content : part.kind )), - databaseOpenCalls, transitionQueryCalls: database.getTurnWorkspaceTransitionsCalls, }, { responseParts: [ ['Now working in first', ResponsePartKind.Markdown], ['Now working in second', ResponsePartKind.Markdown], ], - databaseOpenCalls: 1, transitionQueryCalls: 1, }); }); @@ -612,9 +604,6 @@ suite('SessionWorkspaceConversionService', () => { const contribution = disposables.add(new SessionWorkspaceConversionContribution( new class extends mock() { }(), new class extends mock() { }(), - createHydrationStateManager(session, true), - createSessionDataService(database), - new NullLogService(), )); const turns: Turn[] = [{ id: 'turn-1', @@ -627,6 +616,7 @@ suite('SessionWorkspaceConversionService', () => { const restored = await contribution.onHydrateTurns({ session: session.toString(), chat: buildChatUri(session, 'peer-chat'), + workspaceTransitions: await database.getTurnWorkspaceTransitions(), }, turns); assert.deepStrictEqual(restored[0].responseParts.map(part => @@ -652,6 +642,7 @@ suite('SessionWorkspaceConversionService', () => { provider.setWorkingDirectory = async (_chat, _context, workingDirectory) => { providerCalls.push(workingDirectory.toString()); }; + completePriorTurn(harness.stateManager, harness.chat); startTurn(harness.stateManager, harness.chat); await harness.database.setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'true'); harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', workspaceFolder, true, 'client-1'); @@ -1093,6 +1084,7 @@ suite('SessionWorkspaceConversionService', () => { const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => true, database); const provider: IAgent = harness.agent; provider.setWorkingDirectory = async () => { }; + completePriorTurn(harness.stateManager, harness.chat); startTurn(harness.stateManager, harness.chat); harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); completeTurn(harness.stateManager, harness.chat); @@ -1168,6 +1160,7 @@ suite('SessionWorkspaceConversionService', () => { const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => true, database); const provider: IAgent = harness.agent; provider.setWorkingDirectory = async () => { }; + completePriorTurn(harness.stateManager, harness.chat); startTurn(harness.stateManager, harness.chat); harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); completeTurn(harness.stateManager, harness.chat); @@ -1213,6 +1206,7 @@ suite('SessionWorkspaceConversionService', () => { const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => true, database); const provider: IAgent = harness.agent; provider.setWorkingDirectory = async () => { }; + completePriorTurn(harness.stateManager, harness.chat); startTurn(harness.stateManager, harness.chat); harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); completeTurn(harness.stateManager, harness.chat); diff --git a/src/vs/platform/configuration/common/configurations.ts b/src/vs/platform/configuration/common/configurations.ts index 0a2e5ca0e6f353..9b88ebcb5e1db7 100644 --- a/src/vs/platform/configuration/common/configurations.ts +++ b/src/vs/platform/configuration/common/configurations.ts @@ -122,8 +122,10 @@ export class PolicyConfiguration extends Disposable implements IPolicyConfigurat async initialize(): Promise { this.logService.trace('PolicyConfiguration#initialize'); - this.update(await this.updatePolicyDefinitions(this.defaultConfiguration.configurationModel.keys), false); - this.update(await this.updatePolicyDefinitions(Object.keys(this.configurationRegistry.getExcludedConfigurationProperties())), false); + this.update(await this.updatePolicyDefinitions([ + ...this.defaultConfiguration.configurationModel.keys, + ...Object.keys(this.configurationRegistry.getExcludedConfigurationProperties()), + ]), false); this._register(this.policyService.onDidChange(policyNames => this.onDidChangePolicies(policyNames))); this._register(this.defaultConfiguration.onDidChangeConfiguration(async ({ properties }) => this.update(await this.updatePolicyDefinitions(properties), true))); return this._configurationModel; diff --git a/src/vs/platform/configuration/test/common/policyConfiguration.test.ts b/src/vs/platform/configuration/test/common/policyConfiguration.test.ts index d289130c0ba6d9..39947f5eeae3c3 100644 --- a/src/vs/platform/configuration/test/common/policyConfiguration.test.ts +++ b/src/vs/platform/configuration/test/common/policyConfiguration.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { IStringDictionary } from '../../../../base/common/collections.js'; import { Event } from '../../../../base/common/event.js'; import { URI } from '../../../../base/common/uri.js'; import { DefaultConfiguration, PolicyConfiguration } from '../../common/configurations.js'; @@ -15,17 +16,28 @@ import { Extensions, IConfigurationNode, IConfigurationRegistry } from '../../co import { Registry } from '../../../registry/common/platform.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; import { deepClone } from '../../../../base/common/objects.js'; -import { IPolicyService } from '../../../policy/common/policy.js'; +import { AbstractPolicyService, IPolicyService, PolicyDefinition } from '../../../policy/common/policy.js'; import { FilePolicyService } from '../../../policy/common/filePolicyService.js'; import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { PolicyCategory } from '../../../../base/common/policy.js'; +class TestPolicyService extends AbstractPolicyService { + + updateCount = 0; + + protected _updatePolicyDefinitions(_policyDefinitions: IStringDictionary): Promise { + this.updateCount++; + return Promise.resolve(); + } +} + suite('PolicyConfiguration', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); let testObject: PolicyConfiguration; + let defaultConfiguration: DefaultConfiguration; let fileService: IFileService; let policyService: IPolicyService; const policyFile = URI.file('policyFile').with({ scheme: 'vscode-tests' }); @@ -132,7 +144,7 @@ suite('PolicyConfiguration', () => { suiteTeardown(() => Registry.as(Extensions.Configuration).deregisterConfigurations([policyConfigurationNode])); setup(async () => { - const defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); + defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); await defaultConfiguration.initialize(); fileService = disposables.add(new FileService(new NullLogService())); const diskFileSystemProvider = disposables.add(new InMemoryFileSystemProvider()); @@ -141,6 +153,23 @@ suite('PolicyConfiguration', () => { testObject = disposables.add(new PolicyConfiguration(defaultConfiguration, policyService, new NullLogService())); }); + test('initialize: submits included and excluded policy definitions together', async () => { + const testPolicyService = disposables.add(new TestPolicyService()); + const testPolicyConfiguration = disposables.add(new PolicyConfiguration(defaultConfiguration, testPolicyService, new NullLogService())); + + await testPolicyConfiguration.initialize(); + + assert.deepStrictEqual({ + updateCount: testPolicyService.updateCount, + hasIncludedPolicy: !!testPolicyService.policyDefinitions.PolicySettingA, + hasExcludedPolicy: !!testPolicyService.policyDefinitions.PolicyInternalSetting, + }, { + updateCount: 1, + hasIncludedPolicy: true, + hasExcludedPolicy: true, + }); + }); + test('initialize: with policies', async () => { await fileService.writeFile(policyFile, VSBuffer.fromString(JSON.stringify({ 'PolicySettingA': 'policyValueA' }))); diff --git a/src/vs/platform/contextkey/common/contextkey.ts b/src/vs/platform/contextkey/common/contextkey.ts index 595a8d4bbd1455..598b2ccb6c37ed 100644 --- a/src/vs/platform/contextkey/common/contextkey.ts +++ b/src/vs/platform/contextkey/common/contextkey.ts @@ -5,7 +5,7 @@ import { CharCode } from '../../../base/common/charCode.js'; import { Event } from '../../../base/common/event.js'; -import { isChrome, isEdge, isFirefox, isLinux, isMacintosh, isSafari, isWeb, isWindows } from '../../../base/common/platform.js'; +import { isChrome, isEdge, isFirefox, isLinux, isMacintosh, isSafari, isWeb, isWindows, isChromeOS } from '../../../base/common/platform.js'; import { isFalsyOrWhitespace } from '../../../base/common/strings.js'; import { Scanner, LexingError, Token, TokenType } from './scanner.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; @@ -19,6 +19,7 @@ CONSTANT_VALUES.set('true', true); CONSTANT_VALUES.set('isMac', isMacintosh); CONSTANT_VALUES.set('isLinux', isLinux); CONSTANT_VALUES.set('isWindows', isWindows); +CONSTANT_VALUES.set('isChromeOS', isChromeOS); CONSTANT_VALUES.set('isWeb', isWeb); CONSTANT_VALUES.set('isMacNative', isMacintosh && !isWeb); CONSTANT_VALUES.set('isEdge', isEdge); diff --git a/src/vs/platform/contextkey/common/contextkeys.ts b/src/vs/platform/contextkey/common/contextkeys.ts index c256dba0aa0230..606fcc058a1cd0 100644 --- a/src/vs/platform/contextkey/common/contextkeys.ts +++ b/src/vs/platform/contextkey/common/contextkeys.ts @@ -3,13 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { isIOS, isLinux, isMacintosh, isMobile, isWeb, isWindows } from '../../../base/common/platform.js'; +import { isIOS, isLinux, isMacintosh, isMobile, isWeb, isWindows, isChromeOS } from '../../../base/common/platform.js'; import { localize } from '../../../nls.js'; import { RawContextKey } from './contextkey.js'; export const IsMacContext = new RawContextKey('isMac', isMacintosh, localize('isMac', "Whether the operating system is macOS")); export const IsLinuxContext = new RawContextKey('isLinux', isLinux, localize('isLinux', "Whether the operating system is Linux")); export const IsWindowsContext = new RawContextKey('isWindows', isWindows, localize('isWindows', "Whether the operating system is Windows")); +export const IsChromeOSContext = new RawContextKey('isChromeOS', isChromeOS, localize('isChromeOS', "Whether the operating system is ChromeOS")); export const IsWebContext = new RawContextKey('isWeb', isWeb, localize('isWeb', "Whether the platform is a web browser")); export const IsMacNativeContext = new RawContextKey('isMacNative', isMacintosh && !isWeb, localize('isMacNative', "Whether the operating system is macOS on a non-browser platform")); diff --git a/src/vs/platform/contextkey/test/common/contextkey.test.ts b/src/vs/platform/contextkey/test/common/contextkey.test.ts index 8307e894e2fca1..2390f1ee0249ee 100644 --- a/src/vs/platform/contextkey/test/common/contextkey.test.ts +++ b/src/vs/platform/contextkey/test/common/contextkey.test.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { isLinux, isMacintosh, isWindows } from '../../../../base/common/platform.js'; +import { isLinux, isMacintosh, isWindows, isChromeOS } from '../../../../base/common/platform.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ContextKeyExpr, ContextKeyExpression, implies } from '../../common/contextkey.js'; @@ -154,6 +154,7 @@ suite('ContextKeyExpr', () => { testNormalize('isMac', isMacintosh ? 'true' : 'false'); testNormalize('isLinux', isLinux ? 'true' : 'false'); testNormalize('isWindows', isWindows ? 'true' : 'false'); + testNormalize('isChromeOS', isChromeOS ? 'true' : 'false'); }); test('issue #101015: distribute OR', () => { diff --git a/src/vs/platform/instantiation/test/common/instantiationServiceMock.ts b/src/vs/platform/instantiation/test/common/instantiationServiceMock.ts index deaefef16e7d7f..47b7f5e1e6c318 100644 --- a/src/vs/platform/instantiation/test/common/instantiationServiceMock.ts +++ b/src/vs/platform/instantiation/test/common/instantiationServiceMock.ts @@ -122,10 +122,10 @@ export class TestInstantiationService extends InstantiationService implements ID private _getOrCreateService(serviceMock: IServiceMock, opts: SinonOptions, reset?: boolean): any { const service: any = this._serviceCollection.get(serviceMock.id); if (!reset && service) { - if (opts.mock && service['sinonOptions'] && !!service['sinonOptions'].mock) { + if (opts.mock && service.sinonOptions && !!service.sinonOptions.mock) { return service; } - if (opts.stub && service['sinonOptions'] && !!service['sinonOptions'].stub) { + if (opts.stub && service.sinonOptions && !!service.sinonOptions.stub) { return service; } } @@ -135,7 +135,7 @@ export class TestInstantiationService extends InstantiationService implements ID private _createService(serviceMock: IServiceMock, opts: SinonOptions): any { serviceMock.service = serviceMock.service ? serviceMock.service : this._servciesMap.get(serviceMock.id); const service = opts.mock ? sinon.mock(serviceMock.service) : this._createStub(serviceMock.service); - service['sinonOptions'] = opts; + service.sinonOptions = opts; return service; } diff --git a/src/vs/platform/mcp/common/mcpGalleryService.ts b/src/vs/platform/mcp/common/mcpGalleryService.ts index 038dbe46a85771..f05fd8bd0a76c2 100644 --- a/src/vs/platform/mcp/common/mcpGalleryService.ts +++ b/src/vs/platform/mcp/common/mcpGalleryService.ts @@ -62,6 +62,68 @@ interface IGalleryMcpServersResult { readonly servers: IGalleryMcpServer[]; } +function toSupportedPackageRegistryType(input: unknown): RegistryType | undefined { + switch (input) { + case 'npm': + return RegistryType.NODE; + case 'docker': + case 'docker-hub': + case 'oci': + return RegistryType.DOCKER; + case 'pypi': + return RegistryType.PYTHON; + case 'nuget': + return RegistryType.NUGET; + default: + return undefined; + } +} + +function filterMcpPackages( + packages: readonly T[] | undefined, + getRegistryType: (serverPackage: T) => unknown, + convert: (serverPackage: T, registryType: RegistryType) => IMcpServerPackage, +): IMcpServerPackage[] | undefined { + if (!packages) { + return undefined; + } + + const result: IMcpServerPackage[] = []; + for (const serverPackage of packages) { + const registryType = toSupportedPackageRegistryType(getRegistryType(serverPackage)); + if (registryType) { + result.push(convert(serverPackage, registryType)); + } + } + return result; +} + +function getMcpServerUrlInGallery(mcpServerUrl: string, mcpGalleryManifest: IMcpGalleryManifest): string | undefined { + const mcpGalleryUrl = getMcpGalleryManifestResourceUri(mcpGalleryManifest, McpGalleryResourceType.McpServersQueryService) ?? mcpGalleryManifest.url; + try { + const serverUrl = new URL(mcpServerUrl); + const galleryUrl = new URL(mcpGalleryUrl); + if ( + (serverUrl.protocol !== `${Schemas.http}:` && serverUrl.protocol !== `${Schemas.https}:`) + || serverUrl.username + || serverUrl.password + || galleryUrl.username + || galleryUrl.password + || serverUrl.origin !== galleryUrl.origin + ) { + return undefined; + } + + const galleryPath = galleryUrl.pathname.replace(/\/+$/, ''); + if (serverUrl.pathname !== galleryPath && !serverUrl.pathname.startsWith(`${galleryPath}/`)) { + return undefined; + } + return serverUrl.href; + } catch { + return undefined; + } +} + interface IRawGalleryMcpServer { readonly name: string; readonly description: string; @@ -257,7 +319,7 @@ namespace McpServerSchemaVersion_v2025_07_09 { for (const server of from.servers) { const rawServer = this.toRawGalleryMcpServer(server); if (!rawServer) { - return undefined; + continue; } servers.push(rawServer); } @@ -362,23 +424,24 @@ namespace McpServerSchemaVersion_v2025_07_09 { } } - function convertRegistryType(input: string): RegistryType { - switch (input) { - case 'npm': - return RegistryType.NODE; - case 'docker': - case 'docker-hub': - case 'oci': - return RegistryType.DOCKER; - case 'pypi': - return RegistryType.PYTHON; - case 'nuget': - return RegistryType.NUGET; - case 'mcpb': - return RegistryType.MCPB; - default: - return RegistryType.NODE; - } + const packages = filterMcpPackages( + from.packages, + serverPackage => serverPackage.registry_type ?? serverPackage.registry_name, + (serverPackage, registryType) => ({ + identifier: serverPackage.identifier ?? serverPackage.name, + registryType, + version: serverPackage.version, + fileSha256: serverPackage.file_sha256, + registryBaseUrl: serverPackage.registry_base_url, + transport: serverPackage.transport ? convertTransport(serverPackage.transport) : { type: TransportType.STDIO }, + packageArguments: serverPackage.package_arguments?.map(convertServerArgument), + runtimeHint: serverPackage.runtime_hint, + runtimeArguments: serverPackage.runtime_arguments?.map(convertServerArgument), + environmentVariables: serverPackage.environment_variables?.map(convertKeyValueInput), + }), + ); + if (!packages?.length && !from.remotes?.length) { + return undefined; } const gitHubInfo: RawGitHubInfo | undefined = from._meta['io.modelcontextprotocol.registry/publisher-provided']?.github as RawGitHubInfo | undefined; @@ -396,18 +459,7 @@ namespace McpServerSchemaVersion_v2025_07_09 { version: from.version, createdAt: from.created_at, updatedAt: from.updated_at, - packages: from.packages?.map(p => ({ - identifier: p.identifier ?? p.name, - registryType: convertRegistryType(p.registry_type ?? p.registry_name), - version: p.version, - fileSha256: p.file_sha256, - registryBaseUrl: p.registry_base_url, - transport: p.transport ? convertTransport(p.transport) : { type: TransportType.STDIO }, - packageArguments: p.package_arguments?.map(convertServerArgument), - runtimeHint: p.runtime_hint, - runtimeArguments: p.runtime_arguments?.map(convertServerArgument), - environmentVariables: p.environment_variables?.map(convertKeyValueInput), - })), + packages, remotes: from.remotes?.map(remote => { const type = (remote).type ?? (remote).transport_type ?? (remote).transport; return { @@ -502,7 +554,7 @@ namespace McpServerSchemaVersion_v0_1 { interface RawGalleryMcpServerPackage { readonly identifier: string; - readonly registryType: RegistryType; + readonly registryType: string; readonly transport: RawGalleryTransport; readonly fileSha256?: string; readonly environmentVariables?: ReadonlyArray; @@ -567,11 +619,7 @@ namespace McpServerSchemaVersion_v0_1 { for (const server of from.servers) { const rawServer = this.toRawGalleryMcpServer(server); if (!rawServer) { - if (servers.length === 0) { - return undefined; - } else { - continue; - } + continue; } servers.push(rawServer); } @@ -600,6 +648,14 @@ namespace McpServerSchemaVersion_v0_1 { const { 'io.modelcontextprotocol.registry/official': registryInfo, ...apicInfo } = from._meta; const githubInfo = from.server._meta?.['io.modelcontextprotocol.registry/publisher-provided']?.github as IGitHubInfo | undefined; + const packages = filterMcpPackages( + from.server.packages, + serverPackage => serverPackage.registryType, + (serverPackage, registryType) => ({ ...serverPackage, registryType }), + ); + if (!packages?.length && !from.server.remotes?.length) { + return undefined; + } return { name: from.server.name, @@ -614,7 +670,7 @@ namespace McpServerSchemaVersion_v0_1 { readme: githubInfo?.readme, icons: from.server.icons, websiteUrl: from.server.websiteUrl, - packages: from.server.packages, + packages, remotes: from.server.remotes, status: registryInfo?.status, registryInfo, @@ -633,31 +689,40 @@ namespace McpServerSchemaVersion_v0 { class Serializer implements IGalleryMcpServerDataSerializer { - private readonly galleryMcpServerDataSerializers: IGalleryMcpServerDataSerializer[] = []; - - constructor() { - this.galleryMcpServerDataSerializers.push(McpServerSchemaVersion_v0_1.SERIALIZER); - this.galleryMcpServerDataSerializers.push(McpServerSchemaVersion_v2025_07_09.SERIALIZER); - } - public toRawGalleryMcpServerResult(input: unknown): IRawGalleryMcpServersResult | undefined { - for (const serializer of this.galleryMcpServerDataSerializers) { - const result = serializer.toRawGalleryMcpServerResult(input); - if (result) { - return result; + if (!isObject(input)) { + return undefined; + } + const candidate = input as { readonly servers?: unknown; readonly metadata?: unknown }; + if (!Array.isArray(candidate.servers) || !isObject(candidate.metadata)) { + return undefined; + } + const metadata = candidate.metadata as { readonly count?: unknown; readonly nextCursor?: unknown; readonly next_cursor?: unknown }; + if (metadata.count !== undefined && typeof metadata.count !== 'number') { + return undefined; + } + + const servers: IRawGalleryMcpServer[] = []; + for (const server of candidate.servers) { + const rawServer = this.toRawGalleryMcpServer(server); + if (rawServer) { + servers.push(rawServer); } } - return undefined; + return { + metadata: { + count: metadata.count ?? 0, + nextCursor: isString(metadata.nextCursor) ? metadata.nextCursor : isString(metadata.next_cursor) ? metadata.next_cursor : undefined, + }, + servers, + }; } public toRawGalleryMcpServer(input: unknown): IRawGalleryMcpServer | undefined { - for (const serializer of this.galleryMcpServerDataSerializers) { - const result = serializer.toRawGalleryMcpServer(input); - if (result) { - return result; - } + if (isObject(input) && isObject((input as { readonly server?: unknown }).server)) { + return McpServerSchemaVersion_v0_1.SERIALIZER.toRawGalleryMcpServer(input); } - return undefined; + return McpServerSchemaVersion_v2025_07_09.SERIALIZER.toRawGalleryMcpServer(input); } } @@ -801,7 +866,7 @@ export class McpGalleryService extends Disposable implements IMcpGalleryService } attempted = true; try { - const mcpServer = await this.getMcpServer(url); + const mcpServer = await this.getMcpServer(url, mcpGalleryManifest); if (mcpServer) { if (mcpServer.name === name) { return mcpServer; @@ -1021,9 +1086,19 @@ export class McpGalleryService extends Disposable implements IMcpGalleryService } async getMcpServer(mcpServerUrl: string, mcpGalleryManifest?: IMcpGalleryManifest | null): Promise { + const manifest = mcpGalleryManifest ?? await this.mcpGalleryManifestService.getMcpGalleryManifest(); + if (!manifest) { + throw new Error('Cannot fetch MCP server because no MCP gallery is configured'); + } + const validatedMcpServerUrl = getMcpServerUrlInGallery(mcpServerUrl, manifest); + if (!validatedMcpServerUrl) { + throw new Error('Cannot fetch MCP server from outside the configured MCP gallery'); + } + const context = await this.requestService.request({ type: 'GET', - url: mcpServerUrl, + url: validatedMcpServerUrl, + followRedirects: 0, callSite: 'mcpGalleryService.getMcpServer' }, CancellationToken.None); @@ -1034,7 +1109,7 @@ export class McpGalleryService extends Disposable implements IMcpGalleryService return undefined; } - if (context.res.statusCode && context.res.statusCode >= 400) { + if (!isSuccess(context)) { throw new Error(`Failed to fetch MCP server from ${mcpServerUrl}: server responded with ${context.res.statusCode}`); } @@ -1043,17 +1118,12 @@ export class McpGalleryService extends Disposable implements IMcpGalleryService throw new Error(`Failed to fetch MCP server from ${mcpServerUrl}: empty response`); } - if (!mcpGalleryManifest) { - mcpGalleryManifest = await this.mcpGalleryManifestService.getMcpGalleryManifest(); - } - mcpGalleryManifest = mcpGalleryManifest && mcpServerUrl.startsWith(mcpGalleryManifest.url) ? mcpGalleryManifest : null; - - const server = this.serializeMcpServer(data, mcpGalleryManifest); + const server = this.serializeMcpServer(data, manifest); if (!server) { throw new Error(`Failed to serialize MCP server from ${mcpServerUrl}`, data); } - return this.toGalleryMcpServer(server, mcpGalleryManifest); + return this.toGalleryMcpServer(server, manifest); } private serializeMcpServer(data: unknown, mcpGalleryManifest: IMcpGalleryManifest | null): IRawGalleryMcpServer | undefined { diff --git a/src/vs/platform/mcp/common/mcpManagementService.ts b/src/vs/platform/mcp/common/mcpManagementService.ts index b15a81ad236617..6b98a35988233d 100644 --- a/src/vs/platform/mcp/common/mcpManagementService.ts +++ b/src/vs/platform/mcp/common/mcpManagementService.ts @@ -186,8 +186,8 @@ export abstract class AbstractCommonMcpManagementService extends Disposable impl case RegistryType.DOCKER: return 'docker'; case RegistryType.PYTHON: return 'uvx'; case RegistryType.NUGET: return 'dnx'; + default: throw new Error(`Unsupported MCP server package registry type: ${packageType}`); } - return packageType; } protected getVariables(variableInputs: Record): IMcpServerVariable[] { diff --git a/src/vs/platform/mcp/test/common/mcpGalleryService.test.ts b/src/vs/platform/mcp/test/common/mcpGalleryService.test.ts index a045937e30d27c..9cad5402979b69 100644 --- a/src/vs/platform/mcp/test/common/mcpGalleryService.test.ts +++ b/src/vs/platform/mcp/test/common/mcpGalleryService.test.ts @@ -10,18 +10,73 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { IFileService } from '../../../files/common/files.js'; import { NullLogService } from '../../../log/common/log.js'; -import { IRequestContext } from '../../../../base/parts/request/common/request.js'; +import { IRequestContext, IRequestOptions } from '../../../../base/parts/request/common/request.js'; import { IRequestService } from '../../../request/common/request.js'; import { IGalleryMcpServer, McpGalleryResolveStatus } from '../../common/mcpManagement.js'; import { IMcpGalleryManifest, IMcpGalleryManifestService, McpGalleryManifestStatus, McpGalleryResourceType } from '../../common/mcpGalleryManifest.js'; import { McpGalleryService } from '../../common/mcpGalleryService.js'; +const SERVERS_URL = 'https://registry.test/servers'; const NAMED_TEMPLATE = 'https://registry.test/servers/{name}'; function serverUrl(name: string): string { return `https://registry.test/servers/${name}`; } +function serverDocumentData(name: string, registryTypes: readonly string[], remotes?: readonly { type: string; url: string }[]) { + return { + server: { + name, + description: 'Test server', + version: '1.0.0', + packages: registryTypes.map(registryType => ({ + identifier: 'test-package', + registryType, + transport: { type: 'stdio' } + })), + remotes + }, + _meta: { + 'io.modelcontextprotocol.registry/official': { + status: 'active', + isLatest: true, + publishedAt: '2026-01-01T00:00:00.000Z' + } + } + }; +} + +function serverDocument(registryType?: string): string { + return JSON.stringify(serverDocumentData('io.github.owner/server', registryType ? [registryType] : [])); +} + +function modernServerDocumentData(name: string, registryTypes: readonly string[]) { + return { + $schema: 'https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json', + name, + description: 'Test server', + version: '1.0.0', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + packages: registryTypes.map(registryType => ({ + registry_name: registryType, + registry_type: registryType, + name: 'test-package', + identifier: 'test-package', + version: '1.0.0', + transport: { type: 'stdio' } + })), + _meta: { + 'io.modelcontextprotocol.registry/official': { + id: name, + is_latest: true, + published_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z' + } + } + }; +} + class TestMcpGalleryService extends McpGalleryService { readonly responses = new Map(); @@ -40,10 +95,12 @@ class TestMcpGalleryService extends McpGalleryService { class StatusRequestService implements IRequestService { readonly _serviceBrand: undefined; readonly onDidCompleteRequest = Event.None; + readonly requests: IRequestOptions[] = []; constructor(private readonly statusCode: number, private readonly body: string = '') { } - async request(): Promise { + async request(options: IRequestOptions): Promise { + this.requests.push(options); return { res: { statusCode: this.statusCode, headers: {} }, stream: bufferToStream(VSBuffer.fromString(this.body)), @@ -69,7 +126,10 @@ function createManifestService(manifest: IMcpGalleryManifest | null): IMcpGaller const manifest: IMcpGalleryManifest = { version: 'v0', url: 'https://registry.test', - resources: [{ id: NAMED_TEMPLATE, type: McpGalleryResourceType.McpServerNamedResourceUri }] + resources: [ + { id: SERVERS_URL, type: McpGalleryResourceType.McpServersQueryService }, + { id: NAMED_TEMPLATE, type: McpGalleryResourceType.McpServerNamedResourceUri } + ] }; suite('McpGalleryService - resolveMcpServersFromGallery', () => { @@ -194,3 +254,169 @@ suite('McpGalleryService - getMcpServer HTTP status classification', () => { ]); }); }); + +suite('McpGalleryService - getMcpServer validation', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createService(requestService: IRequestService, galleryManifest: IMcpGalleryManifest = manifest): McpGalleryService { + const store = disposables.add(new DisposableStore()); + return store.add(new McpGalleryService( + requestService, + {} as IFileService, + new NullLogService(), + createManifestService(galleryManifest), + )); + } + + test('rejects URLs outside the configured gallery before requesting them', async () => { + const requestService = new StatusRequestService(200, serverDocument()); + const service = createService(requestService); + + for (const url of [ + 'https://registry.test.example/servers/server', + 'https://registry.test/servers-other/server', + 'https://registry.test/servers%2Foutside/server', + 'https://registry.test/servers/%2e%2e/outside/server', + ]) { + await assert.rejects(() => service.getMcpServer(url), /outside the configured MCP gallery/); + } + + assert.deepStrictEqual(requestService.requests, []); + }); + + test('rejects opaque-origin gallery URLs before requesting them', async () => { + const requestService = new StatusRequestService(200, serverDocument()); + const service = createService(requestService, { + version: 'v0.1', + url: 'file:///registry', + resources: [{ id: 'file:///registry/servers', type: McpGalleryResourceType.McpServersQueryService }] + }); + + for (const url of [ + 'file:///registry/servers/server', + 'data:/registry/servers/server', + 'custom:/registry/servers/server', + ]) { + await assert.rejects(() => service.getMcpServer(url), /outside the configured MCP gallery/); + } + + assert.deepStrictEqual(requestService.requests, []); + }); + + test('does not follow redirects when requesting a gallery server', async () => { + const requestService = new StatusRequestService(302, serverDocument()); + const service = createService(requestService); + const url = serverUrl('io.github.owner/server'); + + await assert.rejects(() => service.getMcpServer(url), /302/); + + assert.deepStrictEqual(requestService.requests.map(request => ({ + url: request.url, + followRedirects: request.followRedirects + })), [{ + url, + followRedirects: 0 + }]); + }); + + test('rejects unsupported v0.1 package registry types', async () => { + const requestService = new StatusRequestService(200, serverDocument('unsupported')); + const service = createService(requestService, { ...manifest, version: 'v0.1' }); + + await assert.rejects(() => service.getMcpServer(serverUrl('io.github.owner/server')), /Failed to serialize MCP server/); + }); + + test('filters unsupported packages while preserving supported launch options', async () => { + const data = serverDocumentData( + 'io.github.owner/server', + ['mcpb', 'npm'], + [{ type: 'streamable-http', url: 'https://mcp.example/server' }] + ); + const requestService = new StatusRequestService(200, JSON.stringify(data)); + const service = createService(requestService, { ...manifest, version: 'v0.1' }); + + const server = await service.getMcpServer(serverUrl('io.github.owner/server')); + + assert.deepStrictEqual({ + packageTypes: server?.configuration.packages?.map(serverPackage => serverPackage.registryType), + remotes: server?.configuration.remotes + }, { + packageTypes: ['npm'], + remotes: [{ type: 'streamable-http', url: 'https://mcp.example/server' }] + }); + }); + + test('skips unusable servers without dropping a v0.1 gallery page', async () => { + const data = { + metadata: { count: 2 }, + servers: [ + serverDocumentData('io.github.owner/unsupported', ['mcpb']), + serverDocumentData('io.github.owner/supported', ['npm']) + ] + }; + const requestService = new StatusRequestService(200, JSON.stringify(data)); + const service = createService(requestService, { ...manifest, version: 'v0.1' }); + + const page = (await service.query()).firstPage; + + assert.deepStrictEqual(page.items.map(server => server.name), ['io.github.owner/supported']); + }); + + test('skips unusable servers without dropping a modern gallery page', async () => { + const data = { + metadata: { count: 2 }, + servers: [ + modernServerDocumentData('io.github.owner/unsupported', ['mcpb']), + modernServerDocumentData('io.github.owner/supported', ['npm']) + ] + }; + const requestService = new StatusRequestService(200, JSON.stringify(data)); + const service = createService(requestService); + + const page = (await service.query()).firstPage; + + assert.deepStrictEqual(page.items.map(server => server.name), ['io.github.owner/supported']); + }); + + test('skips malformed wrapped entries without misclassifying a modern gallery page', async () => { + const data = { + metadata: { count: 2, next_cursor: 'next-page' }, + servers: [ + { server: {} }, + modernServerDocumentData('io.github.owner/supported', ['npm']) + ] + }; + const requestService = new StatusRequestService(200, JSON.stringify(data)); + const service = createService(requestService); + + const page = (await service.query()).firstPage; + + assert.deepStrictEqual({ + names: page.items.map(server => server.name), + hasMore: page.hasMore + }, { + names: ['io.github.owner/supported'], + hasMore: true + }); + }); + + test('accepts a gallery page without optional count metadata', async () => { + const data = { + metadata: { next_cursor: 'next-page' }, + servers: [modernServerDocumentData('io.github.owner/supported', ['npm'])] + }; + const requestService = new StatusRequestService(200, JSON.stringify(data)); + const service = createService(requestService); + + const page = (await service.query()).firstPage; + + assert.deepStrictEqual({ + names: page.items.map(server => server.name), + hasMore: page.hasMore + }, { + names: ['io.github.owner/supported'], + hasMore: true + }); + }); +}); diff --git a/src/vs/platform/mcp/test/common/mcpManagementService.test.ts b/src/vs/platform/mcp/test/common/mcpManagementService.test.ts index 8a160c900d15fa..a65e3925dc3075 100644 --- a/src/vs/platform/mcp/test/common/mcpManagementService.test.ts +++ b/src/vs/platform/mcp/test/common/mcpManagementService.test.ts @@ -936,6 +936,21 @@ suite('McpManagementService - getMcpServerConfigurationFromManifest', () => { }, /No server package found/); }); + test('unsupported package registry type should throw error', () => { + const manifest: IGalleryMcpServerConfiguration = { + packages: [{ + registryType: 'unsupported' as RegistryType, + transport: { type: TransportType.STDIO }, + identifier: 'test-package', + version: '1.0.0' + }] + }; + + assert.throws(() => { + service.getMcpServerConfigurationFromManifest(manifest, manifest.packages![0].registryType); + }, /Unsupported MCP server package registry type: unsupported/); + }); + test('manifest with no matching package type should use first package', () => { const manifest: IGalleryMcpServerConfiguration = { packages: [{ diff --git a/src/vs/platform/windows/electron-main/windowImpl.ts b/src/vs/platform/windows/electron-main/windowImpl.ts index 94aee9da97656e..5904f68759d693 100644 --- a/src/vs/platform/windows/electron-main/windowImpl.ts +++ b/src/vs/platform/windows/electron-main/windowImpl.ts @@ -101,23 +101,24 @@ class DockBadgeManager { private readonly counts = new Map(); acquireBadge(window: IBaseWindow): IDisposable { - this.attention.add(window.id); + const windowId = window.id; + this.attention.add(windowId); this.update(); return { dispose: () => { - this.attention.delete(window.id); + this.attention.delete(windowId); this.update(); } }; } - setCount(window: IBaseWindow, count: number): void { + setCount(windowId: number, count: number): void { if (count > 0) { - this.counts.set(window.id, count); - } else if (!this.counts.delete(window.id)) { + this.counts.set(windowId, count); + } else if (!this.counts.delete(windowId)) { return; // window had no count to begin with } @@ -142,6 +143,8 @@ class DockBadgeManager { export abstract class BaseWindow extends Disposable implements IBaseWindow { + private applicationBadgeWindowId: number | undefined; + //#region Events private readonly _onDidClose = this._register(new Emitter()); @@ -295,7 +298,11 @@ export abstract class BaseWindow extends Disposable implements IBaseWindow { // Release this window's share of the application wide badge, so that a // closed or crashed window cannot leave a phantom count behind. - this._register(toDisposable(() => DockBadgeManager.INSTANCE.setCount(this, 0))); + this._register(toDisposable(() => { + if (this.applicationBadgeWindowId !== undefined) { + DockBadgeManager.INSTANCE.setCount(this.applicationBadgeWindowId, 0); + } + })); } protected applyState(state: IWindowState, hasMultipleDisplays = electron.screen.getAllDisplays().length > 0): void { @@ -394,7 +401,8 @@ export abstract class BaseWindow extends Disposable implements IBaseWindow { // macOS (dock) and Linux (Unity launcher) render the count themselves, // on a badge shared by the whole application. else { - DockBadgeManager.INSTANCE.setCount(this, count); + this.applicationBadgeWindowId ??= this.id; + DockBadgeManager.INSTANCE.setCount(this.applicationBadgeWindowId, count); } } diff --git a/src/vs/sessions/AUTOMATIONS.md b/src/vs/sessions/AUTOMATIONS.md index 3291f09c69a0b7..0f88a69e205dc9 100644 --- a/src/vs/sessions/AUTOMATIONS.md +++ b/src/vs/sessions/AUTOMATIONS.md @@ -56,7 +56,7 @@ The Sessions layer direction remains defined by [LAYERS.md](LAYERS.md). Non-prov - editable name and prompt; - schedule; - execution target; -- optional model, mode, and permission selection; +- optional provider-owned session template; - enabled state; - runtime timestamps. @@ -72,12 +72,30 @@ The same logical agent may be available from multiple providers. Consumers must Workspace targets may omit `providerId` and `sessionTypeId`. Such definitions use global legacy routing and cannot migrate to a provider until their target identifies one. Quick-chat targets always identify both. +### Session template + +`IAutomationSessionTemplate` is the canonical session configuration for new definitions. It contains an optional model, optional custom agent, and opaque provider-owned configuration. Shared Automation code preserves this data but does not interpret provider Mode or Approvals vocabularies. + +Target identity remains separate from the template. The target owns provider selection, workspace, isolation, and branch. The template owns model, custom agent, Mode, Approvals, and other provider-defined session configuration. + +Saved template values are preferences, not durable permission grants. The owning provider resolves them against its current model and agent availability, configuration schema, feature enablement, and managed policy whenever a draft or run session is created. Policy may change the effective value without rewriting the saved preference. + +Legacy `modelId`, `mode`, and `permissionLevel` fields remain decode and input aliases for older ledgers and callers. New dialog and AHP projections write the session template. Compatibility aliases are translated at the owning store or provider boundary and have no authority to replace an explicit template. + ### Run `IAutomationRun` records one execution attempt. `pending` and `running` are non-terminal; `completed` and `failed` are terminal. A run may expose the created session resource once that session is committed. At most one non-terminal run may occupy an Automation's active-run slot within one authority. +### Catalogue availability + +Every Automation store exposes whether its complete catalogue is `loading`, `ready`, `unavailable`, or in `error`. An empty catalogue is authoritative only in the `ready` state. `loading` is reserved for initial provider discovery and authoritative snapshots that are still in flight. `unavailable` means a known provider catalogue cannot currently be reached without treating that condition as storage or migration failure. + +Provider stores map their connection and persistence lifecycle into this provider-neutral state. Agent Host stores become ready when an authoritative catalogue snapshot and every source still participating in the projection are readable, independently of migration authority. Known disconnect, disabled capability, and unsupported capability are unavailable rather than perpetually loading. + +`ProviderAutomationService` keeps the initial aggregate loading until all AfterRestored workbench contributions have completed provider registration. A provider-less window then settles to its legacy-store state, so a legacy-only empty catalogue can be authoritative. After provider settlement, the aggregate reports `error` when any current store fails, otherwise `loading` while any store is loading, `unavailable` while any store is unavailable, and `ready` only when all current stores are ready. + ## Multi-host routing VS Code may register one local Agent Host provider and multiple remote Agent Host providers at the same time. A remote connection has its own provider identity, Automation store, AHP catalogue, and migration state. @@ -126,6 +144,8 @@ Before Agent Host authority is activated, the legacy store owns: - session-resource linkage; - terminal lifecycle updates. +The browser runner passes the complete saved session template into provider draft creation. Providers explicitly advertise support for restoring and capturing this configuration; execution fails rather than silently using defaults when a canonical template reaches an unsupported provider. Deprecated flat aliases also flow through ordinary model, mode, and permission operations for older providers. Workspace isolation and branch remain target-owned and are configured separately. + Renderer-window leader election prevents duplicate scheduled execution across windows. ### Agent Host authority @@ -311,12 +331,18 @@ Updates that do not change the target remain allowed while an active run delays 8. Migration and retargeting preserve concurrent edits through snapshot comparison. 9. Mixed expected deferrals and real failures are reported as failures. 10. Provider-specific state stays behind `ISessionsProviderAutomations`; UI and tools consume provider-neutral models. +11. Same-target edits preserve unknown template values unless the user explicitly changes them. +12. Retargeting does not carry a previous provider's template into the new authority. +13. Runtime policy and provider schema are revalidated for every run without treating saved configuration as a grant. +14. Consumers distinguish a confirmed empty catalogue from loading, unavailability, and failure through the provider-neutral catalogue state. ## Concrete behavior Focused tests own concrete migration, retry, repair, conflict, and execution behavior: - `contrib/automations/test/browser/automationService.test.ts`; +- `contrib/automations/test/browser/automationRunner.test.ts`; +- `contrib/automations/test/browser/automationTools.test.ts`; - `contrib/automations/test/browser/providerAutomationService.test.ts`; - `contrib/automations/test/browser/automationScheduler.test.ts`; - `contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts`. diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index 1d52a2c1e306b5..d5e72662662533 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -139,6 +139,10 @@ A provider that supersedes sessions from another provider may implement `resolve `createNewSession` and `createQuickChat` return untitled drafts. A draft remains `Untitled` while its first request is prepared; `isNewSessionRequestInProgress` separately lets the UI present that activity without treating the session as committed. Draft preparation receives the first query so a provider can materialize query-dependent execution state before replacing the draft. A draft enters the committed catalog when its first request is sent. The management service owns the currently presented draft; the provider owns its backend resources. `deleteNewSession` disposes an abandoned draft. +Automation editing uses an independent draft so it cannot replace the ordinary New Session composer. Providers advertise `supportsAutomationSessionConfiguration` when they restore `ISessionsProviderCreateSessionOptions.automationConfiguration` before the draft's first configuration resolution and implement `getAutomationSessionConfiguration` to capture the current template. The management service rejects canonical templates for providers without this capability, while deprecated flat aliases continue through ordinary model, mode, and permission operations. It distinguishes unsupported capture from a valid empty template, a replaced draft, and capture failure. + +Provider-specific configuration remains opaque to shared Sessions code. Scoped Automation and New Session surfaces consume the same provider menu contributions and `ISessionContext`; providers may advertise presentation capabilities such as a combined phone Mode/Model picker without exposing provider identity checks to shared UI. + ### Operations Providers implement only operations advertised by their contracts, including request sending, model selection, rename, archive, read state, deletion, and chat creation. Capability checks happen before invocation. Once invoked, an operation returns a defined result or rejects; unsupported behavior must not be reported as a success-shaped fallback. diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index 49de5a94e17d68..7736a997f6708f 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -47,6 +47,8 @@ export const Menus = { AutomationsHistoryItem: new MenuId('SessionsAutomationsHistoryItem'), /** Context menu for session-backed Automation history rows. */ AutomationsHistoryItemContext: new MenuId('SessionsAutomationsHistoryItemContext'), + /** Input toolbar actions in the Automation dialog prompt editor. */ + AutomationsDialogInputToolbar: new MenuId('AutomationsDialogInputToolbar'), NewSessionConfig: new MenuId('NewSessions.SessionConfigMenu'), NewSessionControl: new MenuId('NewSessions.SessionControlMenu'), diff --git a/src/vs/sessions/browser/parts/customViewNode.ts b/src/vs/sessions/browser/parts/customViewNode.ts index 4a34e358636f16..9be844e12b1d3b 100644 --- a/src/vs/sessions/browser/parts/customViewNode.ts +++ b/src/vs/sessions/browser/parts/customViewNode.ts @@ -4,9 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import './media/customViewGridPart.css'; -import { $, isAncestorOfActiveElement } from '../../../base/browser/dom.js'; +import { $, addDisposableListener, EventType, getWindow, isAncestorOfActiveElement, scheduleAtNextAnimationFrame } from '../../../base/browser/dom.js'; import { DomScrollableElement } from '../../../base/browser/ui/scrollbar/scrollableElement.js'; -import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { Disposable, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { autorun } from '../../../base/common/observable.js'; import { ScrollbarVisibility } from '../../../base/common/scrollable.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../platform/actions/browser/toolbar.js'; @@ -98,6 +98,15 @@ export class CustomViewNode extends Disposable { vertical: ScrollbarVisibility.Auto, useShadows: false, })); + this._register(addDisposableListener(scrollContent, EventType.SCROLL, () => { + this._scrollable.setScrollPosition({ scrollTop: scrollContent.scrollTop }); + })); + const focusScrollSync = this._register(new MutableDisposable()); + this._register(addDisposableListener(scrollContent, EventType.FOCUS_IN, () => { + focusScrollSync.value = scheduleAtNextAnimationFrame(getWindow(scrollContent), () => { + this._scrollable.setScrollPosition({ scrollTop: scrollContent.scrollTop }); + }); + })); this._scrollable.getDomNode().classList.add('custom-view-body'); this.element.appendChild(this._scrollable.getDomNode()); diff --git a/src/vs/sessions/browser/parts/titlebarPart.ts b/src/vs/sessions/browser/parts/titlebarPart.ts index d643be71cbbcb5..ed939b55964a75 100644 --- a/src/vs/sessions/browser/parts/titlebarPart.ts +++ b/src/vs/sessions/browser/parts/titlebarPart.ts @@ -16,7 +16,7 @@ import { DisposableStore } from '../../../base/common/lifecycle.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; import { agentsBackground, agentsPanelForeground } from '../../common/theme.js'; import { isMacintosh, isWeb, isNative, platformLocale } from '../../../base/common/platform.js'; -import { EventType, EventHelper, append, $, addDisposableListener, prepend, getWindow, getWindowId } from '../../../base/browser/dom.js'; +import { EventType, EventHelper, append, $, addDisposableListener, prepend, getWindow, getWindowId, AnimationFrameScheduler } from '../../../base/browser/dom.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { IStorageService } from '../../../platform/storage/common/storage.js'; @@ -85,6 +85,7 @@ export class TitlebarPart extends Part implements ITitlebarPart { private centerContent!: HTMLElement; private rightContent!: HTMLElement; private readonly overflowManagedToolBarElements: HTMLElement[] = []; + private titleBarToolBarOverflowScheduler!: AnimationFrameScheduler; get leftContainer(): HTMLElement { return this.leftContent; } get rightContainer(): HTMLElement { return this.rightContent; } @@ -144,6 +145,8 @@ export class TitlebarPart extends Part implements ITitlebarPart { protected override createContentArea(parent: HTMLElement): HTMLElement { this.element = parent; this.rootContainer = append(parent, $('.titlebar-container.sessions-titlebar-container.has-center')); + // Measure in the render phase so DOM reads do not force layout during startup. + this.titleBarToolBarOverflowScheduler = this._register(new AnimationFrameScheduler(this.rootContainer, () => this.updateTitleBarToolBarOverflow())); // Draggable region prepend(this.rootContainer, $('div.titlebar-drag-region')); @@ -268,7 +271,7 @@ export class TitlebarPart extends Part implements ITitlebarPart { })); const updateScreenReaderButtonBar = () => { screenReaderToolBarElement.classList.toggle('has-no-actions', screenReaderButtonBar.buttons.length === 0); - this.updateTitleBarToolBarOverflow(); + this.titleBarToolBarOverflowScheduler.schedule(); }; this._register(screenReaderButtonBar.onDidChange(updateScreenReaderButtonBar)); updateScreenReaderButtonBar(); @@ -328,12 +331,12 @@ export class TitlebarPart extends Part implements ITitlebarPart { override layout(width: number, height: number): void { this.updateLayout(); super.layoutContents(width, height); - this.updateTitleBarToolBarOverflow(); + this.titleBarToolBarOverflowScheduler.schedule(); } private registerOverflowManagedToolBar(element: HTMLElement, toolBar: MenuWorkbenchToolBar): void { this.overflowManagedToolBarElements.push(element); - this._register(toolBar.onDidChangeMenuItems(() => this.updateTitleBarToolBarOverflow())); + this._register(toolBar.onDidChangeMenuItems(() => this.titleBarToolBarOverflowScheduler.schedule())); } private updateTitleBarToolBarOverflow(): void { diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidget.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidget.ts index 579c1364839752..c442a1a2cc0e77 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidget.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidget.ts @@ -26,6 +26,7 @@ import { IEditorDecorationsCollection, ScrollType } from '../../../../editor/com import { OverviewRulerLane } from '../../../../editor/common/model.js'; import * as nls from '../../../../nls.js'; import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { themeColorFromId } from '../../../../platform/theme/common/themeService.js'; import { ICodeReviewService } from '../../codeReview/browser/codeReviewService.js'; import { createAgentFeedbackContext } from './agentFeedbackEditorUtils.js'; @@ -528,10 +529,7 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid const acceptButton = buttonStore.add(new Button(buttonBar, { title: acceptTooltip, - buttonBackground: 'var(--vscode-charts-purple)', - buttonHoverBackground: 'color-mix(in srgb, var(--vscode-charts-purple) 85%, var(--vscode-foreground))', - buttonForeground: 'var(--vscode-button-foreground)', - buttonBorder: 'var(--vscode-charts-purple)', + ...defaultButtonStyles, })); acceptButton.label = nls.localize('acceptFeedbackButton', "Accept"); buttonStore.add(acceptButton.onDidClick(() => { diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css index df5e427e497f3c..58eddafe4dc48a 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css +++ b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css @@ -208,8 +208,8 @@ .agent-feedback-widget-item-prReview .agent-feedback-widget-item-type, .agent-feedback-widget-item-agentFeedback .agent-feedback-widget-item-type { - background: color-mix(in srgb, var(--vscode-charts-purple) 22%, var(--vscode-editorWidget-background)); - color: var(--vscode-charts-purple); + background: color-mix(in srgb, var(--vscode-button-background) 22%, var(--vscode-editorWidget-background)); + color: var(--vscode-button-background); } /* Line info */ diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.test.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.test.ts index 9d508e7f6a51b6..5b94fe9471f164 100644 --- a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.test.ts +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.test.ts @@ -188,6 +188,21 @@ suite('AgentFeedbackEditorWidget', () => { }, resolvedComment); }); + test('created feedback uses the standard primary button colors', () => { + const createdComment: ISessionEditorComment = { ...comment, state: AgentFeedbackState.Created }; + withWidget(({ domNode }) => { + const acceptButton = domNode.querySelector('.agent-feedback-widget-actions-bar .monaco-button:not(.secondary)')!; + + assert.deepStrictEqual({ + backgroundColor: acceptButton.style.backgroundColor, + color: acceptButton.style.color, + }, { + backgroundColor: 'var(--vscode-button-background)', + color: 'var(--vscode-button-foreground)', + }); + }, createdComment); + }); + function prReviewComment(sourcePullRequest: IFeedbackPullRequest): ISessionEditorComment { return { ...comment, diff --git a/src/vs/sessions/contrib/automations/browser/automationDialog.ts b/src/vs/sessions/contrib/automations/browser/automationDialog.ts index 18d8feeec04428..d03e3c041ebf32 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialog.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialog.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as DOM from '../../../../base/browser/dom.js'; +import { raceCancellationError, raceTimeout } from '../../../../base/common/async.js'; import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { IButton } from '../../../../base/browser/ui/button/button.js'; @@ -11,12 +12,12 @@ import { InputBox } from '../../../../base/browser/ui/inputbox/inputBox.js'; import { ISelectOptionItem, SelectBox } from '../../../../base/browser/ui/selectBox/selectBox.js'; import { Checkbox } from '../../../../base/browser/ui/toggle/toggle.js'; import { IAction } from '../../../../base/common/actions.js'; -import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, constObservable, derived, IObservable } from '../../../../base/common/observable.js'; +import { autorun, constObservable, derived, disposableObservableValue, IObservable, ISettableObservable, observableSignalFromEvent, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; @@ -34,7 +35,8 @@ import { IInstantiationService } from '../../../../platform/instantiation/common import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { IProductService } from '../../../../platform/product/common/productService.js'; +import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { Link } from '../../../../platform/opener/browser/link.js'; import { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { defaultCheckboxStyles, defaultInputBoxStyles, defaultSelectBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { hasNativeContextMenu } from '../../../../platform/window/common/window.js'; @@ -44,20 +46,28 @@ import { MobileSessionTypePicker } from '../../chat/browser/mobile/mobileSession import { isMobilePickerSheetTarget } from '../../../browser/parts/mobile/mobilePickerSheet.js'; import { ISession, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL } from '../../../services/sessions/common/session.js'; import { IGitRepository, IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; -import { AutomationInterval } from '../../../../workbench/contrib/chat/common/automations/automation.js'; +import { AutomationInterval, AutomationTarget } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { DAYS_OF_WEEK } from '../../../../workbench/contrib/chat/common/automations/schedule.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; -import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; -import { ChatAgentLocation, isChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; +import { ChatAgentLocation } from '../../../../workbench/contrib/chat/common/constants.js'; import { AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { IChatWidget, ISessionTypePickerDelegate } from '../../../../workbench/contrib/chat/browser/chat.js'; import { ChatInputPart, IChatInputPartOptions, IChatInputStyles } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPart.js'; -import { isModeConsideredBuiltIn } from '../../../../workbench/contrib/chat/browser/widget/input/modePickerActionItem.js'; +import { ChatInputPickerResponsiveLayout, IChatInputPickerResponsiveLayoutItem } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { AutomationIsolationModel, normalizeAutomationBranchNames } from '../common/isolationGroupModel.js'; -import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { IProviderSessionType, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { IAutomationSessionConfiguration } from '../../../services/sessions/common/sessionsProvider.js'; import { showMobileWorkspacePickerSheet, shouldUseMobileWorkspacePickerSheet } from '../../chat/browser/mobile/mobileWorkspacePickerSheet.js'; import { AutomationInputCompletions } from './automationInputCompletions.js'; +import { NewChatModelPickerService, INewChatModelPickerService } from '../../chat/browser/newChatModelPicker.js'; +import { createNewSessionConfigToolbar, createNewSessionControlToolbar } from '../../chat/browser/newSessionConfigToolbars.js'; +import { ISessionModelSelection, SessionModelSelection } from '../../chat/browser/sessionModelSelection.js'; +import { ISessionContext, SessionContext } from '../../../services/sessions/browser/sessionContext.js'; +import { VisibleSession } from '../../../services/sessions/browser/visibleSessions.js'; +import { setActiveSessionContextKeys } from '../../../services/sessions/common/sessionContextKeys.js'; +import { SessionUsesCombinedConfigPickerContext } from '../../../common/contextkeys.js'; +import { Menus } from '../../../browser/menus.js'; const $ = DOM.$; @@ -114,11 +124,11 @@ export function registerAutomationDialogKeyboardNavigation( let suppressPopupEscapeKeyUp = false; const visibleFocusableElements = (): readonly HTMLElement[] => getFocusableElements().filter(element => { - if (!element.isConnected || element.tabIndex < 0 || element.hasAttribute('disabled')) { + if (!element.isConnected || element.tabIndex < 0 || element.hasAttribute('disabled') || element.getAttribute('aria-disabled') === 'true') { return false; } for (let current: HTMLElement | null = element; current; current = current.parentElement) { - if (current.hidden || current.getAttribute('aria-hidden') === 'true') { + if (current.hidden || current.hasAttribute('inert') || current.getAttribute('aria-hidden') === 'true') { return false; } const style = targetWindow.getComputedStyle(current); @@ -205,101 +215,194 @@ export interface IValidationState { interface IRenderFormHandle { readonly getPrompt: () => string; - readonly getMode: () => string | undefined; - readonly getPermissionLevel: () => string | undefined; - readonly getModelId: () => string | undefined; + readonly getSessionConfiguration: (token: CancellationToken) => Promise; readonly getBranch: () => string | undefined; - readonly waitForAutomationSessionSync: () => Promise; + readonly waitForAutomationSessionSync: (token: CancellationToken) => Promise; + readonly setSaving: (saving: boolean) => void; + readonly showSessionConfigurationError: (message: string | undefined) => void; + readonly focusSessionConfigurationError: () => void; readonly getFocusableElements: () => readonly HTMLElement[]; readonly acceptPromptSuggestion: () => boolean; } export type AutomationSessionDraftTarget = - | { readonly kind: 'workspace'; readonly folderUri: URI; readonly providerId: string | undefined; readonly sessionTypeId: string } - | { readonly kind: 'quickChat'; readonly providerId: string; readonly sessionTypeId: string }; + | { readonly kind: 'workspace'; readonly folderUri: URI; readonly providerId: string | undefined; readonly sessionTypeId: string; readonly sessionConfiguration?: IAutomationSessionConfiguration } + | { readonly kind: 'quickChat'; readonly providerId: string; readonly sessionTypeId: string; readonly sessionConfiguration?: IAutomationSessionConfiguration }; type AutomationSessionDraftService = Pick< ISessionsManagementService, - 'automationSession' | 'createAutomationSession' | 'createAutomationQuickChat' | 'discardAutomationSession' + 'automationSession' | 'createAutomationSession' | 'createAutomationQuickChat' | 'discardAutomationSession' | 'getAutomationSessionConfiguration' | 'supportsAutomationSessionConfiguration' | 'isNewSessionTargetAvailable' | 'isQuickChatTargetAvailable' >; +export type AutomationSessionConfigurationCapture = + | { readonly kind: 'captured'; readonly configuration: IAutomationSessionConfiguration } + | { readonly kind: 'preserved'; readonly configuration: IAutomationSessionConfiguration | undefined } + | { readonly kind: 'failed'; readonly error: unknown }; + +const AUTOMATION_CONFIGURATION_CAPTURE_TIMEOUT_MS = 5_000; +const AUTOMATION_CONFIGURATION_RETARGET_CAPTURE_TIMEOUT_MS = 1_000; + export class AutomationSessionDraftSynchronizer extends Disposable { + readonly availability = observableValue<'idle' | 'pending' | 'available' | 'unavailable'>(this, 'idle'); + private readonly configurationsByTarget = new Map(); private requestedTarget: AutomationSessionDraftTarget | undefined; private appliedTarget: AutomationSessionDraftTarget | undefined; + private appliedConfiguration: IAutomationSessionConfiguration | undefined; private session: ISession | undefined; private generation = 0; private syncScheduled = false; + private syncInProgress = false; private syncPromise = Promise.resolve(); private disposed = false; + private synchronizationError: unknown | undefined; constructor( private readonly sessionsManagementService: AutomationSessionDraftService, private readonly canSelectWorkspace: (folderUri: URI, preferredProviderId: string | undefined) => Promise, private readonly onError: (error: unknown) => void, + private readonly configurationCaptureTimeoutMs = AUTOMATION_CONFIGURATION_CAPTURE_TIMEOUT_MS, + private readonly retargetConfigurationCaptureTimeoutMs = AUTOMATION_CONFIGURATION_RETARGET_CAPTURE_TIMEOUT_MS, ) { super(); } update(target: AutomationSessionDraftTarget | undefined): void { + if (this.targetsEqual(this.requestedTarget, target) + && (!target || this.syncScheduled || this.syncInProgress || !!this.session && this.sessionsManagementService.automationSession.get()?.sessionId === this.session.sessionId)) { + return; + } + if (target?.sessionConfiguration) { + const key = this.targetKey(target); + if (!this.configurationsByTarget.has(key)) { + this.configurationsByTarget.set(key, target.sessionConfiguration); + } + } this.requestedTarget = target; this.generation++; + this.synchronizationError = undefined; + this.availability.set(target ? 'pending' : 'idle', undefined); this.scheduleSync(); } - async waitForSync(): Promise { + async waitForSync(token: CancellationToken = CancellationToken.None): Promise { let pendingSync: Promise; do { pendingSync = this.syncPromise; - await pendingSync; + await raceCancellationError(pendingSync, token); } while (pendingSync !== this.syncPromise); } + async getSessionConfiguration(token: CancellationToken = CancellationToken.None): Promise { + const deadline = Date.now() + this.configurationCaptureTimeoutMs; + while (!this.disposed) { + const synchronized = await this.waitForResultBeforeDeadline(this.waitForSync(token), deadline, token); + if (!synchronized) { + return this.captureFailed(new Error(`Timed out after ${this.configurationCaptureTimeoutMs}ms while synchronizing the Automation session configuration.`)); + } + const generation = this.generation; + const session = this.session; + const target = this.requestedTarget; + if (!session || !target) { + if (this.synchronizationError) { + return { kind: 'failed', error: this.synchronizationError }; + } + return { kind: 'preserved', configuration: this.configurationForTarget(target) }; + } + const remaining = deadline - Date.now(); + if (remaining <= 0) { + return this.captureFailed(new Error(`Timed out after ${this.configurationCaptureTimeoutMs}ms while capturing the Automation session configuration.`)); + } + const captured = await this.captureSessionConfiguration(session, target, remaining, token); + if (generation !== this.generation || session !== this.session) { + continue; + } + return captured; + } + return { kind: 'preserved', configuration: this.configurationForTarget(this.requestedTarget) }; + } + private scheduleSync(): void { - if (this.syncScheduled) { + this.syncScheduled = true; + if (this.syncInProgress) { return; } - this.syncScheduled = true; - this.syncPromise = Promise.resolve().then(() => { - this.syncScheduled = false; - if (!this.disposed) { - return this.sync(this.generation); + this.syncInProgress = true; + this.syncPromise = (async () => { + try { + while (this.syncScheduled && !this.disposed) { + this.syncScheduled = false; + await this.sync(this.generation); + } + } finally { + this.syncInProgress = false; } - return undefined; - }); + })(); } private async sync(generation: number): Promise { const target = this.requestedTarget; if (!target) { this.discardSession(); + this.availability.set('idle', undefined); return; } if (this.matchesAppliedTarget(target)) { + this.availability.set('available', undefined); return; } try { if (target.kind === 'workspace' && !await this.canSelectWorkspace(target.folderUri, target.providerId)) { if (generation === this.generation) { this.discardSession(); + this.availability.set('unavailable', undefined); } return; } if (this.disposed || generation !== this.generation) { return; } + if (this.session && this.appliedTarget) { + await this.captureSessionConfiguration(this.session, this.appliedTarget, this.retargetConfigurationCaptureTimeoutMs); + if (this.disposed || generation !== this.generation) { + return; + } + } + const targetAvailable = target.kind === 'quickChat' + ? this.sessionsManagementService.isQuickChatTargetAvailable({ + providerId: target.providerId, + sessionTypeId: target.sessionTypeId, + }) + : this.sessionsManagementService.isNewSessionTargetAvailable(target.folderUri, { + providerId: target.providerId, + sessionTypeId: target.sessionTypeId, + }); + if (!targetAvailable) { + this.discardSession(); + this.availability.set('unavailable', undefined); + return; + } + const sessionConfiguration = this.configurationForTarget(target); this.session = target.kind === 'quickChat' ? this.sessionsManagementService.createAutomationQuickChat({ providerId: target.providerId, sessionTypeId: target.sessionTypeId, + sessionTemplate: sessionConfiguration?.sessionTemplate, + automationConfiguration: sessionConfiguration, }) : this.sessionsManagementService.createAutomationSession(target.folderUri, { providerId: target.providerId, sessionTypeId: target.sessionTypeId, + sessionTemplate: sessionConfiguration?.sessionTemplate, + automationConfiguration: sessionConfiguration, }); this.appliedTarget = target; + this.appliedConfiguration = sessionConfiguration; + this.availability.set(this.sessionsManagementService.supportsAutomationSessionConfiguration(this.session) ? 'available' : 'unavailable', undefined); } catch (error) { if (!this.disposed && generation === this.generation) { + this.synchronizationError = error; this.discardSession(); + this.availability.set('unavailable', undefined); this.onError(error); } } @@ -311,7 +414,8 @@ export class AutomationSessionDraftSynchronizer extends Disposable { || this.sessionsManagementService.automationSession.get()?.sessionId !== this.session.sessionId || this.appliedTarget.kind !== target.kind || this.appliedTarget.providerId !== target.providerId - || this.appliedTarget.sessionTypeId !== target.sessionTypeId) { + || this.appliedTarget.sessionTypeId !== target.sessionTypeId + || this.appliedConfiguration !== this.configurationForTarget(target)) { return false; } return target.kind === 'quickChat' @@ -324,6 +428,65 @@ export class AutomationSessionDraftSynchronizer extends Disposable { } this.session = undefined; this.appliedTarget = undefined; + this.appliedConfiguration = undefined; + } + + private async captureSessionConfiguration(session: ISession, target: AutomationSessionDraftTarget, timeoutMs: number, token: CancellationToken = CancellationToken.None): Promise { + try { + const result = await raceTimeout( + raceCancellationError(this.sessionsManagementService.getAutomationSessionConfiguration(session).then(configuration => ({ configuration })), token), + timeoutMs, + ); + if (!result) { + throw new Error(`Timed out after ${timeoutMs}ms while capturing Automation session configuration.`); + } + if (result.configuration === null) { + return { kind: 'preserved', configuration: this.configurationForTarget(target) }; + } + if (result.configuration === undefined) { + throw new Error('The Automation session draft was replaced before its configuration could be captured.'); + } + this.configurationsByTarget.set(this.targetKey(target), result.configuration); + return { kind: 'captured', configuration: result.configuration }; + } catch (error) { + if (token.isCancellationRequested) { + throw error; + } + return this.captureFailed(error); + } + } + + private captureFailed(error: unknown): AutomationSessionConfigurationCapture { + this.onError(error); + return { kind: 'failed', error }; + } + + private async waitForResultBeforeDeadline(promise: Promise, deadline: number, token: CancellationToken): Promise { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + return false; + } + return await raceTimeout(raceCancellationError(promise.then(() => true), token), remaining) ?? false; + } + + private configurationForTarget(target: AutomationSessionDraftTarget | undefined): IAutomationSessionConfiguration | undefined { + return target ? this.configurationsByTarget.get(this.targetKey(target)) ?? target.sessionConfiguration : undefined; + } + + private targetsEqual(first: AutomationSessionDraftTarget | undefined, second: AutomationSessionDraftTarget | undefined): boolean { + if (first === second) { + return true; + } + if (!first || !second || first.kind !== second.kind || first.providerId !== second.providerId || first.sessionTypeId !== second.sessionTypeId || first.sessionConfiguration !== second.sessionConfiguration) { + return false; + } + return first.kind === 'quickChat' || (second.kind === 'workspace' && isEqual(first.folderUri, second.folderUri)); + } + + private targetKey(target: AutomationSessionDraftTarget): string { + return target.kind === 'quickChat' + ? `quickChat:${target.providerId}:${target.sessionTypeId}` + : `workspace:${target.folderUri.toString()}:${target.providerId ?? ''}:${target.sessionTypeId}`; } override dispose(): void { @@ -334,29 +497,27 @@ export class AutomationSessionDraftSynchronizer extends Disposable { } } -export function resolveAutomationModelIdentifier( - languageModelsService: Pick, - identifier: string, - logicalSessionType: string | undefined, - modelTarget: string | undefined, -): string { - if (!logicalSessionType || !modelTarget) { - return identifier; - } - const sourceModel = languageModelsService.lookupLanguageModel(identifier); - if (sourceModel?.targetChatSessionType !== logicalSessionType) { - return identifier; - } - return languageModelsService.getLanguageModelIds().find(candidateIdentifier => { - const candidate = languageModelsService.lookupLanguageModel(candidateIdentifier); - return candidate?.targetChatSessionType === modelTarget && candidate.id === sourceModel.id; - }) ?? identifier; -} - const AUTOMATIONS_HARNESS_CHIP_ACTION_ID = 'workbench.action.chat.renderAutomationsHarnessChip'; const AUTOMATIONS_WORKSPACE_PICKER_ACTION_ID = 'workbench.action.chat.renderAutomationsWorkspacePicker'; const AUTOMATIONS_ISOLATION_GROUP_ACTION_ID = 'workbench.action.chat.renderAutomationsIsolationGroup'; +export function getAutomationTargetHint(target: Pick, sessionTypes: readonly IProviderSessionType[]): string | undefined { + if (!target.isQuickChat && !target.folderUri) { + return localize('automation.form.targetHint.choose', "Choose a workspace or No workspace to see which agents can run this automation."); + } + if (sessionTypes.length === 0) { + return localize('automation.form.targetHint.unavailable', "No agents are currently available for this target."); + } + if (sessionTypes.length === 1 + && sessionTypes[0].sessionType.id === target.sessionTypeId + && (!target.providerId || sessionTypes[0].providerId === target.providerId)) { + return target.isQuickChat + ? localize('automation.form.targetHint.singleQuickChat', "Only {0} is available without a workspace. Choose a workspace to use a different agent.", sessionTypes[0].sessionType.label) + : localize('automation.form.targetHint.singleWorkspace', "Only {0} is available for this workspace. Change the workspace to use a different agent.", sessionTypes[0].sessionType.label); + } + return undefined; +} + type BranchLoadState = 'noFolder' | 'loadingRepository' | 'noRepository' | 'loadingBranches' | 'ready' | 'empty' | 'error'; function setAutomationControlVisible(container: HTMLElement, visible: boolean): void { @@ -368,6 +529,29 @@ function setAutomationControlVisible(container: HTMLElement, visible: boolean): } } +function getAutomationSessionToolbarResponsiveItems(toolbar: MenuWorkbenchToolBar, compactModelPicker?: ISettableObservable): IChatInputPickerResponsiveLayoutItem[] { + const items: IChatInputPickerResponsiveLayoutItem[] = []; + for (let index = 0; index < toolbar.getItemsLength(); index++) { + const element = toolbar.getItemElement(index); + const action = toolbar.getItemAction(index); + if (!element || !action) { + continue; + } + items.push({ + element, + canShrink: true, + isCompact: () => element.classList.contains('compact-picker'), + setCompact: compact => { + element.classList.toggle('compact-picker', compact); + if (action.id === 'sessions.modelPicker') { + compactModelPicker?.set(compact, undefined); + } + }, + }); + } + return items; +} + export class AutomationIsolationGroupActionViewItem extends BaseActionViewItem { private readonly renderDisposables = this._register(new DisposableStore()); private readonly branchRepoDisposable = this._register(new MutableDisposable()); @@ -784,7 +968,7 @@ registerAction2(class OpenAutomationsWorkspacePickerAction extends Action2 { menu: [{ id: MenuId.ChatInputSecondary, group: 'navigation', - order: 0, + order: -2, when: ChatContextKeys.inAutomationsDialog, }], }); @@ -822,18 +1006,16 @@ export function renderForm( contextKeyService: IContextKeyService, contextViewService: IContextViewService, configurationService: IConfigurationService, - languageModelsService: ILanguageModelsService, layoutService: IWorkbenchLayoutService, logService: ILogService, - productService: IProductService, sessionsManagementService: ISessionsManagementService, workspaceTrustRequestService: IWorkspaceTrustRequestService, initialPrompt: string, - initialMode: string | undefined, - initialPermissionLevel: string | undefined, - initialModelId: string | undefined, + initialTarget: AutomationTarget | undefined, + initialSessionConfiguration: IAutomationSessionConfiguration | undefined, ): IRenderFormHandle { - const nameRow = DOM.append(form, $('.automation-form-row')); + const formContent = DOM.append(form, $('.automation-form-content')); + const nameRow = DOM.append(formContent, $('.automation-form-row')); DOM.append(nameRow, $('span.automation-form-label', undefined, localize('automation.form.name', "Name"))); const nameInputContainer = DOM.append(nameRow, $('.automation-form-input-host')); const nameInput = disposables.add(new InputBox(nameInputContainer, contextViewService, { @@ -847,7 +1029,7 @@ export function renderForm( revalidate(); })); - const scheduleRow = DOM.append(form, $('.automation-form-row.automation-form-schedule-row')); + const scheduleRow = DOM.append(formContent, $('.automation-form-row.automation-form-schedule-row')); const useCustomDrawn = !hasNativeContextMenu(configurationService); const intervalGroup = DOM.append(scheduleRow, $('.automation-form-schedule-group')); @@ -915,8 +1097,8 @@ export function renderForm( // The picker is authoritative for the session type const isolationModel = new AutomationIsolationModel(state); - const workspaceControlsVisible = derived(reader => !isolationModel.isQuickChatObs.read(reader)); - const sessionTypePicker = disposables.add(instantiationService.createInstance(MobileSessionTypePicker, constObservable(undefined), { persistSelection: false, telemetrySource: 'AutomationSessionTypePicker', showChevron: false })); + const workspaceControlsVisible = derived(reader => !isolationModel.isQuickChatObs.read(reader) && isolationModel.folderUriObs.read(reader) !== undefined); + const sessionTypePicker = disposables.add(instantiationService.createInstance(MobileSessionTypePicker, constObservable(undefined), { persistSelection: false, telemetrySource: 'AutomationSessionTypePicker' })); sessionTypePicker.setQuickChatSource(isolationModel.isQuickChatObs); sessionTypePicker.setFolderSource(isolationModel.folderUriObs, { initialPick: state.sessionTypeId @@ -951,6 +1133,7 @@ export function renderForm( const workspacePicker = disposables.add(instantiationService.createInstance(MobileAutomationsWorkspacePicker, { restoreFromSessions: false, + canRestoreWorkspace: () => false, canSelectWorkspace: (folderUri, preferredProviderId) => canSelectAutomationWorkspace(folderUri, preferredProviderId, sessionsManagementService, workspaceTrustRequestService), })); @@ -962,6 +1145,22 @@ export function renderForm( (folderUri, preferredProviderId) => canSelectAutomationWorkspace(folderUri, preferredProviderId, sessionsManagementService, workspaceTrustRequestService), error => logService.error('[AutomationDialog] Failed to synchronize the automation session draft.', error), )); + let resolvedInitialProviderId = initialTarget?.providerId; + let resolvedInitialSessionTypeId = initialTarget?.sessionTypeId; + const getInitialSessionConfiguration = (folderUri: URI | undefined, providerId: string | undefined, sessionTypeId: string, isQuickChat: boolean) => { + if (!initialTarget || !initialSessionConfiguration || initialTarget.kind !== (isQuickChat ? 'quickChat' : 'workspace')) { + return undefined; + } + if (initialTarget.kind === 'workspace' && (!folderUri || !isEqual(initialTarget.folderUri, folderUri))) { + return undefined; + } + resolvedInitialProviderId ??= providerId; + resolvedInitialSessionTypeId ??= sessionTypeId; + if (resolvedInitialProviderId !== providerId || resolvedInitialSessionTypeId !== sessionTypeId) { + return undefined; + } + return initialSessionConfiguration; + }; const updateAutomationSessionTarget = () => { const folderUri = isolationModel.folderUriObs.get(); const pick = sessionTypePicker.selectedPick; @@ -973,10 +1172,21 @@ export function renderForm( if (isQuickChat) { const providerId = pick.providerId; if (providerId) { - automationSessionDraftSynchronizer.update({ kind: 'quickChat', providerId, sessionTypeId: pick.sessionTypeId }); + automationSessionDraftSynchronizer.update({ + kind: 'quickChat', + providerId, + sessionTypeId: pick.sessionTypeId, + sessionConfiguration: getInitialSessionConfiguration(undefined, providerId, pick.sessionTypeId, true), + }); } } else if (folderUri) { - automationSessionDraftSynchronizer.update({ kind: 'workspace', folderUri, providerId: pick.providerId, sessionTypeId: pick.sessionTypeId }); + automationSessionDraftSynchronizer.update({ + kind: 'workspace', + folderUri, + providerId: pick.providerId, + sessionTypeId: pick.sessionTypeId, + sessionConfiguration: getInitialSessionConfiguration(folderUri, pick.providerId, pick.sessionTypeId, false), + }); } }; disposables.add(sessionTypePicker.onDidChangeSelectedPick(() => { @@ -997,21 +1207,43 @@ export function renderForm( } })); - if (!state.isQuickChat && !state.folderUri && workspacePicker.selectedFolderUri) { - isolationModel.setWorkspace(workspacePicker.selectedFolderUri); - } - disposables.add(autorun(reader => { isolationModel.isQuickChatObs.read(reader); updateAutomationSessionTarget(); revalidate(); })); - const promptRow = DOM.append(form, $('.automation-form-row')); + const promptSection = DOM.append(formContent, $('.automation-prompt-section')); + const promptRow = DOM.append(promptSection, $('.automation-form-row')); DOM.append(promptRow, $('span.automation-form-label', undefined, localize('automation.form.prompt', "Prompt"))); const promptHost = DOM.append(promptRow, $('.automation-form-prompt-host.interactive-session')); const editorOverflowWidgetsDomNode = layoutService.getContainer(DOM.getWindow(promptHost)).appendChild($('.chat-editor-overflow.automation-dialog-editor-overflow.monaco-editor')); disposables.add(toDisposable(() => editorOverflowWidgetsDomNode.remove())); + const activeAutomationSession = disposables.add(disposableObservableValue(form, undefined)); + disposables.add(autorun(reader => { + const session = sessionsManagementService.automationSession.read(reader); + activeAutomationSession.set(session ? new VisibleSession(session, session.mainChat.read(reader)) : undefined, undefined); + })); + const scopedContextKeyService = disposables.add(contextKeyService.createScoped(promptSection)); + ChatContextKeys.location.bindTo(scopedContextKeyService).set(ChatAgentLocation.Chat); + ChatContextKeys.inChatSession.bindTo(scopedContextKeyService).set(true); + ChatContextKeys.inAutomationsDialog.bindTo(scopedContextKeyService).set(true); + const newChatModelPickerService = new NewChatModelPickerService(); + const sessionModelSelection = disposables.add(instantiationService.createInstance(SessionModelSelection, activeAutomationSession, { modelConfiguration: true })); + const scopedInstantiationService = disposables.add(instantiationService.createChild(new ServiceCollection( + [IContextKeyService, scopedContextKeyService], + [ISessionContext, new SessionContext(activeAutomationSession)], + [INewChatModelPickerService, newChatModelPickerService], + [ISessionModelSelection, sessionModelSelection], + ))); + const usesCombinedConfigPicker = SessionUsesCombinedConfigPickerContext.bindTo(scopedContextKeyService); + const sessionTypesChanged = observableSignalFromEvent(form, sessionsManagementService.onDidChangeSessionTypes); + disposables.add(autorun(reader => { + sessionTypesChanged.read(reader); + const session = activeAutomationSession.read(reader); + setActiveSessionContextKeys(session, scopedContextKeyService, reader); + usesCombinedConfigPicker.set(!!session && sessionsManagementService.usesCombinedNewSessionConfigPicker(session)); + })); const chatInputStyles: IChatInputStyles = { overlayBackground: 'var(--vscode-input-background)', @@ -1026,12 +1258,12 @@ export function renderForm( renderInputToolbarBelowInput: false, renderWorkingSet: false, enableImplicitContext: false, - supportsChangingModes: true, - hideCustomChatModes: true, + supportsChangingModes: false, suppressModePreferredModel: true, suppressModelPersistence: true, menus: { executeToolbar: MenuId.AutomationsDialogInput, + inputToolbar: Menus.AutomationsDialogInputToolbar, telemetrySource: 'automations.dialog', }, widgetViewKindTag: 'automations-dialog', @@ -1116,105 +1348,115 @@ export function renderForm( unlockFromCodingAgent: () => { }, }; - // Bind context keys required by chat input toolbar `when` clauses. - const scopedContextKeyService = disposables.add(contextKeyService.createScoped(promptHost)); - ChatContextKeys.location.bindTo(scopedContextKeyService).set(ChatAgentLocation.Chat); - ChatContextKeys.inChatSession.bindTo(scopedContextKeyService).set(true); - ChatContextKeys.inAutomationsDialog.bindTo(scopedContextKeyService).set(true); - const scopedInstantiationService = disposables.add( - instantiationService.createChild(new ServiceCollection([IContextKeyService, scopedContextKeyService])) - ); - const chatInput = disposables.add( scopedInstantiationService.createInstance(ChatInputPart, ChatAgentLocation.Chat, chatInputOptions, chatInputStyles, false), ); chatInput.render(promptHost, initialPrompt, stubWidget as IChatWidget); chatInput.inputEditor.updateOptions({ placeholder: localize('automation.form.prompt.placeholder', "Describe what you want to automate") }); disposables.add(scopedInstantiationService.createInstance(AutomationInputCompletions, chatInput.inputEditor)); - - if (initialMode) { - const getUnfilteredInitialMode = () => { - const modes = chatInput.currentChatModesObs.get(); - return modes.findModeById(initialMode) ?? modes.findModeByName(initialMode); - }; - const isHiddenCustomInitialMode = () => { - const mode = getUnfilteredInitialMode(); - return !!mode && chatInputOptions.hideCustomChatModes && !isModeConsideredBuiltIn(mode, productService); - }; - - if (isHiddenCustomInitialMode()) { - logService.trace(`[AutomationDialog] Skipping hidden custom initial mode "${initialMode}". Falling back to the default mode.`); - } else { - chatInput.setChatMode(initialMode, /* storeSelection */ false); + const targetHint = DOM.append(promptSection, $('.automation-target-hint')); + const targetHintMessage = DOM.append(targetHint, $('span.automation-target-hint-message', { + role: 'status', + 'aria-atomic': 'true', + })); + const chooseWorkspaceContainer = DOM.append(targetHint, $('span.automation-target-hint-action')); + disposables.add(instantiationService.createInstance(Link, chooseWorkspaceContainer, { + label: localize('automation.form.chooseWorkspace', "Choose Workspace"), + href: '#', + }, { + opener: () => workspacePicker.showPicker(), + })); + const selectedSessionTypeChanged = observableSignalFromEvent(targetHint, sessionTypePicker.onDidChangeSelectedPick); + disposables.add(autorun(reader => { + sessionTypesChanged.read(reader); + selectedSessionTypeChanged.read(reader); + const isQuickChat = isolationModel.isQuickChatObs.read(reader); + const folderUri = isolationModel.folderUriObs.read(reader); + const sessionTypes = isQuickChat + ? sessionsManagementService.getQuickChatSessionTypes() + : folderUri ? sessionsManagementService.getSessionTypesForFolder(folderUri) : []; + const message = getAutomationTargetHint(state, sessionTypes); + setAutomationControlVisible(targetHint, message !== undefined); + if (targetHintMessage.textContent !== (message ?? '')) { + targetHintMessage.textContent = message ?? ''; } - // Retry on cold-start when extension-contributed modes arrive late. - if (chatInput.currentModeObs.get().id !== initialMode && !isHiddenCustomInitialMode()) { - const baseline = chatInput.currentModeObs.get().id; - const retry = disposables.add(new MutableDisposable()); - const tryApply = () => { - if (chatInput.currentModeObs.get().id !== baseline) { - retry.clear(); - return; - } - if (isHiddenCustomInitialMode()) { - logService.trace(`[AutomationDialog] Skipping hidden custom initial mode "${initialMode}" after modes updated. Falling back to the default mode.`); - retry.clear(); - return; - } - const modes = chatInput.currentChatModesObs.get(); - if (modes.findModeById(initialMode) || modes.findModeByName(initialMode)) { - chatInput.setChatMode(initialMode, /* storeSelection */ false); - if (chatInput.currentModeObs.get().id === initialMode) { - retry.clear(); - } - } - }; - retry.value = autorun(reader => { - const modes = chatInput.currentChatModesObs.read(reader); - reader.store.add(modes.onDidChange(tryApply)); - tryApply(); - }); + })); + const sessionConfigurationRow = DOM.append(promptSection, $('.automation-form-row')); + const sessionConfigurationLabel = DOM.append(sessionConfigurationRow, $('span.automation-form-label', { + id: 'automation-session-configuration-label', + }, localize('automation.form.sessionConfiguration', "Session configuration"))); + const sessionConfiguration = DOM.append(sessionConfigurationRow, $('.automation-session-configuration', { + role: 'group', + 'aria-labelledby': sessionConfigurationLabel.id, + })); + const sessionConfigContainer = DOM.append(sessionConfiguration, $('.automation-session-config.sessions-chat-config-toolbar')); + const compactModelPicker = observableValue(sessionConfigContainer, false); + const sessionConfigToolbar = disposables.add(createNewSessionConfigToolbar( + sessionConfigContainer, + scopedInstantiationService, + compactModelPicker, + localize('automation.form.sessionConfigurationOptions', "Session configuration options"), + )); + const sessionControlsContainer = DOM.append(sessionConfiguration, $('.automation-session-controls')); + const sessionControlsToolbar = disposables.add(createNewSessionControlToolbar( + sessionControlsContainer, + scopedInstantiationService, + localize('automation.form.sessionControls', "Session controls"), + )); + const sessionConfigLayout = disposables.add(new ChatInputPickerResponsiveLayout('AutomationDialog.sessionConfig', sessionConfigContainer, { + getItems: () => getAutomationSessionToolbarResponsiveItems(sessionConfigToolbar, compactModelPicker), + hasOverflow: () => sessionConfigToolbar.hasOverflow(), + relayout: () => sessionConfigToolbar.relayout(), + })); + sessionConfigLayout.layout(); + const sessionControlsLayout = disposables.add(new ChatInputPickerResponsiveLayout('AutomationDialog.sessionControls', sessionControlsContainer, { + getItems: () => getAutomationSessionToolbarResponsiveItems(sessionControlsToolbar), + hasOverflow: () => sessionControlsToolbar.hasOverflow(), + relayout: () => sessionControlsToolbar.relayout(), + })); + sessionControlsLayout.layout(); + const sessionConfigurationUnavailable = DOM.append(sessionConfiguration, $('span.automation-session-configuration-unavailable', { + role: 'status', + 'aria-atomic': 'true', + })); + const sessionConfigurationError = DOM.append(sessionConfiguration, $('span.automation-session-configuration-error', { + role: 'alert', + tabindex: '-1', + })); + DOM.hide(sessionConfigurationError); + disposables.add(autorun(reader => { + const hasTarget = isolationModel.isQuickChatObs.read(reader) || isolationModel.folderUriObs.read(reader) !== undefined; + setAutomationControlVisible(sessionConfigurationRow, hasTarget); + const availability = automationSessionDraftSynchronizer.availability.read(reader); + const pending = availability === 'pending'; + const controlsUnavailable = availability !== 'available'; + sessionConfiguration.classList.toggle('controls-unavailable', controlsUnavailable); + for (const container of [sessionConfigContainer, sessionControlsContainer]) { + container.toggleAttribute('inert', controlsUnavailable); + container.setAttribute('aria-hidden', String(controlsUnavailable)); + container.setAttribute('aria-busy', String(pending)); } - } - if (initialPermissionLevel && isChatPermissionLevel(initialPermissionLevel)) { - chatInput.setPermissionLevel(initialPermissionLevel); - } - // On edit, apply the saved model with late-arrival retry if needed. - chatInput.resetLanguageModelToDefault(); - - const resolveInitialModelId = () => initialModelId ? resolveAutomationModelIdentifier( - languageModelsService, - initialModelId, - state.sessionTypeId, - sessionTypePicker.modelTargetChatSessionType.get(), - ) : undefined; - const resolvedInitialModelId = resolveInitialModelId(); - if (resolvedInitialModelId && !chatInput.switchModelByIdentifier(resolvedInitialModelId, /* storeSelection */ false)) { - const baseline = chatInput.selectedLanguageModel.get()?.identifier; - const retry = disposables.add(new MutableDisposable()); - retry.value = Event.any( - languageModelsService.onDidChangeLanguageModels, - Event.fromObservableLight(sessionTypePicker.modelTargetChatSessionType), - )(() => { - if (chatInput.selectedLanguageModel.get()?.identifier !== baseline) { - retry.clear(); - return; - } - const modelIdentifier = resolveInitialModelId(); - if (modelIdentifier && chatInput.switchModelByIdentifier(modelIdentifier, /* storeSelection */ false)) { - retry.clear(); - } - }); - } + sessionConfigurationUnavailable.textContent = pending + ? localize('automation.form.sessionConfigurationLoading', "Loading session configuration…") + : availability === 'unavailable' + ? localize('automation.form.sessionConfigurationUnavailable', "Session configuration unavailable") + : ''; + })); disposables.add(chatInput.inputEditor.onDidChangeModelContent(() => { revalidate(); })); - chatInput.layout(580); + const layoutChatInput = () => { + const width = promptHost.getBoundingClientRect().width; + if (width > 0) { + chatInput.layout(width); + } + }; + layoutChatInput(); queueMicrotask(() => { if (!disposables.isDisposed) { - chatInput.layout(580); + layoutChatInput(); } }); @@ -1228,7 +1470,7 @@ export function renderForm( }, DOM.getWindow(promptHost))); disposables.add(resizeObserver.observe(promptHost)); - const enabledRow = DOM.append(form, $('.automation-form-row.automation-form-checkbox-row')); + const enabledRow = DOM.append(formContent, $('.automation-form-row.automation-form-checkbox-row')); const enabledLabelText = localize('automation.form.enabled', "Enabled (the scheduler runs this automation when due)"); const enabledCheckbox = disposables.add(new Checkbox(enabledLabelText, state.enabled, defaultCheckboxStyles)); DOM.append(enabledRow, enabledCheckbox.domNode); @@ -1245,17 +1487,42 @@ export function renderForm( disposables.add(DOM.addStandardDisposableListener(enabledLabel, 'click', () => { setEnabled(!enabledCheckbox.checked); })); + const saveStatus = DOM.append(form, $('span.automation-form-save-status', { + role: 'status', + 'aria-atomic': 'true', + })); + DOM.hide(saveStatus); return { getPrompt: () => chatInput.inputEditor.getValue(), - getMode: () => chatInput.currentModeObs.get().id, - getPermissionLevel: () => chatInput.currentPermissionLevelObs.get(), - getModelId: () => chatInput.selectedLanguageModel.get()?.identifier, + getSessionConfiguration: token => automationSessionDraftSynchronizer.getSessionConfiguration(token), getBranch: () => isolationModel.persistedBranch, - waitForAutomationSessionSync: () => { + waitForAutomationSessionSync: token => { updateAutomationSessionTarget(); - return automationSessionDraftSynchronizer.waitForSync(); + return automationSessionDraftSynchronizer.waitForSync(token); + }, + setSaving: saving => { + formContent.toggleAttribute('inert', saving); + formContent.setAttribute('aria-busy', String(saving)); + form.classList.toggle('saving', saving); + if (saving) { + DOM.show(saveStatus); + saveStatus.textContent = localize('automation.form.saving', "Saving automation…"); + } else { + DOM.hide(saveStatus); + saveStatus.textContent = ''; + } + }, + showSessionConfigurationError: message => { + if (message) { + DOM.show(sessionConfigurationError); + sessionConfigurationError.textContent = message; + } else { + DOM.hide(sessionConfigurationError); + sessionConfigurationError.textContent = ''; + } }, + focusSessionConfigurationError: () => sessionConfigurationError.focus(), getFocusableElements: () => { // eslint-disable-next-line no-restricted-syntax -- the dialog owns this form subtree and supplies its dynamic focus order. return Array.from(form.querySelectorAll('input, select, textarea, button, a[href], [tabindex]')); @@ -1391,7 +1658,7 @@ export class AutomationsWorkspacePicker extends WorkspacePicker { const noWorkspace = this.targetModel?.isQuickChat === true; const label = noWorkspace ? localize('automation.form.noWorkspace', "No workspace") - : workspace?.label ?? localize('pickWorkspace', "workspace"); + : workspace?.label ?? localize('automation.form.selectWorkspace', "Select workspace"); const icon = noWorkspace ? Codicon.commentDiscussion : workspace?.icon ?? Codicon.project; trigger.setAttribute('aria-label', workspace || noWorkspace diff --git a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts index 38e8f2c7f16ba0..bcd4947c6a8780 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts @@ -5,9 +5,13 @@ import './media/automationDialog.css'; import * as DOM from '../../../../base/browser/dom.js'; -import { IButton } from '../../../../base/browser/ui/button/button.js'; +import { ButtonBar, IButton } from '../../../../base/browser/ui/button/button.js'; import { Dialog } from '../../../../base/browser/ui/dialog/dialog.js'; -import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { DeferredPromise } from '../../../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { isCancellationError } from '../../../../base/common/errors.js'; +import { DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { isWindows } from '../../../../base/common/platform.js'; import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -15,18 +19,17 @@ import { IContextViewService } from '../../../../platform/contextview/browser/co import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { IProductService } from '../../../../platform/product/common/productService.js'; import { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; -import { defaultDialogStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { defaultButtonStyles, defaultDialogStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { createWorkbenchDialogOptions } from '../../../../workbench/browser/parts/dialogs/dialog.js'; import { AutomationTarget, IAutomationSchedule } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { IAutomationDialogResult, IAutomationDialogService, IShowAutomationDialogOptions } from '../../../../workbench/contrib/chat/common/automations/automationDialogService.js'; import { ICreateAutomationOptions, IUpdateAutomationOptions } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; -import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; import { IHostService } from '../../../../workbench/services/host/browser/host.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; -import { IFormState, IValidationState, isAutomationDialogPopupTarget, registerAutomationDialogKeyboardNavigation, renderForm, shouldPassThroughAutomationDialogCommand, updateSaveButtonState } from './automationDialog.js'; +import { IAutomationSessionConfiguration } from '../../../services/sessions/common/sessionsProvider.js'; +import { AutomationSessionConfigurationCapture, IFormState, IValidationState, isAutomationDialogPopupTarget, registerAutomationDialogKeyboardNavigation, renderForm, shouldPassThroughAutomationDialogCommand, updateSaveButtonState } from './automationDialog.js'; const $ = DOM.$; @@ -69,11 +72,9 @@ export class AutomationDialogService implements IAutomationDialogService { @IContextKeyService private readonly contextKeyService: IContextKeyService, @IContextViewService private readonly contextViewService: IContextViewService, @IConfigurationService private readonly configurationService: IConfigurationService, - @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @IKeybindingService private readonly keybindingService: IKeybindingService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @ILogService private readonly logService: ILogService, - @IProductService private readonly productService: IProductService, @IHostService private readonly hostService: IHostService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, @@ -87,6 +88,12 @@ export class AutomationDialogService implements IAutomationDialogService { const isEdit = !!existing; const initialTarget = initial?.target; const initialWorkspaceTarget = initialTarget?.kind === 'workspace' ? initialTarget : undefined; + const initialSessionConfiguration: IAutomationSessionConfiguration | undefined = initial ? { + sessionTemplate: initial.sessionTemplate, + modelId: initial.modelId, + mode: initial.mode, + permissionLevel: initial.permissionLevel, + } : undefined; const state: IFormState = { name: initial?.name ?? '', @@ -111,48 +118,175 @@ export class AutomationDialogService implements IAutomationDialogService { let cancelButton: IButton | undefined; let revalidate: () => void = () => { }; let getPrompt: () => string = () => initial?.prompt ?? ''; - let getMode: () => string | undefined = () => initial?.mode; - let getPermissionLevel: () => string | undefined = () => initial?.permissionLevel; - let getModelId: () => string | undefined = () => initial?.modelId; + let getSessionConfiguration: (token: CancellationToken) => Promise = async () => ({ kind: 'preserved', configuration: initialSessionConfiguration }); let getBranch: () => string | undefined = () => initialWorkspaceTarget?.isolation.kind === 'worktree' ? initialWorkspaceTarget.isolation.branch : undefined; - let waitForAutomationSessionSync: () => Promise = async () => { }; + let waitForAutomationSessionSync: (token: CancellationToken) => Promise = async () => { }; + let setSaving: (saving: boolean) => void = () => { }; + let showSessionConfigurationError: (message: string | undefined) => void = () => { }; + let focusSessionConfigurationError: () => void = () => { }; let getFocusableElements: () => readonly HTMLElement[] = () => []; let focusFirst: () => void = () => { }; + let saveInProgress = false; + const saveCancellation = disposables.add(new MutableDisposable()); + const completion = new DeferredPromise(); const title = isEdit ? localize('automation.dialog.editTitle', "Edit automation") : localize('automation.dialog.createTitle', "New automation"); - const buttonLabels = [ - isEdit ? localize('automation.dialog.save', "Save") : localize('automation.dialog.create', "Create"), - localize('automation.dialog.cancel', "Cancel"), - ]; + const saveButtonLabel = isEdit ? localize('automation.dialog.save', "Save") : localize('automation.dialog.create', "Create"); + const cancelButtonLabel = localize('automation.dialog.cancel', "Cancel"); + const savingButtonLabel = localize('automation.dialog.saving', "Saving…"); + const captureErrorMessage = localize('automation.dialog.captureError', "The automation wasn't saved because its session configuration couldn't be captured. Check the provider connection and try again."); + + const buildResult = (sessionConfigurationCapture: Exclude): IAutomationDialogResult | undefined => { + const schedule: IAutomationSchedule = { + interval: state.interval, + scheduleHour: state.hour, + scheduleMinute: state.minute, + scheduleDay: state.day, + }; + const prompt = getPrompt(); + const sessionConfiguration = sessionConfigurationCapture.configuration; + const sessionTemplate = sessionConfiguration?.sessionTemplate; + const target = createAutomationTarget(state, getBranch()); + if (!target) { + return undefined; + } + if (existing) { + const patch: IUpdateAutomationOptions = { + name: state.name, + prompt, + schedule, + target, + ...(sessionConfigurationCapture.kind === 'captured' ? { + sessionTemplate: sessionTemplate ?? null, + } : {}), + enabled: state.enabled, + }; + return { kind: 'update', id: existing.id, value: patch }; + } + const create: ICreateAutomationOptions = { + name: state.name, + prompt, + schedule, + target, + ...(sessionTemplate + ? { sessionTemplate } + : sessionConfiguration ? { + ...(sessionConfiguration.modelId !== undefined ? { modelId: sessionConfiguration.modelId } : {}), + ...(sessionConfiguration.mode !== undefined ? { mode: sessionConfiguration.mode } : {}), + ...(sessionConfiguration.permissionLevel !== undefined ? { permissionLevel: sessionConfiguration.permissionLevel } : {}), + } : {}), + enabled: state.enabled, + }; + return { kind: 'create', value: create }; + }; + + const closeDialog = (result: IAutomationDialogResult | undefined) => { + if (completion.isSettled) { + return; + } + saveCancellation.value?.cancel(); + void completion.complete(result); + dialog.dispose(); + }; + + const save = async () => { + if (saveInProgress) { + return; + } + revalidate(); + if (validation.nameError || validation.promptError || validation.folderError || validation.sessionTypeError || validation.branchError) { + return; + } + if ((!state.isQuickChat && !state.folderUri) || !state.sessionTypeId || (state.isQuickChat && !state.providerId)) { + return; + } + + saveInProgress = true; + showSessionConfigurationError(undefined); + setSaving(true); + if (saveButton) { + saveButton.enabled = false; + saveButton.label = savingButtonLabel; + } + cancelButton?.focus(); + const cancellation = new CancellationTokenSource(); + saveCancellation.value = cancellation; + let shouldClose = false; + let shouldFocusError = false; + try { + await waitForAutomationSessionSync(cancellation.token); + const sessionConfigurationCapture = await getSessionConfiguration(cancellation.token); + if (sessionConfigurationCapture.kind === 'failed') { + showSessionConfigurationError(captureErrorMessage); + shouldFocusError = true; + return; + } + const result = buildResult(sessionConfigurationCapture); + if (result) { + shouldClose = true; + closeDialog(result); + } + } catch (error) { + if (!isCancellationError(error) && !cancellation.token.isCancellationRequested) { + this.logService.error('[AutomationDialog] Failed to save the automation session configuration.', error); + showSessionConfigurationError(captureErrorMessage); + shouldFocusError = true; + } + } finally { + if (saveCancellation.value === cancellation) { + saveCancellation.clear(); + } + saveInProgress = false; + if (!shouldClose && !completion.isSettled) { + setSaving(false); + if (saveButton) { + saveButton.label = saveButtonLabel; + } + revalidate(); + if (shouldFocusError) { + focusSessionConfigurationError(); + } + } + } + }; const activeContainer = this.layoutService.activeContainer; const dialog = disposables.add(new Dialog( activeContainer, title, - buttonLabels, + [], createWorkbenchDialogOptions({ type: 'none', extraClasses: ['automation-dialog'], - cancelId: 1, + disableDefaultAction: true, isExternalFocusAllowed: isAutomationDialogPopupTarget, // textLinkForeground stamps inline styles onto chat input picker chips. dialogStyles: { ...defaultDialogStyles, textLinkForeground: undefined }, - buttonOptions: [ - { - styleButton: button => { - saveButton = button; - revalidate(); - }, - }, - { - styleButton: button => { - cancelButton = button; - }, - }, - ], + renderFooter: container => { + container.classList.add('dialog-buttons', 'automation-dialog-footer-actions'); + container.parentElement?.classList.add('dialog-buttons-row', 'automation-dialog-footer-row'); + const buttonBar = disposables.add(new ButtonBar(container)); + const createSaveButton = () => { + saveButton = buttonBar.addButton(defaultButtonStyles); + saveButton.label = saveButtonLabel; + disposables.add(saveButton.onDidClick(() => void save())); + }; + const createCancelButton = () => { + cancelButton = buttonBar.addButton({ ...defaultButtonStyles, secondary: true }); + cancelButton.label = cancelButtonLabel; + disposables.add(cancelButton.onDidClick(() => closeDialog(undefined))); + }; + if (isWindows) { + createSaveButton(); + createCancelButton(); + } else { + createCancelButton(); + createSaveButton(); + } + }, renderBody: container => { container.classList.add('automation-dialog-body'); @@ -167,13 +301,14 @@ export class AutomationDialogService implements IAutomationDialogService { const formPane = DOM.append(container, $('.automation-form-pane')); const form = DOM.append(formPane, $('.automation-form')); - const handle = renderForm(form, state, disposables, validation, () => revalidate(), this.instantiationService, this.contextKeyService, this.contextViewService, this.configurationService, this.languageModelsService, this.layoutService, this.logService, this.productService, this.sessionsManagementService, this.workspaceTrustRequestService, initial?.prompt ?? '', initial?.mode, initial?.permissionLevel, initial?.modelId); + const handle = renderForm(form, state, disposables, validation, () => revalidate(), this.instantiationService, this.contextKeyService, this.contextViewService, this.configurationService, this.layoutService, this.logService, this.sessionsManagementService, this.workspaceTrustRequestService, initial?.prompt ?? '', initialTarget, initialSessionConfiguration); getPrompt = handle.getPrompt; - getMode = handle.getMode; - getPermissionLevel = handle.getPermissionLevel; - getModelId = handle.getModelId; + getSessionConfiguration = handle.getSessionConfiguration; getBranch = handle.getBranch; waitForAutomationSessionSync = handle.waitForAutomationSessionSync; + setSaving = handle.setSaving; + showSessionConfigurationError = handle.showSessionConfigurationError; + focusSessionConfigurationError = handle.focusSessionConfigurationError; getFocusableElements = handle.getFocusableElements; const keyboardNavigation = disposables.add(registerAutomationDialogKeyboardNavigation( DOM.getWindow(container), @@ -186,7 +321,12 @@ export class AutomationDialogService implements IAutomationDialogService { handle.acceptPromptSuggestion, )); focusFirst = keyboardNavigation.focusFirst; - revalidate = () => updateSaveButtonState(saveButton, state, validation, form, getPrompt, getBranch); + revalidate = () => { + updateSaveButtonState(saveButton, state, validation, form, getPrompt, getBranch); + if (saveInProgress && saveButton) { + saveButton.enabled = false; + } + }; revalidate(); }, }, this.keybindingService, this.layoutService, this.hostService, automationDialogAllowableCommands, @@ -197,64 +337,9 @@ export class AutomationDialogService implements IAutomationDialogService { disposables.add(toDisposable(() => activeContainer.classList.remove('automation-dialog-open'))); try { - const resultPromise = dialog.show(); + void dialog.show().then(() => closeDialog(undefined)); focusFirst(); - const result = await resultPromise; - if (result.button !== 0) { - return undefined; - } - // Guard against submit-with-Enter bypassing live validation. - revalidate(); - if (validation.nameError || validation.promptError || validation.folderError || validation.sessionTypeError || validation.branchError) { - return undefined; - } - if ((!state.isQuickChat && !state.folderUri) || !state.sessionTypeId || (state.isQuickChat && !state.providerId)) { - return undefined; - } - await waitForAutomationSessionSync(); - - const schedule: IAutomationSchedule = { - interval: state.interval, - scheduleHour: state.hour, - scheduleMinute: state.minute, - scheduleDay: state.day, - }; - - const prompt = getPrompt(); - const mode = getMode(); - const permissionLevel = getPermissionLevel(); - const modelId = getModelId(); - const branch = getBranch(); - const target = createAutomationTarget(state, branch); - if (!target) { - return undefined; - } - - if (existing) { - const patch: IUpdateAutomationOptions = { - name: state.name, - prompt, - schedule, - target, - modelId: modelId ?? null, - mode: mode ?? null, - permissionLevel: permissionLevel ?? null, - enabled: state.enabled, - }; - return { kind: 'update', id: existing.id, value: patch }; - } - - const create: ICreateAutomationOptions = { - name: state.name, - prompt, - schedule, - target, - modelId, - mode, - permissionLevel, - enabled: state.enabled, - }; - return { kind: 'create', value: create }; + return await completion.p; } finally { disposables.dispose(); } diff --git a/src/vs/sessions/contrib/automations/browser/automationModelConfiguration.ts b/src/vs/sessions/contrib/automations/browser/automationModelConfiguration.ts new file mode 100644 index 00000000000000..93543957b4fd3c --- /dev/null +++ b/src/vs/sessions/contrib/automations/browser/automationModelConfiguration.ts @@ -0,0 +1,97 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IAction } from '../../../../base/common/actions.js'; +import { IStringDictionary } from '../../../../base/common/collections.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { deepClone } from '../../../../base/common/objects.js'; +import { extractSchemaDefaults, filterConfigurationToSchema, resolveModelConfiguration } from '../../../../workbench/contrib/chat/browser/widget/input/chatModelConfigurationLogic.js'; +import { assertAutomationSessionTemplate, IAutomationSessionTemplate, isAutomationModelConfiguration } from '../../../../workbench/contrib/chat/common/automations/automation.js'; +import { createModelConfigurationActions, ILanguageModelsService, type IModelConfigurationAccess } from '../../../../workbench/contrib/chat/common/languageModels.js'; + +/** Model preferences for one Automation draft, retaining unavailable values independently of effective configuration. */ +export class AutomationModelConfiguration extends Disposable implements IModelConfigurationAccess { + private readonly preferences = new Map>(); + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + + constructor( + private readonly languageModelsService: ILanguageModelsService, + template?: IAutomationSessionTemplate, + ) { + assertAutomationSessionTemplate(template); + super(); + if (template?.modelId && template.modelConfiguration !== undefined) { + this.preferences.set(template.modelId, deepClone(template.modelConfiguration)); + } + } + + getModelConfiguration(modelId: string): IStringDictionary | undefined { + const metadata = this.languageModelsService.lookupLanguageModel(modelId); + const defaults = extractSchemaDefaults(metadata?.configurationSchema); + let preferences = this.preferences.get(modelId); + if (!preferences) { + const globalConfiguration = this.languageModelsService.getModelConfiguration(modelId); + preferences = resolveModelConfiguration(undefined, defaults, globalConfiguration); + if (metadata?.configurationSchema || Object.keys(preferences).length > 0) { + this.preferences.set(modelId, deepClone(preferences)); + } + } + const effective = metadata + ? resolveModelConfiguration(filterConfigurationToSchema(preferences, metadata.configurationSchema), defaults, undefined) + : { ...preferences }; + return Object.keys(effective).length > 0 ? effective : undefined; + } + + async setModelConfiguration(modelId: string, values: IStringDictionary): Promise { + if (!isAutomationModelConfiguration(values)) { + throw new Error('Automation model configuration must contain only JSON primitive values.'); + } + this.getModelConfiguration(modelId); + this.preferences.set(modelId, deepClone({ ...this.preferences.get(modelId), ...values })); + this._onDidChange.fire(modelId); + // Only an explicit picker change updates the defaults shared with ordinary New Session. + await this.languageModelsService.setModelConfiguration(modelId, values); + } + + getModelConfigurationActions(modelId: string): IAction[] { + return createModelConfigurationActions( + this.languageModelsService.lookupLanguageModel(modelId)?.configurationSchema, + this.getModelConfiguration(modelId) ?? {}, + (key, value) => this.setModelConfiguration(modelId, { [key]: value }), + ); + } + + captureModelConfiguration(modelId: string | undefined): IAutomationSessionTemplate['modelConfiguration'] { + if (!modelId) { + return undefined; + } + this.getModelConfiguration(modelId); + const preferences = this.preferences.get(modelId); + if (preferences !== undefined && !isAutomationModelConfiguration(preferences)) { + throw new Error('Automation model configuration must contain only JSON primitive values.'); + } + return preferences === undefined ? undefined : deepClone(preferences); + } + + getModelConfigurationForRequest(modelId: string | undefined): IAutomationSessionTemplate['modelConfiguration'] { + const effective = modelId && this.preferences.has(modelId) + ? this.getModelConfiguration(modelId) ?? {} + : undefined; + if (effective !== undefined && !isAutomationModelConfiguration(effective)) { + throw new Error('Automation model configuration must contain only JSON primitive values.'); + } + return effective; + } + + /** Carries preferences when a provider resolves a native model identifier to its editor-qualified identity. */ + rebindModelConfiguration(previousModelId: string, modelId: string): void { + const preferences = this.preferences.get(previousModelId); + if (preferences && !this.preferences.has(modelId)) { + this.preferences.set(modelId, deepClone(preferences)); + } + } +} diff --git a/src/vs/sessions/contrib/automations/browser/automationRunner.ts b/src/vs/sessions/contrib/automations/browser/automationRunner.ts index 883e428c911d92..111ce80c0a7cfd 100644 --- a/src/vs/sessions/contrib/automations/browser/automationRunner.ts +++ b/src/vs/sessions/contrib/automations/browser/automationRunner.ts @@ -16,6 +16,7 @@ import { IAutomationService } from '../../../../workbench/contrib/chat/common/au import { publishAutomationRun, publishAutomationRunError } from '../../../../workbench/contrib/chat/common/automations/automationTelemetry.js'; import { ISession, SessionStatus } from '../../../services/sessions/common/session.js'; import { ICreateNewSessionOptions, ISendRequestOptions, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { IAutomationSessionConfiguration } from '../../../services/sessions/common/sessionsProvider.js'; /** Sessions-layer runner. Never throws; failures are recorded on the run row. */ export class AutomationRunner implements IAutomationRunner { @@ -82,14 +83,27 @@ export class AutomationRunner implements IAutomationRunner { ? target.isolation.kind === 'folder' ? 'workspace' : target.isolation.kind === 'worktree' ? 'worktree' : undefined : undefined; const branch = target.kind === 'workspace' && target.isolation.kind === 'worktree' ? target.isolation.branch : undefined; + const automationConfiguration: IAutomationSessionConfiguration | undefined = automation.sessionTemplate + ? { sessionTemplate: automation.sessionTemplate } + : automation.modelId !== undefined || automation.mode !== undefined || automation.permissionLevel !== undefined + ? { + modelId: automation.modelId, + mode: automation.mode, + permissionLevel: automation.permissionLevel, + } + : undefined; - const createOptions: ICreateNewSessionOptions | undefined = target.providerId !== undefined || target.sessionTypeId !== undefined || automation.modelId !== undefined || automation.mode !== undefined || automation.permissionLevel !== undefined || isolationMode !== undefined || branch !== undefined + const createOptions: ICreateNewSessionOptions | undefined = target.providerId !== undefined || target.sessionTypeId !== undefined || automationConfiguration !== undefined || isolationMode !== undefined || branch !== undefined ? { providerId: target.providerId, sessionTypeId: target.sessionTypeId, - modelId: automation.modelId, - modeId: automation.mode, - permissionLevel: automation.permissionLevel, + ...(automationConfiguration ? { + sessionTemplate: automation.sessionTemplate, + automationConfiguration, + } : {}), + ...((automation.sessionTemplate?.modelId ?? automation.modelId) ? { modelId: automation.sessionTemplate?.modelId ?? automation.modelId } : {}), + ...(!automation.sessionTemplate && automation.mode ? { modeId: automation.mode } : {}), + ...(!automation.sessionTemplate && automation.permissionLevel ? { permissionLevel: automation.permissionLevel } : {}), isolationMode, branch, } @@ -162,7 +176,7 @@ export class AutomationRunner implements IAutomationRunner { title: automation.name?.substring(0, 100), }; - this.logService.trace(`[AutomationRunner] running ${automation.id}: target=${target.kind}, provider=${createOptions?.providerId ?? '(default)'}, sessionType=${createOptions?.sessionTypeId ?? '(default)'}, model=${createOptions?.modelId ?? '(default)'}, mode=${createOptions?.modeId ?? '(default)'}, permissionLevel=${createOptions?.permissionLevel ?? '(default)'}`); + this.logService.trace(`[AutomationRunner] running ${automation.id}: target=${target.kind}, provider=${createOptions?.providerId ?? '(default)'}, sessionType=${createOptions?.sessionTypeId ?? '(default)'}, model=${automationConfiguration?.modelId ?? '(default)'}, mode=${automationConfiguration?.mode ?? '(default)'}, permissionLevel=${automationConfiguration?.permissionLevel ?? '(default)'}`); this.logService.info(`[AutomationRunner] creating a session for run ${runId} (automation ${automation.id}).`); let session: ISession | undefined; diff --git a/src/vs/sessions/contrib/automations/browser/automationService.ts b/src/vs/sessions/contrib/automations/browser/automationService.ts index e0242910477256..5530a938905e82 100644 --- a/src/vs/sessions/contrib/automations/browser/automationService.ts +++ b/src/vs/sessions/contrib/automations/browser/automationService.ts @@ -15,11 +15,16 @@ import { AutomationRunTrigger, AutomationTarget, AutomationWorkspaceIsolation, + assertAutomationSessionTemplate, IAutomationDescriptor, IAutomationRun, + IAutomationSessionTemplate, + isAutomationModelConfiguration, } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { + AutomationCatalogueState, type AutomationMutationGuard, + assertAutomationSessionTemplateAuthority, IAutomationRunClaim, IAutomationService, ICreateAutomationOptions, @@ -34,8 +39,9 @@ import { computeNextRunAt } from '../../../../workbench/contrib/chat/common/auto import { ChatPermissionLevel, isChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; import { AUTOMATION_STORAGE_KEY, IAutomationStorageService } from '../common/automationStorageService.js'; -const LEGACY_SCHEMA_VERSIONS = new Set([1, 2]); -const CURRENT_SCHEMA_VERSION = 3; +const LEGACY_TARGET_SCHEMA_VERSIONS = new Set([1, 2]); +const CURRENT_TARGET_SCHEMA_VERSIONS = new Set([3, 4]); +const CURRENT_SCHEMA_VERSION = 4; const MAX_RUNS_PER_AUTOMATION = 50; @@ -44,6 +50,7 @@ interface ISerializedAutomationBase { readonly name: string; readonly prompt: string; readonly schedule: IAutomationDescriptor['schedule']; + readonly sessionTemplate?: IAutomationSessionTemplate; readonly modelId?: string; readonly mode?: string; readonly permissionLevel?: string; @@ -82,7 +89,7 @@ interface ILegacySerializedAutomation extends ISerializedAutomationBase { } interface ISerializedLedger { - readonly schemaVersion: 3; + readonly schemaVersion: 4; // Optimistic-concurrency counter. 0 for legacy blobs without this field. readonly revision?: number; readonly automations: readonly ISerializedAutomation[]; @@ -116,14 +123,15 @@ export class AutomationStore extends Disposable implements IAutomationStore { private readonly _automations: ISettableObservable; private readonly _runs: ISettableObservable; + private readonly _catalogueState: ISettableObservable; private _now: () => Date; private readonly _runsForCache = new Map>(); private _lastSeenRevision = 0; - private _canCompleteMigration = true; readonly automations: IObservable; readonly runs: IObservable; + readonly catalogueState: IObservable; constructor( private readonly storageKey: string, @@ -138,14 +146,15 @@ export class AutomationStore extends Disposable implements IAutomationStore { const result = this.readLedger(this.storageService.get(this.storageKey, StorageScope.APPLICATION)); const initial = result.kind === 'unsupportedSchema' ? EMPTY_LEDGER : result.ledger; - this._canCompleteMigration = result.kind === 'ledger'; if (result.kind !== 'unsupportedSchema') { this._lastSeenRevision = result.revision; } this._automations = observableValue(this, initial.automations); this._runs = observableValue(this, initial.runs); + this._catalogueState = observableValue(this, result.kind === 'ledger' ? 'ready' : 'error'); this.automations = this._automations; this.runs = this._runs; + this.catalogueState = this._catalogueState; this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION, this.storageKey, this._store)(() => { this.refreshFromStorage(); @@ -162,7 +171,7 @@ export class AutomationStore extends Disposable implements IAutomationStore { } canCompleteMigration(): boolean { - return this._canCompleteMigration; + return this._catalogueState.get() === 'ready'; } runsFor(automationId: string): IObservable { @@ -184,9 +193,13 @@ export class AutomationStore extends Disposable implements IAutomationStore { prompt: options.prompt, schedule: options.schedule, target: normalizeAutomationTarget(options.target), - modelId: options.modelId, - mode: options.mode, - permissionLevel: isChatPermissionLevel(options.permissionLevel) ? options.permissionLevel : undefined, + ...(options.sessionTemplate + ? { sessionTemplate: options.sessionTemplate } + : { + modelId: options.modelId, + mode: options.mode, + permissionLevel: isChatPermissionLevel(options.permissionLevel) ? options.permissionLevel : undefined, + }), enabled: options.enabled ?? true, createdAt: nowIso, updatedAt: nowIso, @@ -465,9 +478,11 @@ export class AutomationStore extends Disposable implements IAutomationStore { while (true) { const readResult = this.readLedger(raw); if (readResult.kind === 'unsupportedSchema') { + this._catalogueState.set('error', undefined); throw new Error('Cannot modify automations: storage was written by a newer version'); } if (readResult.kind === 'invalid') { + this._catalogueState.set('error', undefined); throw new Error('Cannot modify automations: persisted storage contains data this version cannot safely interpret'); } @@ -502,30 +517,33 @@ export class AutomationStore extends Disposable implements IAutomationStore { } } - private acceptLedger(ledger: ILedger, revision: number): void { + private acceptLedger(ledger: ILedger, revision: number, catalogueState: AutomationCatalogueState = 'ready'): void { if (revision < this._lastSeenRevision) { + if (catalogueState === 'error') { + this._catalogueState.set(catalogueState, undefined); + } return; } - this.setLedger(ledger, revision); + this.setLedger(ledger, revision, catalogueState); } - private setLedger(ledger: ILedger, revision: number): void { + private setLedger(ledger: ILedger, revision: number, catalogueState: AutomationCatalogueState = 'ready'): void { this._lastSeenRevision = revision; transaction(tx => { this._automations.set(ledger.automations, tx); this._runs.set(ledger.runs, tx); + this._catalogueState.set(catalogueState, tx); }); } private refreshFromStorage(): void { const result = this.readLedger(this.storageService.get(this.storageKey, StorageScope.APPLICATION)); if (result.kind === 'unsupportedSchema') { - this._canCompleteMigration = false; + this._catalogueState.set('error', undefined); return; } - this._canCompleteMigration = result.kind === 'ledger'; - this.acceptLedger(result.ledger, result.revision); + this.acceptLedger(result.ledger, result.revision, result.kind === 'ledger' ? 'ready' : 'error'); } private readLedger(raw: string | undefined): ReadLedgerResult { @@ -533,19 +551,19 @@ export class AutomationStore extends Disposable implements IAutomationStore { return { kind: 'ledger', ledger: EMPTY_LEDGER, revision: 0 }; } try { - const parsed = JSON.parse(raw) as ISerializedLedger | ILegacySerializedLedger; + const parsed = JSON.parse(raw) as ISerializedLedger | (Omit & { readonly schemaVersion: 3 }) | ILegacySerializedLedger; if (typeof parsed?.schemaVersion === 'number' && parsed.schemaVersion > CURRENT_SCHEMA_VERSION) { this.logService.warn(`[AutomationService] Ledger has schema v${parsed.schemaVersion}; this build only supports v${CURRENT_SCHEMA_VERSION}. Entering read-only mode.`); return { kind: 'unsupportedSchema' }; } - if (parsed?.schemaVersion !== CURRENT_SCHEMA_VERSION && !LEGACY_SCHEMA_VERSIONS.has(parsed?.schemaVersion)) { + if (!CURRENT_TARGET_SCHEMA_VERSIONS.has(parsed?.schemaVersion) && !LEGACY_TARGET_SCHEMA_VERSIONS.has(parsed?.schemaVersion)) { this.logService.warn(`[AutomationService] Unsupported ledger schema version ${parsed?.schemaVersion}; ignoring.`); return { kind: 'invalid', ledger: EMPTY_LEDGER, revision: 0 }; } const automations: IAutomationDescriptor[] = []; // Malformed rows are dropped individually; only structurally invalid ledgers remain read-only. const invalid = !Array.isArray(parsed.automations) || !Array.isArray(parsed.runs); - if (parsed.schemaVersion === CURRENT_SCHEMA_VERSION) { + if (CURRENT_TARGET_SCHEMA_VERSIONS.has(parsed.schemaVersion)) { const entries = Array.isArray(parsed.automations) ? parsed.automations : []; for (const entry of entries) { try { @@ -611,12 +629,14 @@ export class AutomationService extends AutomationStore implements IAutomationSer } function serializeAutomation(a: IAutomationDescriptor): ISerializedAutomation { + assertAutomationSessionTemplate(a.sessionTemplate); return { id: a.id, name: a.name, prompt: a.prompt, schedule: a.schedule, target: serializeAutomationTarget(a.target), + sessionTemplate: a.sessionTemplate, modelId: a.modelId, mode: a.mode, permissionLevel: a.permissionLevel, @@ -679,10 +699,11 @@ function deserializeLegacyAutomation(s: ILegacySerializedAutomation): IAutomatio } function createAutomationFromSerialized(s: ISerializedAutomationBase, target: AutomationTarget): IAutomationDescriptor { + const sessionTemplate = deserializeAutomationSessionTemplate(s.sessionTemplate); // Default to most restrictive if the persisted value is invalid. - const permissionLevel = isChatPermissionLevel(s.permissionLevel) + const permissionLevel = !sessionTemplate && isChatPermissionLevel(s.permissionLevel) ? s.permissionLevel - : ChatPermissionLevel.Default; + : sessionTemplate ? undefined : ChatPermissionLevel.Default; return Object.freeze({ id: s.id, @@ -690,8 +711,9 @@ function createAutomationFromSerialized(s: ISerializedAutomationBase, target: Au prompt: s.prompt, schedule: s.schedule, target, - modelId: s.modelId, - mode: s.mode, + ...(sessionTemplate ? { sessionTemplate } : {}), + modelId: sessionTemplate ? undefined : s.modelId, + mode: sessionTemplate ? undefined : s.mode, permissionLevel, enabled: s.enabled, createdAt: s.createdAt, @@ -715,15 +737,37 @@ function updateAutomation(current: IAutomationDescriptor, patch: IUpdateAutomati } function mergeAutomation(current: IAutomationDescriptor, patch: IUpdateAutomationOptions): IAutomationDescriptor { + assertAutomationSessionTemplateAuthority(current, patch); + const target = patch.target ? normalizeAutomationTarget(patch.target) : current.target; + const targetAuthorityChanged = patch.target !== undefined + && (target.providerId !== current.target.providerId || target.sessionTypeId !== current.target.sessionTypeId); + const templatePatched = patch.sessionTemplate !== undefined; + const legacyConfigurationPatched = patch.modelId !== undefined || patch.mode !== undefined || patch.permissionLevel !== undefined; + const currentModelId = current.sessionTemplate ? undefined : current.modelId; + const currentMode = current.sessionTemplate ? undefined : current.mode; + const currentPermissionLevel = current.sessionTemplate ? undefined : current.permissionLevel; + const modelId = templatePatched ? undefined : patch.modelId === null ? undefined : (patch.modelId ?? (targetAuthorityChanged ? undefined : currentModelId)); + const mode = templatePatched ? undefined : patch.mode === null ? undefined : (patch.mode ?? (targetAuthorityChanged ? undefined : currentMode)); + const permissionLevel = templatePatched || patch.permissionLevel === null + ? undefined + : patch.permissionLevel && isChatPermissionLevel(patch.permissionLevel) + ? patch.permissionLevel + : targetAuthorityChanged ? ChatPermissionLevel.Default : currentPermissionLevel; + const sessionTemplate = patch.sessionTemplate === null + ? undefined + : patch.sessionTemplate ?? (targetAuthorityChanged || legacyConfigurationPatched + ? undefined + : current.sessionTemplate); return { ...current, name: patch.name ?? current.name, prompt: patch.prompt ?? current.prompt, schedule: patch.schedule ?? current.schedule, - target: patch.target ? normalizeAutomationTarget(patch.target) : current.target, - modelId: patch.modelId === null ? undefined : (patch.modelId ?? current.modelId), - mode: patch.mode === null ? undefined : (patch.mode ?? current.mode), - permissionLevel: patch.permissionLevel === null ? undefined : (patch.permissionLevel && isChatPermissionLevel(patch.permissionLevel) ? patch.permissionLevel : current.permissionLevel), + target, + sessionTemplate, + modelId, + mode, + permissionLevel, enabled: patch.enabled ?? current.enabled, }; } @@ -746,6 +790,47 @@ function normalizeAutomationTarget(target: AutomationTarget): AutomationTarget { ); } +function deserializeAutomationSessionTemplate(value: unknown): IAutomationSessionTemplate | undefined { + if (value === undefined) { + return undefined; + } + if (!isRecord(value)) { + throw new Error('Automation session template must be an object.'); + } + const modelId = value['modelId']; + if (modelId !== undefined && typeof modelId !== 'string') { + throw new Error('Automation session template model must be a string.'); + } + const modelConfiguration = value['modelConfiguration']; + if (modelConfiguration !== undefined && !isAutomationModelConfiguration(modelConfiguration)) { + throw new Error('Automation model configuration must contain only JSON primitive values.'); + } + const rawAgent = value['agent']; + let agent: IAutomationSessionTemplate['agent']; + if (rawAgent !== undefined) { + if (!isRecord(rawAgent) || typeof rawAgent['uri'] !== 'string') { + throw new Error('Automation session template agent must contain a URI.'); + } + agent = { uri: rawAgent['uri'] }; + } + const config = value['config']; + if (config !== undefined && !isRecord(config)) { + throw new Error('Automation session template config must be an object.'); + } + const template: IAutomationSessionTemplate = { + ...(modelId !== undefined ? { modelId } : {}), + ...(modelConfiguration !== undefined ? { modelConfiguration: { ...modelConfiguration } } : {}), + ...(agent ? { agent } : {}), + ...(config !== undefined ? { config: { ...config } } : {}), + }; + assertAutomationSessionTemplate(template); + return template; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + function serializeAutomationTarget(target: AutomationTarget): ISerializedAutomationTarget { return target.kind === 'quickChat' ? { kind: 'quickChat', providerId: target.providerId, sessionTypeId: target.sessionTypeId } diff --git a/src/vs/sessions/contrib/automations/browser/automationTools.ts b/src/vs/sessions/contrib/automations/browser/automationTools.ts index 5719db5c811129..40d3754581ab02 100644 --- a/src/vs/sessions/contrib/automations/browser/automationTools.ts +++ b/src/vs/sessions/contrib/automations/browser/automationTools.ts @@ -15,12 +15,12 @@ import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextke import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; -import { AutomationInterval, AutomationTarget, AutomationWorkspaceIsolation, IAutomationDescriptor, IAutomationRun, IAutomationSchedule } from '../../../../workbench/contrib/chat/common/automations/automation.js'; +import { AutomationInterval, AutomationTarget, AutomationWorkspaceIsolation, IAutomationDescriptor, IAutomationRun, IAutomationSchedule, IAutomationSessionTemplate, isAutomationModelConfiguration } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { IAutomationRunDispatch, IAutomationRunner } from '../../../../workbench/contrib/chat/common/automations/automationRunner.js'; -import { type AutomationMutationGuard, ConfigureAutomationToolReferenceName, IAutomationService, ICreateAutomationOptions, IUpdateAutomationOptions, serializeAutomationEditableState } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { type AutomationMutationGuard, AutomationSessionTemplateAuthorityError, ConfigureAutomationToolReferenceName, IAutomationService, ICreateAutomationOptions, IUpdateAutomationOptions, serializeAutomationEditableState } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; import { ChatAutomationsEnabledContext, CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { IChatAutomationConfiguredData } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; -import { ChatModeKind, ChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; +import { ChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolProgress } from '../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; import { ISession } from '../../../services/sessions/common/session.js'; import { IProviderSessionType, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; @@ -35,8 +35,10 @@ const deleteAutomationConfirmationId = 'delete'; const manualRunLeaderWindowId = 0; const automationIntervals: readonly AutomationInterval[] = ['manual', 'hourly', 'daily', 'weekly']; const automationIsolationKinds: readonly AutomationWorkspaceIsolation['kind'][] = ['default', 'folder', 'worktree']; -const chatModes: readonly ChatModeKind[] = [ChatModeKind.Agent, ChatModeKind.Ask, ChatModeKind.Edit]; const chatPermissionLevels: readonly ChatPermissionLevel[] = [ChatPermissionLevel.Default, ChatPermissionLevel.Assisted, ChatPermissionLevel.AutoApprove, ChatPermissionLevel.Autopilot]; +const MAX_SESSION_TEMPLATE_CONFIG_DEPTH = 32; +const MAX_SESSION_TEMPLATE_CONFIG_NODES = 10_000; +const MAX_SESSION_TEMPLATE_CONFIG_LENGTH = 65_536; interface IAutomationToolOutput { readonly id: string; @@ -56,9 +58,10 @@ interface IAutomationToolOutput { readonly providerId: string; readonly sessionTypeId: string; }; - readonly modelId: string | null; - readonly mode: string | null; - readonly permissionLevel: string | null; + readonly modelId?: string | null; + readonly mode?: string | null; + readonly permissionLevel?: string | null; + readonly sessionTemplate?: IAutomationSessionTemplate; readonly enabled: boolean; readonly createdAt: string; readonly updatedAt: string; @@ -103,7 +106,7 @@ export class ListAutomationsTool implements IToolImpl { icon: Codicon.calendar, displayName: localize('automation.tool.list.displayName', "List Automations"), userDescription: localize('automation.tool.list.userDescription', "List scheduled agent automations"), - modelDescription: 'List all configured scheduled automations and their stable IDs, editable fields, targets, and timing metadata. Use this before configureAutomation, runAutomation, or deleteAutomation when acting on an existing automation. This tool never changes automation state.', + modelDescription: 'List all currently available scheduled automations and their stable IDs, editable fields, targets, and timing metadata. The result includes catalogueState; only "ready" means the list is complete, so never interpret an empty non-ready result as no configured automations. Use this before configureAutomation, runAutomation, or deleteAutomation when acting on an existing automation. This tool never changes automation state.', source: ToolDataSource.Internal, when: automationToolWhen, runsInWorkspace: false, @@ -127,11 +130,14 @@ export class ListAutomationsTool implements IToolImpl { return automationToolError('Automations are disabled.'); } + const catalogueState = this.automationService.catalogueState.get(); const automations = this.automationService.automations.get().map(toAutomationToolOutput); - const result = automationToolResult(JSON.stringify({ automations }, undefined, 2)); - result.toolResultMessage = automations.length === 1 - ? localize('automation.tool.list.result.singular', "Listed 1 automation") - : localize('automation.tool.list.result.plural', "Listed {0} automations", automations.length); + const result = automationToolResult(JSON.stringify({ catalogueState, automations }, undefined, 2)); + result.toolResultMessage = catalogueState !== 'ready' + ? localize('automation.tool.list.result.incomplete', "Listed {0} available automations; catalogue is incomplete", automations.length) + : automations.length === 1 + ? localize('automation.tool.list.result.singular', "Listed 1 automation") + : localize('automation.tool.list.result.plural', "Listed {0} automations", automations.length); return result; } } @@ -152,7 +158,7 @@ export class RunAutomationTool implements IToolImpl { icon: Codicon.play, displayName: localize('automation.tool.run.displayName', "Run Automation"), userDescription: localize('automation.tool.run.userDescription', "Run a configured agent automation now"), - modelDescription: 'Run a configured automation immediately by stable ID. Call listAutomations first to obtain the current ID. This starts a fresh agent session in the background using the saved prompt, target, model, mode, and permission level, even when scheduled runs are disabled. The tool returns after session dispatch commits; do not run it again unless the user asks.', + modelDescription: 'Run a configured automation immediately by stable ID. Call listAutomations first to obtain the current ID. This starts a fresh agent session in the background using the saved prompt, target, and provider session configuration, even when scheduled runs are disabled. The tool returns after session dispatch commits; do not run it again unless the user asks.', source: ToolDataSource.Internal, when: automationToolWhen, runsInWorkspace: false, @@ -375,6 +381,8 @@ Create a new automation only when the user explicitly asks for an automation, or Omit "automationId" to create an automation; "name", "prompt", and "schedule.interval" are then required. If "target" is omitted, the automation targets the current Agents window session. Include "automationId" to update an existing automation, and only provide fields that should change. Call listAutomations first to obtain the stable ID and current values. +Use "sessionTemplate" for provider-owned Model, Agent, Mode, Approvals, and other configuration returned by listAutomations. Omit it on unrelated partial updates, or set it to null to reset provider configuration. Do not combine it with the legacy "modelId", "mode", or "permissionLevel" aliases. + The change uses the current tool-approval policy. When approval is required, the user sees a normal tool confirmation. If the user cancels or denies the request, do not retry unless they ask you to.`, source: ToolDataSource.Internal, when: automationToolWhen, @@ -460,15 +468,45 @@ The change uses the current tool-approval policy. When approval is required, the }, modelId: { type: ['string', 'null'], - description: 'Language model ID, or null to use the provider default.', + description: 'Legacy model alias. Use sessionTemplate for provider-owned configuration.', }, mode: { - enum: [...chatModes, null], - description: 'Chat mode, or null to use the provider default.', + type: ['string', 'null'], + description: 'Legacy Mode alias. Use sessionTemplate for provider-owned configuration.', }, permissionLevel: { enum: [...chatPermissionLevels, null], - description: 'Permission level, or null to use the provider default.', + description: 'Legacy Approvals alias. Use sessionTemplate for provider-owned configuration.', + }, + sessionTemplate: { + type: ['object', 'null'], + additionalProperties: false, + description: 'Provider-owned session configuration returned by listAutomations, or null to reset it.', + properties: { + modelId: { + type: ['string', 'null'], + description: 'Provider model identifier, or null to use its default.', + }, + modelConfiguration: { + type: ['object', 'null'], + description: 'Preferences for the selected model, such as thinking effort. Requires modelId.', + additionalProperties: { type: ['string', 'number', 'boolean', 'null'] }, + }, + agent: { + type: ['object', 'null'], + additionalProperties: false, + description: 'Provider custom-agent selection, or null for none.', + properties: { + uri: { type: 'string' }, + }, + required: ['uri'], + }, + config: { + type: ['object', 'null'], + description: 'Opaque JSON-safe provider configuration.', + additionalProperties: true, + }, + }, }, enabled: { type: 'boolean', @@ -565,6 +603,9 @@ The change uses the current tool-approval policy. When approval is required, the if (error instanceof AutomationToolInputError) { return automationToolError(error.message); } + if (error instanceof AutomationSessionTemplateAuthorityError) { + return automationToolError(error.message); + } throw error; } } @@ -645,7 +686,7 @@ The change uses the current tool-approval policy. When approval is required, the throw new AutomationToolInputError('configureAutomation input must be an object.'); } const input = rawInput; - assertKnownProperties(input, ['automationId', 'name', 'prompt', 'schedule', 'target', 'modelId', 'mode', 'permissionLevel', 'enabled'], 'configureAutomation input'); + assertKnownProperties(input, ['automationId', 'name', 'prompt', 'schedule', 'target', 'modelId', 'mode', 'permissionLevel', 'sessionTemplate', 'enabled'], 'configureAutomation input'); const automationId = readOptionalNonEmptyString(input, 'automationId'); const existing = automationId ? this.automationService.getAutomation(automationId) : undefined; @@ -666,8 +707,15 @@ The change uses the current tool-approval policy. When approval is required, the const currentTarget = this.getCurrentSessionTarget(sessionResource); const target = parseTarget(input, existing, currentTarget); const modelId = readOptionalNullableNonEmptyString(input, 'modelId'); - const mode = readOptionalNullableEnum(input, 'mode', chatModes); + const mode = readOptionalNullableNonEmptyString(input, 'mode'); const permissionLevel = readOptionalNullableEnum(input, 'permissionLevel', chatPermissionLevels); + const sessionTemplate = parseSessionTemplate(input); + if (sessionTemplate !== undefined && (modelId !== undefined || mode !== undefined || permissionLevel !== undefined)) { + throw new AutomationToolInputError('"sessionTemplate" cannot be combined with legacy "modelId", "mode", or "permissionLevel" aliases.'); + } + if (existing?.sessionTemplate && sessionTemplate === undefined && (modelId !== undefined || mode !== undefined || permissionLevel !== undefined)) { + throw new AutomationToolInputError('Legacy "modelId", "mode", and "permissionLevel" aliases cannot update an automation with a canonical session template. Pass the complete updated "sessionTemplate" returned by listAutomations.'); + } const enabled = readOptionalBoolean(input, 'enabled'); const proposedValues: IUpdateAutomationOptions = { @@ -678,6 +726,7 @@ The change uses the current tool-approval policy. When approval is required, the ...(modelId !== undefined ? { modelId } : {}), ...(mode !== undefined ? { mode } : {}), ...(permissionLevel !== undefined ? { permissionLevel } : {}), + ...(sessionTemplate !== undefined ? { sessionTemplate } : {}), ...(enabled !== undefined ? { enabled } : {}), }; const validateTargetAvailability = input.target !== undefined @@ -705,6 +754,7 @@ The change uses the current tool-approval policy. When approval is required, the ...(modelId ? { modelId } : {}), ...(mode ? { mode } : {}), ...(permissionLevel ? { permissionLevel } : {}), + ...(sessionTemplate ? { sessionTemplate } : {}), ...(enabled !== undefined ? { enabled } : {}), }, validateTargetAvailability, @@ -846,6 +896,98 @@ function parseTarget(input: Record, existing: IAutomationDescri return { kind: 'workspace', folderUri, providerId, sessionTypeId, isolation }; } +function parseSessionTemplate(input: Record): IAutomationSessionTemplate | null | undefined { + const value = input.sessionTemplate; + if (value === undefined || value === null) { + return value; + } + if (!isRecord(value)) { + throw new AutomationToolInputError('"sessionTemplate" must be an object or null.'); + } + assertKnownProperties(value, ['modelId', 'modelConfiguration', 'agent', 'config'], '"sessionTemplate"'); + const modelId = readOptionalNullableNonEmptyString(value, 'modelId'); + const modelConfiguration = parseSessionTemplateConfiguration(value.modelConfiguration, 'sessionTemplate.modelConfiguration'); + if (modelConfiguration !== undefined && !isAutomationModelConfiguration(modelConfiguration)) { + throw new AutomationToolInputError('"sessionTemplate.modelConfiguration" must contain only JSON primitive values.'); + } + if (modelConfiguration && !modelId) { + throw new AutomationToolInputError('"sessionTemplate.modelConfiguration" requires "sessionTemplate.modelId".'); + } + + const rawAgent = value.agent; + let agent: IAutomationSessionTemplate['agent']; + if (rawAgent !== undefined && rawAgent !== null) { + if (!isRecord(rawAgent)) { + throw new AutomationToolInputError('"sessionTemplate.agent" must be an object or null.'); + } + assertKnownProperties(rawAgent, ['uri'], '"sessionTemplate.agent"'); + const uri = readOptionalNonEmptyString(rawAgent, 'uri'); + if (!uri) { + throw new AutomationToolInputError('"sessionTemplate.agent.uri" is required.'); + } + agent = { uri }; + } + + const config = parseSessionTemplateConfiguration(value.config, 'sessionTemplate.config'); + return { + ...(modelId ? { modelId } : {}), + ...(modelConfiguration ? { modelConfiguration } : {}), + ...(agent ? { agent } : {}), + ...(config ? { config } : {}), + }; +} + +function parseSessionTemplateConfiguration(rawConfig: unknown, field: string): Readonly> | undefined { + if (rawConfig !== undefined && rawConfig !== null) { + if (!isRecord(rawConfig)) { + throw new AutomationToolInputError(`"${field}" must be an object or null.`); + } + const cloneState = { nodes: 0, rootField: field }; + assertJsonComplexity(field, cloneState, 0); + const config = cloneJsonObject(rawConfig, field, cloneState, 0); + if (JSON.stringify(config).length > MAX_SESSION_TEMPLATE_CONFIG_LENGTH) { + throw new AutomationToolInputError(`"${field}" must not exceed ${MAX_SESSION_TEMPLATE_CONFIG_LENGTH} characters.`); + } + return config; + } + return undefined; +} + +function cloneJsonObject(value: Record, field: string, state: { nodes: number; readonly rootField: string }, depth: number): Record { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new AutomationToolInputError(`"${field}" must contain only JSON values.`); + } + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneJsonValue(entry, `${field}.${key}`, state, depth + 1)])); +} + +function cloneJsonValue(value: unknown, field: string, state: { nodes: number; readonly rootField: string }, depth: number): unknown { + assertJsonComplexity(field, state, depth); + if (value === null || typeof value === 'string' || typeof value === 'boolean') { + return value; + } + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (Array.isArray(value)) { + return value.map((entry, index) => cloneJsonValue(entry, `${field}[${index}]`, state, depth + 1)); + } + if (isRecord(value)) { + return cloneJsonObject(value, field, state, depth); + } + throw new AutomationToolInputError(`"${field}" must be JSON-safe.`); +} + +function assertJsonComplexity(field: string, state: { nodes: number; readonly rootField: string }, depth: number): void { + if (depth > MAX_SESSION_TEMPLATE_CONFIG_DEPTH) { + throw new AutomationToolInputError(`"${field}" exceeds the maximum nesting depth of ${MAX_SESSION_TEMPLATE_CONFIG_DEPTH}.`); + } + state.nodes++; + if (state.nodes > MAX_SESSION_TEMPLATE_CONFIG_NODES) { + throw new AutomationToolInputError(`"${state.rootField}" must not contain more than ${MAX_SESSION_TEMPLATE_CONFIG_NODES} values.`); + } +} + function parseUri(value: string, field: string): URI { try { const uri = URI.parse(value, true); @@ -878,9 +1020,13 @@ function toAutomationToolOutput(automation: IAutomationDescriptor): IAutomationT prompt: automation.prompt, schedule: automation.schedule, target, - modelId: automation.modelId ?? null, - mode: automation.mode ?? null, - permissionLevel: automation.permissionLevel ?? null, + ...(automation.sessionTemplate + ? { sessionTemplate: automation.sessionTemplate } + : { + modelId: automation.modelId ?? null, + mode: automation.mode ?? null, + permissionLevel: automation.permissionLevel ?? null, + }), enabled: automation.enabled, createdAt: automation.createdAt, updatedAt: automation.updatedAt, diff --git a/src/vs/sessions/contrib/automations/browser/automations.contribution.ts b/src/vs/sessions/contrib/automations/browser/automations.contribution.ts index bd7e01248468cb..1a55686c656a3b 100644 --- a/src/vs/sessions/contrib/automations/browser/automations.contribution.ts +++ b/src/vs/sessions/contrib/automations/browser/automations.contribution.ts @@ -4,14 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../base/common/lifecycle.js'; +import { observableFromPromise } from '../../../../base/common/observable.js'; import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import product from '../../../../platform/product/common/product.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; -import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { Extensions as WorkbenchExtensions, IWorkbenchContribution, IWorkbenchContributionsRegistry, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IAutomationDialogService } from '../../../../workbench/contrib/chat/common/automations/automationDialogService.js'; import { IAutomationRunner } from '../../../../workbench/contrib/chat/common/automations/automationRunner.js'; import { IAutomationService } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; @@ -25,8 +27,12 @@ import { AutomationToolsContribution } from './automationTools.js'; import { IAutomationStorageService } from '../common/automationStorageService.js'; import { AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY, AGENT_HOST_AUTOMATION_RUN_TIMEOUT_MINUTES_CONFIG_KEY } from '../../../../platform/agentHost/common/automationMigration.js'; +const initialProvidersSettled = observableFromPromise( + Registry.as(WorkbenchExtensions.Workbench).whenRestored.then(() => true) +).map(result => result.value === true); + registerSingleton(IAutomationStorageService, BrowserAutomationStorageService, InstantiationType.Delayed); -registerSingleton(IAutomationService, ProviderAutomationService, InstantiationType.Delayed); +registerSingleton(IAutomationService, new SyncDescriptor(ProviderAutomationService, [initialProvidersSettled], true)); registerSingleton(IAutomationRunner, AutomationRunner, InstantiationType.Delayed); registerSingleton(IAutomationDialogService, AutomationDialogService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/automations/browser/media/automationDialog.css b/src/vs/sessions/contrib/automations/browser/media/automationDialog.css index 668f808f9e3b05..3ddc18cdf411f0 100644 --- a/src/vs/sessions/contrib/automations/browser/media/automationDialog.css +++ b/src/vs/sessions/contrib/automations/browser/media/automationDialog.css @@ -88,10 +88,20 @@ min-height: 0; } -.monaco-dialog-box.automation-dialog > .dialog-buttons-row { +.monaco-dialog-box.automation-dialog > .dialog-buttons-row:not(.automation-dialog-footer-row) { + display: none; +} + +.monaco-dialog-box.automation-dialog > .automation-dialog-footer-row { + flex-grow: 0; + margin-top: 0; padding: 8px 10px 10px; } +.monaco-dialog-box.automation-dialog:not(.align-vertical) > .automation-dialog-footer-row > .automation-dialog-footer-actions { + margin-left: 0; +} + /* * Strip the base dialog padding on the message row and container so * our titlebar can full-bleed. The form pane below re-adds horizontal @@ -223,10 +233,41 @@ .automation-form { display: flex; flex-direction: column; - gap: 10px; + gap: var(--vscode-spacing-size100); padding: 2px 2px 4px; } +.automation-form-content { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size100); +} + +.automation-prompt-section { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size100); +} + +.automation-target-hint { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--vscode-spacing-size40) var(--vscode-spacing-size80); + font-size: var(--vscode-fontSize-label1); + line-height: 1.4; + color: var(--vscode-descriptionForeground); +} + +.automation-target-hint-message { + flex: 1 1 240px; + min-width: 0; +} + +.automation-target-hint-action { + flex-shrink: 0; +} + /* * Host for the embedded ChatInputPart. The composer brings its own * background, border, and rounded corners (see chat.css @@ -298,38 +339,77 @@ min-width: 0; } -/* - * Hide chips from the embedded chat input's primary toolbar that don't fit - * the automation dialog's reduced surface: - * - "Configure Tools" (`workbench.action.chat.configureTools`, - * `.codicon-settings-compact`) — tool selection is not relevant for a - * scheduled prompt. - * - "Add Context" (`workbench.action.chat.attachContext`, - * `.codicon-add-compact`, the `+`) — the dialog doesn't support - * attachments; the prompt is the only payload sent at run time. - * - "List MCP Servers" (`workbench.mcp.listServer`, `.codicon-server`) — - * MCP server management belongs in the live chat surface, not a one-off - * prompt definition. - * - * We hide the `.action-label` (which carries the codicon class) rather than - * the parent `.action-item`. Targeting the `.action-item` would require a - * `:has()` selector, whose invalidation bookkeeping is paid across every - * `.action-item` in the workbench (see microsoft/vscode#324985). Since - * `.action-item` has no intrinsic padding/margin (actionbar.css), hiding the - * label collapses the slot to zero width, so there is no visual gap. - * - * Known limitation: the hidden chips remain in the toolbar's arrow-key focus - * rotation (the action bar skips only disabled/separator items, not - * `display:none` ones). This is an accepted temporary trade-off — the dialog - * will migrate to `newChatInput.ts`, which builds its own toolbars and never - * renders these `MenuId.ChatInput` chips at all. - */ -.automation-form-prompt-host .chat-input-toolbar .action-label.codicon-settings-compact, -.automation-form-prompt-host .chat-input-toolbar .action-label.codicon-add-compact, -.automation-form-prompt-host .chat-input-toolbar .action-item.chat-mcp { +.automation-session-configuration { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--vscode-spacing-size80); + min-width: 0; + min-height: 22px; + padding-top: var(--vscode-spacing-size40); +} + +.automation-session-configuration.controls-unavailable .automation-session-config, +.automation-session-configuration.controls-unavailable .automation-session-controls { + opacity: 0.5; + pointer-events: none; +} + +.automation-session-config, +.automation-session-controls { + min-width: 0; + max-width: 100%; + overflow: hidden; +} + +.automation-session-configuration-unavailable { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-body2); +} + +.automation-session-configuration-error { + flex-basis: 100%; + color: var(--vscode-inputValidation-errorForeground); + font-size: var(--vscode-fontSize-body2); +} + +.automation-form-save-status { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-body2); +} + +.automation-session-config .monaco-toolbar, +.automation-session-controls .monaco-toolbar, +.automation-session-config .monaco-action-bar, +.automation-session-controls .monaco-action-bar, +.automation-session-config .actions-container, +.automation-session-controls .actions-container { + min-width: 0; + overflow: hidden; +} + +.automation-session-configuration .compact-picker .sessions-chat-dropdown-label, +.automation-session-configuration .compact-picker .chat-input-picker-label, +.automation-session-configuration .compact-picker .chat-session-option-label { display: none; } +.automation-session-controls .action-item.compact-picker, +.automation-session-controls .action-item.compact-picker .action-label { + box-sizing: border-box; + width: 22px; + min-width: 22px; +} + +.automation-session-controls .action-item.compact-picker { + padding: 0; +} + +.automation-session-controls .action-item.compact-picker .action-label { + justify-content: flex-start; + padding: var(--vscode-spacing-size20) var(--vscode-spacing-size20) var(--vscode-spacing-size20) var(--vscode-spacing-size80); +} + /* * Suppress every inter-chip divider drawn by chat.css inside the dialog, * in both the primary (`.chat-input-toolbar`) and secondary diff --git a/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts b/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts index 9a83474304b422..574401ecc650b2 100644 --- a/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts +++ b/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts @@ -11,7 +11,7 @@ import { localize } from '../../../../nls.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IAutomationDescriptor, IAutomationRun, AutomationRunTrigger } from '../../../../workbench/contrib/chat/common/automations/automation.js'; -import { AutomationMutationGuard, IAutomationRunClaim, IAutomationService, ICreateAutomationOptions, IGuardedAutomationUpdateResult, isAutomationActiveRunError, serializeAutomationEditableState, IUpdateAutomationOptions, IUpdateAutomationRunOptions } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { AutomationCatalogueState, AutomationMutationGuard, combineAutomationCatalogueStates, IAutomationRunClaim, IAutomationService, ICreateAutomationOptions, IGuardedAutomationUpdateResult, isAutomationActiveRunError, serializeAutomationEditableState, IUpdateAutomationOptions, IUpdateAutomationRunOptions } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { IAutomation, ISessionsProviderAutomations } from '../../../services/sessions/common/sessionsProvider.js'; import { AutomationService } from './automationService.js'; @@ -40,8 +40,10 @@ export class ProviderAutomationService extends Disposable implements IAutomation readonly automations: IObservable; readonly runs: IObservable; + readonly catalogueState: IObservable; constructor( + initialProvidersSettled: IObservable, @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, @IInstantiationService instantiationService: IInstantiationService, @ILogService private readonly logService: ILogService, @@ -49,6 +51,14 @@ export class ProviderAutomationService extends Disposable implements IAutomation super(); this.legacyStore = this._register(instantiationService.createInstance(AutomationService)); this.providersChanged = observableSignalFromEvent(this, sessionsProvidersService.onDidChangeProviders); + this.catalogueState = derived(this, reader => { + this.providersChanged.read(reader); + const states = this.getStores().map(entry => entry.store.catalogueState.read(reader)); + if (!initialProvidersSettled.read(reader)) { + states.push('loading'); + } + return combineAutomationCatalogueStates(states); + }); this.automations = derived(this, reader => { this.providersChanged.read(reader); return distinctById( @@ -202,11 +212,14 @@ export class ProviderAutomationService extends Disposable implements IAutomation return this.migrationPromise; } - private getStores(): IAutomationStoreEntry[] { - const providerStores = this.sessionsProvidersService.getProviders() + private getProviderStores(): IAutomationStoreEntry[] { + return this.sessionsProvidersService.getProviders() .filter(provider => provider.automations) .map(provider => ({ providerId: provider.id, store: provider.automations! })); - return [...providerStores, { providerId: undefined, store: this.legacyStore }]; + } + + private getStores(): IAutomationStoreEntry[] { + return [...this.getProviderStores(), { providerId: undefined, store: this.legacyStore }]; } private getCreationStore(options: ICreateAutomationOptions): ISessionsProviderAutomations { @@ -298,9 +311,13 @@ export class ProviderAutomationService extends Disposable implements IAutomation prompt: previous.prompt, schedule: previous.schedule, target: previous.target, - modelId: previous.modelId ?? null, - mode: previous.mode ?? null, - permissionLevel: previous.permissionLevel ?? null, + ...(previous.sessionTemplate + ? { sessionTemplate: previous.sessionTemplate } + : { + modelId: previous.modelId ?? null, + mode: previous.mode ?? null, + permissionLevel: previous.permissionLevel ?? null, + }), enabled: previous.enabled, }, expected); if (result.kind === 'conflict') { diff --git a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts index f074e1abadddae..5d310ff15795a3 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts @@ -11,6 +11,7 @@ import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Action, IAction } from '../../../../../base/common/actions.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; +import { getErrorMessage } from '../../../../../base/common/errors.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; @@ -31,16 +32,54 @@ import { ILogService, NullLogService } from '../../../../../platform/log/common/ import { IWorkspaceTrustRequestService, ResourceTrustRequestOptions } from '../../../../../platform/workspace/common/workspaceTrust.js'; import { createWorkbenchDialogOptions } from '../../../../../workbench/browser/parts/dialogs/dialog.js'; import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; -import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; +import { IAutomationSessionTemplate } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; import { GitRefType, IGitRepository, IGitService } from '../../../../../workbench/contrib/git/common/gitService.js'; import { IHostService } from '../../../../../workbench/services/host/browser/host.js'; import { ISession, ISessionWorkspace, SessionTypeAuthRequirement } from '../../../../services/sessions/common/session.js'; -import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; -import { AutomationIsolationGroupActionViewItem, AutomationSessionDraftSynchronizer, canSelectAutomationWorkspace, IFormState, IValidationState, isAutomationDialogPopupTarget, registerAutomationDialogKeyboardNavigation, resolveAutomationModelIdentifier, shouldPassThroughAutomationDialogCommand, updateSaveButtonState } from '../../browser/automationDialog.js'; +import { IAutomationSessionConfiguration } from '../../../../services/sessions/common/sessionsProvider.js'; +import { IProviderSessionType, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { AutomationIsolationGroupActionViewItem, AutomationSessionDraftSynchronizer, canSelectAutomationWorkspace, getAutomationTargetHint, IFormState, IValidationState, isAutomationDialogPopupTarget, registerAutomationDialogKeyboardNavigation, shouldPassThroughAutomationDialogCommand, updateSaveButtonState } from '../../browser/automationDialog.js'; import { AutomationIsolationModel } from '../../common/isolationGroupModel.js'; const FOLDER = URI.file('/workspace'); +suite('Automation target guidance', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const cloud: IProviderSessionType = { + providerId: 'cloud-provider', + sessionType: { id: 'cloud-agent', label: 'Cloud', icon: Codicon.cloud, authRequirement: SessionTypeAuthRequirement.GitHub }, + }; + const local: IProviderSessionType = { + providerId: 'local-provider', + sessionType: { id: 'local-agent', label: 'Local', icon: Codicon.terminal, authRequirement: SessionTypeAuthRequirement.GitHub }, + }; + + test('explains missing targets and the workspace dependency of a single available agent', () => { + const cloudTarget = { ...createFormState(), providerId: cloud.providerId, sessionTypeId: cloud.sessionType.id }; + const localTarget = { ...createFormState(), providerId: local.providerId, sessionTypeId: local.sessionType.id }; + assert.deepStrictEqual({ + unselected: getAutomationTargetHint({ ...localTarget, folderUri: undefined }, []), + cloud: getAutomationTargetHint(cloudTarget, [cloud]), + local: getAutomationTargetHint(localTarget, [local]), + quickChat: getAutomationTargetHint({ ...localTarget, isQuickChat: true, folderUri: undefined }, [local]), + unavailable: getAutomationTargetHint(localTarget, []), + multiple: getAutomationTargetHint(localTarget, [cloud, local]), + retainedUnavailableAgent: getAutomationTargetHint(cloudTarget, [local]), + retainedUnavailableProvider: getAutomationTargetHint({ ...localTarget, providerId: 'unavailable' }, [local]), + }, { + unselected: 'Choose a workspace or No workspace to see which agents can run this automation.', + cloud: 'Only Cloud is available for this workspace. Change the workspace to use a different agent.', + local: 'Only Local is available for this workspace. Change the workspace to use a different agent.', + quickChat: 'Only Local is available without a workspace. Choose a workspace to use a different agent.', + unavailable: 'No agents are currently available for this target.', + multiple: undefined, + retainedUnavailableAgent: undefined, + retainedUnavailableProvider: undefined, + }); + }); +}); + function dispatchKey(target: HTMLElement, type: 'keydown' | 'keyup', key: string, shiftKey = false): KeyboardEvent { const event = new KeyboardEvent(type, { key, bubbles: true, cancelable: true, shiftKey }); target.dispatchEvent(event); @@ -146,12 +185,18 @@ function createWorkspace(requiresWorkspaceTrust: boolean): ISessionWorkspace { }; } -function createAutomationDraftService() { +function createAutomationDraftService( + captureSupported = true, + captureError?: Error, + capturePromise?: Promise, + targetAvailability: { readonly workspace?: boolean; readonly quickChat?: boolean } = {}, +) { const automationSession = observableValue('automationSession', undefined); - const created: Array<{ kind: 'workspace' | 'quickChat'; providerId: string | undefined; sessionTypeId: string; folderUri?: string }> = []; + const created: Array<{ kind: 'workspace' | 'quickChat'; providerId: string | undefined; sessionTypeId: string; folderUri?: string; sessionTemplate?: IAutomationSessionTemplate }> = []; const discarded: string[] = []; + const sessionConfigurations = new Map(); let nextId = 1; - const createDraft = (kind: 'workspace' | 'quickChat', providerId: string | undefined, sessionTypeId: string, folderUri?: URI): ISession => { + const createDraft = (kind: 'workspace' | 'quickChat', providerId: string | undefined, sessionTypeId: string, folderUri?: URI, sessionTemplate?: IAutomationSessionTemplate): ISession => { const previous = automationSession.get(); if (previous) { discarded.push(previous.sessionId); @@ -161,14 +206,27 @@ function createAutomationDraftService() { providerId: providerId ?? 'resolved-provider', sessionType: sessionTypeId, }); - created.push({ kind, providerId, sessionTypeId, folderUri: folderUri?.toString() }); + created.push({ kind, providerId, sessionTypeId, folderUri: folderUri?.toString(), ...(sessionTemplate ? { sessionTemplate } : {}) }); + sessionConfigurations.set(session.sessionId, { sessionTemplate }); automationSession.set(session, undefined); return session; }; const service = upcastPartial({ automationSession, - createAutomationSession: (folderUri, options) => createDraft('workspace', options?.providerId, options?.sessionTypeId ?? 'default', folderUri), - createAutomationQuickChat: options => createDraft('quickChat', options?.providerId, options?.sessionTypeId ?? 'default'), + createAutomationSession: (folderUri, options) => createDraft('workspace', options?.providerId, options?.sessionTypeId ?? 'default', folderUri, options?.sessionTemplate), + createAutomationQuickChat: options => createDraft('quickChat', options?.providerId, options?.sessionTypeId ?? 'default', undefined, options?.sessionTemplate), + isNewSessionTargetAvailable: () => targetAvailability.workspace !== false, + isQuickChatTargetAvailable: () => targetAvailability.quickChat !== false, + supportsAutomationSessionConfiguration: () => captureSupported, + getAutomationSessionConfiguration: async session => { + if (captureError) { + throw captureError; + } + if (capturePromise) { + return capturePromise; + } + return captureSupported ? sessionConfigurations.get(session.sessionId) : null; + }, discardAutomationSession: session => { const current = automationSession.get(); if (!current || (session && session.sessionId !== current.sessionId)) { @@ -178,7 +236,7 @@ function createAutomationDraftService() { automationSession.set(undefined, undefined); }, }); - return { service, created, discarded }; + return { service, created, discarded, sessionConfigurations }; } suite('Automation session draft synchronization', () => { @@ -208,6 +266,7 @@ suite('Automation session draft synchronization', () => { discarded, currentSession: service.automationSession.get()?.sessionId, errorCount, + availability: synchronizer.availability.get(), }, { created: [ { kind: 'workspace', providerId: 'provider-a', sessionTypeId: 'type-a', folderUri: 'file:///workspace' }, @@ -218,9 +277,275 @@ suite('Automation session draft synchronization', () => { discarded: ['automation-1', 'automation-2', 'automation-3', 'automation-4'], currentSession: undefined, errorCount: 0, + availability: 'idle', + }); + }); + + test('restores and captures the target session template', async () => { + const { service, created } = createAutomationDraftService(); + const synchronizer = disposables.add(new AutomationSessionDraftSynchronizer(service, async () => true, () => { })); + const sessionTemplate = { + modelId: 'model', + agent: { uri: 'file:///agent.md' }, + config: { mode: 'plan' }, + }; + + synchronizer.update({ + kind: 'workspace', + folderUri: URI.parse('file:///workspace'), + providerId: 'provider', + sessionTypeId: 'type', + sessionConfiguration: { sessionTemplate }, + }); + + const captured = await synchronizer.getSessionConfiguration(); + + assert.deepStrictEqual({ + created, + captured, + }, { + created: [{ + kind: 'workspace', + providerId: 'provider', + sessionTypeId: 'type', + folderUri: 'file:///workspace', + sessionTemplate, + }], + captured: { kind: 'captured', configuration: { sessionTemplate } }, + }); + }); + + test('preserves saved configuration when workspace and quick-chat targets are unavailable', async () => { + const workspaceConfiguration: IAutomationSessionConfiguration = { + sessionTemplate: { config: { mode: 'plan' } }, + }; + const quickChatConfiguration: IAutomationSessionConfiguration = { + sessionTemplate: { config: { mode: 'autopilot', autoApprove: 'assisted' } }, + }; + const { service, created } = createAutomationDraftService(true, undefined, undefined, { workspace: false, quickChat: false }); + const synchronizer = disposables.add(new AutomationSessionDraftSynchronizer(service, async () => true, () => { })); + + synchronizer.update({ + kind: 'workspace', + folderUri: URI.parse('file:///workspace'), + providerId: 'provider', + sessionTypeId: 'type', + sessionConfiguration: workspaceConfiguration, + }); + const workspaceCapture = await synchronizer.getSessionConfiguration(); + const workspaceAvailability = synchronizer.availability.get(); + + synchronizer.update({ + kind: 'quickChat', + providerId: 'provider', + sessionTypeId: 'type', + sessionConfiguration: quickChatConfiguration, + }); + const quickChatCapture = await synchronizer.getSessionConfiguration(); + + assert.deepStrictEqual({ + created, + workspaceCapture, + workspaceAvailability, + quickChatCapture, + quickChatAvailability: synchronizer.availability.get(), + }, { + created: [], + workspaceCapture: { kind: 'preserved', configuration: workspaceConfiguration }, + workspaceAvailability: 'unavailable', + quickChatCapture: { kind: 'preserved', configuration: quickChatConfiguration }, + quickChatAvailability: 'unavailable', }); }); + test('distinguishes a valid empty capture from unsupported capture', async () => { + const sessionConfiguration: IAutomationSessionConfiguration = { + sessionTemplate: { + modelId: 'model', + config: { mode: 'plan' }, + }, + modelId: 'model', + mode: 'plan', + }; + const supported = createAutomationDraftService(); + const supportedSynchronizer = disposables.add(new AutomationSessionDraftSynchronizer(supported.service, async () => true, () => { })); + supportedSynchronizer.update({ + kind: 'workspace', + folderUri: URI.parse('file:///workspace'), + providerId: 'provider', + sessionTypeId: 'type', + sessionConfiguration, + }); + await supportedSynchronizer.waitForSync(); + const supportedSessionId = supported.service.automationSession.get()!.sessionId; + supported.sessionConfigurations.set(supportedSessionId, {}); + + const unsupported = createAutomationDraftService(false); + const unsupportedSynchronizer = disposables.add(new AutomationSessionDraftSynchronizer(unsupported.service, async () => true, () => { })); + unsupportedSynchronizer.update({ + kind: 'workspace', + folderUri: URI.parse('file:///workspace'), + providerId: 'provider', + sessionTypeId: 'type', + sessionConfiguration, + }); + + assert.deepStrictEqual({ + supported: await supportedSynchronizer.getSessionConfiguration(), + unsupported: await unsupportedSynchronizer.getSessionConfiguration(), + }, { + supported: { kind: 'captured', configuration: {} }, + unsupported: { kind: 'preserved', configuration: sessionConfiguration }, + }); + }); + + test('reports capture failures instead of silently preserving configuration', async () => { + const sessionConfiguration: IAutomationSessionConfiguration = { + sessionTemplate: { config: { mode: 'plan' } }, + }; + const { service } = createAutomationDraftService(true, new Error('capture failed')); + let errorCount = 0; + const synchronizer = disposables.add(new AutomationSessionDraftSynchronizer(service, async () => true, () => errorCount++)); + synchronizer.update({ + kind: 'workspace', + folderUri: URI.parse('file:///workspace'), + providerId: 'provider', + sessionTypeId: 'type', + sessionConfiguration, + }); + + const capture = await synchronizer.getSessionConfiguration(); + assert.deepStrictEqual({ + capture: capture.kind === 'failed' ? { kind: capture.kind, message: getErrorMessage(capture.error) } : capture, + errorCount, + }, { + capture: { kind: 'failed', message: 'capture failed' }, + errorCount: 1, + }); + }); + + test('bounds complete configuration capture and reports timeouts', async () => { + const sessionConfiguration: IAutomationSessionConfiguration = { + sessionTemplate: { config: { mode: 'plan' } }, + }; + const { service } = createAutomationDraftService(true, undefined, new Promise(() => { })); + let errorCount = 0; + const synchronizer = disposables.add(new AutomationSessionDraftSynchronizer(service, async () => true, () => errorCount++, 1)); + synchronizer.update({ + kind: 'workspace', + folderUri: URI.parse('file:///workspace'), + providerId: 'provider', + sessionTypeId: 'type', + sessionConfiguration, + }); + + const capture = await synchronizer.getSessionConfiguration(); + assert.deepStrictEqual({ + capture: capture.kind === 'failed' ? { kind: capture.kind, timedOut: getErrorMessage(capture.error).includes('Timed out') } : capture, + errorCount, + }, { + capture: { kind: 'failed', timedOut: true }, + errorCount: 1, + }); + }); + + test('coalesces an equal target while synchronization is pending', async () => { + const validation = new DeferredPromise(); + const { service, created } = createAutomationDraftService(); + const synchronizer = disposables.add(new AutomationSessionDraftSynchronizer(service, () => validation.p, () => { })); + const target = { + kind: 'workspace', + folderUri: URI.parse('file:///workspace'), + providerId: 'provider', + sessionTypeId: 'type', + } as const; + + synchronizer.update(target); + await Promise.resolve(); + synchronizer.update(target); + validation.complete(true); + await synchronizer.waitForSync(); + + assert.deepStrictEqual(created, [{ + kind: 'workspace', + providerId: 'provider', + sessionTypeId: 'type', + folderUri: 'file:///workspace', + }]); + }); + + test('serializes synchronization when the target changes during validation', async () => { + const firstValidation = new DeferredPromise(); + const validated: string[] = []; + const { service, created } = createAutomationDraftService(); + const synchronizer = disposables.add(new AutomationSessionDraftSynchronizer(service, async folderUri => { + validated.push(folderUri.path); + return folderUri.path === '/first' ? firstValidation.p : true; + }, () => { })); + + synchronizer.update({ kind: 'workspace', folderUri: URI.parse('file:///first'), providerId: 'provider', sessionTypeId: 'type' }); + await Promise.resolve(); + synchronizer.update({ kind: 'workspace', folderUri: URI.parse('file:///second'), providerId: 'provider', sessionTypeId: 'type' }); + const beforeFirstSettled = [...validated]; + firstValidation.complete(true); + await synchronizer.waitForSync(); + + assert.deepStrictEqual({ + beforeFirstSettled, + validated, + created, + }, { + beforeFirstSettled: ['/first'], + validated: ['/first', '/second'], + created: [{ + kind: 'workspace', + providerId: 'provider', + sessionTypeId: 'type', + folderUri: 'file:///second', + }], + }); + }); + + test('carries captured configuration when returning to a previous target', async () => { + const initialConfiguration: IAutomationSessionConfiguration = { + sessionTemplate: { modelId: 'model', modelConfiguration: { thinkingLevel: 'high' }, config: { mode: 'interactive' } }, + }; + const capturedConfiguration: IAutomationSessionConfiguration = { + sessionTemplate: { modelId: 'model', modelConfiguration: { thinkingLevel: 'low' }, config: { mode: 'plan', autoApprove: 'assisted' } }, + }; + const { service, created, sessionConfigurations } = createAutomationDraftService(); + const synchronizer = disposables.add(new AutomationSessionDraftSynchronizer(service, async () => true, () => { })); + const firstTarget = { + kind: 'workspace', + folderUri: URI.parse('file:///first'), + providerId: 'provider', + sessionTypeId: 'type', + sessionConfiguration: initialConfiguration, + } as const; + + synchronizer.update(firstTarget); + await synchronizer.waitForSync(); + sessionConfigurations.set(service.automationSession.get()!.sessionId, capturedConfiguration); + synchronizer.update({ kind: 'workspace', folderUri: URI.parse('file:///second'), providerId: 'provider', sessionTypeId: 'type' }); + await synchronizer.waitForSync(); + synchronizer.update(firstTarget); + await synchronizer.waitForSync(); + + assert.deepStrictEqual(created.map(entry => ({ + folderUri: entry.folderUri, + sessionTemplate: entry.sessionTemplate, + })), [{ + folderUri: 'file:///first', + sessionTemplate: initialConfiguration.sessionTemplate, + }, { + folderUri: 'file:///second', + sessionTemplate: undefined, + }, { + folderUri: 'file:///first', + sessionTemplate: capturedConfiguration.sessionTemplate, + }]); + }); + test('ignores stale workspace validation', async () => { const { service, created } = createAutomationDraftService(); const firstWorkspaceValidation = new DeferredPromise(); @@ -233,9 +558,8 @@ suite('Automation session draft synchronization', () => { synchronizer.update({ kind: 'workspace', folderUri: URI.parse('file:///first'), providerId: 'provider', sessionTypeId: 'type' }); await Promise.resolve(); synchronizer.update({ kind: 'workspace', folderUri: URI.parse('file:///second'), providerId: 'provider', sessionTypeId: 'type' }); - await synchronizer.waitForSync(); firstWorkspaceValidation.complete(true); - await Promise.resolve(); + await synchronizer.waitForSync(); assert.deepStrictEqual(created, [ { kind: 'workspace', providerId: 'provider', sessionTypeId: 'type', folderUri: 'file:///second' }, @@ -258,10 +582,12 @@ suite('Automation session draft synchronization', () => { created, currentSession: service.automationSession.get()?.sessionId, errorCount, + availability: synchronizer.availability.get(), }, { created: [], currentSession: undefined, errorCount: 1, + availability: 'unavailable', }); }); @@ -271,6 +597,9 @@ suite('Automation session draft synchronization', () => { let errorCount = 0; const service = upcastPartial({ automationSession, + isNewSessionTargetAvailable: () => true, + isQuickChatTargetAvailable: () => true, + supportsAutomationSessionConfiguration: () => true, createAutomationSession: (_folderUri, options) => { if (createCount++ === 0) { throw new Error('provider unavailable'); @@ -297,10 +626,12 @@ suite('Automation session draft synchronization', () => { createCount, errorCount, sessionId: automationSession.get()?.sessionId, + availability: synchronizer.availability.get(), }, { createCount: 2, errorCount: 1, sessionId: 'automation-retry', + availability: 'available', }); }); }); @@ -907,36 +1238,6 @@ suite('Automation branch picker', () => { }); }); - test('resolves a legacy model identifier to the selected concrete target', () => { - const legacyIdentifier = 'copilotcli/gpt-5.6-sol'; - const concreteIdentifier = 'agent-host-copilotcli:gpt-5.6-sol'; - const unrelatedIdentifier = 'other/gpt-5.6-sol'; - const modelIds = [legacyIdentifier, unrelatedIdentifier]; - const models = new Map([ - [legacyIdentifier, upcastPartial({ id: 'gpt-5.6-sol', targetChatSessionType: 'copilotcli' })], - [concreteIdentifier, upcastPartial({ id: 'gpt-5.6-sol', targetChatSessionType: 'agent-host-copilotcli' })], - [unrelatedIdentifier, upcastPartial({ id: 'gpt-5.6-sol', targetChatSessionType: 'other' })], - ]); - const languageModelsService = upcastPartial({ - getLanguageModelIds: () => modelIds, - lookupLanguageModel: identifier => models.get(identifier), - }); - - const beforeConcreteTargetArrives = resolveAutomationModelIdentifier(languageModelsService, legacyIdentifier, 'copilotcli', 'agent-host-copilotcli'); - modelIds.push(concreteIdentifier); - - assert.deepStrictEqual({ - beforeConcreteTargetArrives, - afterConcreteTargetArrives: resolveAutomationModelIdentifier(languageModelsService, legacyIdentifier, 'copilotcli', 'agent-host-copilotcli'), - alreadyConcrete: resolveAutomationModelIdentifier(languageModelsService, concreteIdentifier, 'copilotcli', 'agent-host-copilotcli'), - unrelated: resolveAutomationModelIdentifier(languageModelsService, unrelatedIdentifier, 'copilotcli', 'agent-host-copilotcli'), - }, { - beforeConcreteTargetArrives: legacyIdentifier, - afterConcreteTargetArrives: concreteIdentifier, - alreadyConcrete: concreteIdentifier, - unrelated: unrelatedIdentifier, - }); - }); }); suite('Automation dialog keyboard navigation', () => { @@ -995,10 +1296,15 @@ suite('Automation dialog keyboard navigation', () => { const wrapper = container.appendChild(document.createElement('div')); wrapper.tabIndex = 0; const second = wrapper.appendChild(document.createElement('button')); + const inertContainer = container.appendChild(document.createElement('div')); + inertContainer.setAttribute('inert', ''); + const inert = inertContainer.appendChild(document.createElement('button')); + const ariaDisabled = container.appendChild(document.createElement('button')); + ariaDisabled.setAttribute('aria-disabled', 'true'); const third = container.appendChild(document.createElement('button')); const navigation = disposables.add(registerAutomationDialogKeyboardNavigation( targetWindow, - () => [first, hidden, wrapper, second, third], + () => [first, hidden, wrapper, second, inert, ariaDisabled, third], () => false, )); let downstreamKeyDowns = 0; diff --git a/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts index 3362dbc6d56902..bf804259bc5c8f 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts @@ -367,9 +367,6 @@ suite('AutomationRunner', () => { createOptions: { providerId: 'local-agent-host', sessionTypeId: 'copilotcli', - modelId: undefined, - modeId: undefined, - permissionLevel: undefined, isolationMode: undefined, branch: undefined, }, @@ -575,9 +572,6 @@ suite('AutomationRunner', () => { assert.deepStrictEqual(sessionsMgmt.calls[0].createOptions, { providerId: 'local-agent-host', sessionTypeId: 'agent-host-copilotcli', - modelId: undefined, - modeId: undefined, - permissionLevel: undefined, isolationMode: undefined, branch: undefined, }); @@ -601,7 +595,12 @@ suite('AutomationRunner', () => { assert.deepStrictEqual(sessionsMgmt.calls[0].createOptions, { providerId: undefined, sessionTypeId: undefined, - modelId: undefined, + sessionTemplate: undefined, + automationConfiguration: { + modelId: undefined, + mode: 'agent', + permissionLevel: 'autopilot', + }, modeId: 'agent', permissionLevel: 'autopilot', isolationMode: undefined, @@ -609,6 +608,45 @@ suite('AutomationRunner', () => { }); }); + test('passes the complete session template at draft creation', async () => { + const { service, sessionsMgmt, runner } = setup(); + sessionsMgmt.nextSession = fakeSession('s1'); + const sessionTemplate = { + modelId: 'model', + modelConfiguration: { thinkingLevel: 'low' }, + agent: { uri: 'file:///agents/reviewer.agent.md' }, + config: { + mode: 'plan', + autoApprove: 'assisted', + providerOption: true, + }, + }; + const automation = await service.createAutomation({ + name: 'A', + prompt: 'p', + schedule: hourly(), + target: workspaceTarget(FOLDER_A, { providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }), + sessionTemplate, + modelId: 'stale-model', + mode: 'interactive', + permissionLevel: 'default', + }); + + await runner.runOnce(automation, 'schedule', 1).whenCompleted; + + assert.deepStrictEqual(sessionsMgmt.calls[0].createOptions, { + providerId: 'local-agent-host', + sessionTypeId: 'copilotcli', + sessionTemplate, + automationConfiguration: { + sessionTemplate, + }, + modelId: 'model', + isolationMode: undefined, + branch: undefined, + }); + }); + test('passes a branch only for Worktree isolation', async () => { const { service, sessionsMgmt, runner } = setup(); sessionsMgmt.nextSession = fakeSession('s1'); @@ -633,18 +671,12 @@ suite('AutomationRunner', () => { { providerId: undefined, sessionTypeId: undefined, - modelId: undefined, - modeId: undefined, - permissionLevel: undefined, isolationMode: 'worktree', branch: 'feature/worktree', }, { providerId: undefined, sessionTypeId: undefined, - modelId: undefined, - modeId: undefined, - permissionLevel: undefined, isolationMode: 'workspace', branch: undefined, }, diff --git a/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts index 34aae1239c3bc2..1b5d7cdbc44e2d 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; @@ -12,7 +13,7 @@ import { InMemoryStorageService, StorageScope, StorageTarget } from '../../../.. import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js'; import { AutomationService, AutomationStore } from '../../browser/automationService.js'; import { AutomationRunTrigger, AutomationTarget, AutomationWorkspaceIsolation, IAutomationRun, IAutomationSchedule } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; -import { AutomationActiveRunError, isAutomationActiveRunError } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { AutomationActiveRunError, type AutomationCatalogueState, isAutomationActiveRunError } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { createAutomationService, TestAutomationStorageService } from './automationTestUtils.js'; const FOLDER = URI.parse('file:///workspace'); @@ -78,8 +79,15 @@ suite('AutomationService', () => { test('starts with an empty ledger when nothing is persisted', () => { const { service } = createService(); - assert.deepStrictEqual(service.automations.get(), []); - assert.deepStrictEqual(service.runs.get(), []); + assert.deepStrictEqual({ + automations: service.automations.get(), + runs: service.runs.get(), + catalogueState: service.catalogueState.get(), + }, { + automations: [], + runs: [], + catalogueState: 'ready', + }); }); test('provider stores isolate ledgers by storage key', async () => { @@ -118,6 +126,172 @@ suite('AutomationService', () => { assert.strictEqual(a.enabled, true); }); + test('round-trips the provider session template through persistence', async () => { + const storage = teardown.add(new InMemoryStorageService()); + const service = teardown.add(createAutomationService(storage, new NullLogService(), NullTelemetryService)); + const sessionTemplate = { + modelId: 'agent-host-copilotcli:auto', + modelConfiguration: { thinkingLevel: 'low', contextSize: 200_000, futureOption: true }, + agent: { uri: 'file:///agents/reviewer.agent.md' }, + config: { + mode: 'plan', + autoApprove: 'assisted', + providerOption: { enabled: true }, + }, + }; + + await service.createAutomation({ + name: 'Daily review', + prompt: 'Summarize what changed', + schedule: dailySchedule(), + target: workspaceTarget(), + sessionTemplate, + }); + const restored = teardown.add(createAutomationService(storage, new NullLogService(), NullTelemetryService)); + const persisted = JSON.parse(storage.get('chat.automations.ledger', StorageScope.APPLICATION)!); + + assert.deepStrictEqual({ + schemaVersion: persisted.schemaVersion, + template: restored.automations.get()[0].sessionTemplate, + modelId: restored.automations.get()[0].modelId, + mode: restored.automations.get()[0].mode, + permissionLevel: restored.automations.get()[0].permissionLevel, + }, { + schemaVersion: 4, + template: sessionTemplate, + modelId: undefined, + mode: undefined, + permissionLevel: undefined, + }); + }); + + test('provider-neutral updates preserve opaque templates without projecting legacy aliases', async () => { + const { service } = createService(); + const sessionTemplate = { + modelId: 'old-model', + modelConfiguration: { thinkingLevel: 'unavailable-value' }, + config: { mode: 'ask', autoApprove: 'autopilot', providerOption: true }, + }; + const automation = await service.createAutomation({ + name: 'Daily review', + prompt: 'Summarize what changed', + schedule: dailySchedule(), + target: { ...workspaceTarget(), providerId: 'default-copilot', sessionTypeId: 'copilotcli' }, + sessionTemplate, + modelId: 'stale-model', + mode: 'interactive', + permissionLevel: 'default', + }); + + const updated = await service.updateAutomation(automation.id, { name: 'Updated review' }); + + assert.deepStrictEqual({ + sessionTemplate: updated.sessionTemplate, + modelId: updated.modelId, + mode: updated.mode, + permissionLevel: updated.permissionLevel, + }, { + sessionTemplate, + modelId: undefined, + mode: undefined, + permissionLevel: undefined, + }); + }); + + test('rejects model configuration without a model identifier before changing persistence', async () => { + const { service, storage } = createService(); + for (const modelId of [undefined, '', ' ']) { + await assert.rejects(service.createAutomation({ + name: 'Invalid model configuration', + prompt: 'Do not save', + schedule: dailySchedule(), + target: workspaceTarget(), + sessionTemplate: { modelId, modelConfiguration: { thinkingLevel: 'low' } }, + }), /model configuration requires a model identifier/); + } + const sessionTemplate = { modelId: 'model', modelConfiguration: { thinkingLevel: 'low' } }; + const automation = await service.createAutomation({ + name: 'Valid model configuration', + prompt: 'Keep the saved configuration', + schedule: dailySchedule(), + target: workspaceTarget(), + sessionTemplate, + }); + const persisted = storage.get('chat.automations.ledger', StorageScope.APPLICATION); + await assert.rejects(service.updateAutomation(automation.id, { + sessionTemplate: { modelConfiguration: {} }, + }), /model configuration requires a model identifier/); + await assert.rejects(service.importAutomationSnapshot({ + automation: { ...automation, id: 'invalid-import', sessionTemplate: { modelConfiguration: { thinkingLevel: 'low' } } }, + runs: [], + }), /model configuration requires a model identifier/); + + assert.deepStrictEqual({ + templates: service.automations.get().map(entry => entry.sessionTemplate), + persisted: storage.get('chat.automations.ledger', StorageScope.APPLICATION), + }, { + templates: [sessionTemplate], + persisted, + }); + }); + + test('rejects legacy alias updates to a canonical session template', async () => { + const { service } = createService(); + const sessionTemplate = { + modelId: 'model', + modelConfiguration: { thinkingLevel: 'low' }, + config: { mode: 'ask', autoApprove: 'autopilot' }, + }; + const automation = await service.createAutomation({ + name: 'Daily review', + prompt: 'Summarize what changed', + schedule: dailySchedule(), + target: workspaceTarget(), + sessionTemplate, + }); + + await assert.rejects( + () => service.updateAutomation(automation.id, { mode: 'autopilot' }), + /cannot be updated through legacy configuration aliases/, + ); + assert.deepStrictEqual(service.getAutomation(automation.id)?.sessionTemplate, sessionTemplate); + }); + + test('an explicit session template replaces stale legacy aliases', async () => { + const { service } = createService(); + const automation = await service.createAutomation({ + name: 'Daily review', + prompt: 'Summarize what changed', + schedule: dailySchedule(), + target: workspaceTarget(), + sessionTemplate: { + modelId: 'old-model', + config: { mode: 'interactive', autoApprove: 'default' }, + }, + modelId: 'old-model', + mode: 'interactive', + permissionLevel: 'default', + }); + const sessionTemplate = { + modelId: 'new-model', + config: { mode: 'plan', autoApprove: 'assisted' }, + }; + + const updated = await service.updateAutomation(automation.id, { sessionTemplate }); + + assert.deepStrictEqual({ + sessionTemplate: updated.sessionTemplate, + modelId: updated.modelId, + mode: updated.mode, + permissionLevel: updated.permissionLevel, + }, { + sessionTemplate, + modelId: undefined, + mode: undefined, + permissionLevel: undefined, + }); + }); + test('createAutomation with manual schedule leaves nextRunAt undefined', async () => { const { service } = createService(); const a = await service.createAutomation({ @@ -208,16 +382,18 @@ suite('AutomationService', () => { assert.strictEqual(b.name, 'B'); }); - test('updateAutomation can clear modelId/mode/permissionLevel by passing null but keeps folderUri', async () => { + test('updateAutomation can clear the session template and legacy fields by passing null but keeps folderUri', async () => { const { service } = createService(); const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: dailySchedule(), target: workspaceTarget(), + sessionTemplate: { modelId: 'model', modelConfiguration: { thinkingLevel: 'low' }, config: { mode: 'autopilot' } }, modelId: 'gpt-4', mode: 'agent', permissionLevel: 'autopilot', }); - const b = await service.updateAutomation(a.id, { modelId: null, mode: null, permissionLevel: null }); + const b = await service.updateAutomation(a.id, { sessionTemplate: null, modelId: null, mode: null, permissionLevel: null }); + assert.strictEqual(b.sessionTemplate, undefined); assert.strictEqual(b.modelId, undefined); assert.strictEqual(b.mode, undefined); assert.strictEqual(b.permissionLevel, undefined); @@ -232,6 +408,36 @@ suite('AutomationService', () => { assert.strictEqual(b.target.kind === 'workspace' ? b.target.folderUri.toString() : undefined, other.toString()); }); + test('updateAutomation clears provider configuration when the target authority changes', async () => { + const { service } = createService(); + const a = await service.createAutomation({ + name: 'A', + prompt: 'p', + schedule: dailySchedule(), + target: { ...workspaceTarget(), providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, + sessionTemplate: { config: { mode: 'autopilot', autoApprove: 'assisted' } }, + modelId: 'gpt-4', + mode: 'autopilot', + permissionLevel: 'assisted', + }); + + const b = await service.updateAutomation(a.id, { + target: { ...workspaceTarget(), providerId: 'local-agent-host', sessionTypeId: 'claude' }, + }); + + assert.deepStrictEqual({ + sessionTemplate: b.sessionTemplate, + modelId: b.modelId, + mode: b.mode, + permissionLevel: b.permissionLevel, + }, { + sessionTemplate: undefined, + modelId: undefined, + mode: undefined, + permissionLevel: 'default', + }); + }); + test('updateAutomation rejects incomplete workspace-less targets', async () => { const { service } = createService(); const automation = await service.createAutomation({ name: 'A', prompt: 'p', schedule: dailySchedule(), target: workspaceTarget() }); @@ -613,6 +819,7 @@ suite('AutomationService', () => { // but the service is now in read-only mode. assert.deepStrictEqual(service.automations.get(), []); assert.deepStrictEqual(service.runs.get(), []); + assert.strictEqual(service.catalogueState.get(), 'error'); // A subsequent mutation must be rejected (read-only mode) and must not // destroy the on-disk newer ledger. @@ -638,7 +845,68 @@ suite('AutomationService', () => { // The onDidChangeValue refresh must NOT clear our observables to // empty. We keep displaying what we last knew about. - assert.strictEqual(service.automations.get().length, 1); + assert.deepStrictEqual({ + automationCount: service.automations.get().length, + catalogueState: service.catalogueState.get(), + }, { + automationCount: 1, + catalogueState: 'error', + }); + }); + + test('refreshFromStorage reports malformed storage after a newer valid revision', async () => { + const storage = teardown.add(new InMemoryStorageService()); + const service = teardown.add(createAutomationService(storage, new NullLogService(), NullTelemetryService)); + await service.createAutomation({ name: 'Local', prompt: 'p', schedule: dailySchedule(), target: workspaceTarget() }); + const emissions: Array<{ automationCount: number; catalogueState: AutomationCatalogueState }> = []; + teardown.add(autorun(reader => emissions.push({ + automationCount: service.automations.read(reader).length, + catalogueState: service.catalogueState.read(reader), + }))); + + storage.store('chat.automations.ledger', '{', StorageScope.APPLICATION, StorageTarget.MACHINE); + + assert.deepStrictEqual({ + automationCount: service.automations.get().length, + catalogueState: service.catalogueState.get(), + emissions, + }, { + automationCount: 1, + catalogueState: 'error', + emissions: [ + { automationCount: 1, catalogueState: 'ready' }, + { automationCount: 1, catalogueState: 'error' }, + ], + }); + }); + + test('publishes catalogue contents and readability atomically on refresh and recovery', async () => { + const { service, storage } = createService(); + const initialLedger = JSON.stringify({ + schemaVersion: 4, revision: 5, + automations: [serializeLedgerAutomation('saved', 'Saved')], + runs: [], + }); + storage.store('chat.automations.ledger', initialLedger, StorageScope.APPLICATION, StorageTarget.MACHINE); + const emissions: Array<{ ids: string[]; catalogueState: AutomationCatalogueState; readable: boolean }> = []; + teardown.add(autorun(reader => emissions.push({ + ids: service.automations.read(reader).map(automation => automation.id), + catalogueState: service.catalogueState.read(reader), + readable: service.canCompleteMigration(), + }))); + + storage.store('chat.automations.ledger', JSON.stringify({ + schemaVersion: 4, revision: 6, automations: [], runs: null, + }), StorageScope.APPLICATION, StorageTarget.MACHINE); + storage.store('chat.automations.ledger', initialLedger, StorageScope.APPLICATION, StorageTarget.MACHINE); + await service.updateAutomation('saved', { name: 'Recovered' }); + + assert.deepStrictEqual(emissions, [ + { ids: ['saved'], catalogueState: 'ready', readable: true }, + { ids: [], catalogueState: 'error', readable: false }, + { ids: ['saved'], catalogueState: 'ready', readable: true }, + { ids: ['saved'], catalogueState: 'ready', readable: true }, + ]); }); test('persist bumps the revision counter on every write', async () => { @@ -668,7 +936,7 @@ suite('AutomationService', () => { test('successful CAS accepts a restored lower revision without accepting stale notifications', async () => { const storage = teardown.add(new InMemoryStorageService()); storage.store('chat.automations.ledger', JSON.stringify({ - schemaVersion: 3, + schemaVersion: 4, revision: 40, automations: [serializeLedgerAutomation('newer', 'Before restore')], runs: [], @@ -796,7 +1064,7 @@ suite('AutomationService', () => { runIds: persisted.runs.map((run: { id: string }) => run.id), canCompleteMigration: service.canCompleteMigration(), }, { - schemaVersion: 3, + schemaVersion: 4, automationIds: ['keep', 'quick'], keepName: 'Updated', runIds: ['r-keep', 'r-quick'], @@ -804,7 +1072,7 @@ suite('AutomationService', () => { }); }); - test('migrates schema v2 flat targets to schema v3 target unions', async () => { + test('migrates schema v2 flat targets to the current target union', async () => { const storage = teardown.add(new InMemoryStorageService()); const common = { prompt: 'p', @@ -832,7 +1100,7 @@ suite('AutomationService', () => { await service.updateAutomation('workspace', { name: 'Updated' }); const migrated = JSON.parse(storage.get('chat.automations.ledger', -1)!); - assert.strictEqual(migrated.schemaVersion, 3); + assert.strictEqual(migrated.schemaVersion, 4); }); test('round-trips a folderUri through persistence', async () => { diff --git a/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts index faff57245539c6..046f98543130db 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts @@ -18,7 +18,7 @@ import { NullTelemetryService } from '../../../../../platform/telemetry/common/t import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { AutomationRunTrigger, AutomationTarget, IAutomationDescriptor, IAutomationRun, IAutomationSchedule } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; import { IAutomationRunDispatch, IAutomationRunner, IAutomationRunOperation } from '../../../../../workbench/contrib/chat/common/automations/automationRunner.js'; -import { IAutomationService, ICreateAutomationOptions, IGuardedAutomationUpdateResult, IUpdateAutomationOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { type AutomationCatalogueState, AutomationSessionTemplateAuthorityError, IAutomationService, ICreateAutomationOptions, IGuardedAutomationUpdateResult, IUpdateAutomationOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { ChatAutomationsEnabledContext, CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { IToolImpl, IToolInvocation, IToolResult, ToolProgress } from '../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; import { IChat, ISession, ISessionType, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; @@ -58,6 +58,7 @@ function createAutomation(overrides?: Partial): IAutomati } class FakeAutomationService extends mock() { + override readonly catalogueState = observableValue(this, 'ready'); override readonly automations = observableValue(this, []); override readonly runs = observableValue(this, []); readonly created: ICreateAutomationOptions[] = []; @@ -106,6 +107,7 @@ class FakeAutomationService extends mock() { prompt: patch.prompt ?? existing.prompt, schedule: patch.schedule ?? existing.schedule, target: patch.target ?? existing.target, + sessionTemplate: patch.sessionTemplate === null ? undefined : patch.sessionTemplate ?? existing.sessionTemplate, modelId: patch.modelId === null ? undefined : patch.modelId ?? existing.modelId, mode: patch.mode === null ? undefined : patch.mode ?? existing.mode, permissionLevel: patch.permissionLevel === null ? undefined : patch.permissionLevel ?? existing.permissionLevel, @@ -230,6 +232,7 @@ function editableAutomationKey(automation: IAutomationDescriptor): string { target: automation.target.kind === 'workspace' ? { ...automation.target, folderUri: automation.target.folderUri.toString() } : automation.target, + sessionTemplate: automation.sessionTemplate, modelId: automation.modelId, mode: automation.mode, permissionLevel: automation.permissionLevel, @@ -239,7 +242,7 @@ function editableAutomationKey(automation: IAutomationDescriptor): string { function serializeAutomationLedger(automations: readonly IAutomationDescriptor[], revision = 1): string { return JSON.stringify({ - schemaVersion: 3, + schemaVersion: 4, revision, automations: automations.map(automation => ({ ...automation, @@ -405,20 +408,34 @@ suite('AutomationTools', () => { requiresExplicitAutomationIntent: modelDescription.includes('only when the user explicitly asks for an automation'), allowsRecurringScheduleIntent: modelDescription.includes('or for a prompt to run on a recurring schedule'), excludesMonitoringRequests: modelDescription.includes('Do not infer that intent from requests merely to monitor, watch, follow, or keep something'), + usesProviderTemplate: modelDescription.includes('Use "sessionTemplate" for provider-owned Model, Agent, Mode, Approvals'), + rejectsMixedAliases: modelDescription.includes('Do not combine it with the legacy "modelId", "mode", or "permissionLevel" aliases'), }, { requiresExplicitAutomationIntent: true, allowsRecurringScheduleIntent: true, excludesMonitoringRequests: true, + usesProviderTemplate: true, + rejectsMixedAliases: true, }); }); test('listAutomations returns stable IDs and editable fields', async () => { - const automation = createAutomation(); + const sessionTemplate = { + modelId: 'gpt-test', + agent: { uri: 'file:///agents/reviewer.agent.md' }, + config: { + mode: 'agent', + autoApprove: 'default', + providerOption: { enabled: true }, + }, + }; + const automation = createAutomation({ sessionTemplate }); const tool = new ListAutomationsTool(new FakeAutomationService([automation]), createConfigurationService()); const result = await invoke(tool, {}); assert.deepStrictEqual(JSON.parse(getText(result)), { + catalogueState: 'ready', automations: [{ id: 'automation-1', name: 'Daily review', @@ -431,9 +448,7 @@ suite('AutomationTools', () => { sessionTypeId: 'copilot', isolation: { kind: 'default' }, }, - modelId: 'gpt-test', - mode: 'agent', - permissionLevel: 'default', + sessionTemplate, enabled: true, createdAt: NOW, updatedAt: NOW, @@ -443,6 +458,65 @@ suite('AutomationTools', () => { }); }); + test('listAutomations describes when its catalogue is complete', () => { + const description = new ListAutomationsTool(new FakeAutomationService(), createConfigurationService()).getToolData().modelDescription ?? ''; + + assert.deepStrictEqual({ + reportsState: description.includes('catalogueState'), + definesComplete: description.includes('only "ready" means the list is complete'), + warnsAboutFalseEmpty: description.includes('never interpret an empty non-ready result as no configured automations'), + }, { reportsState: true, definesComplete: true, warnsAboutFalseEmpty: true }); + }); + + for (const catalogueState of ['loading', 'unavailable', 'error'] as const) { + test(`listAutomations preserves available rows in an incomplete ${catalogueState} catalogue`, async () => { + const automationService = new FakeAutomationService(); + automationService.catalogueState.set(catalogueState, undefined); + const tool = new ListAutomationsTool(automationService, createConfigurationService()); + const empty = await invoke(tool, {}); + const sessionTemplate = { modelId: 'provider-model', config: { providerOption: true } }; + automationService.automations.set([createAutomation({ sessionTemplate })], undefined); + const populated = await invoke(tool, {}); + const populatedContent = JSON.parse(getText(populated)); + + assert.deepStrictEqual({ + empty: JSON.parse(getText(empty)), + emptyMessage: empty.toolResultMessage, + populated: { + state: populatedContent.catalogueState, + ids: populatedContent.automations.map((automation: { id: string }) => automation.id), + sessionTemplate: populatedContent.automations[0].sessionTemplate, + }, + populatedMessage: populated.toolResultMessage, + }, { + empty: { catalogueState, automations: [] }, + emptyMessage: 'Listed 0 available automations; catalogue is incomplete', + populated: { state: catalogueState, ids: ['automation-1'], sessionTemplate }, + populatedMessage: 'Listed 1 available automations; catalogue is incomplete', + }); + }); + } + + test('listAutomations emits flat aliases only for legacy rows', async () => { + const automation = createAutomation(); + const tool = new ListAutomationsTool(new FakeAutomationService([automation]), createConfigurationService()); + + const result = await invoke(tool, {}); + const listed = JSON.parse(getText(result)).automations[0]; + + assert.deepStrictEqual({ + sessionTemplate: listed.sessionTemplate, + modelId: listed.modelId, + mode: listed.mode, + permissionLevel: listed.permissionLevel, + }, { + sessionTemplate: undefined, + modelId: 'gpt-test', + mode: 'agent', + permissionLevel: 'default', + }); + }); + test('runAutomation confirms and starts a manual run', async () => { const automation = createAutomation(); const automationService = new FakeAutomationService([automation]); @@ -830,6 +904,162 @@ suite('AutomationTools', () => { }); }); + test('configureAutomation accepts a provider mode returned by listAutomations', async () => { + const existing = createAutomation({ mode: 'autopilot' }); + const automationService = new FakeAutomationService([existing]); + const tool = new ConfigureAutomationTool( + automationService, + new FakeSessionsManagementService(undefined), + createConfigurationService(), + ); + const parameters = { + automationId: existing.id, + mode: 'autopilot', + }; + const prepared = await tool.prepareToolInvocation!({ + parameters, + toolCallId: 'update-call', + chatSessionResource: SESSION_RESOURCE, + }, CancellationToken.None); + + await invoke(tool, parameters, SESSION_RESOURCE, CancellationToken.None, undefined, prepared.toolSpecificData); + + assert.deepStrictEqual(automationService.updated, [{ + id: existing.id, + patch: { mode: 'autopilot' }, + }]); + }); + + test('configureAutomation updates the complete provider session template', async () => { + const existing = createAutomation({ + sessionTemplate: { + modelId: 'old-model', + config: { mode: 'interactive', providerOption: false }, + }, + }); + const automationService = new FakeAutomationService([existing]); + const tool = new ConfigureAutomationTool( + automationService, + new FakeSessionsManagementService(undefined), + createConfigurationService(), + ); + const sessionTemplate = { + modelId: 'new-model', + modelConfiguration: { thinkingLevel: 'low', contextSize: 200_000, futureOption: null }, + agent: { uri: 'file:///agents/reviewer.agent.md' }, + config: { + mode: 'plan', + autoApprove: 'assisted', + providerOption: { enabled: true }, + }, + }; + + await invoke(tool, { + automationId: existing.id, + sessionTemplate, + }); + + assert.deepStrictEqual(automationService.updated, [{ + id: existing.id, + patch: { sessionTemplate }, + }]); + }); + + test('listAutomations preserves model options for a configureAutomation round-trip', async () => { + const sessionTemplate = { modelId: 'model', modelConfiguration: { thinkingLevel: 'low', futureOption: true } }; + const existing = createAutomation({ sessionTemplate }); + const automationService = new FakeAutomationService([existing]); + const configurationService = createConfigurationService(); + const listed = await invoke(new ListAutomationsTool(automationService, configurationService), {}); + const returnedTemplate = JSON.parse(getText(listed)).automations[0].sessionTemplate; + const tool = new ConfigureAutomationTool(automationService, new FakeSessionsManagementService(undefined), configurationService); + await invoke(tool, { automationId: existing.id, sessionTemplate: returnedTemplate }); + + assert.deepStrictEqual(automationService.updated, [{ id: existing.id, patch: { sessionTemplate } }]); + }); + + test('configureAutomation validates and bounds model-specific configuration', async () => { + const automationService = new FakeAutomationService(); + const tool = new ConfigureAutomationTool(automationService, new FakeSessionsManagementService(undefined), createConfigurationService()); + const errors: IToolResult['toolResultError'][] = []; + for (const sessionTemplate of [ + { modelConfiguration: { thinkingLevel: 'low' } }, + { modelId: 'model', modelConfiguration: { nested: { value: true } } }, + { modelId: 'model', modelConfiguration: { value: Number.POSITIVE_INFINITY } }, + { modelId: 'model', modelConfiguration: { value: 'x'.repeat(70_000) } }, + { modelId: 'model', modelConfiguration: Object.fromEntries(Array.from({ length: 10_001 }, (_, index) => [index, null])) }, + ]) { + const result = await invoke(tool, { + name: 'Invalid model configuration', + prompt: 'Do not save', + schedule: { interval: 'manual' }, + target: { kind: 'workspace', folderUri: FOLDER.toString() }, + sessionTemplate, + }); + errors.push(result.toolResultError); + } + + assert.deepStrictEqual({ errors, created: automationService.created }, { + errors: [ + '"sessionTemplate.modelConfiguration" requires "sessionTemplate.modelId".', + '"sessionTemplate.modelConfiguration" must contain only JSON primitive values.', + '"sessionTemplate.modelConfiguration.value" must be JSON-safe.', + '"sessionTemplate.modelConfiguration" must not exceed 65536 characters.', + '"sessionTemplate.modelConfiguration" must not contain more than 10000 values.', + ], + created: [], + }); + }); + + test('configureAutomation reports legacy alias updates to a canonical template as input errors', async () => { + const existing = createAutomation({ + sessionTemplate: { + modelId: 'model', + config: { mode: 'interactive', autoApprove: 'default' }, + }, + }); + const automationService = new FakeAutomationService([existing]); + const tool = new ConfigureAutomationTool( + automationService, + new FakeSessionsManagementService(undefined), + createConfigurationService(), + ); + + const result = await invoke(tool, { + automationId: existing.id, + permissionLevel: 'autoApprove', + }); + + assert.deepStrictEqual({ + error: result.toolResultError, + updates: automationService.updated, + }, { + error: 'Legacy "modelId", "mode", and "permissionLevel" aliases cannot update an automation with a canonical session template. Pass the complete updated "sessionTemplate" returned by listAutomations.', + updates: [], + }); + }); + + test('configureAutomation surfaces authority changes detected during the guarded update', async () => { + const existing = createAutomation(); + const automationService = new class extends FakeAutomationService { + override async updateAutomationIfUnchanged(): Promise { + throw new AutomationSessionTemplateAuthorityError(); + } + }([existing]); + const tool = new ConfigureAutomationTool( + automationService, + new FakeSessionsManagementService(undefined), + createConfigurationService(), + ); + + const result = await invoke(tool, { + automationId: existing.id, + permissionLevel: 'autoApprove', + }); + + assert.strictEqual(result.toolResultError, 'A canonical Automation session template cannot be updated through legacy configuration aliases.'); + }); + test('configureAutomation rejects editable changes made while awaiting approval', async () => { const existing = createAutomation(); const automationService = new FakeAutomationService([existing]); @@ -1133,13 +1363,76 @@ suite('AutomationTools', () => { branch: 'main', }, }); + const mixedConfigurationResult = await invoke(tool, { + name: 'Mixed configuration', + prompt: 'Do not save', + schedule: { interval: 'manual' }, + target: { kind: 'workspace', folderUri: FOLDER.toString() }, + mode: 'agent', + sessionTemplate: { config: { mode: 'plan' } }, + }); + const unsafeConfigurationResult = await invoke(tool, { + name: 'Unsafe configuration', + prompt: 'Do not save', + schedule: { interval: 'manual' }, + target: { kind: 'workspace', folderUri: FOLDER.toString() }, + sessionTemplate: { config: { value: new Date(0) } }, + }); assert.deepStrictEqual({ staleError: staleResult.toolResultError, targetError: malformedTargetResult.toolResultError, + mixedConfigurationError: mixedConfigurationResult.toolResultError, + unsafeConfigurationError: unsafeConfigurationResult.toolResultError, }, { staleError: 'Automation "missing" does not exist. Call listAutomations to refresh the available IDs.', targetError: '"target.folderUri" must be a valid absolute URI.', + mixedConfigurationError: '"sessionTemplate" cannot be combined with legacy "modelId", "mode", or "permissionLevel" aliases.', + unsafeConfigurationError: '"sessionTemplate.config.value" must contain only JSON values.', + }); + }); + + test('configureAutomation bounds opaque provider configuration', async () => { + const tool = new ConfigureAutomationTool( + new FakeAutomationService(), + new FakeSessionsManagementService(undefined), + createConfigurationService(), + ); + let deeplyNested: Record = {}; + for (let depth = 0; depth < 40; depth++) { + deeplyNested = { nested: deeplyNested }; + } + const target = { kind: 'workspace', folderUri: FOLDER.toString() }; + const deeplyNestedResult = await invoke(tool, { + name: 'Deep configuration', + prompt: 'Do not save', + schedule: { interval: 'manual' }, + target, + sessionTemplate: { config: deeplyNested }, + }); + const oversizedResult = await invoke(tool, { + name: 'Large configuration', + prompt: 'Do not save', + schedule: { interval: 'manual' }, + target, + sessionTemplate: { config: { value: 'x'.repeat(70_000) } }, + }); + const tooManyValuesResult = await invoke(tool, { + name: 'Wide configuration', + prompt: 'Do not save', + schedule: { interval: 'manual' }, + target, + sessionTemplate: { config: { values: Array.from({ length: 10_001 }, () => null) } }, + }); + + assert.deepStrictEqual({ + depthBounded: typeof deeplyNestedResult.toolResultError === 'string' && deeplyNestedResult.toolResultError.includes('exceeds the maximum nesting depth of 32'), + sizeBounded: oversizedResult.toolResultError, + nodeCountBounded: tooManyValuesResult.toolResultError, + }, { + depthBounded: true, + sizeBounded: '"sessionTemplate.config" must not exceed 65536 characters.', + nodeCountBounded: '"sessionTemplate.config" must not contain more than 10000 values.', }); }); diff --git a/src/vs/sessions/contrib/automations/test/browser/providerAutomationService.test.ts b/src/vs/sessions/contrib/automations/test/browser/providerAutomationService.test.ts index 7d39b2550ff979..1315fe079d8ea3 100644 --- a/src/vs/sessions/contrib/automations/test/browser/providerAutomationService.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/providerAutomationService.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { Emitter } from '../../../../../base/common/event.js'; +import { autorun, type ITransaction, observableValue, transaction } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; @@ -16,7 +17,7 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js'; import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { IAutomation, IAutomationSnapshotImportResult, ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; -import { AutomationActiveRunError } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { AutomationActiveRunError, AutomationCatalogueState } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { AutomationStore } from '../../browser/automationService.js'; import { ProviderAutomationService } from '../../browser/providerAutomationService.js'; import { AUTOMATION_STORAGE_KEY, IAutomationStorageService, providerAutomationStorageKey } from '../../common/automationStorageService.js'; @@ -32,6 +33,15 @@ class FailingStaleRunRecoveryAutomationStore extends AutomationStore { } } +class MutableCatalogueAutomationStore extends AutomationStore { + private readonly state = observableValue(this, 'ready'); + override readonly catalogueState = this.state; + + setCatalogueState(state: AutomationCatalogueState, tx?: ITransaction): void { + this.state.set(state, tx); + } +} + class MigrationDeferringAutomationStore extends AutomationStore { recoveryCalls = 0; migrationCalls = 0; @@ -127,17 +137,25 @@ class DestinationDeletingTransferAutomationStore extends AutomationStore { suite('ProviderAutomationService', () => { const teardown = ensureNoDisposablesAreLeakedInTestSuite(); - function createService(legacyRaw?: string, providerRaw?: string, providerFailure?: 'staleRunRecovery' | 'migration' | 'transfer' | 'acknowledgement' | 'concurrentMigrationUpdate' | 'concurrentMigrationDelete' | 'concurrentMigrationRun' | 'continuousMigrationUpdate' | 'concurrentTransferRun' | 'destinationDeleteDuringRollback'): { + function createService( + legacyRaw?: string, + providerRaw?: string, + providerFailure?: 'staleRunRecovery' | 'migration' | 'transfer' | 'acknowledgement' | 'concurrentMigrationUpdate' | 'concurrentMigrationDelete' | 'concurrentMigrationRun' | 'continuousMigrationUpdate' | 'concurrentTransferRun' | 'destinationDeleteDuringRollback', + registerDefaultProvider = true, + initialProvidersSettled = true, + ): { readonly service: ProviderAutomationService; readonly providerStore: AutomationStore; readonly storage: InMemoryStorageService; readonly automationStorage: TestAutomationStorageService; readonly addProvider: (provider: ISessionsProvider) => void; + readonly settleInitialProviders: () => void; } { const storage = teardown.add(new InMemoryStorageService()); if (legacyRaw) { storage.store(AUTOMATION_STORAGE_KEY, legacyRaw, StorageScope.APPLICATION, StorageTarget.MACHINE); } + if (providerRaw) { storage.store(providerAutomationStorageKey(PROVIDER_ID), providerRaw, StorageScope.APPLICATION, StorageTarget.MACHINE); } @@ -196,7 +214,7 @@ suite('ProviderAutomationService', () => { order: 0, automations: providerStore, }); - const registeredProviders: ISessionsProvider[] = [provider]; + const registeredProviders: ISessionsProvider[] = registerDefaultProvider ? [provider] : []; const providersChanged = teardown.add(new Emitter()); const providers = upcastPartial({ onDidChangeProviders: providersChanged.event, @@ -210,7 +228,8 @@ suite('ProviderAutomationService', () => { instantiationService.stub(IAutomationStorageService, automationStorage); instantiationService.stub(ISessionsProvidersService, providers); instantiationService.stub(IInstantiationService, instantiationService); - const service = teardown.add(instantiationService.createInstance(ProviderAutomationService)); + const providersSettled = observableValue('initialProvidersSettled', initialProvidersSettled); + const service = teardown.add(instantiationService.createInstance(ProviderAutomationService, providersSettled)); return { service, providerStore, @@ -220,9 +239,142 @@ suite('ProviderAutomationService', () => { registeredProviders.push(addedProvider); providersChanged.fire({ added: [addedProvider], removed: [] }); }, + settleInitialProviders: () => providersSettled.set(true, undefined), }; } + test('aggregates provider catalogue state', () => { + const { service, storage, automationStorage, addProvider } = createService(); + const emissions: AutomationCatalogueState[] = []; + teardown.add(autorun(reader => emissions.push(service.catalogueState.read(reader)))); + const store = teardown.add(new MutableCatalogueAutomationStore( + providerAutomationStorageKey('stateful-provider'), + storage, + new NullLogService(), + NullTelemetryService, + automationStorage, + )); + store.setCatalogueState('loading'); + addProvider(upcastPartial({ id: 'stateful-provider', order: 1, automations: store })); + const loading = service.catalogueState.get(); + store.setCatalogueState('error'); + const error = service.catalogueState.get(); + store.setCatalogueState('unavailable'); + const unavailable = service.catalogueState.get(); + store.setCatalogueState('ready'); + const ready = service.catalogueState.get(); + + assert.deepStrictEqual({ loading, error, unavailable, ready }, { + loading: 'loading', + error: 'error', + unavailable: 'unavailable', + ready: 'ready', + }); + assert.deepStrictEqual(emissions, ['ready', 'loading', 'error', 'unavailable', 'ready']); + }); + + test('settles a provider-less catalogue after initial provider contributions complete', () => { + const { service, providerStore, addProvider, settleInitialProviders } = createService(undefined, undefined, undefined, false, false); + const beforeSettlement = service.catalogueState.get(); + settleInitialProviders(); + const afterSettlement = service.catalogueState.get(); + addProvider(upcastPartial({ id: PROVIDER_ID, order: 0, automations: providerStore })); + + assert.deepStrictEqual({ + beforeSettlement, + afterSettlement, + afterRegistration: service.catalogueState.get(), + }, { + beforeSettlement: 'loading', + afterSettlement: 'ready', + afterRegistration: 'ready', + }); + }); + + test('keeps registered providers loading until initial contributions settle', () => { + const { service, providerStore, addProvider, settleInitialProviders } = createService(undefined, undefined, undefined, false, false); + const emissions: AutomationCatalogueState[] = []; + teardown.add(autorun(reader => emissions.push(service.catalogueState.read(reader)))); + addProvider(upcastPartial({ id: PROVIDER_ID, order: 0, automations: providerStore })); + const beforeSettlement = service.catalogueState.get(); + settleInitialProviders(); + + assert.deepStrictEqual({ beforeSettlement, emissions }, { + beforeSettlement: 'loading', + emissions: ['loading', 'ready'], + }); + }); + + test('legacy rows do not make initial provider discovery authoritative', async () => { + const { service, settleInitialProviders } = createService(undefined, undefined, undefined, false, false); + await service.createAutomation({ + name: 'Legacy only', + prompt: 'Review changes.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'workspace', folderUri: FOLDER, isolation: { kind: 'default' } }, + }); + const beforeSettlement = service.catalogueState.get(); + settleInitialProviders(); + + assert.deepStrictEqual({ + beforeSettlement, + afterSettlement: service.catalogueState.get(), + names: service.automations.get().map(automation => automation.name), + }, { + beforeSettlement: 'loading', + afterSettlement: 'ready', + names: ['Legacy only'], + }); + }); + + test('aggregates error, loading, and unavailable states independently of provider order', () => { + const { service, storage, automationStorage, addProvider } = createService(); + const first = teardown.add(new MutableCatalogueAutomationStore('first', storage, new NullLogService(), NullTelemetryService, automationStorage)); + const second = teardown.add(new MutableCatalogueAutomationStore('second', storage, new NullLogService(), NullTelemetryService, automationStorage)); + addProvider(upcastPartial({ id: 'first', order: 1, automations: first })); + addProvider(upcastPartial({ id: 'second', order: 2, automations: second })); + let observedState: AutomationCatalogueState = 'ready'; + teardown.add(autorun(reader => observedState = service.catalogueState.read(reader))); + const states: readonly AutomationCatalogueState[] = ['ready', 'unavailable', 'loading', 'error']; + const actual = states.map(firstState => states.map(secondState => { + transaction(tx => { + first.setCatalogueState(firstState, tx); + second.setCatalogueState(secondState, tx); + }); + return observedState; + })); + + assert.deepStrictEqual(actual, [ + ['ready', 'unavailable', 'loading', 'error'], + ['unavailable', 'unavailable', 'loading', 'error'], + ['loading', 'loading', 'loading', 'error'], + ['error', 'error', 'error', 'error'], + ]); + }); + + test('does not let provider loading mask a legacy catalogue error', () => { + const { service, storage, automationStorage, addProvider } = createService('{', undefined, undefined, false); + const emissions: AutomationCatalogueState[] = []; + teardown.add(autorun(reader => emissions.push(service.catalogueState.read(reader)))); + const store = teardown.add(new MutableCatalogueAutomationStore( + providerAutomationStorageKey('loading-provider'), + storage, + new NullLogService(), + NullTelemetryService, + automationStorage, + )); + store.setCatalogueState('loading'); + addProvider(upcastPartial({ id: 'loading-provider', order: 1, automations: store })); + + assert.deepStrictEqual({ + catalogueState: service.catalogueState.get(), + emissions, + }, { + catalogueState: 'error', + emissions: ['error'], + }); + }); + test('routes new Automations to their provider store', async () => { const { service, providerStore, storage } = createService(); await service.createAutomation({ @@ -246,6 +398,40 @@ suite('ProviderAutomationService', () => { }); }); + test('an unavailable remote catalogue does not block local automation operations', async () => { + const { service, providerStore, storage, automationStorage, addProvider } = createService(); + const remote = teardown.add(new MutableCatalogueAutomationStore('remote', storage, new NullLogService(), NullTelemetryService, automationStorage)); + remote.setCatalogueState('unavailable'); + addProvider(upcastPartial({ id: 'remote', order: 1, automations: remote })); + + const created = await service.createAutomation({ + name: 'Local review', + prompt: 'Review local changes.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'workspace', folderUri: FOLDER, providerId: PROVIDER_ID, sessionTypeId: SESSION_TYPE_ID, isolation: { kind: 'default' } }, + }); + await service.updateAutomation(created.id, { name: 'Updated local review' }); + const claim = await service.recordRunStart(created.id, 'manual', 1); + + assert.deepStrictEqual({ + catalogueState: service.catalogueState.get(), + localNames: providerStore.automations.get().map(automation => automation.name), + remoteAutomations: remote.automations.get(), + canRun: service.canRunAutomation(created.id), + canUpdate: service.canUpdateAutomation(created.id), + claimed: claim.claimed, + activeRunId: providerStore.getActiveRunFor(created.id)?.id, + }, { + catalogueState: 'unavailable', + localNames: ['Updated local review'], + remoteAutomations: [], + canRun: true, + canUpdate: true, + claimed: true, + activeRunId: claim.run.id, + }); + }); + test('transfers Automations and runs when updates change store ownership', async () => { const { service, providerStore, storage } = createService(); const legacyTarget = { kind: 'workspace', folderUri: FOLDER, providerId: 'provider-without-storage', sessionTypeId: 'other', isolation: { kind: 'default' } } as const; @@ -414,6 +600,9 @@ suite('ProviderAutomationService', () => { prompt: 'prompt', schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, target: legacyTarget, + modelId: 'legacy-model', + mode: 'ask', + permissionLevel: 'autopilot', }); await assert.rejects(service.updateAutomation(created.id, { @@ -446,9 +635,9 @@ suite('ProviderAutomationService', () => { legacyPrompt: 'prompt', legacySchedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, legacyTarget: { ...legacyTarget, folderUri: FOLDER.toString() }, - legacyModelId: undefined, - legacyMode: undefined, - legacyPermissionLevel: undefined, + legacyModelId: 'legacy-model', + legacyMode: 'ask', + legacyPermissionLevel: 'autopilot', legacyEnabled: true, legacyRunStatuses: ['pending'], }); @@ -560,7 +749,7 @@ suite('ProviderAutomationService', () => { }, acknowledgedAutomationIds: ['automation-1'], runIds: ['run-1'], - legacy: { schemaVersion: 3, revision: 2, automations: [], runs: [] }, + legacy: { schemaVersion: 4, revision: 2, automations: [], runs: [] }, }); }); @@ -1068,13 +1257,16 @@ suite('ProviderAutomationService', () => { runs: [], }); const { service, providerStore, storage } = createService(futureLedger); + const catalogueState = service.catalogueState.get(); await assert.rejects(service.waitForMigrationForTesting(), /cannot be migrated safely/); assert.deepStrictEqual({ + catalogueState, providerAutomations: providerStore.automations.get(), persisted: storage.get(AUTOMATION_STORAGE_KEY, StorageScope.APPLICATION), }, { + catalogueState: 'error', providerAutomations: [], persisted: futureLedger, }); diff --git a/src/vs/sessions/contrib/chat/browser/modelPicker.ts b/src/vs/sessions/contrib/chat/browser/modelPicker.ts index 7999b27e48ed0c..2d6f2937bd27a8 100644 --- a/src/vs/sessions/contrib/chat/browser/modelPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/modelPicker.ts @@ -58,6 +58,7 @@ export class ModelPicker extends Disposable { this._delegate = { currentModel, + modelConfiguration: this._selectionModel.modelConfiguration, setModel: model => { const previousModel = this._selectionModel.state.get().currentModel; if (this._selectionModel.selectModel(model.identifier)) { diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index 99829456b7ed81..e34503b278759e 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -98,9 +98,9 @@ import { chatInputStackClass, chatInputStackSlotClass, ChatInputStackSlot, refre import { IChatSubmitRequestHandlerService } from '../../../../workbench/contrib/chat/browser/chatSubmitRequestHandlerService.js'; import { isPhoneLayout } from '../../../browser/parts/mobile/mobileLayout.js'; import { INewChatModelPickerService, NewChatModelPickerService } from './newChatModelPicker.js'; -import { ModelPicker, ModelPickerActionViewItem } from './modelPicker.js'; import { ISessionModelSelection, SessionModelSelection } from './sessionModelSelection.js'; import { hasSendableModelSelection } from './sessionModelPickerState.js'; +import { createNewSessionConfigToolbar, createNewSessionControlToolbar } from './newSessionConfigToolbars.js'; import { ISessionContext, SessionContext } from '../../../services/sessions/browser/sessionContext.js'; import { AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING } from './sessionsChatHistory.js'; import { IChatStatusItemService } from '../../../../workbench/contrib/chat/browser/chatStatus/chatStatusItemService.js'; @@ -551,7 +551,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, ) { super(); - this._modelSelection = this._register(this.instantiationService.createInstance(SessionModelSelection, this.options.session)); + this._modelSelection = this._register(this.instantiationService.createInstance(SessionModelSelection, this.options.session, {})); this._canSendRequest = derived(this, reader => { if (this.options.canSubmitWithoutSession?.read(reader)) { return true; @@ -767,9 +767,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation const newChatBottomContainer = dom.append(parent, dom.$('.new-chat-bottom-container')); const newChatControlsContainer = dom.append(newChatBottomContainer, dom.$('.new-chat-controls-container')); const sessionControlsContainer = this._sessionControlsContainer = dom.append(newChatControlsContainer, dom.$('.new-chat-session-controls')); - this._register(this._scopedInstantiationService.createInstance(MenuWorkbenchToolBar, sessionControlsContainer, Menus.NewSessionControl, { - hiddenItemStrategy: HiddenItemStrategy.NoHide, - })); + this._register(createNewSessionControlToolbar(sessionControlsContainer, this._scopedInstantiationService)); this._register({ dispose: () => sessionControlsContainer.remove() }); const repoConfigContainer = dom.append(newChatBottomContainer, dom.$('.new-chat-repo-config-container')); @@ -1137,16 +1135,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation // Session config pickers (such as model) — rendered via MenuWorkbenchToolBar // Visibility controlled by context keys (isActiveSessionBackgroundProvider, isNewChatSession) const configContainer = dom.append(toolbar, dom.$('.sessions-chat-config-toolbar')); - const configToolbar = this._register(this._scopedInstantiationService.createInstance(MenuWorkbenchToolBar, configContainer, Menus.NewSessionConfig, { - hiddenItemStrategy: HiddenItemStrategy.NoHide, - actionViewItemProvider: (action) => { - if (action.id === 'sessions.modelPicker') { - const picker = this._scopedInstantiationService.createInstance(ModelPicker, this._compactModelPicker); - return new ModelPickerActionViewItem(picker); - } - return undefined; - }, - })); + const configToolbar = this._register(createNewSessionConfigToolbar(configContainer, this._scopedInstantiationService, this._compactModelPicker)); // Dictation mic button. Shares the STT service, mic // device, and gating (backend support + `dictation.enabled`) diff --git a/src/vs/sessions/contrib/chat/browser/newSessionConfigToolbars.ts b/src/vs/sessions/contrib/chat/browser/newSessionConfigToolbars.ts new file mode 100644 index 00000000000000..258d5c6daf5661 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/newSessionConfigToolbars.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IObservable } from '../../../../base/common/observable.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { Menus } from '../../../browser/menus.js'; +import { ModelPicker, ModelPickerActionViewItem } from './modelPicker.js'; + +/** Creates the provider/model toolbar shared by New Session configuration surfaces. */ +export function createNewSessionConfigToolbar(container: HTMLElement, instantiationService: IInstantiationService, compactModelPicker: IObservable, ariaLabel?: string): MenuWorkbenchToolBar { + return instantiationService.createInstance(MenuWorkbenchToolBar, container, Menus.NewSessionConfig, { + ariaLabel, + hiddenItemStrategy: HiddenItemStrategy.NoHide, + actionViewItemProvider: action => { + if (action.id === 'sessions.modelPicker') { + const picker = instantiationService.createInstance(ModelPicker, compactModelPicker); + return new ModelPickerActionViewItem(picker); + } + return undefined; + }, + }); +} + +/** Creates the provider-owned control toolbar shared by New Session configuration surfaces. */ +export function createNewSessionControlToolbar(container: HTMLElement, instantiationService: IInstantiationService, ariaLabel?: string): MenuWorkbenchToolBar { + return instantiationService.createInstance(MenuWorkbenchToolBar, container, Menus.NewSessionControl, { + ariaLabel, + hiddenItemStrategy: HiddenItemStrategy.NoHide, + }); +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts index a02bbd78b13ec7..69a0d9b009666d 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts @@ -27,7 +27,7 @@ import { IGitHubService } from '../../github/browser/githubService.js'; import { IResolvedSessionPullRequest, SessionPullRequestPresentationModel } from '../../github/browser/pullRequestIconStatus.js'; import { ISessionChatPillVisibilityService, SESSION_CHAT_PILL_KINDS, SessionChatPillKind } from '../../../../workbench/contrib/chat/common/sessionChatPills.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { getGitHubPullRequestRefs, IChat, type IGitHubIssueRef } from '../../../services/sessions/common/session.js'; +import { ChatOriginKind, getGitHubPullRequestRefs, IChat, type IGitHubIssueRef } from '../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { SessionBackgroundActivitiesControl } from './sessionBackgroundActivitiesControl.js'; @@ -224,6 +224,13 @@ export class SessionChatInputToolbar extends Disposable { return this._findOwningSession(chat.resource, reader); }); + /** + * Whether the reflected chat is a subagent (worker) chat. Its pills describe + * the session the subagent was spawned from rather than the subagent's own + * work, so the row stays hidden there. + */ + private readonly _isSubagentChat: IObservable = derived(this, reader => this._chat.read(reader)?.origin?.kind === ChatOriginKind.Tool); + /** The current turn's diff stats. */ private readonly _diffStats: IObservable; /** Artifact sections shown in the artifact pill. */ @@ -273,6 +280,7 @@ export class SessionChatInputToolbar extends Disposable { const sessionCustomizations = this._register(instantiationService.createInstance(SessionCustomizations, this._chat, this._session)); this._customizationSections = sessionCustomizations.sections; + const pillsVisible = derived(this, reader => this._debugData.read(reader) !== undefined || !this._isSubagentChat.read(reader)); this._backgroundActivities = this._register(instantiationService.createInstance(SessionBackgroundActivitiesControl, this._session, this._chat, pillsEnabled, constObservable(true))); const gitHubInfo = derived(this, reader => { const session = this._session.read(reader); @@ -349,7 +357,7 @@ export class SessionChatInputToolbar extends Disposable { this._inputPills = this._register(instantiationService.createInstance(ChatInputPills, undefined, { debugName: 'SessionChatInputToolbar.content', compact, - enabled: pillsEnabled, + enabled: pillsVisible, sources: constObservable(sources.sources), offeredKinds: SESSION_CHAT_PILL_KINDS, context: this._session, diff --git a/src/vs/sessions/contrib/chat/browser/sessionModelSelection.ts b/src/vs/sessions/contrib/chat/browser/sessionModelSelection.ts index 94f66dfd74e0a5..911c645a1aa3a3 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionModelSelection.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionModelSelection.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { Emitter } from '../../../../base/common/event.js'; import { LRUCache } from '../../../../base/common/map.js'; import { autorun, IObservable, observableValue } from '../../../../base/common/observable.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -14,7 +15,7 @@ import { ChatInputModelSelectionController, IChatInputModelSelectionRuntime } fr import { ChatModelSelectionDiagnostics } from '../../../../workbench/contrib/chat/browser/widget/input/chatModelSelectionDiagnostics.js'; import { getSelectedModelStorageKey, getStoredSelectedModel, storeSelectedModel } from '../../../../workbench/contrib/chat/common/chatSelectedModel.js'; import { ChatAgentLocation, ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js'; -import { ILanguageModelChatMetadataAndIdentifier } from '../../../../workbench/contrib/chat/common/languageModels.js'; +import { ILanguageModelChatMetadataAndIdentifier, type IModelConfigurationAccess } from '../../../../workbench/contrib/chat/common/languageModels.js'; import { IntendedModelSlot } from '../../../../workbench/contrib/chat/common/model/chatModel.js'; import { IPendingModelSelection, isInConversationModelChoice, ModelSelectionReason, RestoredModelReason } from '../../../../workbench/contrib/chat/common/modelSelection.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; @@ -65,6 +66,7 @@ export const ISessionModelSelection = createDecorator('s export interface ISessionModelSelection { readonly _serviceBrand: undefined; readonly state: IObservable; + readonly modelConfiguration?: IModelConfigurationAccess; selectModel(modelIdentifier: string): boolean; } @@ -83,8 +85,12 @@ export class SessionModelSelection extends Disposable implements ISessionModelSe private readonly _state = observableValue(this, EMPTY_MODEL_SELECTION_STATE); readonly state: IObservable = this._state; + readonly modelConfiguration: IModelConfigurationAccess | undefined; private readonly _providerListener = this._register(new MutableDisposable()); + private readonly _modelConfigurationListener = this._register(new MutableDisposable()); + private readonly _onDidChangeModelConfiguration = this._register(new Emitter()); + private _modelConfigurationAccess: IModelConfigurationAccess | undefined; private readonly _diagnostics: ChatModelSelectionDiagnostics; private readonly _controller: ChatInputModelSelectionController; /** @@ -108,12 +114,21 @@ export class SessionModelSelection extends Disposable implements ISessionModelSe constructor( private readonly _session: IObservable, + options: { readonly modelConfiguration?: boolean }, @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, @IStorageService private readonly _storageService: IStorageService, @IConfigurationService private readonly _configurationService: IConfigurationService, @ILogService logService: ILogService, ) { super(); + this.modelConfiguration = options.modelConfiguration ? { + getModelConfiguration: modelId => this._modelConfigurationAccess?.getModelConfiguration(modelId), + setModelConfiguration: async (modelId, values) => { + await this._modelConfigurationAccess?.setModelConfiguration(modelId, values); + }, + getModelConfigurationActions: modelId => this._modelConfigurationAccess?.getModelConfigurationActions(modelId) ?? [], + onDidChange: this._onDidChangeModelConfiguration.event, + } : undefined; this._diagnostics = new ChatModelSelectionDiagnostics(logService, this._storageService, () => { const session = this._session.get(); return { @@ -220,8 +235,7 @@ export class SessionModelSelection extends Disposable implements ISessionModelSe getBoundConversationKey: () => this._boundConversationKey, getIntentHolder: () => this._conversation().intent, applyModel: model => this._pushModelToProvider(model), - // The optional members are absent on purpose: the snapshot is already the session's pool, - // `_refresh` owns refreshing, and sessions have no per-model configuration. + // The provider owns Automation model preferences; the controller only selects the model. }; } @@ -244,6 +258,16 @@ export class SessionModelSelection extends Disposable implements ISessionModelSe this._setProvider(provider); this._activeSession = session; this._activeProvider = provider; + const modelConfiguration = this.modelConfiguration && session ? provider?.getAutomationModelConfiguration?.(session.sessionId) : undefined; + if (modelConfiguration !== this._modelConfigurationAccess) { + this._modelConfigurationAccess = modelConfiguration; + this._modelConfigurationListener.value = modelConfiguration?.onDidChange?.(modelId => this._onDidChangeModelConfiguration.fire(modelId)); + const displayedModelId = this._state.get().currentModel?.identifier; + if (displayedModelId) { + // A new draft may use the same model with different preferences. + this._onDidChangeModelConfiguration.fire(displayedModelId); + } + } if (!session || !provider) { this._boundSessionKey = undefined; @@ -296,6 +320,13 @@ export class SessionModelSelection extends Disposable implements ISessionModelSe this._controller.beginConversationSwitch(); } + if (this.modelConfiguration && chatModelId && snapshot.desiredModelResolution.kind === 'unavailable') { + // An unavailable saved model must not lose its preferences merely by opening its Automation. + this._conversation().seeded = false; + this._publish(options, { reference: chatModelId }); + return; + } + // Only a conversation that could be written to has anything to wait for. A display-only one // writes nothing either way (see `_pushModelToProvider`), so waiting would blank its picker // and block its composer to prevent a write that was never going to happen. diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index 92d9eba42bb0e6..e096b93fb827e9 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -1823,7 +1823,7 @@ export class WorkspacePicker extends Disposable { } private _restoreSelectedWorkspace(): IRestoredWorkspaceSelection | undefined { - if (this.isNoWorkspaceSelected()) { + if (!this._canRestoreWorkspace()) { return undefined; } 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 0eb26479dae37e..78382596f94c76 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts @@ -7,6 +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 { constObservable, derived } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../base/test/common/mock.js'; @@ -15,15 +16,19 @@ import { IClipboardService } from '../../../../../platform/clipboard/common/clip import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import type { IChatPillEntry } from '../../../../../workbench/browser/chatPills.js'; +import { IBrowserViewWorkbenchService } from '../../../../../workbench/contrib/browserView/common/browserView.js'; +import { ISessionChatPillVisibilityService } from '../../../../../workbench/contrib/chat/common/sessionChatPills.js'; +import { workbenchInstantiationService } from '../../../../../workbench/test/browser/workbenchTestServices.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'; -import { type IGitHubIssueRef, type IGitHubPullRequestRef, type ISessionWorkspace } from '../../../../services/sessions/common/session.js'; +import { ChatOriginKind, SessionStatus, type IChat, type IGitHubIssueRef, type IGitHubPullRequestRef, type ISessionWorkspace } from '../../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { GitHubIssueState, GitHubPullRequestState, type IGitHubIssue, type IGitHubPullRequest } from '../../../github/common/types.js'; -import { buildSessionIssueSections, buildSessionPullRequestSections, computeSessionInputPillStats } from '../../browser/sessionChatInputToolbar.js'; +import { buildSessionIssueSections, buildSessionPullRequestSections, computeSessionInputPillStats, SessionChatInputToolbar } from '../../browser/sessionChatInputToolbar.js'; suite('SessionChatInputToolbar', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); test('uses session-scoped changes rather than the last turn', () => { const session = upcastPartial({ @@ -183,4 +188,70 @@ suite('SessionChatInputToolbar', () => { }, }); }); + + test('hides the pills in a subagent chat', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const chat = upcastPartial({ + resource: URI.parse('chat:main'), + title: constObservable('Main chat'), + status: constObservable(SessionStatus.InProgress), + }); + const subagentChat = upcastPartial({ + resource: URI.parse('chat:subagent'), + title: constObservable('Subagent'), + status: constObservable(SessionStatus.InProgress), + origin: { kind: ChatOriginKind.Tool, parentChat: chat.resource }, + }); + const forkedChat = upcastPartial({ + resource: URI.parse('chat:fork'), + title: constObservable('Fork'), + status: constObservable(SessionStatus.InProgress), + origin: { kind: ChatOriginKind.Fork, parentChat: chat.resource }, + }); + const session = upcastPartial({ + sessionId: 'provider:session', + resource: URI.parse('session:1'), + chats: constObservable([chat, subagentChat, forkedChat]), + workspace: constObservable(upcastPartial({ folders: [] })), + changesets: constObservable([]), + changes: constObservable([{ + modifiedUri: URI.file('/session-change.ts'), + insertions: 10, + deletions: 4, + }]), + }); + instantiationService.stub(IBrowserViewWorkbenchService, upcastPartial({ + onDidChangeBrowserViews: Event.None, + getKnownBrowserViews: () => new Map(), + })); + instantiationService.stub(ISessionChatPillVisibilityService, upcastPartial({ + readHiddenKinds: () => new Set(), + isVisible: () => true, + hide: () => { }, + toggle: () => { }, + })); + instantiationService.stub(ISessionChangesStatsCache, upcastPartial({ get: () => undefined })); + instantiationService.stub(ISessionsProvidersService, upcastPartial({ getProvider: () => undefined })); + instantiationService.stub(ISessionsService, upcastPartial({ + visibleSessions: constObservable([]), + activeSession: constObservable(undefined), + })); + const toolbar = store.add(instantiationService.createInstance(SessionChatInputToolbar, false, undefined)); + const read = () => ({ + pills: Array.from(toolbar.element.querySelectorAll('.chat-pill-label')).map(label => label.textContent), + visible: toolbar.visible, + }); + + toolbar.setSession(session, chat); + const main = read(); + toolbar.setSession(session, subagentChat); + const subagent = read(); + toolbar.setSession(session, forkedChat); + + assert.deepStrictEqual({ main, subagent, fork: read() }, { + main: { pills: ['1 File', 'Subagent'], visible: true }, + subagent: { pills: [], visible: false }, + fork: { pills: ['1 File'], visible: true }, + }); + }); }); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionModelSelection.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionModelSelection.test.ts index 36d6d5d99ddb3a..295acc1a67154b 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionModelSelection.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionModelSelection.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { derived, ISettableObservable, observableValue, transaction } 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 { IConfigurationChangeEvent, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; @@ -14,7 +15,7 @@ import { NullLogService } from '../../../../../platform/log/common/log.js'; import { InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { getSelectedModelStorageKey, storeSelectedModel } from '../../../../../workbench/contrib/chat/common/chatSelectedModel.js'; import { ChatAgentLocation, ChatConfiguration } from '../../../../../workbench/contrib/chat/common/constants.js'; -import { ILanguageModelChatMetadataAndIdentifier } from '../../../../../workbench/contrib/chat/common/languageModels.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; import { isInConversationModelChoice, resolveModelIdentifier } from '../../../../../workbench/contrib/chat/common/modelSelection.js'; import { conformanceInputs, IModelSelectionConformanceScenario, ModelSelectionConformanceModel, modelSelectionConformanceScenarios } from '../../../../../workbench/contrib/chat/test/browser/widget/input/modelSelectionConformance.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; @@ -22,6 +23,7 @@ import { ISessionsProvider, ISessionModelPickerOptions } from '../../../../servi import { ChatModelSource, IChat, SessionStatus } from '../../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { restoreReasonForSource, SessionModelSelection } from '../../browser/sessionModelSelection.js'; +import { AutomationModelConfiguration } from '../../../automations/browser/automationModelConfiguration.js'; function model(identifier: string): ILanguageModelChatMetadataAndIdentifier { return { @@ -211,6 +213,7 @@ function runConformanceScenario( } const selection = register(new SessionModelSelection( observableValue('conformanceSession', testSession.session), + {}, createProvidersService([provider]), storage, createConfigurationService(configuredModel ? models.get(configuredModel)!.metadata.id : undefined), @@ -245,6 +248,183 @@ suite('SessionModelSelection', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + suite('Automation model configuration', () => { + class ConfigurationService extends mock() { + metadata: ILanguageModelChatMetadata | undefined = { + ...first.metadata, + configurationSchema: { + type: 'object', + properties: { + thinkingLevel: { type: 'string', enum: ['low', 'medium', 'high'], default: 'medium' }, + }, + }, + }; + configuration: Record = { thinkingLevel: 'high' }; + readonly writes: Record[] = []; + override lookupLanguageModel(): ILanguageModelChatMetadata | undefined { return this.metadata; } + override getModelConfiguration(): Record { return this.configuration; } + override async setModelConfiguration(_modelId: string, values: Record): Promise { + this.writes.push({ ...values }); + this.configuration = { ...this.configuration, ...values }; + } + } + + test('scopes picker options to each Automation and only remembers explicit choices', async () => { + const languageModelsService = new ConfigurationService(); + const firstConfiguration = disposables.add(new AutomationModelConfiguration(languageModelsService, { + modelId: first.identifier, modelConfiguration: { thinkingLevel: 'low' }, + })); + const secondConfiguration = disposables.add(new AutomationModelConfiguration(languageModelsService, { + modelId: first.identifier, modelConfiguration: { thinkingLevel: 'high' }, + })); + const firstSession = createSession('provider', SessionStatus.Untitled, first.identifier, 'first-automation'); + const secondSession = createSession('provider', SessionStatus.Untitled, first.identifier, 'second-automation'); + const activeSession = observableValue('automation', firstSession.session); + const provider = disposables.add(createProvider('provider')); + provider.getAutomationModelConfiguration = sessionId => sessionId === firstSession.session.sessionId ? firstConfiguration : secondConfiguration; + const providers = createProvidersService([provider]); + const storage = disposables.add(new InMemoryStorageService()); + const selection = disposables.add(new SessionModelSelection(activeSession, { modelConfiguration: true }, providers, storage, createConfigurationService(), disposables.add(new NullLogService()))); + const normalSelection = disposables.add(new SessionModelSelection(activeSession, {}, providers, storage, createConfigurationService(), disposables.add(new NullLogService()))); + const access = selection.modelConfiguration!; + const values = [access.getModelConfiguration(first.identifier)]; + const writesAfterOpening = [...languageModelsService.writes]; + const changes: (Record | undefined)[] = []; + disposables.add(access.onDidChange!(modelId => changes.push(access.getModelConfiguration(modelId)))); + + await access.setModelConfiguration(first.identifier, { thinkingLevel: 'medium' }); + activeSession.set(secondSession.session, undefined); + values.push(access.getModelConfiguration(first.identifier)); + await access.setModelConfiguration(first.identifier, { thinkingLevel: 'high' }); + activeSession.set(firstSession.session, undefined); + values.push(access.getModelConfiguration(first.identifier)); + await access.setModelConfiguration(first.identifier, { thinkingLevel: 'medium' }); + selection.dispose(); + + assert.deepStrictEqual({ + values, writesAfterOpening, changes, + writes: languageModelsService.writes, + normalPickerConfiguration: normalSelection.modelConfiguration, + first: firstConfiguration.captureModelConfiguration(first.identifier), + second: secondConfiguration.captureModelConfiguration(first.identifier), + }, { + values: [{ thinkingLevel: 'low' }, { thinkingLevel: 'high' }, { thinkingLevel: 'medium' }], + writesAfterOpening: [], + changes: [ + { thinkingLevel: 'medium' }, + { thinkingLevel: 'high' }, + { thinkingLevel: 'high' }, + { thinkingLevel: 'medium' }, + { thinkingLevel: 'medium' }, + ], + writes: [{ thinkingLevel: 'medium' }, { thinkingLevel: 'high' }, { thinkingLevel: 'medium' }], + normalPickerConfiguration: undefined, + first: { thinkingLevel: 'medium' }, + second: { thinkingLevel: 'high' }, + }); + }); + + test('restores unavailable options without rewriting them or shared defaults', async () => { + const languageModelsService = new ConfigurationService(); + const metadata = languageModelsService.metadata; + languageModelsService.metadata = undefined; + const preferences = { thinkingLevel: 'retired', futureOption: true }; + const configuration = disposables.add(new AutomationModelConfiguration(languageModelsService, { modelId: first.identifier, modelConfiguration: preferences })); + const beforeRegistration = configuration.getModelConfiguration(first.identifier); + languageModelsService.metadata = metadata; + const effective = configuration.getModelConfiguration(first.identifier); + const captured = configuration.captureModelConfiguration(first.identifier); + const writesAfterRestore = [...languageModelsService.writes]; + await configuration.setModelConfiguration(first.identifier, { thinkingLevel: 'low' }); + const edited = configuration.captureModelConfiguration(first.identifier); + configuration.dispose(); + const reopened = disposables.add(new AutomationModelConfiguration(languageModelsService, { modelId: first.identifier, modelConfiguration: captured })); + const reopenedConfiguration = reopened.getModelConfiguration(first.identifier); + reopened.dispose(); + + assert.deepStrictEqual({ + beforeRegistration, effective, captured, writesAfterRestore, edited, reopenedConfiguration, + writes: languageModelsService.writes, + }, { + beforeRegistration: preferences, + effective: { thinkingLevel: 'medium' }, + captured: preferences, + writesAfterRestore: [], + edited: { thinkingLevel: 'low', futureOption: true }, + reopenedConfiguration: { thinkingLevel: 'medium' }, + writes: [{ thinkingLevel: 'low' }], + }); + }); + + test('preserves an unavailable saved model and its options until an explicit model choice', () => { + const languageModelsService = new ConfigurationService(); + const missingModel = 'test/missing'; + const configuration = disposables.add(new AutomationModelConfiguration(languageModelsService, { + modelId: missingModel, modelConfiguration: { thinkingLevel: 'low', futureOption: true }, + })); + const session = createSession('provider', SessionStatus.Untitled, missingModel); + const provider = disposables.add(createProvider('provider', (identifier, source) => session.modelId.set(identifier, undefined, source))); + provider.getAutomationModelConfiguration = () => configuration; + const selection = disposables.add(new SessionModelSelection( + observableValue('automation', session.session), + { modelConfiguration: true }, + createProvidersService([provider]), + disposables.add(new InMemoryStorageService()), + createConfigurationService(), + disposables.add(new NullLogService()), + )); + const restored = { + modelId: session.modelId.get(), + preferences: configuration.captureModelConfiguration(session.modelId.get()), + writes: [...provider.writes], + }; + selection.selectModel(first.identifier); + + assert.deepStrictEqual({ + restored, + selected: session.modelId.get(), + preferences: configuration.captureModelConfiguration(session.modelId.get()), + sharedWrites: languageModelsService.writes, + }, { + restored: { modelId: missingModel, preferences: { thinkingLevel: 'low', futureOption: true }, writes: [] }, + selected: first.identifier, + preferences: { thinkingLevel: 'high' }, + sharedWrites: [], + }); + }); + + test('captures initial defaults once and preserves an explicit empty configuration', () => { + const languageModelsService = new ConfigurationService(); + const firstDraft = disposables.add(new AutomationModelConfiguration(languageModelsService)); + const firstValue = firstDraft.getModelConfiguration(first.identifier); + languageModelsService.configuration = { thinkingLevel: 'low' }; + const secondDraft = disposables.add(new AutomationModelConfiguration(languageModelsService)); + const resetDraft = disposables.add(new AutomationModelConfiguration(languageModelsService, { modelId: first.identifier, modelConfiguration: {} })); + + assert.deepStrictEqual({ + firstValue, + first: firstDraft.captureModelConfiguration(first.identifier), + second: secondDraft.captureModelConfiguration(first.identifier), + reset: resetDraft.captureModelConfiguration(first.identifier), + resetEffective: resetDraft.getModelConfigurationForRequest(first.identifier), + writes: languageModelsService.writes, + }, { + firstValue: { thinkingLevel: 'high' }, + first: { thinkingLevel: 'high' }, + second: { thinkingLevel: 'low' }, + reset: {}, + resetEffective: { thinkingLevel: 'medium' }, + writes: [], + }); + }); + + test('rejects configuration without a model before allocating scoped state', () => { + assert.throws(() => disposables.add(new AutomationModelConfiguration(new ConfigurationService(), { + modelConfiguration: {}, + })), /model configuration requires a model identifier/); + }); + }); + suite('model selection conformance', () => { for (const scenario of modelSelectionConformanceScenarios) { test(scenario.name, () => { @@ -272,6 +452,7 @@ suite('SessionModelSelection', () => { provider.modelTarget = codexModelTarget; const draftSelection = disposables.add(new SessionModelSelection( observableValue('draftSession', draft.session), + {}, createProvidersService([provider]), storage, createConfigurationService(), @@ -290,6 +471,7 @@ suite('SessionModelSelection', () => { nextProvider.modelTarget = codexModelTarget; const nextSelection = disposables.add(new SessionModelSelection( observableValue('nextDraftSession', nextDraft.session), + {}, createProvidersService([nextProvider]), storage, createConfigurationService(), @@ -309,6 +491,7 @@ suite('SessionModelSelection', () => { storage.store(legacyModelPickerStorageKey('provider', 'type'), second.identifier, StorageScope.PROFILE, StorageTarget.MACHINE); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), storage, createConfigurationService(), @@ -339,6 +522,7 @@ suite('SessionModelSelection', () => { const provider = disposables.add(createProvider('provider')); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), disposables.add(new InMemoryStorageService()), createConfigurationService(), @@ -358,6 +542,7 @@ suite('SessionModelSelection', () => { storeSelectedModel(storage, ChatAgentLocation.Chat, modelTarget, second.identifier); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), storage, createConfigurationService(second.metadata.id), @@ -383,6 +568,7 @@ suite('SessionModelSelection', () => { const session = observableValue('session', firstSession.session); const selection = disposables.add(new SessionModelSelection( session, + {}, createProvidersService([firstProvider, secondProvider]), disposables.add(new InMemoryStorageService()), createConfigurationService(), @@ -414,6 +600,7 @@ suite('SessionModelSelection', () => { const storage = disposables.add(new InMemoryStorageService()); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), storage, createConfigurationService(), @@ -448,6 +635,7 @@ suite('SessionModelSelection', () => { const logService = disposables.add(new TestLogService()); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), storage, createConfigurationService(), @@ -478,6 +666,7 @@ suite('SessionModelSelection', () => { provider.models = []; const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), storage, createConfigurationService(), @@ -500,6 +689,7 @@ suite('SessionModelSelection', () => { const provider = disposables.add(createProvider('provider', (identifier, source) => testSession.modelId.set(identifier, undefined, source))); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), disposables.add(new InMemoryStorageService()), createConfigurationService(), @@ -518,6 +708,7 @@ suite('SessionModelSelection', () => { const testSession = createSession('missing', SessionStatus.Untitled); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([]), disposables.add(new InMemoryStorageService()), createConfigurationService(), @@ -544,6 +735,7 @@ suite('SessionModelSelection', () => { storeSelectedModel(storage, ChatAgentLocation.Chat, modelTarget, second.identifier); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), storage, createConfigurationService(), @@ -571,6 +763,7 @@ suite('SessionModelSelection', () => { storeSelectedModel(storage, ChatAgentLocation.Chat, modelTarget, second.identifier); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), storage, createConfigurationService(), @@ -622,6 +815,7 @@ suite('SessionModelSelection', () => { const storage = disposables.add(new InMemoryStorageService()); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), storage, createConfigurationService(), @@ -648,6 +842,7 @@ suite('SessionModelSelection', () => { const provider = disposables.add(createProvider('provider', (identifier, source) => testSession.modelId.set(identifier, undefined, source))); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), disposables.add(new InMemoryStorageService()), createConfigurationService('missing-family'), @@ -686,6 +881,7 @@ suite('SessionModelSelection', () => { storeSelectedModel(storage, ChatAgentLocation.Chat, modelTarget, second.identifier); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), storage, createConfigurationService(), @@ -718,6 +914,7 @@ suite('SessionModelSelection', () => { const storage = disposables.add(new InMemoryStorageService()); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), storage, createConfigurationService(second.metadata.id), @@ -746,6 +943,7 @@ suite('SessionModelSelection', () => { const provider = disposables.add(createProvider('provider', (identifier, source) => testSession.modelId.set(identifier, undefined, source))); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), disposables.add(new InMemoryStorageService()), createConfigurationService(second.metadata.id), @@ -767,6 +965,7 @@ suite('SessionModelSelection', () => { const session = observableValue('session', firstSession.session); const selection = disposables.add(new SessionModelSelection( session, + {}, createProvidersService([provider]), disposables.add(new InMemoryStorageService()), createConfigurationService(second.metadata.id), @@ -794,6 +993,7 @@ suite('SessionModelSelection', () => { testSession.activeChat.set(chatOne, undefined); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), storage, createConfigurationService(), @@ -830,6 +1030,7 @@ suite('SessionModelSelection', () => { const storage = disposables.add(new InMemoryStorageService()); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), storage, createConfigurationService(), @@ -864,6 +1065,7 @@ suite('SessionModelSelection', () => { storeSelectedModel(storage, ChatAgentLocation.Chat, modelTarget, second.identifier); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), storage, // `chat.defaultModel` outranks the remembered preference, so a fresh chat should not @@ -896,6 +1098,7 @@ suite('SessionModelSelection', () => { storeSelectedModel(storage, ChatAgentLocation.Chat, modelTarget, second.identifier); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), storage, createConfigurationService(third.metadata.id), @@ -926,6 +1129,7 @@ suite('SessionModelSelection', () => { const session = observableValue('session', testSession.session); const selection = disposables.add(new SessionModelSelection( session, + {}, createProvidersService([provider, otherProvider]), disposables.add(new InMemoryStorageService()), createConfigurationService(), @@ -968,6 +1172,7 @@ suite('SessionModelSelection', () => { storeSelectedModel(storage, ChatAgentLocation.Chat, modelTarget, first.identifier); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), storage, createConfigurationService(second.metadata.id), @@ -996,6 +1201,7 @@ suite('SessionModelSelection', () => { const configuration = createConfigurationService(first.metadata.id); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), disposables.add(new InMemoryStorageService()), configuration, @@ -1028,6 +1234,7 @@ suite('SessionModelSelection', () => { provider.resolveDesired = desiredModelId => desiredModelId === second.identifier ? canonical : undefined; const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), disposables.add(new InMemoryStorageService()), createConfigurationService(), @@ -1052,6 +1259,7 @@ suite('SessionModelSelection', () => { storeSelectedModel(storage, ChatAgentLocation.Chat, modelTarget, opus.identifier); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), storage, createConfigurationService(undefined), @@ -1083,6 +1291,7 @@ suite('SessionModelSelection', () => { const storage = disposables.add(new InMemoryStorageService()); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), storage, createConfigurationService(configured.metadata.id), @@ -1112,6 +1321,7 @@ suite('SessionModelSelection', () => { storeSelectedModel(storage, ChatAgentLocation.Chat, modelTarget, opus.identifier); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), storage, createConfigurationService(undefined), @@ -1163,6 +1373,7 @@ suite('SessionModelSelection', () => { storeSelectedModel(storage, ChatAgentLocation.Chat, modelTarget, opus.identifier); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), storage, createConfigurationService(undefined), @@ -1199,6 +1410,7 @@ suite('SessionModelSelection', () => { testSession.activeChat.set(createChat('chat:/provider/one', second.identifier, ChatModelSource.Chosen, SessionStatus.Untitled), undefined); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), disposables.add(new InMemoryStorageService()), // A configured default is only kept out by a model the conversation chose. @@ -1226,6 +1438,7 @@ suite('SessionModelSelection', () => { const provider = disposables.add(createProvider('provider', (identifier, source) => testSession.modelId.set(identifier, undefined, source))); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), disposables.add(new InMemoryStorageService()), createConfigurationService(first.metadata.id), @@ -1259,6 +1472,7 @@ suite('SessionModelSelection', () => { provider.models = [first]; const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), disposables.add(new InMemoryStorageService()), createConfigurationService(), @@ -1291,6 +1505,7 @@ suite('SessionModelSelection', () => { const provider = disposables.add(createProvider('provider', (identifier, source) => testSession.modelId.set(identifier, undefined, source))); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), disposables.add(new InMemoryStorageService()), createConfigurationService(second.metadata.id), @@ -1324,6 +1539,7 @@ suite('SessionModelSelection', () => { provider.models = [first, second, missing]; const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), disposables.add(new InMemoryStorageService()), createConfigurationService(), @@ -1365,6 +1581,7 @@ suite('SessionModelSelection', () => { const session = observableValue('session', firstSession.session); const selection = disposables.add(new SessionModelSelection( session, + {}, createProvidersService([provider]), disposables.add(new InMemoryStorageService()), createConfigurationService(), @@ -1397,6 +1614,7 @@ suite('SessionModelSelection', () => { const provider = disposables.add(createProvider('provider', (identifier, source) => testSession.modelId.set(identifier, undefined, source))); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), disposables.add(new InMemoryStorageService()), createConfigurationService(second.metadata.id), @@ -1421,6 +1639,7 @@ suite('SessionModelSelection', () => { const provider = disposables.add(createProvider('provider', (identifier, source) => testSession.modelId.set(identifier, undefined, source))); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), disposables.add(new InMemoryStorageService()), createConfigurationService(), @@ -1452,6 +1671,7 @@ suite('SessionModelSelection', () => { const logService = disposables.add(new TestLogService()); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), storage, createConfigurationService(), @@ -1481,6 +1701,7 @@ suite('SessionModelSelection', () => { const logService = disposables.add(new TestLogService()); const selection = disposables.add(new SessionModelSelection( observableValue('session', testSession.session), + {}, createProvidersService([provider]), disposables.add(new InMemoryStorageService()), createConfigurationService(), 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 9773798cdd3160..183e80c9609d7c 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -2562,6 +2562,91 @@ suite('AutomationsWorkspacePicker', () => { ensureNoDisposablesAreLeakedInTestSuite(); + for (const checked of [true, false]) { + test(`does not inherit a ${checked ? 'checked' : 'recent'} cloud workspace when restoration is disabled`, async () => { + const providersService = disposables.add(new MockSessionsProvidersService()); + const provider = createMockProvider('github'); + const cloudUri = URI.parse('vscode-vfs://github/microsoft/vscode/HEAD'); + const storage = disposables.add(new TestStorageService()); + seedStorage(storage, [{ uri: cloudUri, providerId: provider.id, checked }]); + providersService.setProviders([provider]); + const originalRecents = storage.get(STORAGE_KEY_RECENT_WORKSPACES, StorageScope.PROFILE); + const picker = createTestPicker( + disposables, providersService, storage, undefined, TestAutomationsWorkspacePicker, + undefined, undefined, undefined, { restoreFromSessions: false, canRestoreWorkspace: () => false }, + ); + assert.ok(picker instanceof TestAutomationsWorkspacePicker); + picker.setTargetModel(new AutomationIsolationModel({ + isQuickChat: false, folderUri: undefined, isolationMode: undefined, branch: undefined, + })); + const container = document.createElement('div'); + picker.render(container); + const initialUri = picker.selectedFolderUri; + providersService.setProviders([provider]); + await timeout(0); + picker.refreshAutomaticSelection(); + + assert.deepStrictEqual({ + initialUri, + afterRefresh: picker.selectedFolderUri, + label: container.querySelector('.sessions-chat-dropdown-label')?.textContent, + ariaLabel: container.querySelector('.action-label')?.getAttribute('aria-label'), + recentsUnchanged: originalRecents === storage.get(STORAGE_KEY_RECENT_WORKSPACES, StorageScope.PROFILE), + noWorkspaceAvailable: picker.getItems().some(item => item.label === 'No workspace'), + }, { + initialUri: undefined, + afterRefresh: undefined, + label: 'Select workspace', + ariaLabel: 'Pick a workspace for this automation', + recentsUnchanged: true, + noWorkspaceAvailable: true, + }); + }); + } + + test('preserves an explicitly seeded target and allows changing it without restoring another workspace', async () => { + const providersService = disposables.add(new MockSessionsProvidersService()); + const provider = createMockProvider('github'); + const cloudUri = URI.parse('vscode-vfs://github/microsoft/vscode/HEAD'); + const localUri = URI.file('/local/project'); + const storage = disposables.add(new TestStorageService()); + seedStorage(storage, [{ uri: localUri, providerId: provider.id, checked: true }]); + providersService.setProviders([provider]); + const originalRecents = storage.get(STORAGE_KEY_RECENT_WORKSPACES, StorageScope.PROFILE); + const picker = createTestPicker( + disposables, providersService, storage, undefined, TestAutomationsWorkspacePicker, + undefined, undefined, undefined, { restoreFromSessions: false, canRestoreWorkspace: () => false }, + ); + assert.ok(picker instanceof TestAutomationsWorkspacePicker); + const model = new AutomationIsolationModel({ + isQuickChat: false, folderUri: cloudUri, isolationMode: undefined, branch: undefined, + }); + picker.setTargetModel(model); + picker.setSelectedWorkspace(cloudUri, { fireEvent: false, persist: false }); + const container = document.createElement('div'); + picker.render(container); + providersService.setProviders([provider]); + await timeout(0); + const seededTarget = picker.selectedFolderUri?.toString(); + await picker.select('No workspace'); + const quickChat = model.isQuickChat; + await picker.select('local/project'); + + assert.deepStrictEqual({ + seededTarget, + quickChat, + selectedFolder: model.folderUri?.toString(), + isQuickChat: model.isQuickChat, + recentsUnchanged: originalRecents === storage.get(STORAGE_KEY_RECENT_WORKSPACES, StorageScope.PROFILE), + }, { + seededTarget: cloudUri.toString(), + quickChat: true, + selectedFolder: localUri.toString(), + isQuickChat: false, + recentsUnchanged: true, + }); + }); + test('selects No workspace and restores a folder through the same picker', async () => { const providersService = disposables.add(new MockSessionsProvidersService()); const provider = createMockProvider('local-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 d99f1fb1e6ff40..9318b75e26230b 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 @@ -48,6 +48,10 @@ Within that contract, Agent Host providers expose the host's `ahp-automations:// Imported prompts retain Automation provenance through `MessageKind.Automation`. The projection converts editor-qualified model identifiers to provider-native `ModelSelection.id` values at the AHP boundary while preserving the editor identity exposed to Sessions. The provider also mirrors `chat.automations.enabled` and `chat.automations.runTimeoutMinutes` into host configuration; disabling Automations removes new run authority without deleting definitions or terminating sessions already running. +`AutomationDefinition.session` is authoritative for host-owned model, custom-agent, and provider configuration. The projection removes target-owned working directory, isolation, and branch values from the editor-facing template and restores them only at the AHP boundary. Unknown provider values remain opaque and survive same-target edits. + +The browser fallback and host-owned executor both create sessions from this template. A draft restores it before the first `resolveSessionConfig` call and captures the provider-resolved state when saved. Initial values that are unavailable or policy-clamped remain saved preferences until the user explicitly changes them; the effective draft and every run still use current schema and managed-policy enforcement. + ## Identity The local provider uses: @@ -91,6 +95,8 @@ create draft The first send waits for tracked draft configuration. Cancellation disposes the draft. Later configuration changes are scoped to the committed session and do not recreate the entire facade. +Automation drafts use the same `NewSession` implementation but are tracked separately by the management service. Agent Host providers advertise Automation configuration support, restore the initial template before configuration resolution, and capture it asynchronously after pending resolution. Capture rechecks draft identity, omits transient, permission-grant, target-owned, and host-owned values, preserves untouched opaque preferences, and rejects superseded drafts. + Existing-session requests route by the provider resource and chat resource. Host notifications update adapters and catalog membership reactively. ## Persistence and discovery diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts index 2f01f89da9aaa9..a2907f2a60ac24 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts @@ -26,7 +26,7 @@ import { IsSessionsWindowContext } from '../../../../../workbench/common/context import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { ISession, ISessionAgentRef, SessionStatus } from '../../../../services/sessions/common/session.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; -import { ModePicker, ModePickerModel } from '../../copilotChatSessions/browser/modePicker.js'; +import { ModePicker, ScopedModePickerModelCache } from '../../copilotChatSessions/browser/modePicker.js'; import { ISessionContext } from '../../../../services/sessions/browser/sessionContext.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IAction } from '../../../../../base/common/actions.js'; @@ -88,7 +88,6 @@ class AgentHostAgentPickerContribution extends Disposable implements IWorkbenchC constructor( @IActionViewItemService actionViewItemService: IActionViewItemService, - @IInstantiationService instantiationService: IInstantiationService, @ISessionsService sessionsService: ISessionsService, @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, @IChatService private readonly chatService: IChatService, @@ -97,8 +96,11 @@ class AgentHostAgentPickerContribution extends Disposable implements IWorkbenchC @ILogService private readonly logService: ILogService, ) { super(); - const modePickerModel = this._register(instantiationService.createInstance(ModePickerModel)); let settingAgentInternally = false; + const modePickerModels = this._register(new ScopedModePickerModelCache(session => { + const provider = sessionsProvidersService.getProvider(session.providerId); + return !!provider && isAgentHostProvider(provider); + })); const initAgentFromActiveSession = () => { const session = sessionsService.activeSession.get(); @@ -112,11 +114,8 @@ class AgentHostAgentPickerContribution extends Disposable implements IWorkbenchC this._register(autorun(reader => { const session = sessionsService.activeSession.read(reader); - const provider = this._getProvider(session, sessionsProvidersService); const selectedAgentUri = session?.mode.read(reader)?.id; - modePickerModel.setSession(provider ? session : undefined, selectedAgentUri); - const isUntitled = session?.status.read(reader) === SessionStatus.Untitled; this._syncChatInputMode(session, selectedAgentUri, sessionsProvidersService); this._initAgent(session, selectedAgentUri, isUntitled, sessionsProvidersService, () => settingAgentInternally = true, () => settingAgentInternally = false); @@ -141,8 +140,9 @@ class AgentHostAgentPickerContribution extends Disposable implements IWorkbenchC const factory = (_action: IAction, _options: IActionViewItemOptions, scopedInstantiationService: IInstantiationService) => { const { session } = scopedInstantiationService.invokeFunction(accessor => accessor.get(ISessionContext)); - const picker = scopedInstantiationService.createInstance(ModePicker, modePickerModel, session); const disposableStore = new DisposableStore(); + const modePickerModel = disposableStore.add(modePickerModels.acquire(session, scopedInstantiationService)); + const picker = scopedInstantiationService.createInstance(ModePicker, modePickerModel.model, session); disposableStore.add(picker.onDidSelect(mode => { this._selectMode(mode, session.get(), sessionsProvidersService); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts index 9358174e3acbc6..5ae0f3818a2720 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts @@ -5,6 +5,7 @@ import { disposableTimeout, timeout } from '../../../../../base/common/async.js'; import { CancellationError, isCancellationError } from '../../../../../base/common/errors.js'; +import { Event } from '../../../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, toDisposable, type IReference } from '../../../../../base/common/lifecycle.js'; import { autorun, derived, type IObservable, observableSignalFromEvent, observableValue } from '../../../../../base/common/observable.js'; import { hasKey } from '../../../../../base/common/types.js'; @@ -12,9 +13,9 @@ import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { localize } from '../../../../../nls.js'; import { type IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; -import { AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_META_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY } from '../../../../../platform/agentHost/common/automationMigration.js'; +import { AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_META_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY, applyLegacyAutomationSessionConfig, migrateLegacyAutomationSessionConfig } from '../../../../../platform/agentHost/common/automationMigration.js'; import { isAgentHostAutomationCatalogMigrated, isAgentHostLegacyAutomationImport, isAgentHostLegacyAutomationImportPending } from '../../../../../platform/agentHost/common/meta/automationMeta.js'; -import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; +import { omitAutomationSessionTemplateConfigValues, pickAutomationDefinitionOwnedConfigValues, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { type IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ActionType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import { AutomationMisfirePolicy, AutomationOperation, AutomationRunOriginKind, AutomationRunStatus, AutomationTriggerKind, MessageKind, type AutomationDefinition, type AutomationEntry, type AutomationRunSummary, type AutomationState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -22,10 +23,9 @@ import { AUTOMATION_CATALOG_URI, isAhpAutomationCatalogChannel, ROOT_STATE_URI, import { ILogService } from '../../../../../platform/log/common/log.js'; import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; -import type { AutomationRunTrigger, AutomationTarget, IAutomationDescriptor, IAutomationRun, IAutomationSchedule } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; -import { AutomationActiveRunError, type AutomationMutationGuard, type IAutomationRunClaim, type ICreateAutomationOptions, type IGuardedAutomationUpdateResult, isAutomationActiveRunError, serializeAutomationEditableState, type IUpdateAutomationOptions, type IUpdateAutomationRunOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { assertAutomationSessionTemplate, type AutomationRunTrigger, type AutomationTarget, type IAutomationDescriptor, type IAutomationRun, type IAutomationSchedule, type IAutomationSessionTemplate } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; +import { AutomationActiveRunError, type AutomationCatalogueState, assertAutomationSessionTemplateAuthority, combineAutomationCatalogueStates, type AutomationMutationGuard, type IAutomationRunClaim, type ICreateAutomationOptions, type IGuardedAutomationUpdateResult, isAutomationActiveRunError, serializeAutomationEditableState, type IUpdateAutomationOptions, type IUpdateAutomationRunOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { publishAutomationMigration } from '../../../../../workbench/contrib/chat/common/automations/automationTelemetry.js'; -import { ChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import type { IAutomation, IAutomationSnapshotImportResult, IGuardedAutomationSnapshotRemovalResult, ISessionsProviderAutomations } from '../../../../services/sessions/common/sessionsProvider.js'; import { IAutomationStorageService } from '../../../automations/common/automationStorageService.js'; @@ -77,6 +77,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro private readonly _catalogReference: IReference>; private readonly _catalog: IAgentSubscription; private readonly _catalogChanged; + private readonly _catalogError; private readonly _ready = observableValue(this, false); private readonly _runsForCache = new Map>(); private readonly _pendingWaits = this._register(new DisposableMap()); @@ -88,6 +89,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro readonly automations: IObservable; readonly runs: IObservable; + readonly catalogueState: IObservable; constructor( private readonly _providerId: string, @@ -116,6 +118,14 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro )); this._catalog = this._catalogReference.object; this._catalogChanged = observableSignalFromEvent(this, this._catalog.onDidChange); + this._catalogError = observableSignalFromEvent(this, this._catalog.onDidError ?? Event.None); + this.catalogueState = derived(this, reader => { + this._catalogChanged.read(reader); + this._catalogError.read(reader); + const hostState = this._catalog.value instanceof Error ? 'error' : this._catalog.verifiedValue ? 'ready' : 'loading'; + const legacyState = this._ready.read(reader) ? 'ready' : this._legacySource?.catalogueState.read(reader) ?? 'ready'; + return combineAutomationCatalogueStates([hostState, legacyState]); + }); if (this._catalog.onDidError) { this._register(this._catalog.onDidError(error => this._logService.error(`[AgentHostAutomationStore] Catalogue subscription failed: ${error.message}`))); } @@ -126,7 +136,11 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro && (isAgentHostAutomationCatalogMigrated(catalog) || catalog.entries.some(automation => automation.operations.includes(AutomationOperation.Run))) && !catalog.entries.some(automation => isAgentHostLegacyAutomationImportPending(automation.definition)) - && (!this._legacySource || this._legacySource.automations.read(reader).length === 0) + && (!this._legacySource || ( + this._legacySource.catalogueState.read(reader) === 'ready' + && this._legacySource.canCompleteMigration?.() !== false + && this._legacySource.automations.read(reader).length === 0 + )) && !this._migrationPromise && !this._ready.read(reader)) { this._ready.set(true, undefined); @@ -205,6 +219,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro prompt: options.prompt, schedule: options.schedule, target: options.target, + sessionTemplate: options.sessionTemplate, modelId: options.modelId, mode: options.mode, permissionLevel: options.permissionLevel, @@ -224,7 +239,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro this._requireOperation(id, AutomationOperation.Update); const current = this._requireAutomation(id); const updated = this._applyPatch(current, patch); - const state = await this._replaceDescriptor(updated); + const state = await this._replaceDescriptor(updated, false, undefined, patch.sessionTemplate === null); return this._requireProjectedAutomation(state); } @@ -452,9 +467,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro let migratedCount = 0; let failedCount = 0; try { - if (source?.canCompleteMigration?.() === false) { - throw new Error('Legacy Automation storage cannot be migrated safely by this version.'); - } + this._requireLegacySourceReadable(); const failures: Error[] = []; for (const automation of discovered) { try { @@ -480,6 +493,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro this._requireLegacySourceDrained(); await this._waitForCatalog(() => true); + this._requireLegacySourceDrained(); const resources = discovered.map(automation => automationResource(automation.id)); this._connection.dispatch(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, @@ -497,6 +511,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro // covers reconnect races and cross-provider transfers that stage // pending without a subsequent acknowledgement path. await this._drainPendingImports(); + this._requireLegacySourceDrained(); this._ready.set(true, undefined); const durationMs = Date.now() - startedAt; this._logService.info(`[AgentHostAutomationStore] Automation migration completed: discovered=${discovered.length}, migrated=${resources.length}, failed=0, durationMs=${durationMs}.`); @@ -545,7 +560,14 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro } } + private _requireLegacySourceReadable(): void { + if (this._legacySource && (this._legacySource.catalogueState.get() !== 'ready' || this._legacySource.canCompleteMigration?.() === false)) { + throw new Error('Legacy Automation storage cannot be migrated safely by this version.'); + } + } + private _requireLegacySourceDrained(): void { + this._requireLegacySourceReadable(); const remaining = this._legacySource?.automations.get().length ?? 0; if (remaining > 0) { throw new Error(`Automation migration source changed during migration; ${remaining} definition(s) remain.`); @@ -664,7 +686,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro this._logService.warn(`[AgentHostAutomationStore] Cannot project Automation with no provider: resource=${state.resource}.`); return undefined; } - const config = state.definition.session.config; + const modelId = this._projectModelId(state.definition.session.model?.id, state.definition.session.provider); const newestRun = state.runs[0]; return { id: automationId(state.resource), @@ -672,9 +694,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro prompt: state.definition.message.text, schedule: projectSchedule(state.definition.triggers), target, - modelId: this._projectModelId(state.definition.session.model?.id, state.definition.session.provider), - mode: readString(config?.[SessionConfigKey.Mode]), - permissionLevel: readString(config?.[SessionConfigKey.AutoApprove]), + sessionTemplate: projectAutomationSessionTemplate(state.definition, modelId), enabled: state.definition.enabled, createdAt: state.createdAt, updatedAt: state.modifiedAt, @@ -783,13 +803,13 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro return state; } - private async _replaceDescriptor(descriptor: IAutomationDescriptor, imported = false, importPending?: boolean): Promise { + private async _replaceDescriptor(descriptor: IAutomationDescriptor, imported = false, importPending?: boolean, resetSessionTemplate = false): Promise { const resource = automationResource(descriptor.id); const current = this._findAutomationEntry(descriptor.id); if (!current) { throw new Error(`Automation does not exist: ${descriptor.id}`); } - const definition = this._definitionFromDescriptor(descriptor, current.definition, imported, importPending); + const definition = this._definitionFromDescriptor(descriptor, current.definition, imported, importPending, resetSessionTemplate); const expected = this._requireProjectedAutomation({ ...current, definition }); const state = await this._dispatchAndWait( { @@ -830,11 +850,29 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro return state; } - private _definitionFromDescriptor(descriptor: IAutomationDescriptor, existing?: AutomationDefinition, imported = false, importPending?: boolean): AutomationDefinition { - const config = { ...existing?.session.config }; - const provider = descriptor.target.sessionTypeId ?? this._providerFromModelId(descriptor.modelId); - setOptional(config, SessionConfigKey.Mode, descriptor.mode); - setOptional(config, SessionConfigKey.AutoApprove, descriptor.permissionLevel === ChatPermissionLevel.Autopilot ? ChatPermissionLevel.Assisted : descriptor.permissionLevel); + private _definitionFromDescriptor(descriptor: IAutomationDescriptor, existing?: AutomationDefinition, imported = false, importPending?: boolean, resetSessionTemplate = false): AutomationDefinition { + const sessionTemplate = descriptor.sessionTemplate; + assertAutomationSessionTemplate(sessionTemplate); + const modelId = sessionTemplate ? sessionTemplate.modelId : descriptor.modelId; + const provider = descriptor.target.sessionTypeId ?? this._providerFromModelId(modelId); + const existingSession = existing && existing.session.provider === provider ? existing.session : undefined; + let projectedConfig: Record; + if (sessionTemplate) { + projectedConfig = { + ...pickAutomationDefinitionOwnedConfigValues(existingSession?.config), + ...omitAutomationSessionTemplateConfigValues({ ...sessionTemplate.config }), + }; + } else if (resetSessionTemplate) { + projectedConfig = pickAutomationDefinitionOwnedConfigValues(existingSession?.config); + } else { + projectedConfig = applyLegacyAutomationSessionConfig( + provider, + existingSession?.config, + descriptor.mode, + descriptor.permissionLevel, + ); + } + const config = imported ? migrateLegacyAutomationSessionConfig(provider, projectedConfig) : projectedConfig; if (descriptor.target.kind === 'workspace') { setOptional(config, SessionConfigKey.Isolation, descriptor.target.isolation.kind === 'default' ? undefined : descriptor.target.isolation.kind); setOptional(config, SessionConfigKey.Branch, descriptor.target.isolation.kind === 'worktree' ? descriptor.target.isolation.branch : undefined); @@ -856,7 +894,11 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro message: { text: descriptor.prompt, origin: { kind: MessageKind.Automation } }, session: { provider, - model: descriptor.modelId ? { id: this._toHostModelId(descriptor.modelId, provider) } : undefined, + model: modelId ? { + id: this._toHostModelId(modelId, provider), + ...(sessionTemplate?.modelConfiguration !== undefined ? { config: sessionTemplate.modelConfiguration } : {}), + } : undefined, + agent: resetSessionTemplate ? undefined : sessionTemplate ? sessionTemplate.agent : existingSession?.agent, workingDirectories: descriptor.target.kind === 'workspace' ? [(this._boundaryMapper?.toHost(descriptor.target.folderUri) ?? descriptor.target.folderUri).toString()] : undefined, @@ -901,23 +943,38 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro } private _applyPatch(current: IAutomationDescriptor, patch: IUpdateAutomationOptions): IAutomationDescriptor { + assertAutomationSessionTemplateAuthority(current, patch); const now = new Date(); const schedule = patch.schedule ?? current.schedule; const enabled = patch.enabled ?? current.enabled; const target = patch.target ?? current.target; const targetAuthorityChanged = patch.target !== undefined && (patch.target.providerId !== current.target.providerId || patch.target.sessionTypeId !== current.target.sessionTypeId); + const currentModelId = current.sessionTemplate?.modelId ?? current.modelId; + const currentMode = readString(current.sessionTemplate?.config?.[SessionConfigKey.Mode]) ?? current.mode; + const currentPermissionLevel = readString(current.sessionTemplate?.config?.[SessionConfigKey.AutoApprove]) ?? current.permissionLevel; + const templatePatched = patch.sessionTemplate !== undefined; + const modelId = templatePatched || patch.modelId === null + ? undefined + : patch.modelId ?? (targetAuthorityChanged ? undefined : currentModelId); + const mode = templatePatched || patch.mode === null ? undefined : patch.mode ?? (targetAuthorityChanged ? undefined : currentMode); + const permissionLevel = templatePatched || patch.permissionLevel === null ? undefined : patch.permissionLevel ?? (targetAuthorityChanged ? undefined : currentPermissionLevel); + const provider = target.sessionTypeId ?? this._providerFromModelId(modelId); + const sessionTemplate = patch.sessionTemplate === null + ? undefined + : patch.sessionTemplate ?? (targetAuthorityChanged + ? undefined + : synchronizeAutomationSessionTemplate(current.sessionTemplate, provider, modelId, mode, permissionLevel)); return { ...current, ...(patch.name !== undefined ? { name: patch.name } : {}), ...(patch.prompt !== undefined ? { prompt: patch.prompt } : {}), schedule, target, - modelId: patch.modelId === null - ? undefined - : patch.modelId ?? (targetAuthorityChanged ? undefined : current.modelId), - mode: patch.mode === null ? undefined : patch.mode ?? current.mode, - permissionLevel: patch.permissionLevel === null ? undefined : patch.permissionLevel ?? current.permissionLevel, + sessionTemplate, + modelId, + mode, + permissionLevel, enabled, updatedAt: now.toISOString(), }; @@ -1226,6 +1283,31 @@ function readString(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } +function projectAutomationSessionTemplate(definition: AutomationDefinition, modelId: string | undefined): IAutomationSessionTemplate | undefined { + const config = omitAutomationSessionTemplateConfigValues({ ...definition.session.config }); + return createAutomationSessionTemplate(modelId, definition.session.model?.config, definition.session.agent, config); +} + +function synchronizeAutomationSessionTemplate(template: IAutomationSessionTemplate | undefined, provider: string | undefined, modelId: string | undefined, mode: string | undefined, permissionLevel: string | undefined): IAutomationSessionTemplate | undefined { + if (!template) { + return undefined; + } + const config = applyLegacyAutomationSessionConfig(provider, template.config, mode, permissionLevel); + return createAutomationSessionTemplate(modelId, modelId === template.modelId ? template.modelConfiguration : undefined, template.agent, config); +} + +function createAutomationSessionTemplate(modelId: string | undefined, modelConfiguration: IAutomationSessionTemplate['modelConfiguration'], agent: IAutomationSessionTemplate['agent'], config: Readonly>): IAutomationSessionTemplate | undefined { + if (!modelId && !agent && Object.keys(config).length === 0) { + return undefined; + } + return { + ...(modelId ? { modelId } : {}), + ...(modelId && modelConfiguration !== undefined ? { modelConfiguration } : {}), + ...(agent ? { agent: { uri: agent.uri } } : {}), + ...(Object.keys(config).length > 0 ? { config } : {}), + }; +} + function setOptional(target: Record, key: string, value: unknown): void { if (value === undefined) { delete target[key]; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 1944b6b622f501..5010b63361b17a 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -29,7 +29,8 @@ import { buildAnnotationsUri } from '../../../../../platform/agentHost/common/an import { ChangesetKind } from '../../../../../platform/agentHost/common/changesetUri.js'; import { parseGitHubIssueUrl } from '../../../../../platform/agentHost/common/githubIssueReferences.js'; import { getEffectiveAgents } from '../../../../../platform/agentHost/common/customAgents.js'; -import { KNOWN_MODE_VALUES, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; +import { KNOWN_MODE_VALUES, omitAutomationSessionTemplateConfigValues, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; +import { applyLegacyAutomationSessionConfig } from '../../../../../platform/agentHost/common/automationMigration.js'; import { migrateLegacyAutopilotConfig } from '../../../../../platform/agentHost/common/agentHostSchema.js'; import { readAgentDevContainerWorktreeMetadata, withAgentDevContainerWorktreeMetadata, type IAgentDevContainerWorktreeMetadata } from '../../../../../platform/agentHost/common/meta/agentDevContainerWorktreeMeta.js'; import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; @@ -50,6 +51,8 @@ import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browse import { ChatMode } from '../../../../../workbench/contrib/chat/common/chatModes.js'; import { IChatSendRequestOptions, IChatService, type IChatModelReference } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionFileChange, IChatSessionFileChange2, IChatSessionsService } 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 { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, getChatPermissionLevelFromDefaultConfiguration, isChatPermissionLevel, type IChatDefaultConfiguration } from '../../../../../workbench/contrib/chat/common/constants.js'; import { isAutoApprovePolicyRestricted, normalizeSessionConfigValue } from '../../../../../workbench/contrib/chat/common/agentHostConfigPolicy.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; @@ -60,7 +63,7 @@ import { isSessionConfigComplete } from '../../../../common/sessionConfig.js'; import { ChatInteractivity, ChatModelSource, ChatOriginKind, DEFAULT_CHAT_CAPABILITIES, effectiveChatInteractivity, getGitHubPullRequestRefs, getHighestPriorityPullRequestIcon, IChat, IChatCapabilities, IGitHubInfo, IGitHubIssueRef, IGitHubPullRequestRef, isActiveSessionStatus, ISession, ISessionAgentRef, ISessionArtifact, ISessionCapabilities, ISessionChangesSummary, ISessionChatCustomization, ISessionChangeset, ISessionCreationReference, ISessionFileChange, ISessionTurnFileChange, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, ISideChatSelection, sessionFileChangesEqual, sessionWorkspaceEqual, SessionRemoteConnectionFailureReason, SessionRemoteConnectionStatus, SessionStatus, SessionTypeAuthRequirement, toSessionId, TURN_CHANGES_CHANGESET_ID } from '../../../../services/sessions/common/session.js'; import { dedupeLinks, getPresentedArtifacts, linkKey, partitionSessionArtifacts } from './agentHostSessionArtifacts.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; -import { IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProviderCreateSessionOptions, ISessionWorktreeConfiguration } from '../../../../services/sessions/common/sessionsProvider.js'; +import { IAutomationSessionConfiguration, IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProviderCreateSessionOptions, ISessionWorktreeConfiguration } from '../../../../services/sessions/common/sessionsProvider.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; import { computePullRequestRefPresentation } from '../../../github/browser/pullRequestIconStatus.js'; import { IPullRequestIconCache } from '../../../github/browser/pullRequestIconCache.js'; @@ -1958,6 +1961,8 @@ interface INewSessionConstructionContext { * present from the very first `resolveConfig`/`createSession`. */ readonly initialConfigValues?: Record; + /** Provider-owned Automation values restored before the first configuration resolution. */ + readonly initialSessionTemplate?: IAutomationSessionTemplate; /** * Optional property schemas to seed into the new session's config before its * first {@link NewSession.resolveConfig} round-trip. Carried over from the @@ -2062,6 +2067,8 @@ class NewSession extends Disposable { private _config: ResolveSessionConfigResult | undefined = { schema: { type: 'object', properties: {} }, values: {} }; private _configResolution: Promise | undefined; private _configOperation: Promise | undefined; + private _unresolvedConfigValues: Record | undefined; + private readonly _explicitlySetConfigProperties = new Set(); /** * Monotonic counter for in-flight {@link resolveConfig} calls. Each call @@ -2106,6 +2113,8 @@ class NewSession extends Disposable { private readonly _activeClientScope: IAgentCustomizationScope; private readonly _initialMetadata: Record | undefined; + private readonly _initialSessionTemplate: IAutomationSessionTemplate | undefined; + readonly modelConfiguration: AutomationModelConfiguration; get initialMetadata(): Record | undefined { return this._initialMetadata; } private readonly _logService: ILogService; @@ -2115,8 +2124,10 @@ class NewSession extends Disposable { ctx: INewSessionConstructionContext, private readonly _options: IAgentHostAdapterOptions, @ISessionsService sessionsService: ISessionsService, + @ILanguageModelsService languageModelsService: ILanguageModelsService, ) { super(); + this.modelConfiguration = this._register(new AutomationModelConfiguration(languageModelsService, ctx.initialSessionTemplate)); const workspaceUri = ctx.workspace?.folders[0]?.root; this._kind = sessionKind(!!ctx.quickChat); if (this._kind.requiresWorkspace && !workspaceUri) { @@ -2132,6 +2143,7 @@ class NewSession extends Disposable { this._activeClientScope = ctx.activeClientScope; this._register(this._activeClientScope); this._initialMetadata = ctx.initialMetadata; + this._initialSessionTemplate = ctx.initialSessionTemplate; const resource = URI.from({ scheme: ctx.resourceScheme, path: `/${generateUuid()}` }); this._isActiveSessionObs = derived(this, reader => isEqual(sessionsService.activeSession.read(reader)?.resource, resource)); @@ -2145,11 +2157,11 @@ class NewSession extends Disposable { this._workspace = observableValue(this, ctx.workspace); const changes = observableValueOpts({ owner: this, equalsFn: sessionFileChangesEqual }, []); const checkpoints = observableValue(this, undefined); - this._selectedModelId = undefined; - this._selectedAgent = undefined; + this._selectedModelId = ctx.initialSessionTemplate?.modelId; + this._selectedAgent = ctx.initialSessionTemplate?.agent ? { uri: ctx.initialSessionTemplate.agent.uri, name: '' } : undefined; this._modelId = observableValue(this, this._selectedModelId); - this._modelSource = observableValue(this, undefined); - const mode = observableValue<{ readonly id: string; readonly kind: string } | undefined>(this, undefined); + this._modelSource = observableValue(this, this._selectedModelId ? ChatModelSource.Chosen : undefined); + const mode = observableValue<{ readonly id: string; readonly kind: string } | undefined>(this, this._selectedAgent ? { id: this._selectedAgent.uri, kind: AGENT_MODE_KIND } : undefined); this._mode = mode; const isArchived = observableValue(this, false); const isRead = observableValue(this, true); @@ -2231,6 +2243,18 @@ class NewSession extends Disposable { getSelectedModelId(): string | undefined { return this._selectedModelId; } clearSelectedModelId(): void { this._selectedModelId = undefined; } + getSelectedModel(): ModelSelection | undefined { + const modelId = this._selectedModelId; + if (!modelId) { + return undefined; + } + const prefix = `${this.session.resource.scheme}:`; + const config = this.modelConfiguration.getModelConfigurationForRequest(modelId); + return { + id: modelId.startsWith(prefix) ? modelId.slice(prefix.length) : modelId, + ...(config !== undefined ? { config } : {}), + }; + } /** Untitled skeleton title used until the first request commits the session. */ get untitledTitle(): string { return this._kind.untitledTitle; } setSelectedAgent(agent: ISessionAgentRef | undefined): void { @@ -2239,6 +2263,7 @@ class NewSession extends Disposable { } getSelectedAgent(): ISessionAgentRef | undefined { return this._selectedAgent; } + getInitialSessionTemplate(): IAutomationSessionTemplate | undefined { return this._initialSessionTemplate; } clearSelectedAgent(): void { this._selectedAgent = undefined; this._mode.set(undefined, undefined); @@ -2368,9 +2393,9 @@ class NewSession extends Disposable { * during the async re-resolve. {@link resolveConfig} replaces both * schema and values when its response lands. */ - setConfigValue(property: string, value: unknown): void { + setConfigValue(property: string, value: unknown, explicitlySet = false): void { const current = this._config; - const values = { ...(current?.values ?? {}) }; + const values = { ...(current?.values ?? this._unresolvedConfigValues) }; if (value === undefined) { delete values[property]; } else { @@ -2380,9 +2405,16 @@ class NewSession extends Disposable { schema: current?.schema ?? { type: 'object', properties: {} }, values, }; + if (explicitlySet) { + this._explicitlySetConfigProperties.add(property); + } this._syncWorktreePending(); } + wasConfigValueExplicitlySet(property: string): boolean { + return this._explicitlySetConfigProperties.has(property); + } + /** * `true` while a {@link resolveConfig} round-trip is in flight. See * {@link _isResolvingConfig} for why this is distinct from {@link ISession.loading}. @@ -2414,17 +2446,19 @@ class NewSession extends Disposable { */ async resolveConfig(connection: IAgentConnection, strict = false): Promise { const seq = ++this._configRequestSeq; + const values = this._config?.values ?? this._unresolvedConfigValues; this._isResolvingConfig.set(true, undefined); try { const result = await connection.resolveSessionConfig({ provider: this.agentProvider, workingDirectory: this.workspaceUri, - config: this._config?.values, + config: values, }); if (seq !== this._configRequestSeq) { return false; } this._config = result; + this._unresolvedConfigValues = undefined; this._syncWorktreePending(); return true; } catch (error) { @@ -2432,6 +2466,7 @@ class NewSession extends Disposable { return false; } this._config = undefined; + this._unresolvedConfigValues = values; this._syncWorktreePending(); if (strict) { throw error; @@ -2517,6 +2552,7 @@ class NewSession extends Disposable { await connection.createSession({ provider: this.agentProvider, session: backendUri, + ...(this._initialSessionTemplate?.modelId ? { model: this.getSelectedModel() } : {}), workingDirectories: this.workspaceUri ? [this.workspaceUri] : undefined, config: this._config?.values, _meta: this._initialMetadata, @@ -2700,6 +2736,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement abstract readonly label: string; abstract readonly icon: ThemeIcon; abstract readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; + readonly usesCombinedNewSessionConfigPicker = true; + readonly supportsAutomationSessionConfiguration = true; get order(): number { return 0; } @@ -3516,7 +3554,13 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement throw new Error(`Cannot resolve workspace for URI: ${workspaceUri.toString()}`); } - return this._createDraftSession(sessionType, workspace, false, options?.metadata); + return this._createDraftSession( + sessionType, + workspace, + false, + options?.metadata, + options?.automationConfiguration, + ); } startNewSessionRequest(sessionId: string, activity?: string): IDisposable { @@ -3527,7 +3571,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return newSession.startRequest(activity); } - createQuickChat(sessionTypeId: string): ISession { + createQuickChat(sessionTypeId: string, options?: ISessionsProviderCreateSessionOptions): ISession { const sessionType = this.sessionTypes.find(t => t.id === sessionTypeId); if (!sessionType) { throw new Error(this._noAgentsErrorMessage()); @@ -3539,7 +3583,13 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // workspace-less: no `resolveWorkspace`, no `workingDirectory`. The // agent host runs it in a throwaway scratch cwd and tags it via the // `quickChat` create flag. - return this._createDraftSession(sessionType, undefined, true); + return this._createDraftSession( + sessionType, + undefined, + true, + options?.metadata, + options?.automationConfiguration, + ); } /** @@ -3547,7 +3597,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement * given session type. Shared by {@link createNewSession} (workspace-bound) * and {@link createQuickChat} (workspace-less, `quickChat === true`). */ - private _createDraftSession(sessionType: ISessionType, workspace: ISessionWorkspace | undefined, quickChat: boolean, initialMetadata?: Record): ISession { + private _createDraftSession(sessionType: ISessionType, workspace: ISessionWorkspace | undefined, quickChat: boolean, initialMetadata?: Record, initialAutomationConfiguration?: IAutomationSessionConfiguration): ISession { // Tear-down of superseded drafts is handled by the management layer // (it calls `deleteNewSession` on the previous pending session). Each // new session is tracked independently in `_newSessions` so several can @@ -3555,7 +3605,14 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // composer re-seeds a fresh draft). const connection = this.connection; const resourceScheme = this.resourceSchemeForProvider(sessionType.id); + const initialSessionTemplate = this._resolveAutomationSessionTemplate(sessionType.id, initialAutomationConfiguration); const activeClientScope = this._activeClientService.acquireScope(resourceScheme, workspace?.folders.map(folder => folder.root) ?? []); + const initialConfigValues = initialAutomationConfiguration + ? { + ...this._derivedNewSessionConfig(workspace), + ...this._normalizeAutomationSessionConfig(initialSessionTemplate?.config), + } + : this._initialNewSessionConfig(workspace); let newSession: NewSession; try { newSession = this._instantiationService.createInstance(NewSession, { @@ -3568,7 +3625,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement backendSessionScheme: this._backendSessionScheme(sessionType.id), authenticationPending: this.authenticationPending, logService: this._logService, - initialConfigValues: this._initialNewSessionConfig(workspace), + initialConfigValues, + initialSessionTemplate, initialConfigSchema: this._seededConfigSchema(), initialMetadata, instantiationService: this._instantiationService, @@ -3615,6 +3673,39 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return newSession.session; } + private _resolveAutomationSessionTemplate(sessionTypeId: string, configuration: IAutomationSessionConfiguration | undefined): IAutomationSessionTemplate | undefined { + if (!configuration) { + return undefined; + } + if (configuration.sessionTemplate) { + const template = configuration.sessionTemplate; + assertAutomationSessionTemplate(template); + const config = omitAutomationSessionTemplateConfigValues({ ...template.config }); + return { + ...(template.modelId ? { modelId: template.modelId } : {}), + ...(template.modelConfiguration !== undefined ? { modelConfiguration: template.modelConfiguration } : {}), + ...(template.agent ? { agent: template.agent } : {}), + ...(Object.keys(config).length > 0 ? { config } : {}), + }; + } + const config = applyLegacyAutomationSessionConfig(sessionTypeId, undefined, configuration.mode, configuration.permissionLevel); + if (!configuration.modelId && Object.keys(config).length === 0) { + return undefined; + } + return { + ...(configuration.modelId ? { modelId: configuration.modelId } : {}), + ...(Object.keys(config).length > 0 ? { config } : {}), + }; + } + + private _normalizeAutomationSessionConfig(config: Readonly> | undefined): Record { + const policyRestricted = isAutoApprovePolicyRestricted(this._baseConfigurationService); + return Object.fromEntries(Object.entries(config ?? {}).map(([key, value]) => [ + key, + normalizeSessionConfigValue(key, value, policyRestricted), + ])); + } + protected _resumeNewSessionAfterAuthenticationSettles(): void { const connection = this.connection; if (!connection) { @@ -3659,8 +3750,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement private async _refreshNewSessionConfig(session: NewSession, options: { readonly expected?: Readonly>; readonly markSessionLoading?: boolean; + readonly strict?: boolean; } = {}): Promise { const { expected, markSessionLoading } = options; + const strict = options.strict || expected !== undefined; const connection = this.connection; if (!connection) { // {@link resolveConfig} (the only other clear path) is skipped @@ -3669,8 +3762,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement session.endResolveConfigSync(); session.setLoading(false); this._onDidChangeSessionConfig.fire(session.sessionId); - if (expected) { - throw new Error('Cannot set session repository config without an agent host connection.'); + if (strict) { + throw new Error('Cannot resolve session config without an agent host connection.'); } return; } @@ -3679,7 +3772,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } let applied: boolean; try { - applied = await session.resolveConfig(connection, !!expected); + applied = await session.resolveConfig(connection, strict); } catch (error) { session.setLoading(false); this._onDidChangeSessionConfig.fire(session.sessionId); @@ -3687,8 +3780,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } // Bail if a newer call superseded us — its own pulse will take over. if (!applied || this._newSessions.get(session.sessionId) !== session) { - if (expected) { - throw new Error('Session repository config was superseded before it could be applied.'); + if (strict) { + throw new Error('Session config was superseded before it could be applied.'); } return; } @@ -3817,22 +3910,81 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // here (rather than remembered) since it is derived from a setting, not // a user pick; an empty value is omitted so the default branch naming // is preserved. + Object.assign(remembered, this._derivedNewSessionConfig(workspace)); + + return Object.keys(remembered).length > 0 ? remembered : undefined; + } + + private _derivedNewSessionConfig(workspace: ISessionWorkspace | undefined): Record { + const config: Record = {}; const resource = workspace?.folders[0]?.root; const branchPrefix = this._baseConfigurationService.getValue('git.branchPrefix', { resource }); if (typeof branchPrefix === 'string' && branchPrefix.length > 0) { - remembered[SessionConfigKey.WorktreeBranchPrefix] = branchPrefix; + config[SessionConfigKey.WorktreeBranchPrefix] = branchPrefix; } const worktreeIncludeFiles = this._baseConfigurationService.getValue('git.worktreeIncludeFiles', { resource }); if (Array.isArray(worktreeIncludeFiles) && worktreeIncludeFiles.length > 0) { - remembered[SessionConfigKey.WorktreeIncludeFiles] = worktreeIncludeFiles; + config[SessionConfigKey.WorktreeIncludeFiles] = worktreeIncludeFiles; } - - return Object.keys(remembered).length > 0 ? remembered : undefined; + return config; } // -- Dynamic session config ---------------------------------------------- + getAutomationModelConfiguration(sessionId: string): AutomationModelConfiguration | undefined { + return this._getNewSession(sessionId)?.modelConfiguration; + } + + async getAutomationSessionConfiguration(sessionId: string): Promise { + const newSession = this._getNewSession(sessionId); + if (!newSession) { + return undefined; + } + await newSession.waitForConfigurationReady(); + if (this._getNewSession(sessionId) !== newSession) { + return undefined; + } + if (!newSession.getConfig()) { + await newSession.trackConfigResolution(this._refreshNewSessionConfig(newSession, { strict: true })); + await newSession.waitForConfigurationReady(); + } + if (this._getNewSession(sessionId) !== newSession) { + return undefined; + } + const resolvedConfig = newSession.getConfig(); + if (!resolvedConfig) { + throw new Error('Cannot capture unresolved Automation session configuration.'); + } + const config = { ...resolvedConfig.values }; + const initialConfig = newSession.getInitialSessionTemplate()?.config ?? {}; + for (const [key, value] of Object.entries(initialConfig)) { + if (!newSession.wasConfigValueExplicitlySet(key)) { + config[key] = value; + } + } + const templateConfig = omitAutomationSessionTemplateConfigValues(config); + const modelId = newSession.getSelectedModelId(); + const modelConfiguration = newSession.modelConfiguration.captureModelConfiguration(modelId); + const agent = newSession.getSelectedAgent(); + const sessionTemplate: IAutomationSessionTemplate | undefined = !modelId && !agent && Object.keys(templateConfig).length === 0 + ? undefined + : { + ...(modelId ? { modelId } : {}), + ...(modelConfiguration !== undefined ? { modelConfiguration } : {}), + ...(agent ? { agent: { uri: agent.uri } } : {}), + ...(Object.keys(templateConfig).length > 0 ? { config: templateConfig } : {}), + }; + const mode = templateConfig[SessionConfigKey.Mode]; + const permissionLevel = templateConfig[SessionConfigKey.AutoApprove]; + return { + sessionTemplate, + modelId, + mode: typeof mode === 'string' ? mode : undefined, + permissionLevel: typeof permissionLevel === 'string' ? permissionLevel : undefined, + }; + } + getSessionConfig(sessionId: string): ResolveSessionConfigResult | undefined { // New-session config wins (during pre-creation flow). Otherwise lazily // subscribe to the session's state so the running picker can seed its @@ -3891,7 +4043,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (property === SessionConfigKey.Isolation) { newSession.setConfigValue(SessionConfigKey.Branch, undefined); } - newSession.setConfigValue(property, normalizedValue); + newSession.setConfigValue(property, normalizedValue, true); this._onDidChangeSessionConfig.fire(sessionId); await newSession.trackConfigResolution(this._refreshNewSessionConfig(newSession)); return; @@ -4339,6 +4491,13 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement setModel(sessionId: string, chatResource: URI, modelId: string, source: ChatModelSource): void { const newSession = this._getNewSession(sessionId); if (newSession) { + const previousModelId = newSession.getSelectedModelId(); + if (previousModelId && previousModelId !== modelId) { + const resolution = this.getModelsSnapshot(sessionId, previousModelId).desiredModelResolution; + if (resolution.kind === 'available' && resolution.model.identifier === modelId) { + newSession.modelConfiguration.rebindModelConfiguration(previousModelId, modelId); + } + } newSession.setSelectedModelId(modelId, source); return; } @@ -4994,6 +5153,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement const sendOptions: IChatSendRequestOptions = { location: ChatAgentLocation.Chat, userSelectedModelId: selectedModelId, + userSelectedModelConfiguration: newSession.modelConfiguration.getModelConfigurationForRequest(selectedModelId), modeInfo: selectedAgent ? { kind: ChatModeKind.Agent, isBuiltin: false, diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index 6f746ec0c4c3bc..aa4eb29977d3d7 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -264,6 +264,10 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide }; bindConnection(); this._register(this._agentHostService.onAgentHostStart(bindConnection)); + this._register(this._agentHostService.onAgentHostExit(() => { + connectionListeners.clear(); + automations.clearConnection(); + })); // Eagerly populate the session cache once authentication has settled. // Without this, the sidebar would only call `getSessions()` after some diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts index 2c22db7792db02..1ee6dfd389d47f 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts @@ -8,7 +8,7 @@ import { renderIcon } from '../../../../../../base/browser/ui/iconLabel/iconLabe import { Gesture, EventType as TouchEventType } from '../../../../../../base/browser/touch.js'; import { BaseActionViewItem } from '../../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { Disposable, DisposableMap, DisposableStore } from '../../../../../../base/common/lifecycle.js'; -import { autorun, IObservable } from '../../../../../../base/common/observable.js'; +import { autorun, IObservable, observableSignalFromEvent } from '../../../../../../base/common/observable.js'; import { localize, localize2 } from '../../../../../../nls.js'; import { IActionViewItemService } from '../../../../../../platform/actions/browser/actionViewItemService.js'; import { Action2, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; @@ -25,7 +25,7 @@ import { ChatPetAchievementIds, didExplicitlySwitchChatPetModel } from '../../.. import { IChatPetService } from '../../../../../../workbench/contrib/chat/browser/chatPetService.js'; import { Menus } from '../../../../../browser/menus.js'; import { SessionUsesCombinedConfigPickerContext, IsPhoneLayoutContext } from '../../../../../common/contextkeys.js'; -import { type IAgentHostSessionsProvider, isAgentHostProvider, isAgentHostProviderId } from '../../../../../common/agentHostSessionsProvider.js'; +import { type IAgentHostSessionsProvider, isAgentHostProvider } from '../../../../../common/agentHostSessionsProvider.js'; import { IActiveSession } from '../../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../../../services/sessions/browser/sessionsService.js'; import { ISessionsProvidersService } from '../../../../../services/sessions/browser/sessionsProvidersService.js'; @@ -372,6 +372,7 @@ class MobileChatInputConfigPickerContribution extends Disposable implements IWor @IActionViewItemService actionViewItemService: IActionViewItemService, @IInstantiationService instantiationService: IInstantiationService, @ISessionsService sessionsService: ISessionsService, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, @IContextKeyService contextKeyService: IContextKeyService, ) { super(); @@ -381,9 +382,11 @@ class MobileChatInputConfigPickerContribution extends Disposable implements IWor // bottom sheet. Publish this as a neutral context key so the core model // picker can gate itself out without depending on agent-host identity. const usesCombinedPicker = SessionUsesCombinedConfigPickerContext.bindTo(contextKeyService); + const providersChanged = observableSignalFromEvent(this, sessionsProvidersService.onDidChangeProviders); this._register(autorun(reader => { + providersChanged.read(reader); const session = sessionsService.activeSession.read(reader); - usesCombinedPicker.set(!!session && isAgentHostProviderId(session.providerId)); + usesCombinedPicker.set(!!session && sessionsProvidersService.getProvider(session.providerId)?.usesCombinedNewSessionConfigPicker === true); })); this._register(actionViewItemService.register( diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/reconnectableAgentHostAutomationStore.ts b/src/vs/sessions/contrib/providers/agentHost/browser/reconnectableAgentHostAutomationStore.ts index 819eb4a17b4549..630edb28eb6e91 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/reconnectableAgentHostAutomationStore.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/reconnectableAgentHostAutomationStore.ts @@ -7,12 +7,12 @@ import { disposableTimeout } from '../../../../../base/common/async.js'; import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { isCancellationError } from '../../../../../base/common/errors.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; -import { autorun, derived, disposableObservableValue, observableSignalFromEvent, observableValue, waitForState, type IObservable } from '../../../../../base/common/observable.js'; +import { autorun, derived, disposableObservableValue, observableSignalFromEvent, observableValue, transaction, waitForState, type IObservable, type ITransaction } from '../../../../../base/common/observable.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import type { AutomationRunTrigger, IAutomationDescriptor, IAutomationRun } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; -import { isAutomationActiveRunError, type AutomationMutationGuard, type IAutomationRunClaim, type ICreateAutomationOptions, type IGuardedAutomationUpdateResult, type IUpdateAutomationOptions, type IUpdateAutomationRunOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { type AutomationCatalogueState, isAutomationActiveRunError, type AutomationMutationGuard, type IAutomationRunClaim, type ICreateAutomationOptions, type IGuardedAutomationUpdateResult, type IUpdateAutomationOptions, type IUpdateAutomationRunOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import type { IAutomation, IAutomationSnapshotImportResult, IGuardedAutomationSnapshotRemovalResult, ISessionsProviderAutomations } from '../../../../services/sessions/common/sessionsProvider.js'; import { AgentHostAutomationStore, type IAgentHostAutomationBoundaryMapper, type IAgentHostAutomationConnection } from './agentHostAutomationStore.js'; import { CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; @@ -37,6 +37,20 @@ export class ReconnectableAgentHostAutomationStore extends Disposable implements readonly automations = derived(this, reader => this._currentStore.read(reader)?.automations.read(reader) ?? this._legacySource?.automations.read(reader) ?? []); readonly runs = derived(this, reader => this._currentStore.read(reader)?.runs.read(reader) ?? this._legacySource?.runs.read(reader) ?? []); + readonly catalogueState: IObservable = derived(this, reader => { + const authorityState = this._authorityState.read(reader); + const legacyState = this._legacySource?.catalogueState.read(reader) ?? 'ready'; + switch (authorityState.kind) { + case 'initializing': + return legacyState === 'error' ? 'error' : 'loading'; + case 'supported': + return authorityState.store.catalogueState.read(reader); + case 'unsupported': + case 'disabled': + case 'disconnected': + return legacyState === 'error' ? 'error' : 'unavailable'; + } + }); constructor( private readonly _providerId: string, @@ -51,20 +65,19 @@ export class ReconnectableAgentHostAutomationStore extends Disposable implements } override dispose(): void { - this._connectionBinding.clear(); - this._migrationRetry.clear(); - this._setAuthorityState({ kind: 'disconnected' }); + this.clearConnection(); this._disposeCancellation.cancel(); this._disposeCancellation.dispose(); - this._currentStore.set(undefined, undefined); super.dispose(); } setConnection(connection: IAgentHostAutomationConnection): void { this._connectionBinding.clear(); this._migrationRetry.clear(); - this._currentStore.set(undefined, undefined); - this._setAuthorityState({ kind: 'initializing' }); + transaction(tx => { + this._currentStore.set(undefined, tx); + this._setAuthorityState({ kind: 'initializing' }, tx); + }); this._connectionBinding.add(autorun(reader => { this._configurationChanged.read(reader); const initializeResult = connection.initializeResult.read(reader); @@ -73,9 +86,11 @@ export class ReconnectableAgentHostAutomationStore extends Disposable implements if (!enabled) { if (current) { this._migrationRetry.clear(); - this._currentStore.set(undefined, undefined); } - this._setAuthorityState({ kind: 'disabled' }); + transaction(tx => { + this._currentStore.set(undefined, tx); + this._setAuthorityState({ kind: 'disabled' }, tx); + }); return; } if (!initializeResult) { @@ -85,15 +100,19 @@ export class ReconnectableAgentHostAutomationStore extends Disposable implements if (!initializeResult.automations) { if (current) { this._migrationRetry.clear(); - this._currentStore.set(undefined, undefined); } - this._setAuthorityState({ kind: 'unsupported' }); + transaction(tx => { + this._currentStore.set(undefined, tx); + this._setAuthorityState({ kind: 'unsupported' }, tx); + }); return; } if (!current) { const store = this._instantiationService.createInstance(AgentHostAutomationStore, this._providerId, connection, this._legacySource, this._boundaryMapper); - this._currentStore.set(store, undefined); - this._setAuthorityState({ kind: 'supported', store }); + transaction(tx => { + this._currentStore.set(store, tx); + this._setAuthorityState({ kind: 'supported', store }, tx); + }); this._completeMigration(store); } else { this._setAuthorityState({ kind: 'supported', store: current }); @@ -104,8 +123,10 @@ export class ReconnectableAgentHostAutomationStore extends Disposable implements clearConnection(): void { this._connectionBinding.clear(); this._migrationRetry.clear(); - this._currentStore.set(undefined, undefined); - this._setAuthorityState({ kind: 'disconnected' }); + transaction(tx => { + this._currentStore.set(undefined, tx); + this._setAuthorityState({ kind: 'disconnected' }, tx); + }); } getAutomation(id: string): IAutomationDescriptor | undefined { @@ -224,13 +245,13 @@ export class ReconnectableAgentHostAutomationStore extends Disposable implements } } - private _setAuthorityState(state: AutomationAuthorityState): void { + private _setAuthorityState(state: AutomationAuthorityState, tx?: ITransaction): void { const current = this._authorityState.get(); if (current.kind === state.kind && (current.kind !== 'supported' || state.kind !== 'supported' || current.store === state.store)) { return; } - this._authorityState.set(state, undefined); + this._authorityState.set(state, tx); } private _completeMigration(store: AgentHostAutomationStore): void { diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts index ef15dc40a8eb5d..2f5f66c53ae99d 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts @@ -7,14 +7,15 @@ import assert from 'assert'; import { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableStore, type IReference } from '../../../../../../base/common/lifecycle.js'; -import { observableValue } from '../../../../../../base/common/observable.js'; +import { autorun, observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { ConfigurationTarget, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import type { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_META_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY } from '../../../../../../platform/agentHost/common/automationMigration.js'; +import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ActionType, type ActionEnvelope } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AutomationOperation, AutomationRunOriginKind, AutomationRunStatus, AutomationTriggerKind, MessageKind, type AutomationEntry, type AutomationState, type RootState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -22,7 +23,7 @@ import { AUTOMATION_CATALOG_URI, ROOT_STATE_URI, StateComponents } from '../../. import type { InitializeResult } from '../../../../../../platform/agentHost/common/state/protocol/common/commands.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; -import { InMemoryStorageService, IStorageService } from '../../../../../../platform/storage/common/storage.js'; +import { InMemoryStorageService, IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { NullTelemetryService, NullTelemetryServiceShape } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; import { AgentHostAutomationStore } from '../../browser/agentHostAutomationStore.js'; @@ -32,14 +33,18 @@ import { CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../../../../../workbench/co import { TestAutomationStorageService } from '../../../../automations/test/browser/automationTestUtils.js'; import { AutomationStore } from '../../../../automations/browser/automationService.js'; import { ReconnectableAgentHostAutomationStore } from '../../browser/reconnectableAgentHostAutomationStore.js'; +import type { AutomationCatalogueState } from '../../../../../../workbench/contrib/chat/common/automations/automationService.js'; class TestAutomationConnection { private readonly _onDidAction = new Emitter(); readonly onDidAction = this._onDidAction.event; private readonly _onDidCatalogChange = new Emitter(); + private readonly _onDidCatalogError = new Emitter(); private readonly _onDidRootChange = new Emitter(); private _catalog: AutomationState = { entries: [] }; + private _catalogError: Error | undefined; + private _catalogAvailable: boolean; private _root: RootState; private _serverSeq = 0; private _migrationComplete: boolean; @@ -53,8 +58,9 @@ class TestAutomationConnection { updateError: Error | undefined; readonly createRequested = new DeferredPromise(); - constructor(migrationComplete: boolean) { + constructor(migrationComplete: boolean, catalogAvailable = true) { this._migrationComplete = migrationComplete; + this._catalogAvailable = catalogAvailable; this._root = { agents: [], activeSessions: 0, @@ -82,6 +88,23 @@ class TestAutomationConnection { }); } + setFirstAutomationSessionConfig(config: Record): void { + const [automation, ...rest] = this._catalog.entries; + if (!automation) { + throw new Error('No Automation is available.'); + } + this._catalog = { + entries: [{ + ...automation, + definition: { + ...automation.definition, + session: { ...automation.definition.session, config }, + }, + }, ...rest], + }; + this._onDidCatalogChange.fire(this._catalog); + } + getSubscription( kind: StateComponents.AutomationCatalog, resource: URI, @@ -92,9 +115,10 @@ class TestAutomationConnection { const connection = this; return { object: { - get value() { return connection._catalog; }, - get verifiedValue() { return connection._catalog; }, + get value() { return connection._catalogError ?? (connection._catalogAvailable ? connection._catalog : undefined); }, + get verifiedValue() { return connection._catalogAvailable ? connection._catalog : undefined; }, onDidChange: this._onDidCatalogChange.event, + onDidError: this._onDidCatalogError.event, onWillApplyAction: Event.None, onDidApplyAction: Event.None, }, @@ -102,6 +126,17 @@ class TestAutomationConnection { }; } + setCatalogError(error: Error): void { + this._catalogError = error; + this._onDidCatalogError.fire(error); + } + + setCatalogAvailable(available = true): void { + this._catalogAvailable = available; + this._catalogError = undefined; + this._onDidCatalogChange.fire(this._catalog); + } + dispatch(channel: string, action: Parameters[1]): void { this.dispatched.push({ channel, action }); if (action.type === ActionType.AutomationCreateRequested) { @@ -269,6 +304,7 @@ class TestAutomationConnection { dispose(): void { this._onDidAction.dispose(); this._onDidCatalogChange.dispose(); + this._onDidCatalogError.dispose(); this._onDidRootChange.dispose(); } } @@ -374,7 +410,7 @@ suite('AgentHostAutomationStore', () => { name: id, prompt: 'Review history.', schedule: { interval: 'manual', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 1 }, - target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, enabled: true, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', @@ -392,6 +428,116 @@ suite('AgentHostAutomationStore', () => { }; } + test('reports loading until the authoritative catalogue is ready', () => { + const connection = new TestAutomationConnection(false, false); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + const loading = store.catalogueState.get(); + connection.setCatalogAvailable(); + + assert.deepStrictEqual({ + loading, + afterSnapshot: store.catalogueState.get(), + }, { + loading: 'loading', + afterSnapshot: 'ready', + }); + }); + + test('reports catalogue errors after the ready state was observed', async () => { + const connection = new TestAutomationConnection(true); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + await store.createAutomation({ + name: 'Review changes', + prompt: 'Review the current changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + }); + + const ready = store.catalogueState.get(); + connection.setCatalogError(new Error('catalogue unavailable')); + + assert.deepStrictEqual({ + ready, + afterError: store.catalogueState.get(), + }, { + ready: 'ready', + afterError: 'error', + }); + }); + + for (const hasHostAutomation of [false, true]) { + test(`does not mask an unreadable legacy source with a ${hasHostAutomation ? 'populated' : 'empty'} host snapshot`, async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + if (hasHostAutomation) { + const hostStore = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + await hostStore.createAutomation({ + name: 'Known host automation', + prompt: 'Review changes.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, + }); + } + const storageKey = providerAutomationStorageKey('local-agent-host'); + storage.store(storageKey, '{', StorageScope.APPLICATION, StorageTarget.MACHINE); + const legacy = disposables.add(new AutomationStore(storageKey, storage, new NullLogService(), NullTelemetryService, automationStorage)); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, legacy, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + await assert.rejects(store.completeMigration(), /cannot be migrated safely/); + + assert.deepStrictEqual({ + legacyState: legacy.catalogueState.get(), + hostState: store.catalogueState.get(), + canCompleteMigration: legacy.canCompleteMigration(), + names: store.automations.get().map(automation => automation.name), + completions: connection.dispatched.filter(entry => entry.channel === ROOT_STATE_URI).length, + }, { + legacyState: 'error', + hostState: 'error', + canCompleteMigration: false, + names: hasHostAutomation ? ['Known host automation'] : [], + completions: 0, + }); + }); + } + + test('rechecks legacy readability after waiting for the host catalogue', async () => { + const connection = disposables.add(new TestAutomationConnection(false, false)); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const storageKey = providerAutomationStorageKey('local-agent-host'); + const legacy = disposables.add(new AutomationStore(storageKey, storage, new NullLogService(), NullTelemetryService, automationStorage)); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, legacy, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + const migration = assert.rejects(store.completeMigration(), /cannot be migrated safely/); + storage.store(storageKey, '{', StorageScope.APPLICATION, StorageTarget.MACHINE); + connection.setCatalogAvailable(); + await migration; + const afterFailure = { + state: store.catalogueState.get(), + completions: connection.dispatched.filter(entry => entry.channel === ROOT_STATE_URI).length, + }; + + storage.store(storageKey, JSON.stringify({ schemaVersion: 4, revision: 1, automations: [], runs: [] }), StorageScope.APPLICATION, StorageTarget.MACHINE); + await store.completeMigration(); + + assert.deepStrictEqual({ + afterFailure, + afterRepair: { + state: store.catalogueState.get(), + completions: connection.dispatched.filter(entry => entry.channel === ROOT_STATE_URI).length, + }, + }, { + afterFailure: { state: 'error', completions: 0 }, + afterRepair: { state: 'ready', completions: 1 }, + }); + }); + test('uses the exact catalogue channel and projects authoritative creates', async () => { const connection = new TestAutomationConnection(true); disposables.add(connection); @@ -404,13 +550,15 @@ suite('AgentHostAutomationStore', () => { name: 'Review changes', prompt: 'Review the current changes.', schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, - target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, + mode: 'agent', permissionLevel: 'autopilot', }); const create = connection.dispatched[0].action; const trigger = create.type === ActionType.AutomationCreateRequested ? create.definition.triggers[0] : undefined; assert.deepStrictEqual({ + catalogueState: store.catalogueState.get(), subscribedChannel: connection.subscribedChannel, dispatchChannel: connection.dispatched[0].channel, definitionMeta: create.type === ActionType.AutomationCreateRequested ? create.definition._meta : undefined, @@ -424,21 +572,443 @@ suite('AgentHostAutomationStore', () => { enabled: automation.enabled, }, }, { + catalogueState: 'ready', subscribedChannel: URI.parse(AUTOMATION_CATALOG_URI).toString(), dispatchChannel: AUTOMATION_CATALOG_URI, definitionMeta: undefined, - sessionConfig: { autoApprove: 'assisted' }, + sessionConfig: { mode: 'autopilot', autoApprove: 'assisted' }, triggerExpression: '30 9 * * *', automation: { name: 'Review changes', prompt: 'Review the current changes.', schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, - target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, enabled: true, }, }); }); + test('does not forward generic chat modes to Agent Host session config', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const store = disposables.add(new AgentHostAutomationStore( + 'local-agent-host', + connection, + undefined, + undefined, + new NullLogService(), + storage, + NullTelemetryService, + new TestAutomationStorageService(storage), + )); + + await store.createAutomation({ + name: 'Review changes', + prompt: 'Review the current changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, + mode: 'agent', + permissionLevel: 'default', + }); + + const create = connection.dispatched[0].action; + assert.deepStrictEqual( + create.type === ActionType.AutomationCreateRequested ? create.definition.session.config : undefined, + { autoApprove: 'default' }, + ); + }); + + test('applies legacy Autopilot configuration to the default provider', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const store = disposables.add(new AgentHostAutomationStore( + 'local-agent-host', + connection, + undefined, + undefined, + new NullLogService(), + storage, + NullTelemetryService, + new TestAutomationStorageService(storage), + )); + + await store.createAutomation({ + name: 'Review changes', + prompt: 'Review the current changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { + kind: 'workspace', + folderUri: URI.file('/workspace'), + providerId: undefined, + sessionTypeId: undefined, + isolation: { kind: 'default' }, + }, + mode: 'agent', + permissionLevel: 'autopilot', + }); + + const create = connection.dispatched[0].action; + assert.deepStrictEqual( + create.type === ActionType.AutomationCreateRequested ? create.definition.session.config : undefined, + { mode: 'autopilot', autoApprove: 'assisted' }, + ); + }); + + test('preserves Agent Host config on an unrelated canonical update', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const store = disposables.add(new AgentHostAutomationStore( + 'local-agent-host', + connection, + undefined, + undefined, + new NullLogService(), + storage, + NullTelemetryService, + new TestAutomationStorageService(storage), + )); + const automation = await store.createAutomation({ + name: 'Review changes', + prompt: 'Review the current changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, + mode: 'autopilot', + permissionLevel: 'assisted', + }); + + const current = store.getAutomation(automation.id); + await store.updateAutomation(automation.id, { + name: 'Review renamed changes', + sessionTemplate: current?.sessionTemplate, + }); + + const update = connection.dispatched.at(-1)?.action; + assert.deepStrictEqual( + update?.type === ActionType.AutomationUpdateRequested ? update.changes.session?.config : undefined, + { mode: 'autopilot', autoApprove: 'assisted' }, + ); + }); + + test('does not apply legacy Copilot configuration to another session type', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const store = disposables.add(new AgentHostAutomationStore( + 'local-agent-host', + connection, + undefined, + undefined, + new NullLogService(), + storage, + NullTelemetryService, + new TestAutomationStorageService(storage), + )); + + await store.createAutomation({ + name: 'Review changes', + prompt: 'Review the current changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'claude' }, + mode: 'agent', + permissionLevel: 'autopilot', + }); + + const create = connection.dispatched[0].action; + assert.deepStrictEqual( + create.type === ActionType.AutomationCreateRequested ? create.definition.session : undefined, + { + provider: 'claude', + model: undefined, + agent: undefined, + workingDirectories: undefined, + config: undefined, + }, + ); + }); + + test('filters session-owned values from canonical templates', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const store = disposables.add(new AgentHostAutomationStore( + 'local-agent-host', + connection, + undefined, + undefined, + new NullLogService(), + storage, + NullTelemetryService, + new TestAutomationStorageService(storage), + )); + + await store.createAutomation({ + name: 'Review changes', + prompt: 'Review the current changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, + sessionTemplate: { + config: { + mode: 'plan', + providerOption: true, + [SessionConfigKey.Permissions]: { allow: ['Shell(echo *)'], deny: [] }, + [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'source ~/.bashrc' }], + }, + }, + }); + + const create = connection.dispatched[0].action; + assert.deepStrictEqual( + create.type === ActionType.AutomationCreateRequested ? create.definition.session.config : undefined, + { mode: 'plan', providerOption: true }, + ); + }); + + test('clears provider configuration and agent with an explicit template reset', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const store = disposables.add(new AgentHostAutomationStore( + 'local-agent-host', + connection, + undefined, + undefined, + new NullLogService(), + storage, + NullTelemetryService, + new TestAutomationStorageService(storage), + )); + const automation = await store.createAutomation({ + name: 'Review changes', + prompt: 'Review the current changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'claude' }, + sessionTemplate: { + modelId: 'model', + modelConfiguration: { thinkingLevel: 'low' }, + agent: { uri: 'file:///agents/reviewer.agent.md' }, + config: { mode: 'plan', providerOption: true }, + }, + }); + const permissions = { + allow: ['Shell(echo *)'], + deny: [], + }; + connection.setFirstAutomationSessionConfig({ + [SessionConfigKey.Permissions]: permissions, + [SessionConfigKey.Mode]: 'plan', + providerOption: true, + }); + + await store.updateAutomation(automation.id, { sessionTemplate: null }); + + const update = connection.dispatched.at(-1)?.action; + assert.deepStrictEqual( + update?.type === ActionType.AutomationUpdateRequested ? update.changes.session : undefined, + { + provider: 'claude', + model: undefined, + agent: undefined, + workingDirectories: undefined, + config: { [SessionConfigKey.Permissions]: permissions }, + }, + ); + }); + + test('drops provider configuration when retargeting to another session type', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const store = disposables.add(new AgentHostAutomationStore( + 'local-agent-host', + connection, + undefined, + undefined, + new NullLogService(), + storage, + NullTelemetryService, + new TestAutomationStorageService(storage), + )); + const automation = await store.createAutomation({ + name: 'Review changes', + prompt: 'Review the current changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, + mode: 'autopilot', + permissionLevel: 'assisted', + }); + + await store.updateAutomation(automation.id, { + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'claude' }, + }); + + const update = connection.dispatched.at(-1)?.action; + assert.deepStrictEqual( + update?.type === ActionType.AutomationUpdateRequested ? update.changes.session : undefined, + { + provider: 'claude', + model: undefined, + agent: undefined, + workingDirectories: undefined, + config: undefined, + }, + ); + }); + + test('round-trips the complete Agent Host session template', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, { + toHost: resource => resource, + fromHost: resource => resource, + resourceSchemeForProvider: provider => `agent-host-${provider}`, + }, new NullLogService(), storage, NullTelemetryService, new TestAutomationStorageService(storage))); + const sessionTemplate = { + modelId: 'agent-host-copilotcli:auto', + modelConfiguration: { thinkingLevel: 'low', futureOption: 'preserved' }, + agent: { uri: 'file:///agents/reviewer.agent.md' }, + config: { + mode: 'plan', + autoApprove: 'assisted', + providerOption: { enabled: true }, + }, + }; + + const automation = await store.createAutomation({ + name: 'Review changes', + prompt: 'Review the current changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { + kind: 'workspace', + folderUri: URI.file('/workspace'), + providerId: 'local-agent-host', + sessionTypeId: 'copilotcli', + isolation: { kind: 'folder' }, + }, + sessionTemplate, + }); + await assert.rejects( + store.updateAutomation(automation.id, { permissionLevel: 'autoApprove' }), + /cannot be updated through legacy configuration aliases/, + ); + const updatedTemplate = { + ...sessionTemplate, + modelId: 'agent-host-copilotcli:gpt-5', + config: { + ...sessionTemplate.config, + mode: 'interactive', + autoApprove: 'autoApprove', + }, + }; + await store.updateAutomation(automation.id, { + name: 'Review renamed changes', + sessionTemplate: updatedTemplate, + }); + + const update = connection.dispatched.at(-1)?.action; + assert.deepStrictEqual({ + projected: store.getAutomation(automation.id)?.sessionTemplate, + updatedSession: update?.type === ActionType.AutomationUpdateRequested ? update.changes.session : undefined, + }, { + projected: updatedTemplate, + updatedSession: { + provider: 'copilotcli', + model: { id: 'gpt-5', config: sessionTemplate.modelConfiguration }, + agent: { uri: 'file:///agents/reviewer.agent.md' }, + workingDirectories: ['file:///workspace'], + config: { + mode: 'interactive', + autoApprove: 'autoApprove', + providerOption: { enabled: true }, + isolation: 'folder', + }, + }, + }); + }); + + test('rejects model configuration without a model identifier instead of dropping it', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, new TestAutomationStorageService(storage))); + const options = { + name: 'Model configuration', + prompt: 'Review changes.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, + } as const; + await assert.rejects(store.createAutomation({ + ...options, + sessionTemplate: { modelConfiguration: { thinkingLevel: 'low' } }, + }), /model configuration requires a model identifier/); + const sessionTemplate = { modelId: 'model', modelConfiguration: { thinkingLevel: 'low' } }; + const automation = await store.createAutomation({ ...options, sessionTemplate }); + const dispatched = connection.dispatched.length; + await assert.rejects(store.updateAutomation(automation.id, { + sessionTemplate: { modelConfiguration: {} }, + }), /model configuration requires a model identifier/); + + assert.deepStrictEqual({ + template: store.getAutomation(automation.id)?.sessionTemplate, + additionalActions: connection.dispatched.length - dispatched, + }, { + template: sessionTemplate, + additionalActions: 0, + }); + }); + + test('explicit empty and omitted model configuration replace stale model options', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, new TestAutomationStorageService(storage))); + const automation = await store.createAutomation({ + name: 'Reset model configuration', + prompt: 'Review changes.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, + sessionTemplate: { modelId: 'model', modelConfiguration: { thinkingLevel: 'low', futureOption: true } }, + }); + const empty = await store.updateAutomation(automation.id, { sessionTemplate: { modelId: 'model', modelConfiguration: {} } }); + const emptyAction = connection.dispatched.at(-1)?.action; + const reset = await store.updateAutomation(automation.id, { sessionTemplate: { modelId: 'model' } }); + const resetAction = connection.dispatched.at(-1)?.action; + + assert.deepStrictEqual({ + empty: empty.sessionTemplate, + emptyModel: emptyAction?.type === ActionType.AutomationUpdateRequested ? emptyAction.changes.session?.model : undefined, + reset: reset.sessionTemplate, + resetModel: resetAction?.type === ActionType.AutomationUpdateRequested ? resetAction.changes.session?.model : undefined, + }, { + empty: { modelId: 'model', modelConfiguration: {} }, + emptyModel: { id: 'model', config: {} }, + reset: { modelId: 'model' }, + resetModel: { id: 'model' }, + }); + }); + + test('applies the first flat permission update when the projected session template is empty', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const store = disposables.add(new AgentHostAutomationStore( + 'local-agent-host', + connection, + undefined, + undefined, + new NullLogService(), + storage, + NullTelemetryService, + new TestAutomationStorageService(storage), + )); + const automation = await store.createAutomation({ + name: 'Review changes', + prompt: 'Review the current changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, + }); + + await store.updateAutomation(automation.id, { permissionLevel: 'autoApprove' }); + + const update = connection.dispatched.at(-1)?.action; + assert.deepStrictEqual( + update?.type === ActionType.AutomationUpdateRequested ? update.changes.session?.config : undefined, + { autoApprove: 'autoApprove' }, + ); + }); + test('switches authority only after host migration completion is verified', async () => { const connection = new TestAutomationConnection(false); disposables.add(connection); @@ -546,7 +1116,7 @@ suite('AgentHostAutomationStore', () => { hostDirectory: create.type === ActionType.AutomationCreateRequested ? create.definition.session.workingDirectories : undefined, hostModel: create.type === ActionType.AutomationCreateRequested ? create.definition.session.model?.id : undefined, clientDirectory: automation.target.kind === 'workspace' ? automation.target.folderUri.toString() : undefined, - clientModel: automation.modelId, + clientModel: automation.sessionTemplate?.modelId, clientSession: claim.run.sessionResource?.toString(), }, { hostDirectory: ['file:///workspace'], @@ -580,7 +1150,7 @@ suite('AgentHostAutomationStore', () => { assert.deepStrictEqual({ hostModel: create.type === ActionType.AutomationCreateRequested ? create.definition.session.model?.id : undefined, - clientModel: automation.modelId, + clientModel: automation.sessionTemplate?.modelId, }, { hostModel: 'auto', clientModel: 'agent-host-copilotcli:auto', @@ -613,7 +1183,7 @@ suite('AgentHostAutomationStore', () => { assert.deepStrictEqual({ hostModel: update?.type === ActionType.AutomationUpdateRequested ? update.changes.session?.model : undefined, - clientModel: updated.modelId, + clientModel: updated.sessionTemplate?.modelId, }, { hostModel: undefined, clientModel: undefined, @@ -654,7 +1224,7 @@ suite('AgentHostAutomationStore', () => { assert.deepStrictEqual({ hostProviders: createActions.map(action => action.definition.session.provider), hostModels: createActions.map(action => action.definition.session.model?.id), - clientModels: [defaultProvider.modelId, nativeColon.modelId], + clientModels: [defaultProvider.sessionTemplate?.modelId, nativeColon.sessionTemplate?.modelId], }, { hostProviders: ['copilotcli', 'copilotcli'], hostModels: ['auto', 'openai/gpt-5:high'], @@ -662,7 +1232,7 @@ suite('AgentHostAutomationStore', () => { }); }); - test('qualifies host-authored models without retargeting historical run sessions', () => { + test('qualifies host-authored models and preserves definition-owned configuration on update', async () => { const connection = new TestAutomationConnection(true); disposables.add(connection); const storage = disposables.add(new InMemoryStorageService()); @@ -679,7 +1249,18 @@ suite('AgentHostAutomationStore', () => { definition: { title: 'Host-authored', message: { text: 'Say hi.', origin: { kind: MessageKind.Automation } }, - session: { provider: 'codex', model: { id: 'auto' } }, + session: { + provider: 'codex', + model: { id: 'auto' }, + config: { + mode: 'plan', + [SessionConfigKey.Permissions]: { allow: ['Shell(echo *)'], deny: [] }, + [SessionConfigKey.WorktreeBranchPrefix]: 'host-prefix/', + [SessionConfigKey.WorktreeIncludeFiles]: ['host.json'], + [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'source ~/.bashrc' }], + [SessionConfigKey.AgentMerge]: true, + }, + }, enabled: true, triggers: [], }, @@ -700,13 +1281,27 @@ suite('AgentHostAutomationStore', () => { createdAt: timestamp, modifiedAt: timestamp, }); + const projected = store.getAutomation('host-authored'); + await store.updateAutomation('host-authored', { enabled: false }); + const update = connection.dispatched.at(-1)?.action; assert.deepStrictEqual({ - modelId: store.getAutomation('host-authored')?.modelId, + sessionTemplate: projected?.sessionTemplate, sessionResource: store.runs.get()[0].sessionResource?.toString(), + updatedConfig: update?.type === ActionType.AutomationUpdateRequested ? update.changes.session?.config : undefined, }, { - modelId: 'agent-host-codex:auto', + sessionTemplate: { + modelId: 'agent-host-codex:auto', + config: { mode: 'plan' }, + }, sessionResource: 'agent-host-copilotcli:/host-authored-session', + updatedConfig: { + mode: 'plan', + [SessionConfigKey.Permissions]: { allow: ['Shell(echo *)'], deny: [] }, + [SessionConfigKey.WorktreeBranchPrefix]: 'host-prefix/', + [SessionConfigKey.WorktreeIncludeFiles]: ['host.json'], + [SessionConfigKey.AgentMerge]: true, + }, }); }); @@ -1634,6 +2229,169 @@ suite('AgentHostAutomationStore', () => { }); }); + test('reports disconnected authority as non-authoritative without a false-ready emission', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const legacy = disposables.add(new AutomationStore(providerAutomationStorageKey('local-agent-host'), storage, new NullLogService(), NullTelemetryService, automationStorage)); + const instantiationService = disposables.add(new TestInstantiationService()); + const configurationService = new TestConfigurationService({ [CHAT_AUTOMATIONS_ENABLED_SETTING]: true }); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IStorageService, storage); + instantiationService.stub(ITelemetryService, NullTelemetryService); + instantiationService.stub(IAutomationStorageService, automationStorage); + const store = disposables.add(new ReconnectableAgentHostAutomationStore( + 'local-agent-host', + legacy, + undefined, + instantiationService, + new NullLogService(), + configurationService, + )); + const emissions: { automationCount: number; catalogueState: AutomationCatalogueState }[] = []; + disposables.add(autorun(reader => { + emissions.push({ + automationCount: store.automations.read(reader).length, + catalogueState: store.catalogueState.read(reader), + }); + })); + const initiallyDisconnected = store.catalogueState.get(); + const emissionsBeforeConnect = emissions.length; + store.setConnection(connection); + const connectEmissions = emissions.slice(emissionsBeforeConnect); + await store.createAutomation({ + name: 'Host automation', + prompt: 'Review changes.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + }); + const connected = store.catalogueState.get(); + const emissionsBeforeDisconnect = emissions.length; + store.clearConnection(); + const disconnectEmissions = emissions.slice(emissionsBeforeDisconnect); + const afterDisconnect = store.catalogueState.get(); + connection.setCatalogAvailable(false); + store.setConnection(connection); + const duringReconnect = { state: store.catalogueState.get(), count: store.automations.get().length }; + connection.setCatalogAvailable(); + await store.completeMigration(); + + assert.deepStrictEqual({ + initiallyDisconnected, + connectEmissions, + connected, + afterDisconnect, + disconnectEmissions, + duringReconnect, + afterReconnect: { state: store.catalogueState.get(), count: store.automations.get().length }, + }, { + initiallyDisconnected: 'unavailable', + connectEmissions: [ + { automationCount: 0, catalogueState: 'loading' }, + { automationCount: 0, catalogueState: 'ready' }, + ], + connected: 'ready', + afterDisconnect: 'unavailable', + disconnectEmissions: [{ automationCount: 0, catalogueState: 'unavailable' }], + duringReconnect: { state: 'loading', count: 0 }, + afterReconnect: { state: 'ready', count: 1 }, + }); + }); + + test('keeps readable legacy rows visible while their provider is unavailable', async () => { + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const storageKey = providerAutomationStorageKey('remote-agent-host'); + const legacy = disposables.add(new AutomationStore(storageKey, storage, new NullLogService(), NullTelemetryService, automationStorage)); + await legacy.createAutomation({ + name: 'Legacy automation', + prompt: 'Review changes.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'remote-agent-host', sessionTypeId: 'copilotcli' }, + }); + const instantiationService = disposables.add(new TestInstantiationService()); + const store = disposables.add(new ReconnectableAgentHostAutomationStore('remote-agent-host', legacy, undefined, instantiationService, new NullLogService(), new TestConfigurationService())); + const availableRows = { state: store.catalogueState.get(), names: store.automations.get().map(automation => automation.name) }; + storage.store(storageKey, '{', StorageScope.APPLICATION, StorageTarget.MACHINE); + + assert.deepStrictEqual({ + availableRows, + afterError: { state: store.catalogueState.get(), names: store.automations.get().map(automation => automation.name) }, + }, { + availableRows: { state: 'unavailable', names: ['Legacy automation'] }, + afterError: { state: 'error', names: ['Legacy automation'] }, + }); + }); + + test('transitions to unsupported and disabled authority atomically', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const legacy = disposables.add(new AutomationStore(providerAutomationStorageKey('local-agent-host'), storage, new NullLogService(), NullTelemetryService, automationStorage)); + const instantiationService = disposables.add(new TestInstantiationService()); + const configurationService = new TestConfigurationService({ [CHAT_AUTOMATIONS_ENABLED_SETTING]: true }); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IStorageService, storage); + instantiationService.stub(ITelemetryService, NullTelemetryService); + instantiationService.stub(IAutomationStorageService, automationStorage); + const store = disposables.add(new ReconnectableAgentHostAutomationStore( + 'local-agent-host', + legacy, + undefined, + instantiationService, + new NullLogService(), + configurationService, + )); + store.setConnection(connection); + await store.createAutomation({ + name: 'Host automation', + prompt: 'Review changes.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + }); + const emissions: { automationCount: number; catalogueState: AutomationCatalogueState }[] = []; + disposables.add(autorun(reader => { + emissions.push({ + automationCount: store.automations.read(reader).length, + catalogueState: store.catalogueState.read(reader), + }); + })); + + const beforeUnsupported = emissions.length; + connection.initializeResult.set({ + protocolVersion: '1', + serverSeq: 0, + snapshots: [], + }, undefined); + const unsupportedEmissions = emissions.slice(beforeUnsupported); + + connection.initializeResult.set({ + protocolVersion: '1', + serverSeq: 0, + snapshots: [], + automations: { create: {}, runCancellation: {} }, + }, undefined); + const beforeDisabled = emissions.length; + await configurationService.setUserConfiguration(CHAT_AUTOMATIONS_ENABLED_SETTING, false); + configurationService.onDidChangeConfigurationEmitter.fire({ + source: ConfigurationTarget.USER, + affectedKeys: new Set([CHAT_AUTOMATIONS_ENABLED_SETTING]), + change: { keys: [CHAT_AUTOMATIONS_ENABLED_SETTING], overrides: [] }, + affectsConfiguration: candidate => candidate === CHAT_AUTOMATIONS_ENABLED_SETTING, + }); + const disabledEmissions = emissions.slice(beforeDisabled); + + assert.deepStrictEqual({ + unsupportedEmissions, + disabledEmissions, + }, { + unsupportedEmissions: [{ automationCount: 0, catalogueState: 'unavailable' }], + disabledEmissions: [{ automationCount: 0, catalogueState: 'unavailable' }], + }); + }); + test('migration remains retryable while connection capabilities are initializing', async () => { const connection = disposables.add(new TestAutomationConnection(false)); connection.initializeResult.set(undefined, undefined); 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 046178e6b0006f..48e67ec5b05544 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 @@ -18,7 +18,9 @@ import { runWithFakedTimers } from '../../../../../../base/test/common/timeTrave import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { AgentSession, type IAgentCreateChatRequestOptions, type IAgentCreateSessionConfig, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agent.js'; import { AgentHostCodexAgentEnabledSettingId, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY } from '../../../../../../platform/agentHost/common/automationMigration.js'; import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; +import type { InitializeResult } from '../../../../../../platform/agentHost/common/state/protocol/common/commands.js'; import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, CustomizationEnablementKind, CustomizationLoadStatus, CustomizationType, McpServerStatus, MessageKind, SessionLifecycle, type AgentCustomization, type AgentInfo, type AutomationState, type ChangesSummary, type Customization, type RootState, type SessionActiveClient, type SessionConfigState, type SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChangesetStatus, isAhpAutomationCatalogChannel, ResponsePartKind, SessionSourceControlOutcome, SessionStatus as ProtocolSessionStatus, StateComponents, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, withSessionCreationReference, withSessionEhcliAdoptable, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionWorkspaceless, type ChangesetState, type ChatState, type ChatSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; @@ -38,6 +40,7 @@ import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService, Resour import { IChatWidget, IChatWidgetService } from '../../../../../../workbench/contrib/chat/browser/chat.js'; import { IChatService, type ChatSendResult, type IChatModelReference, type IChatSendRequestOptions } from '../../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionsService, isIChatSessionFileChange2 } from '../../../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { ChatModeKind } from '../../../../../../workbench/contrib/chat/common/constants.js'; import { ILanguageModelsService, type ILanguageModelChatMetadata } from '../../../../../../workbench/contrib/chat/common/languageModels.js'; import type { IChatModel, IChatModelInputState, IInputModel } from '../../../../../../workbench/contrib/chat/common/model/chatModel.js'; @@ -88,7 +91,9 @@ class MockAgentHostService extends mock() { override get rootState(): IAgentSubscription { return this._rootStateSubscription; } private readonly _onAgentHostStart = new Emitter(); override readonly onAgentHostStart = this._onAgentHostStart.event; - override readonly initializeResult = constObservable({ + private readonly _onAgentHostExit = new Emitter(); + override readonly onAgentHostExit = this._onAgentHostExit.event; + override readonly initializeResult = observableValue(this, { protocolVersion: '1', serverSeq: 0, snapshots: [], @@ -97,6 +102,7 @@ class MockAgentHostService extends mock() { override readonly clientId = 'test-local-client'; private readonly _sessions = new Map(); + public automationCatalog: AutomationState = { entries: [] }; public disposedSessions: URI[] = []; public onDisposeSession: ((session: URI) => void) | undefined; public failDisposeSessionFor: string | undefined; @@ -191,7 +197,7 @@ class MockAgentHostService extends mock() { } public createdSessionUris: URI[] = []; - public createSessionConfigs: { config?: Record; metadata?: Record; workingDirectory?: URI }[] = []; + public createSessionConfigs: { model?: IAgentCreateSessionConfig['model']; config?: Record; metadata?: Record; workingDirectory?: URI }[] = []; /** * Per-call hook used by tests to interleave operations across the * `createSession` await — e.g. to verify that no subscription is opened @@ -208,6 +214,7 @@ class MockAgentHostService extends mock() { override async createSession(config?: IAgentCreateSessionConfig): Promise { const uri = config?.session ?? URI.parse('copilotcli:///auto-' + this._nextSeq); this.createSessionConfigs.push({ + ...(config?.model ? { model: config.model } : {}), config: config?.config, ...(config?._meta ? { metadata: config._meta } : {}), workingDirectory: config?.workingDirectories?.[0], @@ -274,7 +281,7 @@ class MockAgentHostService extends mock() { override getSubscription(_kind: StateComponents, resource: URI): IReference> { const key = resource.toString(); if (isAhpAutomationCatalogChannel(key) && !this._sessionStateValues.has(key)) { - this._sessionStateValues.set(key, { entries: [] }); + this._sessionStateValues.set(key, this.automationCatalog); } return this._getSubscription(key); } @@ -380,6 +387,10 @@ class MockAgentHostService extends mock() { this._onAgentHostStart.fire(); } + fireAgentHostExit(): void { + this._onAgentHostExit.fire(0); + } + setRootStateError(): void { const error = new Error('root state failed'); this._rootStateValue = error; @@ -400,6 +411,7 @@ class MockAgentHostService extends mock() { this._onDidRootStateChange.dispose(); this._onDidRootStateError.dispose(); this._onAgentHostStart.dispose(); + this._onAgentHostExit.dispose(); for (const emitter of this._sessionStateEmitters.values()) { emitter.dispose(); } @@ -469,7 +481,7 @@ class BackendSchemeTestProvider extends LocalAgentHostSessionsProvider { function createProvider(disposables: DisposableStore, agentHostService: MockAgentHostService, contributions = [ { type: 'agent-host-copilotcli', name: 'copilot', displayName: 'Copilot', description: 'test', icon: undefined }, -], options?: { sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; acquireOrLoadSession?: (resource: URI) => Promise; languageModelIds?: string[]; lookupLanguageModel?: (modelId: string) => ILanguageModelChatMetadata | undefined; languageModelChanges?: Event; hiddenLanguageModelIds?: ReadonlySet; languageModelVisibilityChanges?: Event; openSession?: boolean; configurationService?: IConfigurationService; activeSession?: IObservable; visibleSessions?: IObservable; activeClient?: Omit; activeClientAgents?: IObservable; activeClientScope?: (sessionType: string, roots: readonly URI[]) => IAgentCustomizationScope; storageService?: IStorageService; isSessionsWindow?: boolean; confirmDelete?: boolean; workspaceTrusted?: boolean; requestWorkspaceTrust?: (uri: URI) => Promise; workspaceTrustBarrier?: DeferredPromise; workspaceTrustError?: Error; setUrisTrust?: (uris: URI[], trusted: boolean) => Promise; gitHubService?: IGitHubService; devContainerAgentHostService?: IDevContainerAgentHostService; sessionsProvidersService?: ISessionsProvidersService; pathService?: IPathService; labelService?: ILabelService; providerCtor?: typeof LocalAgentHostSessionsProvider }): LocalAgentHostSessionsProvider { +], options?: { sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; acquireOrLoadSession?: (resource: URI) => Promise; languageModelsService?: Partial; languageModelIds?: string[]; lookupLanguageModel?: (modelId: string) => ILanguageModelChatMetadata | undefined; languageModelChanges?: Event; hiddenLanguageModelIds?: ReadonlySet; languageModelVisibilityChanges?: Event; openSession?: boolean; configurationService?: IConfigurationService; activeSession?: IObservable; visibleSessions?: IObservable; activeClient?: Omit; activeClientAgents?: IObservable; activeClientScope?: (sessionType: string, roots: readonly URI[]) => IAgentCustomizationScope; storageService?: IStorageService; isSessionsWindow?: boolean; confirmDelete?: boolean; workspaceTrusted?: boolean; requestWorkspaceTrust?: (uri: URI) => Promise; workspaceTrustBarrier?: DeferredPromise; workspaceTrustError?: Error; setUrisTrust?: (uris: URI[], trusted: boolean) => Promise; gitHubService?: IGitHubService; devContainerAgentHostService?: IDevContainerAgentHostService; sessionsProvidersService?: ISessionsProvidersService; pathService?: IPathService; labelService?: ILabelService; providerCtor?: typeof LocalAgentHostSessionsProvider }): LocalAgentHostSessionsProvider { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IAgentHostService, agentHostService); @@ -511,10 +523,13 @@ function createProvider(disposables: DisposableStore, agentHostService: MockAgen instantiationService.stub(ILanguageModelsService, { getLanguageModelIds: () => options?.languageModelIds ?? [], lookupLanguageModel: options?.lookupLanguageModel ?? (() => undefined), + getModelConfiguration: () => undefined, + setModelConfiguration: async () => { }, hasResolvedVendor: () => true, isModelHidden: (modelId: string) => options?.hiddenLanguageModelIds?.has(modelId) ?? false, onDidChangeLanguageModels: options?.languageModelChanges ?? Event.None, onDidChangeModelVisibility: options?.languageModelVisibilityChanges ?? Event.None, + ...options?.languageModelsService, }); instantiationService.stub(ILabelService, options?.labelService ?? new MockLabelService()); instantiationService.stub(ILogService, new NullLogService()); @@ -668,6 +683,29 @@ suite('LocalAgentHostSessionsProvider', () => { // ---- Provider identity ------- + test('Automation catalogue state follows local Agent Host connection lifetime', () => { + agentHost.automationCatalog = { entries: [], _meta: { [AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY]: true } }; + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), automations: { create: {} } }, undefined); + const provider = createProvider(disposables, agentHost, undefined, { + configurationService: new TestConfigurationService({ [CHAT_AUTOMATIONS_ENABLED_SETTING]: true }), + }); + const initial = provider.automations.catalogueState.get(); + + agentHost.fireAgentHostExit(); + const disconnected = provider.automations.catalogueState.get(); + agentHost.fireAgentHostStart(); + + assert.deepStrictEqual({ + initial, + disconnected, + reconnected: provider.automations.catalogueState.get(), + }, { + initial: 'ready', + disconnected: 'unavailable', + reconnected: 'ready', + }); + }); + test('has correct id, label, and sessionType from rootState agents', () => { const provider = createProvider(disposables, agentHost); @@ -4424,6 +4462,45 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); + test('Automation drafts merge derived worktree settings with saved provider configuration', () => { + const configService = new TestConfigurationService(); + configService.setUserConfiguration('git.branchPrefix', 'automation/'); + configService.setUserConfiguration('git.worktreeIncludeFiles', ['product.overrides.json']); + const provider = createProvider(disposables, agentHost, undefined, { configurationService: configService }); + const session = provider.createNewSession( + URI.parse('file:///home/user/project'), + provider.sessionTypes[0].id, + { + automationConfiguration: { + sessionTemplate: { + config: { + mode: 'plan', + autoApprove: 'assisted', + }, + }, + }, + }, + ); + + assert.deepStrictEqual({ + seededImmediately: provider.getSessionConfig(session.sessionId)?.values, + forwardedToAgentHost: agentHost.resolveSessionConfigRequests.at(-1)?.config, + }, { + seededImmediately: { + worktreeBranchPrefix: 'automation/', + worktreeIncludeFiles: ['product.overrides.json'], + mode: 'plan', + autoApprove: 'assisted', + }, + forwardedToAgentHost: { + worktreeBranchPrefix: 'automation/', + worktreeIncludeFiles: ['product.overrides.json'], + mode: 'plan', + autoApprove: 'assisted', + }, + }); + }); + test('createNewSession gives remembered autoApprove precedence over a configured setting while policy still clamps', async () => { const storageService = disposables.add(new InMemoryStorageService()); storageService.store(STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES, JSON.stringify({ @@ -4466,6 +4543,346 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); + test('createNewSession restores and captures an Automation session template', async () => { + const sessionTemplate = { + modelId: 'agent-host-copilotcli:auto', + modelConfiguration: { thinkingLevel: 'low', futureOption: true }, + agent: { uri: 'file:///agents/reviewer.agent.md' }, + config: { + mode: 'plan', + autoApprove: 'assisted', + providerOption: { enabled: true }, + clearedOption: true, + [SessionConfigKey.Permissions]: { allow: ['Shell(echo *)'], deny: [] }, + [SessionConfigKey.WorktreeBranchPrefix]: 'template-prefix/', + [SessionConfigKey.WorktreeIncludeFiles]: ['template.json'], + [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'source ~/.bashrc' }], + [SessionConfigKey.AgentMerge]: true, + }, + }; + agentHost.resolveSessionConfigResult = { + schema: { + type: 'object', + properties: { + clearedOption: { type: 'boolean', title: 'Cleared option' }, + }, + }, + values: { + mode: 'plan', + autoApprove: 'assisted', + [SessionConfigKey.WorktreeBranchPrefix]: 'stale-prefix/', + [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'source ~/.bashrc' }], + }, + }; + const provider = createProvider(disposables, agentHost); + const session = provider.createNewSession( + URI.parse('file:///home/user/project'), + provider.sessionTypes[0].id, + { automationConfiguration: { sessionTemplate } }, + ); + await provider.getAutomationSessionConfiguration(session.sessionId); + await provider.setSessionConfigValue(session.sessionId, 'clearedOption', false); + const captured = await provider.getAutomationSessionConfiguration(session.sessionId); + + assert.deepStrictEqual({ + captured, + initialConfig: agentHost.resolveSessionConfigRequests.at(-2)?.config, + modelId: session.modelId.get(), + agentUri: session.mode.get()?.id, + }, { + captured: { + sessionTemplate: { + ...sessionTemplate, + config: { + mode: 'plan', + autoApprove: 'assisted', + providerOption: { enabled: true }, + }, + }, + modelId: sessionTemplate.modelId, + mode: 'plan', + permissionLevel: 'assisted', + }, + initialConfig: { + mode: 'plan', + autoApprove: 'assisted', + providerOption: { enabled: true }, + clearedOption: true, + }, + modelId: sessionTemplate.modelId, + agentUri: sessionTemplate.agent.uri, + }); + }); + + test('rejects Automation model configuration without a model before acquiring a draft', async () => { + const provider = createProvider(disposables, agentHost); + assert.throws(() => provider.createNewSession(URI.file('/home/user/project'), provider.sessionTypes[0].id, { + automationConfiguration: { sessionTemplate: { modelConfiguration: { thinkingLevel: 'low' } } }, + }), /model configuration requires a model identifier/); + await timeout(0); + assert.deepStrictEqual(agentHost.createSessionConfigs, []); + }); + + test('Automation model options reach eager creation and the browser-executed first request', async () => { + const modelId = 'agent-host-copilotcli:model'; + const metadata: ILanguageModelChatMetadata = { + ...createTestLanguageModel('model'), + targetChatSessionType: 'agent-host-copilotcli', + configurationSchema: { + type: 'object', + properties: { thinkingLevel: { type: 'string', enum: ['low', 'medium', 'high'], default: 'medium' } }, + }, + }; + const sent: IChatSendRequestOptions[] = []; + const sharedWrites: Record[] = []; + const provider = createProvider(disposables, agentHost, undefined, { + languageModelIds: [modelId], + lookupLanguageModel: () => metadata, + languageModelsService: { + getModelConfiguration: () => ({ thinkingLevel: 'high' }), + setModelConfiguration: async (_modelId, values) => { sharedWrites.push(values); }, + }, + sendRequest: async (_resource, _message, options) => { + if (options) { + sent.push(options); + } + return { kind: 'rejected', reason: 'Test request captured' }; + }, + }); + const session = provider.createNewSession(URI.file('/home/user/project'), provider.sessionTypes[0].id, { + automationConfiguration: { + sessionTemplate: { modelId, modelConfiguration: { thinkingLevel: 'low', futureOption: true } }, + }, + }); + const captured = await provider.getAutomationSessionConfiguration(session.sessionId); + const chat = await provider.createNewChat(session.sessionId); + await assert.rejects(provider.sendRequest(session.sessionId, chat.resource, { query: 'hello' }), /Test request captured/); + + assert.deepStrictEqual({ + eagerModel: agentHost.createSessionConfigs[0]?.model, + captured: captured?.sessionTemplate?.modelConfiguration, + sent: sent.map(options => ({ model: options.userSelectedModelId, configuration: options.userSelectedModelConfiguration })), + sharedWrites, + }, { + eagerModel: { id: 'model', config: { thinkingLevel: 'low' } }, + captured: { thinkingLevel: 'low', futureOption: true }, + sent: [{ model: modelId, configuration: { thinkingLevel: 'low' } }], + sharedWrites: [], + }); + }); + + test('Automation picker edits are captured independently and survive provider model qualification', async () => { + const modelId = 'agent-host-copilotcli:model'; + const metadata: ILanguageModelChatMetadata = { + ...createTestLanguageModel('model'), + targetChatSessionType: 'agent-host-copilotcli', + configurationSchema: { + type: 'object', + properties: { thinkingLevel: { type: 'string', enum: ['low', 'medium', 'high'], default: 'medium' } }, + }, + }; + const writes: Record[] = []; + const provider = createProvider(disposables, agentHost, undefined, { + languageModelIds: [modelId], + lookupLanguageModel: id => id === modelId ? metadata : id === 'model' ? { ...metadata, targetChatSessionType: 'copilotcli' } : undefined, + languageModelsService: { + getModelConfiguration: () => ({ thinkingLevel: 'high' }), + setModelConfiguration: async (_modelId, values) => { writes.push(values); }, + }, + }); + const session = provider.createNewSession(URI.file('/home/user/project'), provider.sessionTypes[0].id, { + automationConfiguration: { sessionTemplate: { modelId: 'model', modelConfiguration: { thinkingLevel: 'low' } } }, + }); + provider.setModel(session.sessionId, session.mainChat.get().resource, modelId, ChatModelSource.Chosen); + const restored = await provider.getAutomationSessionConfiguration(session.sessionId); + await provider.getAutomationModelConfiguration(session.sessionId)!.setModelConfiguration(modelId, { thinkingLevel: 'medium' }); + const edited = await provider.getAutomationSessionConfiguration(session.sessionId); + provider.deleteNewSession(session.sessionId); + + assert.deepStrictEqual({ + restored: restored?.sessionTemplate?.modelConfiguration, + edited: edited?.sessionTemplate?.modelConfiguration, + writes, + }, { + restored: { thinkingLevel: 'low' }, + edited: { thinkingLevel: 'medium' }, + writes: [{ thinkingLevel: 'medium' }], + }); + }); + + test('Automation capture rejects failed initial configuration resolution', async () => { + agentHost.failResolveSessionConfig = true; + const provider = createProvider(disposables, agentHost); + const session = provider.createNewSession( + URI.parse('file:///home/user/project'), + provider.sessionTypes[0].id, + { automationConfiguration: { sessionTemplate: { config: { mode: 'plan' } } } }, + ); + + await assert.rejects(provider.getAutomationSessionConfiguration(session.sessionId), /resolveSessionConfig unavailable/); + }); + + test('Automation capture retries failed resolution without dropping edited preferences', async () => { + const initialConfig = { mode: 'interactive', autoApprove: 'default', providerOption: true }; + const updatedConfig = { ...initialConfig, mode: 'plan', autoApprove: 'assisted' }; + agentHost.resolveSessionConfigResult = { + schema: { type: 'object', properties: {} }, + values: initialConfig, + }; + const provider = createProvider(disposables, agentHost); + const session = provider.createNewSession( + URI.parse('file:///home/user/project'), + provider.sessionTypes[0].id, + { automationConfiguration: { sessionTemplate: { config: initialConfig } } }, + ); + await provider.getAutomationSessionConfiguration(session.sessionId); + + agentHost.failResolveSessionConfig = true; + await provider.setSessionConfigValue(session.sessionId, SessionConfigKey.Mode, 'plan'); + await provider.setSessionConfigValue(session.sessionId, SessionConfigKey.AutoApprove, 'assisted'); + await assert.rejects(provider.getAutomationSessionConfiguration(session.sessionId), /resolveSessionConfig unavailable/); + + agentHost.failResolveSessionConfig = false; + agentHost.resolveSessionConfigResult = { + schema: { type: 'object', properties: {} }, + values: updatedConfig, + }; + const captured = await provider.getAutomationSessionConfiguration(session.sessionId); + + assert.deepStrictEqual({ + retriedConfig: agentHost.resolveSessionConfigRequests.at(-1)?.config, + capturedConfig: captured?.sessionTemplate?.config, + initialConfig, + }, { + retriedConfig: updatedConfig, + capturedConfig: updatedConfig, + initialConfig: { mode: 'interactive', autoApprove: 'default', providerOption: true }, + }); + }); + + test('Automation capture waits for tracked configuration operations before reading values', async () => { + const initialConfig = { mode: 'interactive' }; + agentHost.resolveSessionConfigResult = { + schema: { type: 'object', properties: {} }, + values: initialConfig, + }; + const provider = createProvider(disposables, agentHost); + const session = provider.createNewSession( + URI.parse('file:///home/user/project'), + provider.sessionTypes[0].id, + { automationConfiguration: { sessionTemplate: { config: initialConfig } } }, + ); + await provider.getAutomationSessionConfiguration(session.sessionId); + + const barrier = new DeferredPromise(); + const operation = (async () => { + await barrier.p; + agentHost.resolveSessionConfigResult = { + schema: { type: 'object', properties: {} }, + values: { mode: 'plan' }, + }; + await provider.setSessionConfigValue(session.sessionId, SessionConfigKey.Mode, 'plan'); + })(); + provider.trackSessionConfigOperation(session.sessionId, operation); + let captured = false; + const capture = provider.getAutomationSessionConfiguration(session.sessionId).then(result => { + captured = true; + return result; + }); + await timeout(0); + const capturedBeforeOperation = captured; + await barrier.complete(); + + assert.deepStrictEqual({ + capturedBeforeOperation, + config: (await capture)?.sessionTemplate?.config, + }, { + capturedBeforeOperation: false, + config: { mode: 'plan' }, + }); + }); + + test('createNewSession restores legacy Automation fields at the provider boundary', async () => { + const provider = createProvider(disposables, agentHost); + const session = provider.createNewSession( + URI.parse('file:///home/user/project'), + provider.sessionTypes[0].id, + { + automationConfiguration: { + modelId: 'agent-host-copilotcli:auto', + mode: 'autopilot', + permissionLevel: 'assisted', + }, + }, + ); + const captured = await provider.getAutomationSessionConfiguration(session.sessionId); + + assert.deepStrictEqual({ + initialConfig: agentHost.resolveSessionConfigRequests.at(-1)?.config, + modelId: session.modelId.get(), + captured, + }, { + initialConfig: { + mode: 'autopilot', + autoApprove: 'assisted', + }, + modelId: 'agent-host-copilotcli:auto', + captured: { + sessionTemplate: { + modelId: 'agent-host-copilotcli:auto', + config: { + mode: 'autopilot', + autoApprove: 'assisted', + }, + }, + modelId: 'agent-host-copilotcli:auto', + mode: 'autopilot', + permissionLevel: 'assisted', + }, + }); + }); + + test('Automation drafts display policy-clamped approvals without overwriting the saved preference', async () => { + const sessionTemplate = { + config: { + autoApprove: 'autoApprove', + }, + }; + agentHost.resolveSessionConfigResult = { + schema: { + type: 'object', + properties: { + autoApprove: { type: 'string', title: 'Auto Approve', enum: ['default', 'autoApprove'] }, + }, + }, + values: { autoApprove: 'default' }, + }; + const provider = createProvider(disposables, agentHost, undefined, { + configurationService: createPolicyRestrictedConfigurationService(), + }); + const session = provider.createNewSession( + URI.parse('file:///home/user/project'), + provider.sessionTypes[0].id, + { automationConfiguration: { sessionTemplate } }, + ); + + const capturedWithoutEdit = await provider.getAutomationSessionConfiguration(session.sessionId); + await provider.setSessionConfigValue(session.sessionId, SessionConfigKey.AutoApprove, 'default'); + const capturedAfterEdit = await provider.getAutomationSessionConfiguration(session.sessionId); + + assert.deepStrictEqual({ + displayed: provider.getSessionConfig(session.sessionId)?.values.autoApprove, + initialConfig: agentHost.resolveSessionConfigRequests.at(-2)?.config?.autoApprove, + capturedWithoutEdit: capturedWithoutEdit?.sessionTemplate?.config?.autoApprove, + capturedAfterEdit: capturedAfterEdit?.sessionTemplate?.config?.autoApprove, + }, { + displayed: 'default', + initialConfig: 'default', + capturedWithoutEdit: 'autoApprove', + capturedAfterEdit: 'default', + }); + }); + test('createNewSession drops an invalid remembered mode instead of forwarding it', async () => { const storageService = disposables.add(new InMemoryStorageService()); storageService.store(STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES, JSON.stringify({ diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts index f5f35867d6d6d1..c88931381a9a5f 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts @@ -5,21 +5,18 @@ import { BaseActionViewItem } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; -import { autorun } from '../../../../../base/common/observable.js'; import { isWeb } from '../../../../../base/common/platform.js'; import { localize2 } from '../../../../../nls.js'; import { IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; -import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../../../workbench/common/contributions.js'; import { Menus } from '../../../../browser/menus.js'; import { SessionHasGitRepositoryContext, SessionProviderIdContext, SessionTypeContext, IsNewChatSessionContext } from '../../../../common/contextkeys.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; -import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { BranchPicker } from './branchPicker.js'; import { COPILOT_PROVIDER_ID, CopilotChatSessionsProvider, CopilotCloudSessionType } from './copilotChatSessionsProvider.js'; -import { ModePicker, ModePickerModel } from './modePicker.js'; +import { ModePicker, ScopedModePickerModelCache } from './modePicker.js'; import { CopilotPermissionPickerDelegate, PermissionPicker } from './permissionPicker.js'; import { SandboxPicker } from './sandboxPicker.js'; import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; @@ -137,24 +134,12 @@ class CopilotPickerActionViewItemContribution extends Disposable implements IWor constructor( @IActionViewItemService actionViewItemService: IActionViewItemService, - @ISessionsService sessionsService: ISessionsService, @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, - @IInstantiationService instantiationService: IInstantiationService, ) { super(); - const modePickerModel = this._register(instantiationService.createInstance(ModePickerModel)); - this._register(autorun(reader => { - const session = sessionsService.activeSession.read(reader); - if (session) { - const provider = sessionsProvidersService.getProvider(session.providerId); - if (provider instanceof CopilotChatSessionsProvider) { - const selectedModeId = session.mode.read(reader)?.id; - modePickerModel.setSession(session, selectedModeId); - return; - } - } - modePickerModel.setSession(undefined, undefined); - })); + const modePickerModels = this._register(new ScopedModePickerModelCache(session => + sessionsProvidersService.getProvider(session.providerId) instanceof CopilotChatSessionsProvider + )); this._register(actionViewItemService.register( Menus.NewSessionRepositoryConfig, 'sessions.defaultCopilot.branchPicker', @@ -176,8 +161,9 @@ class CopilotPickerActionViewItemContribution extends Disposable implements IWor Menus.NewSessionConfig, 'sessions.defaultCopilot.modePicker', (_action, _options, scopedInstantiationService) => { const { session } = scopedInstantiationService.invokeFunction(accessor => accessor.get(ISessionContext)); - const picker = scopedInstantiationService.createInstance(ModePicker, modePickerModel, session); const disposableStore = new DisposableStore(); + const modePickerModel = disposableStore.add(modePickerModels.acquire(session, scopedInstantiationService)); + const picker = scopedInstantiationService.createInstance(ModePicker, modePickerModel.model, session); disposableStore.add(picker.onDidSelect(mode => { const scopedSession = session.get(); if (!scopedSession) { diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 35e435ee60adaa..4394688dae2bd8 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -25,14 +25,16 @@ import { AgentSessionProviders, AgentSessionTarget } from '../../../../../workbe import { IChatService, IChatSendRequestOptions } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatResponseModel } from '../../../../../workbench/contrib/chat/common/model/chatModel.js'; 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, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, sessionFileChangesEqual, gitHubInfoEqual, sessionWorkspaceEqual, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_GITHUB, ISessionChangeset, IChatCheckpoints, ChatInteractivity, SessionTypeAuthRequirement, ISessionChangesSummary } from '../../../../services/sessions/common/session.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import { basename, dirname, isEqual } from '../../../../../base/common/resources.js'; -import { IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; +import { IAutomationSessionConfiguration, IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, 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 { ILanguageModelToolsService } from '../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; -import { ChatMode, IChatMode, IChatModeService, isBuiltinChatMode } from '../../../../../workbench/contrib/chat/common/chatModes.js'; +import { ChatMode, IChatMode, IChatModes, IChatModeService, isBuiltinChatMode } from '../../../../../workbench/contrib/chat/common/chatModes.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; @@ -130,6 +132,7 @@ export interface ICopilotChatSession { readonly permissionLevel: IObservable; setPermissionLevel(level: ChatPermissionLevel): void; + readonly initialAutomationSessionConfiguration?: IAutomationSessionConfiguration; readonly branch: IObservable; setBranch(branch: string | undefined): void; @@ -321,6 +324,8 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { private _mode: IChatMode | undefined; private _query: string | undefined; private _attachedContext: IChatRequestVariableEntry[] | undefined; + private readonly _lifetimeCts = this._register(new CancellationTokenSource()); + readonly modelConfiguration: AutomationModelConfiguration; readonly target = AgentSessionProviders.Background; readonly selectedOptions = new Map(); @@ -329,6 +334,7 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { get chatMode(): IChatMode | undefined { return this._mode; } get query(): string | undefined { return this._query; } get attachedContext(): IChatRequestVariableEntry[] | undefined { return this._attachedContext; } + get cancellationToken(): CancellationToken { return this._lifetimeCts.token; } get gitRepository(): IGitRepository | undefined { return this._gitRepository; } get disabled(): boolean { if (!this._repoUri) { @@ -344,14 +350,17 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { readonly resource: URI, readonly sessionWorkspace: ISessionWorkspace, providerId: string, + readonly initialAutomationSessionConfiguration: IAutomationSessionConfiguration | undefined, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IGitService private readonly gitService: IGitService, @IGitHubService private readonly gitHubService: IGitHubService, @IPullRequestIconCache private readonly pullRequestIconCache: IPullRequestIconCache, @IStorageService private readonly storageService: IStorageService, @IConfigurationService private readonly configurationService: IConfigurationService, + @ILanguageModelsService languageModelsService: ILanguageModelsService, ) { super(); + this.modelConfiguration = this._register(new AutomationModelConfiguration(languageModelsService, initialAutomationSessionConfiguration?.sessionTemplate)); this.sessionId = toSessionId(providerId, resource); this.providerId = providerId; this.sessionType = AgentSessionProviders.Background; @@ -514,6 +523,7 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { } setModeById(modeId: string, modeKind: string): void { + this._mode = undefined; this._modeObservable.set({ id: modeId, kind: modeKind }, undefined); } @@ -534,8 +544,9 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { } setMode(mode: IChatMode | undefined): void { - if (this._mode?.id !== mode?.id) { + if (this._mode !== mode || this._modeObservable.get()?.id !== mode?.id) { this._mode = mode; + this._modeObservable.set(mode ? { id: mode.id, kind: mode.kind } : undefined, undefined); const modeName = mode?.isBuiltin ? undefined : mode?.name.get(); this.setOption(AGENT_OPTION_ID, modeName ?? ''); } @@ -585,6 +596,11 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { this._description.set(session.description.get(), tx); }); } + + override dispose(): void { + this._lifetimeCts.cancel(); + super.dispose(); + } } function isModelOptionGroup(group: IChatSessionProviderOptionGroup): boolean { @@ -693,17 +709,21 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession } private readonly _whenClauseKeys = new Set(); + readonly modelConfiguration: AutomationModelConfiguration; constructor( readonly resource: URI, readonly sessionWorkspace: ISessionWorkspace, readonly target: AgentSessionTarget, providerId: string, + readonly initialAutomationSessionConfiguration: IAutomationSessionConfiguration | undefined, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IStorageService private readonly storageService: IStorageService, + @ILanguageModelsService languageModelsService: ILanguageModelsService, ) { super(); + this.modelConfiguration = this._register(new AutomationModelConfiguration(languageModelsService, initialAutomationSessionConfiguration?.sessionTemplate)); this.sessionId = toSessionId(providerId, resource); this.providerId = providerId; this.sessionType = target; @@ -1426,11 +1446,13 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions * 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 AUTOMATION_AGENT_RESOLUTION_TIMEOUT_MS = 30_000; readonly id = COPILOT_PROVIDER_ID; readonly label = localize('copilotChatSessionsProvider', "Copilot Chat"); readonly icon = Codicon.copilot; readonly order = 0; + readonly supportsAutomationSessionConfiguration = true; get sessionTypes(): readonly ISessionType[] { const types: ISessionType[] = []; @@ -1689,33 +1711,81 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return this._findChatSession(sessionId); } - createNewSession(workspaceUri: URI, sessionTypeId: string): ISession { + createNewSession(workspaceUri: URI, sessionTypeId: string, options?: ISessionsProviderCreateSessionOptions): ISession { const workspace = this.resolveWorkspace(workspaceUri); if (!workspace) { throw new Error(`Cannot resolve workspace for URI: ${workspaceUri.toString()}`); } + const automationConfiguration = options?.automationConfiguration; + assertAutomationSessionTemplate(automationConfiguration?.sessionTemplate); + let session: NewSession; if (workspaceUri.scheme === GITHUB_REMOTE_FILE_SCHEME) { if (sessionTypeId !== CopilotCloudSessionType.id) { throw new Error('Only Copilot Cloud sessions can be created for GitHub repositories'); } const resource = URI.from({ scheme: AgentSessionProviders.Cloud, path: `/untitled-${generateUuid()}` }); - const session = this.instantiationService.createInstance(RemoteNewSession, resource, workspace, AgentSessionProviders.Cloud, this.id); - this._newSessions.set(session.sessionId, session); + session = this.instantiationService.createInstance(RemoteNewSession, resource, workspace, AgentSessionProviders.Cloud, this.id, automationConfiguration); + } else { + if (sessionTypeId !== CopilotCLISessionType.id) { + throw new Error(`Unsupported session type '${sessionTypeId}' for local workspaces`); + } + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: `/untitled-${generateUuid()}` }); + session = this.instantiationService.createInstance(CopilotCLISession, resource, workspace, this.id, automationConfiguration); + session.setPermissionLevel(this._defaultPermissionLevel()); + } + this._newSessions.set(session.sessionId, session); + try { + this._applyAutomationSessionConfiguration(session, automationConfiguration); return this._chatToSession(session); + } catch (error) { + this._newSessions.deleteAndDispose(session.sessionId); + throw error; } + } + + getAutomationModelConfiguration(sessionId: string): AutomationModelConfiguration | undefined { + return this._newSessions.get(sessionId)?.modelConfiguration; + } - if (sessionTypeId !== CopilotCLISessionType.id) { - throw new Error(`Unsupported session type '${sessionTypeId}' for local workspaces`); + async getAutomationSessionConfiguration(sessionId: string): Promise { + const session = this._newSessions.get(sessionId); + if (!session) { + return undefined; } - const resource = URI.from({ scheme: AgentSessionProviders.Background, path: `/untitled-${generateUuid()}` }); - const session = this.instantiationService.createInstance(CopilotCLISession, resource, workspace, this.id); - session.setPermissionLevel(this._defaultPermissionLevel()); - this._newSessions.set(session.sessionId, session); - return this._chatToSession(session); + const modelId = session.modelId.get(); + const modelConfiguration = session.modelConfiguration.captureModelConfiguration(modelId); + const initialConfiguration = session.initialAutomationSessionConfiguration; + const initialTemplate = initialConfiguration?.sessionTemplate; + const initialMode = initialConfiguration?.mode ?? initialTemplate?.config?.[SessionConfigKey.Mode]; + const mode = session instanceof CopilotCLISession + ? session.mode.get()?.id + : typeof initialMode === 'string' ? initialMode : undefined; + const initialPermissionLevel = initialConfiguration?.permissionLevel ?? initialTemplate?.config?.[SessionConfigKey.AutoApprove]; + const permissionLevel = session instanceof RemoteNewSession && isChatPermissionLevel(initialPermissionLevel) + ? initialPermissionLevel + : session.permissionLevel.get(); + const config = { ...initialTemplate?.config }; + if (mode) { + config[SessionConfigKey.Mode] = mode; + } else { + delete config[SessionConfigKey.Mode]; + } + config[SessionConfigKey.AutoApprove] = permissionLevel; + const agentUri = session.chatMode?.uri?.get(); + const agent = agentUri + ? { uri: agentUri.toString() } + : initialTemplate?.agent && mode === initialTemplate.agent.uri ? initialTemplate.agent : undefined; + const sessionTemplate: IAutomationSessionTemplate = { + ...(modelId ? { modelId } : {}), + ...(modelConfiguration !== undefined ? { modelConfiguration } : {}), + ...(agent ? { agent } : {}), + ...(Object.keys(config).length > 0 ? { config } : {}), + }; + return { sessionTemplate, modelId, mode, permissionLevel }; } - createQuickChat(_sessionTypeId: string): ISession { + createQuickChat(_sessionTypeId: string, _options?: ISessionsProviderCreateSessionOptions): ISession { // This provider is workspace-bound and does not advertise // `supportsQuickChats`; callers must gate on that capability. throw new Error('CopilotChatSessionsProvider does not support quick chats'); @@ -1735,6 +1805,38 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return isChatPermissionLevel(level) ? level : ChatPermissionLevel.Default; } + private _applyAutomationSessionConfiguration(session: ICopilotChatSession, configuration: IAutomationSessionConfiguration | undefined): void { + if (!configuration) { + return; + } + const template = configuration.sessionTemplate; + if (template?.agent && session instanceof RemoteNewSession) { + throw new Error(localize('automationCloudAgentUnsupported', "This provider does not support custom agents in Automation session templates.")); + } + if (template?.agent) { + try { + URI.parse(template.agent.uri, true); + } catch (error) { + throw new Error(localize('automationAgentUriInvalid', "The Automation's selected agent must have an absolute URI."), { cause: error }); + } + } + const modelId = template?.modelId ?? configuration.modelId; + if (modelId) { + session.setModelId(modelId, ChatModelSource.Chosen); + } + const mode = template?.agent?.uri ?? template?.config?.[SessionConfigKey.Mode] ?? configuration.mode; + if (typeof mode === 'string') { + const restored = this._setSessionMode(session, mode); + if (!restored && session instanceof CopilotCLISession) { + session.setModeById(mode, ChatModeKind.Agent); + } + } + const permissionLevel = template?.config?.[SessionConfigKey.AutoApprove] ?? configuration.permissionLevel; + if (!(session instanceof RemoteNewSession) && isChatPermissionLevel(permissionLevel)) { + session.setPermissionLevel(permissionLevel); + } + } + get onDidChangeModels(): Event { // Models can change because language models are (un)registered or because // the extension host updates a cloud session's `models` option group. @@ -1826,6 +1928,13 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions setModel(sessionId: string, chatResource: URI, modelId: string, source: ChatModelSource): void { const newSession = this._newSessions.get(sessionId); if (newSession) { + const previousModelId = newSession.modelId.get(); + if (previousModelId && previousModelId !== modelId) { + const resolution = this.getModelsSnapshot(sessionId, previousModelId).desiredModelResolution; + if (resolution.kind === 'available' && resolution.model.identifier === modelId) { + newSession.modelConfiguration.rebindModelConfiguration(previousModelId, modelId); + } + } newSession.setModelId(modelId, source); // Cloud sessions additionally persist the selection as the value of // the `models` option group so the extension host honours it. @@ -1847,44 +1956,91 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } setMode(sessionId: string, modeId: string): void { - const setSessionMode = (session: ICopilotChatSession): void => { - let mode: IChatMode | undefined; - switch (modeId) { - case ChatModeKind.Agent: - mode = ChatMode.Agent; - break; - case ChatModeKind.Edit: - mode = ChatMode.Edit; - break; - case ChatModeKind.Ask: - mode = ChatMode.Ask; - break; - default: { - const modes = this.chatModeService.createModes(session.resource); - try { - mode = modes.findModeById(modeId) ?? modes.findModeByName(modeId); - } finally { - modes.dispose(); - } - break; - } - } - - if (mode) { - session.setMode(mode); - } - }; - const newSession = this._newSessions.get(sessionId); if (newSession) { - setSessionMode(newSession); + this._setSessionMode(newSession, modeId); return; } this._ensureSessionCache(); const session = this._findChatSession(sessionId); if (session) { - setSessionMode(session); + this._setSessionMode(session, modeId); + } + } + + private _setSessionMode(session: ICopilotChatSession, modeId: string): boolean { + let mode: IChatMode | undefined; + switch (modeId) { + case ChatModeKind.Agent: + mode = ChatMode.Agent; + break; + case ChatModeKind.Edit: + mode = ChatMode.Edit; + break; + case ChatModeKind.Ask: + mode = ChatMode.Ask; + break; + default: { + const modes = this.chatModeService.createModes(session.resource); + try { + mode = this._findSessionMode(session, modes, modeId); + } finally { + modes.dispose(); + } + break; + } + } + if (mode) { + session.setMode(mode); + return true; + } + return false; + } + + private _findSessionMode(session: ICopilotChatSession, modes: IChatModes, modeId: string): IChatMode | undefined { + const initialAgent = session.initialAutomationSessionConfiguration?.sessionTemplate?.agent; + return initialAgent?.uri === modeId + ? modes.custom.find(mode => isEqual(mode.uri?.get(), URI.parse(initialAgent.uri))) + : modes.findModeById(modeId) ?? modes.findModeByName(modeId); + } + + private async _resolveAutomationSessionMode(session: CopilotCLISession): Promise { + const deadline = Date.now() + CopilotChatSessionsProvider.AUTOMATION_AGENT_RESOLUTION_TIMEOUT_MS; + while (true) { + const modeId = session.mode.get()?.id; + if (!modeId || modeId === ChatModeKind.Agent || modeId === ChatModeKind.Ask || modeId === ChatModeKind.Edit) { + return; + } + const selectedMode = session.chatMode; + const store = new DisposableStore(); + const cancellation = store.add(new CancellationTokenSource(session.cancellationToken)); + try { + const modes = store.add(this.chatModeService.createModes(session.resource)); + const remaining = deadline - Date.now(); + const resolved = remaining > 0 && await raceTimeout( + raceCancellationError(modes.waitForPendingUpdates().then(() => true), cancellation.token), + remaining, + ); + if (!resolved) { + throw new Error(localize('automationAgentResolutionTimeout', "Timed out resolving the Automation's custom agent. Check the provider connection and try again.")); + } + if (this._newSessions.get(session.sessionId) !== session) { + throw new CancellationError(); + } + if (session.mode.get()?.id !== modeId || session.chatMode !== selectedMode) { + continue; + } + const mode = this._findSessionMode(session, modes, modeId); + if (!mode) { + throw new Error(localize('automationAgentUnavailable', "The Automation's selected agent '{0}' is unavailable. Choose another agent or restore it before running the Automation.", modeId)); + } + session.setMode(mode); + return; + } finally { + cancellation.cancel(); + store.dispose(); + } } } @@ -2166,7 +2322,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } const resource = URI.from({ scheme: AgentSessionProviders.Background, path: `/untitled-${generateUuid()}` }); - const session = this.instantiationService.createInstance(CopilotCLISession, resource, newWorkspace, this.id); + const session = this.instantiationService.createInstance(CopilotCLISession, resource, newWorkspace, this.id, undefined); session.setModelId(chat.modelId.get(), ChatModelSource.CarriedOver); session.setIsolationMode('workspace'); session.setOption(PARENT_SESSION_OPTION_ID, chat.resource.path.slice(1)); @@ -2392,6 +2548,9 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } private async _sendFirstChat(session: NewSession, chatResource: URI, options: ISendRequestOptions): Promise { + if (session instanceof CopilotCLISession && session.initialAutomationSessionConfiguration) { + await this._resolveAutomationSessionMode(session); + } const { query, attachedContext } = options; @@ -2424,6 +2583,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions const sendOptions: IChatSendRequestOptions = { location: ChatAgentLocation.Chat, userSelectedModelId: session.selectedModelId, + userSelectedModelConfiguration: session.modelConfiguration.getModelConfigurationForRequest(session.selectedModelId), modeInfo: { kind: modeKind, isBuiltin: modeIsBuiltin, diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/modePicker.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/modePicker.ts index 79910f44eb6dca..07c577d04b62a8 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/modePicker.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/modePicker.ts @@ -8,7 +8,7 @@ import { Gesture, EventType as TouchEventType } from '../../../../../base/browse import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; -import { IObservable } from '../../../../../base/common/observable.js'; +import { autorun, IObservable } from '../../../../../base/common/observable.js'; import { localize } from '../../../../../nls.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../../platform/actionWidget/browser/actionList.js'; @@ -20,6 +20,7 @@ import { IChatService } from '../../../../../workbench/contrib/chat/common/chatS import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { getChatSessionType } from '../../../../../workbench/contrib/chat/common/model/chatUri.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import type { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { Target } from '../../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; import { AICustomizationManagementCommands } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; import { AICustomizationManagementSection } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; @@ -86,6 +87,7 @@ export class ModePickerModel extends Disposable { return; } this._sessionResource = undefined; + this._modeChangeListener.clear(); this._chatModesDisposable.value = undefined; this._chatModes = undefined; this._selectedModeId = undefined; @@ -148,6 +150,69 @@ export class ModePickerModel extends Disposable { } } +interface IModePickerModelEntry { + readonly model: ModePickerModel; + readonly store: DisposableStore; + references: number; + disposalGeneration: number; +} + +/** Retains each surface's model across synchronous toolbar rebuilds. */ +export class ScopedModePickerModelCache extends Disposable { + private readonly entries = new Map, IModePickerModelEntry>(); + + constructor(private readonly acceptsSession: (session: ISession) => boolean) { + super(); + } + + acquire(session: IObservable, instantiationService: IInstantiationService): IDisposable & { readonly model: ModePickerModel } { + let entry = this.entries.get(session); + if (!entry) { + const store = new DisposableStore(); + const model = store.add(instantiationService.createInstance(ModePickerModel)); + store.add(autorun(reader => { + const scopedSession = session.read(reader); + model.setSession(scopedSession && this.acceptsSession(scopedSession) ? scopedSession : undefined, scopedSession?.mode.read(reader)?.id); + })); + entry = { model, store, references: 0, disposalGeneration: 0 }; + this.entries.set(session, entry); + } + + entry.references++; + entry.disposalGeneration++; + let disposed = false; + return { + model: entry.model, + dispose: () => { + if (disposed) { + return; + } + disposed = true; + this.release(session, entry); + }, + }; + } + + private release(session: IObservable, entry: IModePickerModelEntry): void { + entry.references--; + const disposalGeneration = ++entry.disposalGeneration; + queueMicrotask(() => { + if (entry.references === 0 && entry.disposalGeneration === disposalGeneration && this.entries.get(session) === entry) { + this.entries.delete(session); + entry.store.dispose(); + } + }); + } + + override dispose(): void { + for (const entry of this.entries.values()) { + entry.store.dispose(); + } + this.entries.clear(); + super.dispose(); + } +} + /** * A self-contained widget for selecting a chat mode (Agent, custom agents) * for local/Background sessions. Shows only modes whose target matches 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 5cf1ee5a28091b..3fd69fc6592a89 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 @@ -6,7 +6,7 @@ import assert from 'assert'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; -import { timeout } from '../../../../../../base/common/async.js'; +import { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; import { DisposableStore, IDisposable, ImmortalReference, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -16,6 +16,7 @@ import { isWeb } from '../../../../../../base/common/platform.js'; import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; import { autorun, constObservable, ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { IConfigurationService, IConfigurationValue } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; @@ -35,6 +36,7 @@ import { IChatWidget, IChatWidgetService } from '../../../../../../workbench/con import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../../../workbench/contrib/chat/common/languageModels.js'; import { ILanguageModelToolsService } from '../../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; import { IChatResponseModel } from '../../../../../../workbench/contrib/chat/common/model/chatModel.js'; +import { ChatMode, CustomChatMode, IChatMode, IChatModes, IChatModeService } from '../../../../../../workbench/contrib/chat/common/chatModes.js'; import { IChatAgentData } from '../../../../../../workbench/contrib/chat/common/participants/chatAgents.js'; import { IGitRepository, IGitService } from '../../../../../../workbench/contrib/git/common/gitService.js'; import { ISessionChangeEvent } from '../../../../../services/sessions/common/sessionsProvider.js'; @@ -43,7 +45,7 @@ import { CloudSandboxEnabledSettingId, type ICloudSandboxCreateSessionRequest } import { RemoteAgentHostsEnabledSettingId } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { CloudSandboxAgentHostContribution, type ICloudSandboxProvisionedSession } from '../../../remoteAgentHost/browser/cloudSandboxAgentHostContribution.js'; import { CloudSandboxSessionsProvider } from '../../../remoteAgentHost/browser/cloudSandboxSessionsProvider.js'; -import { ChatConfiguration, ChatPermissionLevel } from '../../../../../../workbench/contrib/chat/common/constants.js'; +import { ChatConfiguration, 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, ICopilotChatSession } from '../../browser/copilotChatSessionsProvider.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; @@ -61,6 +63,8 @@ import { IGitHubService } from '../../../../github/browser/githubService.js'; import { GitHubPullRequestModel } from '../../../../github/browser/models/githubPullRequestModel.js'; import { IPullRequestIconCache } from '../../../../github/browser/pullRequestIconCache.js'; import { computePullRequestIcon, GitHubPullRequestState, IGitHubPullRequest } from '../../../../github/common/types.js'; +import { Target } from '../../../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { PromptsStorage } from '../../../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; // ---- Helpers ---------------------------------------------------------------- @@ -344,7 +348,7 @@ function createProviderWithConfig( lastFocusedWidget: undefined, onDidChangeFocusedSession: Event.None, }); - instantiationService.stub(ILanguageModelsService, opts?.languageModelsService ?? { lookupLanguageModel: () => undefined }); + instantiationService.stub(ILanguageModelsService, { lookupLanguageModel: () => undefined, getModelConfiguration: () => undefined, setModelConfiguration: async () => { }, ...opts?.languageModelsService }); instantiationService.stub(INotificationService, new class extends mock() { override warn(): void { } }()); @@ -401,7 +405,7 @@ function createProviderForSendTests( disposables: DisposableStore, model: MockAgentSessionsModel, sendRequest: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise, - opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; configurationService?: TestConfigurationService; agentHostEnabled?: boolean; getOptionGroups?: () => IChatSessionProviderOptionGroup[] | undefined; notifications?: string[] }, + opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; configurationService?: TestConfigurationService; agentHostEnabled?: boolean; getOptionGroups?: () => IChatSessionProviderOptionGroup[] | undefined; notifications?: string[]; chatModeService?: IChatModeService; languageModelsService?: Partial }, ): TestSandboxCopilotProvider { const instantiationService = disposables.add(new TestInstantiationService()); @@ -446,10 +450,21 @@ function createProviderForSendTests( lastFocusedWidget: undefined, onDidChangeFocusedSession: Event.None, }); - instantiationService.stub(ILanguageModelsService, { lookupLanguageModel: () => undefined }); + instantiationService.stub(ILanguageModelsService, { lookupLanguageModel: () => undefined, getModelConfiguration: () => undefined, setModelConfiguration: async () => { }, ...opts?.languageModelsService }); instantiationService.stub(INotificationService, new class extends mock() { override warn(message: unknown): void { opts?.notifications?.push(String(message)); } }()); + instantiationService.stub(IChatModeService, opts?.chatModeService ?? { + createModes: () => upcastPartial({ + onDidChange: Event.None, + builtin: [], + custom: [], + findModeById: () => undefined, + findModeByName: () => undefined, + waitForPendingUpdates: async () => { }, + dispose: () => { }, + }), + }); instantiationService.stub(ILanguageModelToolsService, { toToolReferences: () => [] }); instantiationService.stub(IGitService, { openRepository: async () => undefined }); instantiationService.stub(IInstantiationService, instantiationService); @@ -2277,6 +2292,473 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(session?.permissionLevel.get(), ChatPermissionLevel.Default); }); + + test('restores and captures Automation session configuration', async () => { + const provider = createProviderForSendTests(disposables, model, () => new Promise(() => { })); + const sessionTemplate = { + modelId: 'model', + modelConfiguration: { thinkingLevel: 'low', futureOption: true }, + config: { + providerOption: true, + }, + }; + const automationConfiguration = { + sessionTemplate, + modelId: 'model', + mode: ChatModeKind.Ask, + permissionLevel: ChatPermissionLevel.Autopilot, + }; + + const sessionInfo = provider.createNewSession(workspace, CopilotCLISessionType.id, { automationConfiguration }); + const session = provider.getSession(sessionInfo.sessionId); + const captured = await provider.getAutomationSessionConfiguration(sessionInfo.sessionId); + + assert.deepStrictEqual({ + modelId: session?.modelId.get(), + mode: session?.mode.get()?.id, + permissionLevel: session?.permissionLevel.get(), + captured, + }, { + modelId: 'model', + mode: ChatModeKind.Ask, + permissionLevel: ChatPermissionLevel.Autopilot, + captured: { + sessionTemplate: { + modelId: 'model', + modelConfiguration: sessionTemplate.modelConfiguration, + config: { + providerOption: true, + mode: ChatModeKind.Ask, + autoApprove: ChatPermissionLevel.Autopilot, + }, + }, + modelId: 'model', + mode: ChatModeKind.Ask, + permissionLevel: ChatPermissionLevel.Autopilot, + }, + }); + }); + + for (const restored of [true, false]) { + test(`sends ${restored ? 'restored' : 'explicitly selected'} Automation model options through the fallback provider`, async () => { + const sent: IChatSendRequestOptions[] = []; + const writes: Record[] = []; + const provider = createProviderForSendTests(disposables, model, async (_resource, _message, options) => { + if (options) { + sent.push(options); + } + return { kind: 'rejected', reason: 'Test request captured' }; + }, { + languageModelsService: { + lookupLanguageModel: () => ({ + extension: new ExtensionIdentifier('test'), + id: 'model', name: 'Model', vendor: 'test', family: 'test', version: '1', + maxInputTokens: 1, maxOutputTokens: 1, isDefaultForLocation: {}, + configurationSchema: { + type: 'object', + properties: { thinkingLevel: { type: 'string', enum: ['low', 'medium', 'high'], default: 'medium' } }, + }, + }), + getModelConfiguration: () => ({ thinkingLevel: 'high' }), + setModelConfiguration: async (_modelId, values) => { writes.push(values); }, + }, + }); + const session = provider.createNewSession(workspace, CopilotCLISessionType.id, { + automationConfiguration: { + sessionTemplate: { modelId: 'model', ...(restored ? { modelConfiguration: { thinkingLevel: 'low' } } : {}) }, + }, + }); + if (!restored) { + await provider.getAutomationModelConfiguration(session.sessionId)!.setModelConfiguration('model', { thinkingLevel: 'low' }); + } + const captured = await provider.getAutomationSessionConfiguration(session.sessionId); + await assert.rejects(provider.sendRequest(session.sessionId, session.mainChat.get().resource, { query: 'hello' }), /Test request captured/); + + assert.deepStrictEqual({ + captured: captured?.sessionTemplate?.modelConfiguration, + sent: sent.map(options => ({ model: options.userSelectedModelId, configuration: options.userSelectedModelConfiguration })), + writes, + }, { + captured: { thinkingLevel: 'low' }, + sent: [{ model: 'model', configuration: { thinkingLevel: 'low' } }], + writes: restored ? [] : [{ thinkingLevel: 'low' }], + }); + }); + } + + test('rejects Automation model configuration without a model before creating a fallback draft', () => { + const provider = createProviderForSendTests(disposables, model, async () => ({ kind: 'rejected', reason: 'Unexpected send' })); + assert.throws(() => provider.createNewSession(workspace, CopilotCLISessionType.id, { + automationConfiguration: { sessionTemplate: { modelConfiguration: { thinkingLevel: 'low' } } }, + }), /model configuration requires a model identifier/); + assert.deepStrictEqual(provider.getSessions(), []); + }); + + test('preserves an Automation mode while custom modes resolve', async () => { + const provider = createProviderForSendTests(disposables, model, () => new Promise(() => { })); + const mode = 'file:///agents/reviewer.agent.md'; + const sessionInfo = provider.createNewSession(workspace, CopilotCLISessionType.id, { + automationConfiguration: { + mode, + permissionLevel: ChatPermissionLevel.Default, + }, + }); + + const captured = await provider.getAutomationSessionConfiguration(sessionInfo.sessionId); + + assert.deepStrictEqual({ + sessionMode: provider.getSession(sessionInfo.sessionId)?.mode.get()?.id, + capturedMode: captured?.mode, + templateMode: captured?.sessionTemplate?.config?.mode, + }, { + sessionMode: mode, + capturedMode: mode, + templateMode: mode, + }); + }); + }); + + suite('Automation custom agent restoration', () => { + const workspace = URI.file('/test/repo'); + + function createAgent(name: string): IChatMode { + return new CustomChatMode({ + id: name, + uri: URI.file(`/test/repo/.github/agents/${name}.agent.md`), + name, + agentInstructions: { content: `Instructions for ${name}`, toolReferences: [] }, + source: { storage: PromptsStorage.local }, + target: Target.GitHubCopilot, + visibility: { userInvocable: true, agentInvocable: true }, + enabled: true, + }); + } + + function createModeService(getModes: () => readonly IChatMode[], waitForPendingUpdates: () => Promise = async () => { }, onDispose: () => void = () => { }): IChatModeService { + return new class extends mock() { + override createModes(): IChatModes & IDisposable { + return { + onDidChange: Event.None, + builtin: [ChatMode.Agent], + get custom() { return getModes(); }, + findModeById: id => getModes().find(mode => mode.id === id), + findModeByName: name => getModes().find(mode => mode.name.get() === name), + waitForPendingUpdates, + dispose: onDispose, + }; + } + }(); + } + + test('restores canonical agent selection and captures changes and clearing', async () => { + const reviewer = createAgent('reviewer'); + const writer = createAgent('writer'); + const provider = createProviderForSendTests(disposables, model, async () => ({ kind: 'rejected', reason: 'Unexpected send' }), { + chatModeService: createModeService(() => [reviewer, writer]), + }); + const session = provider.createNewSession(workspace, CopilotCLISessionType.id, { + automationConfiguration: { + sessionTemplate: { + agent: { uri: reviewer.id }, + config: { mode: ChatModeKind.Agent, providerOption: true }, + }, + }, + }); + const initial = await provider.getAutomationSessionConfiguration(session.sessionId); + const selected = session.mode.get()?.id; + provider.getSession(session.sessionId)?.setMode(writer); + const changed = await provider.getAutomationSessionConfiguration(session.sessionId); + provider.getSession(session.sessionId)?.setMode(undefined); + const cleared = await provider.getAutomationSessionConfiguration(session.sessionId); + + assert.deepStrictEqual({ + selected, + agents: [initial, changed, cleared].map(configuration => configuration?.sessionTemplate?.agent), + clearedMode: cleared?.mode, + opaqueValue: cleared?.sessionTemplate?.config?.providerOption, + }, { + selected: reviewer.id, + agents: [{ uri: reviewer.id }, { uri: writer.id }, undefined], + clearedMode: undefined, + opaqueValue: true, + }); + }); + + for (const canonical of [true, false]) { + test(`waits for ${canonical ? 'canonical agent' : 'legacy custom mode'} discovery before sending`, async () => { + const reviewer = createAgent('reviewer'); + const ready = new DeferredPromise(); + const discoveryStarted = new DeferredPromise(); + let modes: readonly IChatMode[] = []; + let sentOptions: IChatSendRequestOptions | undefined; + const provider = createProviderForSendTests(disposables, model, async (_resource, _message, options) => { + sentOptions = options; + return { kind: 'rejected', reason: 'Request recorded' }; + }, { + chatModeService: createModeService(() => modes, async () => { + await discoveryStarted.complete(); + await ready.p; + }), + }); + const session = provider.createNewSession(workspace, CopilotCLISessionType.id, { + automationConfiguration: canonical + ? { sessionTemplate: { agent: { uri: reviewer.id } } } + : { mode: reviewer.id }, + }); + const chat = await provider.createNewChat(session.sessionId); + const sending = assert.rejects(provider.sendRequest(session.sessionId, chat.resource, { query: 'Review changes' }), /Request recorded/); + await discoveryStarted.p; + const sentBeforeDiscovery = sentOptions !== undefined; + modes = [reviewer]; + await ready.complete(); + await sending; + + assert.deepStrictEqual({ + sentBeforeDiscovery, + instructions: sentOptions?.modeInfo?.modeInstructions?.content, + agent: sentOptions?.modeInfo?.modeInstructions?.name, + isBuiltin: sentOptions?.modeInfo?.isBuiltin, + }, { + sentBeforeDiscovery: false, + instructions: 'Instructions for reviewer', + agent: 'reviewer', + isBuiltin: false, + }); + }); + } + + test('preserves an unavailable canonical agent but rejects running without it', async () => { + let sent = false; + const provider = createProviderForSendTests(disposables, model, async () => { + sent = true; + return { kind: 'rejected', reason: 'Unexpected send' }; + }); + const agent = { uri: 'file:///agents/missing.agent.md' }; + const session = provider.createNewSession(workspace, CopilotCLISessionType.id, { + automationConfiguration: { sessionTemplate: { agent } }, + }); + const captured = await provider.getAutomationSessionConfiguration(session.sessionId); + await assert.rejects(provider.sendRequest(session.sessionId, session.mainChat.get().resource, { query: 'Review changes' }), /selected agent.*unavailable/); + + assert.deepStrictEqual({ sent, agent: captured?.sessionTemplate?.agent, status: session.status.get() }, { + sent: false, + agent, + status: SessionStatus.Untitled, + }); + }); + + test('rejects a cached canonical agent that disappears during fresh discovery', async () => { + const reviewer = createAgent('reviewer'); + let availableModes: readonly IChatMode[] = [reviewer]; + let sent = false; + const provider = createProviderForSendTests(disposables, model, async () => { + sent = true; + return { kind: 'rejected', reason: 'Unexpected send' }; + }, { + chatModeService: createModeService(() => availableModes, async () => { availableModes = []; }), + }); + const session = provider.createNewSession(workspace, CopilotCLISessionType.id, { + automationConfiguration: { sessionTemplate: { agent: { uri: reviewer.id } } }, + }); + const cachedSelection = session.mode.get()?.id; + await assert.rejects(provider.sendRequest(session.sessionId, session.mainChat.get().resource, { query: 'Review changes' }), /selected agent.*unavailable/); + + assert.deepStrictEqual({ cachedSelection, sent, status: session.status.get() }, { + cachedSelection: reviewer.id, + sent: false, + status: SessionStatus.Untitled, + }); + }); + + test('clearing an unresolved canonical agent does not restore the initial selection', async () => { + const provider = createProviderForSendTests(disposables, model, async () => ({ kind: 'rejected', reason: 'Unexpected send' })); + const session = provider.createNewSession(workspace, CopilotCLISessionType.id, { + automationConfiguration: { + sessionTemplate: { + agent: { uri: 'file:///agents/missing.agent.md' }, + config: { mode: 'file:///agents/previous.agent.md' }, + }, + }, + }); + provider.getSession(session.sessionId)?.setMode(undefined); + const captured = await provider.getAutomationSessionConfiguration(session.sessionId); + + assert.deepStrictEqual({ + mode: captured?.mode, + templateMode: captured?.sessionTemplate?.config?.mode, + agent: captured?.sessionTemplate?.agent, + }, { mode: undefined, templateMode: undefined, agent: undefined }); + }); + + test('does not overwrite a user-selected agent when discovery completes', async () => { + const reviewer = createAgent('reviewer'); + const writer = createAgent('writer'); + const ready = new DeferredPromise(); + const discoveryStarted = new DeferredPromise(); + let sentOptions: IChatSendRequestOptions | undefined; + const provider = createProviderForSendTests(disposables, model, async (_resource, _message, options) => { + sentOptions = options; + return { kind: 'rejected', reason: 'Request recorded' }; + }, { + chatModeService: createModeService(() => [writer], async () => { + await discoveryStarted.complete(); + await ready.p; + }), + }); + const session = provider.createNewSession(workspace, CopilotCLISessionType.id, { + automationConfiguration: { sessionTemplate: { agent: { uri: reviewer.id } } }, + }); + const sending = assert.rejects(provider.sendRequest(session.sessionId, session.mainChat.get().resource, { query: 'Review changes' }), /Request recorded/); + await discoveryStarted.p; + provider.getSession(session.sessionId)?.setMode(writer); + await ready.complete(); + await sending; + + assert.deepStrictEqual({ + agent: sentOptions?.modeInfo?.modeInstructions?.name, + instructions: sentOptions?.modeInfo?.modeInstructions?.content, + }, { agent: 'writer', instructions: 'Instructions for writer' }); + }); + + test('propagates discovery errors without sending default Agent instructions', async () => { + const error = new Error('Agent discovery failed'); + let sent = false; + const provider = createProviderForSendTests(disposables, model, async () => { + sent = true; + return { kind: 'rejected', reason: 'Unexpected send' }; + }, { + chatModeService: createModeService(() => [], async () => { throw error; }), + }); + const session = provider.createNewSession(workspace, CopilotCLISessionType.id, { + automationConfiguration: { sessionTemplate: { agent: { uri: 'file:///agents/reviewer.agent.md' } } }, + }); + + await assert.rejects(provider.sendRequest(session.sessionId, session.mainChat.get().resource, { query: 'Review changes' }), actual => actual === error); + assert.strictEqual(sent, false); + }); + + test('bounds stalled custom-agent discovery and releases each timed-out lookup', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + let disposed = 0; + let sent = false; + const provider = createProviderForSendTests(disposables, model, async () => { + sent = true; + return { kind: 'rejected', reason: 'Unexpected send' }; + }, { + chatModeService: createModeService(() => [], () => new Promise(() => { }), () => disposed++), + }); + const attempts: { elapsed: number; disposed: number; status: SessionStatus }[] = []; + for (let attempt = 0; attempt < 3; attempt++) { + const session = provider.createNewSession(workspace, CopilotCLISessionType.id, { + automationConfiguration: { sessionTemplate: { agent: { uri: 'file:///agents/reviewer.agent.md' } } }, + }); + const started = Date.now(); + await assert.rejects(provider.sendRequest(session.sessionId, session.mainChat.get().resource, { query: 'Review changes' }), /Timed out resolving.*custom agent/); + attempts.push({ elapsed: Date.now() - started, disposed, status: session.status.get() }); + provider.deleteNewSession(session.sessionId); + } + + assert.deepStrictEqual({ sent, attempts }, { + sent: false, + attempts: [ + { elapsed: 30_000, disposed: 2, status: SessionStatus.Untitled }, + { elapsed: 30_000, disposed: 4, status: SessionStatus.Untitled }, + { elapsed: 30_000, disposed: 6, status: SessionStatus.Untitled }, + ], + }); + })); + + test('keeps one discovery deadline when the selected agent changes', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const writer = createAgent('writer'); + const firstRefresh = new DeferredPromise(); + let resolutions = 0; + let disposed = 0; + const provider = createProviderForSendTests(disposables, model, async () => ({ kind: 'rejected', reason: 'Unexpected send' }), { + chatModeService: createModeService(() => [writer], () => ++resolutions === 1 ? firstRefresh.p : new Promise(() => { }), () => disposed++), + }); + const session = provider.createNewSession(workspace, CopilotCLISessionType.id, { + automationConfiguration: { mode: 'file:///agents/reviewer.agent.md' }, + }); + const started = Date.now(); + const sending = assert.rejects(provider.sendRequest(session.sessionId, session.mainChat.get().resource, { query: 'Review changes' }), /Timed out resolving.*custom agent/); + await timeout(20_000); + provider.getSession(session.sessionId)?.setMode(writer); + await firstRefresh.complete(); + await sending; + + assert.deepStrictEqual({ elapsed: Date.now() - started, resolutions, disposed }, { + elapsed: 30_000, + resolutions: 2, + disposed: 3, + }); + })); + + test('disposes pending discovery when the provider is disposed', async () => { + const discoveryStarted = new DeferredPromise(); + let disposed = 0; + let sent = false; + const provider = createProviderForSendTests(disposables, model, async () => { + sent = true; + return { kind: 'rejected', reason: 'Unexpected send' }; + }, { + chatModeService: createModeService(() => [], () => { + void discoveryStarted.complete(); + return new Promise(() => { }); + }, () => disposed++), + }); + const session = provider.createNewSession(workspace, CopilotCLISessionType.id, { + automationConfiguration: { mode: 'file:///agents/reviewer.agent.md' }, + }); + const sending = assert.rejects(provider.sendRequest(session.sessionId, session.mainChat.get().resource, { query: 'Review changes' }), /Canceled/); + await discoveryStarted.p; + provider.dispose(); + await sending; + + assert.deepStrictEqual({ sent, disposed }, { sent: false, disposed: 2 }); + }); + + test('disposes pending discovery when its draft is discarded', async () => { + const ready = new DeferredPromise(); + const discoveryStarted = new DeferredPromise(); + let disposed = 0; + let sent = false; + const provider = createProviderForSendTests(disposables, model, async () => { + sent = true; + return { kind: 'rejected', reason: 'Unexpected send' }; + }, { + chatModeService: createModeService(() => [], async () => { + await discoveryStarted.complete(); + await ready.p; + }, () => disposed++), + }); + const session = provider.createNewSession(workspace, CopilotCLISessionType.id, { + automationConfiguration: { mode: 'file:///agents/reviewer.agent.md' }, + }); + const sending = assert.rejects(provider.sendRequest(session.sessionId, session.mainChat.get().resource, { query: 'Review changes' }), /Canceled/); + await discoveryStarted.p; + provider.deleteNewSession(session.sessionId); + try { + await sending; + assert.deepStrictEqual({ sent, disposed }, { sent: false, disposed: 2 }); + } finally { + await ready.complete(); + } + }); + + test('rejects unsupported canonical custom agents on cloud drafts', () => { + const provider = createProviderForSendTests(disposables, model, async () => ({ kind: 'rejected', reason: 'Unexpected send' })); + assert.throws(() => provider.createNewSession( + URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, path: '/owner/repo/HEAD' }), + CopilotCloudSessionType.id, + { automationConfiguration: { sessionTemplate: { agent: { uri: 'file:///agents/reviewer.agent.md' } } } }, + ), /does not support custom agents/); + }); + + test('rejects non-URI canonical agents instead of interpreting them as built-in modes', () => { + const provider = createProviderForSendTests(disposables, model, async () => ({ kind: 'rejected', reason: 'Unexpected send' })); + assert.throws(() => provider.createNewSession(workspace, CopilotCLISessionType.id, { + automationConfiguration: { sessionTemplate: { agent: { uri: ChatModeKind.Agent } } }, + }), /absolute URI/); + }); }); function waitForSessionAdded(provider: CopilotChatSessionsProvider): Promise { diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/modePicker.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/modePicker.test.ts index 4f108ac1e88c74..95d0ab1f8d7ef3 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/modePicker.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/modePicker.test.ts @@ -9,11 +9,12 @@ import { hash } from '../../../../../../base/common/hash.js'; import { IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { mock } from '../../../../../../base/test/common/mock.js'; +import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IActionListDelegate, IActionListItem } from '../../../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { NullTelemetryServiceShape } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; import { IChatModeService, IChatModes, ChatMode, CustomChatMode } from '../../../../../../workbench/contrib/chat/common/chatModes.js'; import { IChatService } from '../../../../../../workbench/contrib/chat/common/chatService/chatService.js'; @@ -23,7 +24,7 @@ import { PromptsStorage } from '../../../../../../workbench/contrib/chat/common/ import { Target } from '../../../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; import { IChat, ISession } from '../../../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../../../services/sessions/common/sessionsManagement.js'; -import { ModePicker, ModePickerModel } from '../../browser/modePicker.js'; +import { ModePicker, ModePickerModel, ScopedModePickerModelCache } from '../../browser/modePicker.js'; class TestTelemetryService extends NullTelemetryServiceShape { readonly events: { readonly name: string; readonly data: unknown }[] = []; @@ -35,6 +36,136 @@ class TestTelemetryService extends NullTelemetryServiceShape { } } +suite('ScopedModePickerModelCache', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createSession(name: string) { + const resource = URI.parse(`copilotcli:/${name}`); + const customAgent = new CustomChatMode({ + id: name, + uri: URI.file(`/workspace/${name}.agent.md`), + name, + agentInstructions: { content: '', toolReferences: [] }, + source: { storage: PromptsStorage.local }, + target: Target.Undefined, + visibility: { userInvocable: true, agentInvocable: true }, + enabled: true, + }); + const mode = observableValue<{ readonly id: string; readonly kind: string } | undefined>('mode', { id: customAgent.id, kind: ChatMode.Agent.kind }); + const session = upcastPartial({ resource, mode, providerId: 'default-copilot' }); + const scope = observableValue('scope', session); + return { session, scope, mode, customAgent }; + } + + function createInstantiationService(sessions: readonly ReturnType[], disposed: string[]) { + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IChatSessionsService, { + getCustomAgentTargetForSessionType: () => Target.Undefined, + }); + instantiationService.stub(IChatModeService, { + createModes: resource => { + const entry = sessions.find(entry => entry.session.resource.toString() === resource?.toString()); + assert.ok(entry); + const modes: IChatModes & IDisposable = { + onDidChange: Event.None, + builtin: [ChatMode.Agent], + custom: [entry.customAgent], + findModeById: id => id === entry.customAgent.id ? entry.customAgent : id === ChatMode.Agent.id ? ChatMode.Agent : undefined, + findModeByName: name => name === entry.customAgent.name.get() ? entry.customAgent : undefined, + waitForPendingUpdates: async () => { }, + dispose: () => disposed.push(entry.customAgent.name.get()), + }; + return modes; + }, + }); + return instantiationService; + } + + test('keeps selected agents and discovery scoped to independent session surfaces', () => { + const first = createSession('first'); + const second = createSession('second'); + const disposed: string[] = []; + const instantiationService = createInstantiationService([first, second], disposed); + const cache = store.add(new ScopedModePickerModelCache(() => true)); + const firstReference = store.add(cache.acquire(first.scope, instantiationService)); + const secondReference = store.add(cache.acquire(second.scope, instantiationService)); + const initial = [firstReference, secondReference].map(reference => ({ + selected: reference.model.selectedMode.id, + available: reference.model.getAvailableModes().map(mode => mode.id), + })); + + first.mode.set({ id: ChatMode.Agent.id, kind: ChatMode.Agent.kind }, undefined); + const afterChangingFirst = [firstReference.model.selectedMode.id, secondReference.model.selectedMode.id]; + first.scope.set(undefined, undefined); + + assert.deepStrictEqual({ + initial, + afterChangingFirst, + firstModesAfterClosing: firstReference.model.getAvailableModes().map(mode => mode.id), + secondSelection: secondReference.model.selectedMode.id, + disposed, + }, { + initial: [ + { selected: first.customAgent.id, available: ['agent', first.customAgent.id] }, + { selected: second.customAgent.id, available: ['agent', second.customAgent.id] }, + ], + afterChangingFirst: ['agent', second.customAgent.id], + firstModesAfterClosing: ['agent'], + secondSelection: second.customAgent.id, + disposed: ['first'], + }); + }); + + test('retains models across synchronous rebuilds and disposes them after the last release', async () => { + const session = createSession('reviewer'); + const disposed: string[] = []; + const instantiationService = createInstantiationService([session], disposed); + const cache = store.add(new ScopedModePickerModelCache(() => true)); + const first = store.add(cache.acquire(session.scope, instantiationService)); + first.dispose(); + const replacement = store.add(cache.acquire(session.scope, instantiationService)); + await Promise.resolve(); + const afterRebuild = { reused: first.model === replacement.model, disposed: [...disposed] }; + + replacement.dispose(); + replacement.dispose(); + await Promise.resolve(); + + assert.deepStrictEqual({ afterRebuild, disposed }, { + afterRebuild: { reused: true, disposed: [] }, + disposed: ['reviewer'], + }); + }); + + test('follows scoped retargets and clears discovery for unsupported sessions', () => { + const first = createSession('first'); + const second = createSession('second'); + const disposed: string[] = []; + const instantiationService = createInstantiationService([first, second], disposed); + const cache = store.add(new ScopedModePickerModelCache(session => session.providerId === 'default-copilot')); + const reference = store.add(cache.acquire(first.scope, instantiationService)); + + first.scope.set(second.session, undefined); + const retargeted = { + selected: reference.model.selectedMode.id, + available: reference.model.getAvailableModes().map(mode => mode.id), + }; + first.scope.set(upcastPartial({ providerId: 'another-provider', mode: second.mode }), undefined); + + assert.deepStrictEqual({ + retargeted, + selectedAfterSwitchingProvider: reference.model.selectedMode.id, + availableAfterSwitchingProvider: reference.model.getAvailableModes().map(mode => mode.id), + disposed, + }, { + retargeted: { selected: second.customAgent.id, available: ['agent', second.customAgent.id] }, + selectedAfterSwitchingProvider: 'agent', + availableAfterSwitchingProvider: ['agent'], + disposed: ['first', 'second'], + }); + }); +}); + suite('ModePicker', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/sandboxPicker.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/sandboxPicker.test.ts index 14d1f3c15a33f1..64e2f8059ea7e6 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/sandboxPicker.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/sandboxPicker.test.ts @@ -75,6 +75,7 @@ suite('Copilot SandboxPicker', () => { workspace, AgentSessionProviders.Cloud, 'default-copilot', + undefined, )); if (options.useSandbox) { providerSession.setUseSandbox(true); diff --git a/src/vs/sessions/contrib/sessions/browser/automationsNewBadge.ts b/src/vs/sessions/contrib/sessions/browser/automationsNewBadge.ts index 62445fc9f2a4b3..bd41074595a2e7 100644 --- a/src/vs/sessions/contrib/sessions/browser/automationsNewBadge.ts +++ b/src/vs/sessions/contrib/sessions/browser/automationsNewBadge.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { autorun, derived, observableValue } from '../../../../base/common/observable.js'; import { onUnexpectedError } from '../../../../base/common/errors.js'; import { IConfigurationService, isConfigured } from '../../../../platform/configuration/common/configuration.js'; @@ -12,7 +12,9 @@ import { observableMemento, type ObservableMemento } from '../../../../platform/ import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IAutomationService } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; import { IWorkbenchAssignmentService } from '../../../../workbench/services/assignment/common/assignmentService.js'; +import { ILifecycleService, LifecyclePhase } from '../../../../workbench/services/lifecycle/common/lifecycle.js'; import { ICustomViewService } from '../../../services/customView/browser/customViewService.js'; +import { ISessionsWindowUsageService } from '../../../services/sessions/browser/sessionsWindowUsageService.js'; import { AUTOMATIONS_CUSTOM_VIEW_ID } from './automationsConstants.js'; export const AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY = 'sessions.automations.newBadgeSeen'; @@ -23,6 +25,8 @@ export type AutomationsNewBadgeStyle = 'accent' | 'soft' | 'outline'; const DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE: AutomationsNewBadgeStyle = 'outline'; +type AutomationsNewBadgeStartupDecision = 'pending' | 'eligible' | 'suppressed'; + const automationsNewBadgeSeenMemento = observableMemento({ key: AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, defaultValue: false, @@ -35,10 +39,21 @@ export class AutomationsNewBadgeState extends Disposable { private readonly seen: ObservableMemento; private readonly resolvedStyle = observableValue(this, undefined); - private observingActiveView = false; + private readonly forcePreview = observableValue(this, false); + private readonly startupDecision = observableValue(this, 'pending'); + private readonly automationEvidenceObserver = this._register(new MutableDisposable()); + private observersRegistered = false; private initializationPromise: Promise | undefined; private styleRequest = 0; - readonly presentation = derived(this, reader => this.seen.read(reader) ? undefined : this.resolvedStyle.read(reader)); + readonly presentation = derived(this, reader => { + if (this.seen.read(reader)) { + return undefined; + } + if (this.forcePreview.read(reader)) { + return this.resolvedStyle.read(reader); + } + return this.startupDecision.read(reader) === 'eligible' ? this.resolvedStyle.read(reader) : undefined; + }); readonly showNewBadge = derived(this, reader => this.presentation.read(reader) !== undefined); constructor( @@ -48,19 +63,43 @@ export class AutomationsNewBadgeState extends Disposable { @IWorkbenchAssignmentService private readonly assignmentService: IWorkbenchAssignmentService, @IConfigurationService private readonly configurationService: IConfigurationService, @ILogService private readonly logService: ILogService, + @ISessionsWindowUsageService private readonly sessionsWindowUsageService: ISessionsWindowUsageService, + @ILifecycleService private readonly lifecycleService: ILifecycleService, ) { super(); this.seen = this._register(automationsNewBadgeSeenMemento(StorageScope.APPLICATION, StorageTarget.MACHINE, storageService)); } initialize(): Promise { - if (!this.observingActiveView) { - this.observingActiveView = true; + if (!this.observersRegistered) { + this.observersRegistered = true; + if (!this.seen.get()) { + const evidenceObserver = autorun(reader => { + if (this.forcePreview.read(reader)) { + return; + } + if (this.automationService.automations.read(reader).length > 0 || this.automationService.runs.read(reader).length > 0) { + this.markSeen(); + } + }); + this.automationEvidenceObserver.value = evidenceObserver; + if (this.seen.get()) { + this.automationEvidenceObserver.clear(); + } + } this._register(autorun(reader => { if (this.customViewService.activeCustomView.read(reader)?.id === AUTOMATIONS_CUSTOM_VIEW_ID) { this.markSeen(); } })); + this._register(autorun(reader => { + if (this.forcePreview.read(reader) || this.seen.read(reader) || this.startupDecision.read(reader) !== 'eligible') { + return; + } + if (this.automationService.catalogueState.read(reader) !== 'ready') { + this.startupDecision.set('suppressed', undefined); + } + })); } if (!this.initializationPromise) { this.initializationPromise = this.doInitialize(); @@ -77,6 +116,7 @@ export class AutomationsNewBadgeState extends Disposable { } async reset(): Promise { + this.forcePreview.set(true, undefined); this.seen.set(false, undefined); this.storageService.remove(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION); this.resolvedStyle.set(undefined, undefined); @@ -84,19 +124,35 @@ export class AutomationsNewBadgeState extends Disposable { } private async doInitialize(): Promise { - const hasPriorUse = this.seen.get() - || this.automationService.automations.get().length > 0 - || this.automationService.runs.get().length > 0; - if (hasPriorUse) { + if (this._store.isDisposed || this.seen.get() || this.forcePreview.get()) { + return; + } + + if (!this.sessionsWindowUsageService.hadPriorWindowOpen) { + this.startupDecision.set('suppressed', undefined); + return; + } + + await this.lifecycleService.when(LifecyclePhase.Eventually); + if (this._store.isDisposed || this.seen.get() || this.forcePreview.get()) { + return; + } + + if (this.automationService.catalogueState.get() !== 'ready') { + this.startupDecision.set('suppressed', undefined); + return; + } + if (this.automationService.automations.get().length > 0 || this.automationService.runs.get().length > 0) { this.markSeen(); return; } + this.startupDecision.set('eligible', undefined); await this.updateStyle(); } private async updateStyle(): Promise { - if (this.seen.get()) { + if (!this.canResolveStyle()) { return; } @@ -112,12 +168,16 @@ export class AutomationsNewBadgeState extends Disposable { this.logService.warn(`[AutomationsNewBadgeState] Failed to resolve badge style treatment; using '${DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE}'.`, error); } } - if (request !== this.styleRequest || this.seen.get()) { + if (request !== this.styleRequest || !this.canResolveStyle()) { return; } this.resolvedStyle.set(this.normalizeStyle(value), undefined); } + private canResolveStyle(): boolean { + return !this._store.isDisposed && !this.seen.get() && (this.forcePreview.get() || this.startupDecision.get() === 'eligible'); + } + private normalizeStyle(value: string | undefined): AutomationsNewBadgeStyle { if (value === undefined || value === DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE) { return DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE; @@ -130,8 +190,10 @@ export class AutomationsNewBadgeState extends Disposable { } private markSeen(): void { + this.forcePreview.set(false, undefined); if (!this.seen.get()) { this.seen.set(true, undefined); } + this.automationEvidenceObserver.clear(); } } diff --git a/src/vs/sessions/contrib/sessions/browser/media/automationsCards.css b/src/vs/sessions/contrib/sessions/browser/media/automationsCards.css index 39f62e1f5a60e3..53d68f0d3188be 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/automationsCards.css +++ b/src/vs/sessions/contrib/sessions/browser/media/automationsCards.css @@ -30,6 +30,9 @@ .automations-cards-header, .automations-cards-grid, .automations-cards-empty, +.automations-cards-state, +.automations-cards-partial-state, +.automations-templates, .automations-history { width: 100%; max-width: 900px; @@ -285,6 +288,166 @@ font-size: 12px; } +/* Loading and error states */ +.automations-cards-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--vscode-spacing-size80); + padding: var(--vscode-spacing-size400) var(--vscode-spacing-size200); + box-sizing: border-box; + text-align: center; +} + +.automations-cards-state-icon { + font-size: var(--vscode-codiconFontSize); + color: var(--vscode-descriptionForeground); +} + +.automations-cards-error .automations-cards-state-icon { + color: var(--vscode-errorForeground); +} + +.automations-cards-state-title { + margin: 0; + font-size: var(--vscode-fontSize-heading3); + font-weight: var(--vscode-fontWeight-semiBold); + color: var(--vscode-foreground); +} + +.automations-cards-state-description { + margin: 0; + font-size: var(--vscode-fontSize-label1); + line-height: 1.4; + color: var(--vscode-descriptionForeground); +} + +.automations-cards-state-create-button { + margin-top: var(--vscode-spacing-size120); + max-width: 180px; +} + +.automations-cards-partial-state { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size80); + margin-bottom: var(--vscode-spacing-size120); + padding: var(--vscode-spacing-size80) var(--vscode-spacing-size120); + border: var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border); + border-radius: var(--vscode-cornerRadius-small); + box-sizing: border-box; + font-size: var(--vscode-fontSize-label1); + color: var(--vscode-descriptionForeground); +} + +.automations-cards-partial-state-error { + border-color: var(--vscode-inputValidation-warningBorder); + background-color: var(--vscode-inputValidation-warningBackground); + color: var(--vscode-inputValidation-warningForeground); +} + +.automations-cards-partial-state-icon { + flex-shrink: 0; + font-size: var(--vscode-codiconFontSize); +} + +/* Starter templates */ +.automations-templates { + width: 100%; + margin-top: var(--vscode-spacing-size240); + text-align: left; +} + +.automations-templates-title { + margin: 0 0 var(--vscode-spacing-size40); + font-size: var(--vscode-fontSize-heading3); + font-weight: var(--vscode-fontWeight-semiBold); + color: var(--vscode-foreground); +} + +.automations-templates-description { + margin: 0 0 var(--vscode-spacing-size120); + font-size: var(--vscode-fontSize-label1); + line-height: 1.4; + color: var(--vscode-descriptionForeground); +} + +.automations-templates-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); + grid-auto-rows: 1fr; + gap: var(--vscode-spacing-size120); + width: 100%; +} + +.automations-template-card { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--vscode-spacing-size40); + width: 100%; + height: 100%; + min-width: 0; + padding: var(--vscode-spacing-size120) var(--vscode-spacing-size160); + border: var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border); + border-radius: var(--vscode-cornerRadius-medium); + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + touch-action: manipulation; +} + +.automations-template-card:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.automations-template-card:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.automations-template-card-name { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size80); +} + +.automations-template-card-name-text { + flex: 1 1 0; + min-width: 0; + overflow: hidden; + font-size: var(--vscode-fontSize-heading3); + font-weight: var(--vscode-fontWeight-semiBold); + color: var(--vscode-foreground); + white-space: nowrap; + text-overflow: ellipsis; +} + +.automations-template-card-badge, +.automations-template-card-schedule, +.automations-template-card-prompt { + font-size: var(--vscode-fontSize-label1); + color: var(--vscode-descriptionForeground); +} + +.automations-template-card-badge { + flex-shrink: 0; +} + +.automations-template-card-prompt { + display: -webkit-box; + overflow: hidden; + margin-top: var(--vscode-spacing-size20); + line-height: 1.4; + text-overflow: ellipsis; + word-break: break-word; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + /* Run History */ .automations-history { margin-top: 32px; @@ -349,6 +512,8 @@ } .hc-black .automations-card, -.hc-light .automations-card { +.hc-light .automations-card, +.hc-black .automations-template-card, +.hc-light .automations-template-card { border-color: var(--vscode-contrastBorder); } diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index 73c9cf83b78f17..aa6bba6c9669af 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -158,7 +158,8 @@ } .monaco-list-row:hover, - .monaco-list-row.focused:not(.selected) { + .monaco-list-row.focused:not(.selected), + .monaco-list:focus-within .monaco-list-row.focused { .session-title-toolbar { display: block; } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsLifecycleTracker.ts b/src/vs/sessions/contrib/sessions/browser/sessionsLifecycleTracker.ts index 573881ac43a972..6e89fc2dbd02bf 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsLifecycleTracker.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsLifecycleTracker.ts @@ -12,8 +12,6 @@ import { ISession } from '../../../services/sessions/common/session.js'; import { getPullRequestStatusFromIcon, PullRequestStatus } from '../../github/common/types.js'; import { classifySessionWorkspaceTopology, getSessionsTelemetryProviderId, hashSessionIdForTelemetry } from '../../../common/sessionsTelemetry.js'; -/** Storage key for the cumulative number of times this client has been launched. */ -const APP_LAUNCH_COUNT_KEY = 'agentSessions.telemetry.summary.appLaunchCount'; /** Storage key for the per-session lifecycle stats map (JSON encoded). Exported for tests. */ export const SESSIONS_KEY = 'agentSessions.telemetry.summary.sessions'; /** Storage key for the cumulative number of sessions started from the Agents window across all workspaces and providers. */ @@ -238,13 +236,13 @@ export class SessionsLifecycleTracker extends Disposable { private readonly _appLaunchCount: number; private readonly _stats: Map; - constructor(private readonly _storageService: IStorageService) { + constructor( + private readonly _storageService: IStorageService, + appLaunchCount: number, + ) { super(); - const previousAppLaunches = this._storageService.getNumber(APP_LAUNCH_COUNT_KEY, StorageScope.APPLICATION, 0); - this._appLaunchCount = previousAppLaunches + 1; - this._storageService.store(APP_LAUNCH_COUNT_KEY, this._appLaunchCount, StorageScope.APPLICATION, StorageTarget.MACHINE); - + this._appLaunchCount = appLaunchCount; this._stats = this._load(); } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts index 02c2dd7ec96f34..f8168a193c1724 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts @@ -26,6 +26,7 @@ import { ISendRequestOptions, ISessionsProvider } from '../../../services/sessio import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { classifySessionWorkspaceTopology, getSessionsTelemetryProviderId, hashSessionIdForTelemetry } from '../../../common/sessionsTelemetry.js'; import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js'; +import { ISessionsWindowUsageService } from '../../../services/sessions/browser/sessionsWindowUsageService.js'; import { ISessionLifecycleSummary, SessionDoneReason, SessionsLifecycleTracker } from './sessionsLifecycleTracker.js'; import { ITypedCharactersEntry, SessionsTypedCharactersTracker } from './sessionsTypedCharactersTracker.js'; @@ -66,10 +67,11 @@ export class SessionsTelemetryContribution extends Disposable implements IWorkbe @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, @ISessionsTasksService private readonly _sessionsTasksService: ISessionsTasksService, @IModelService modelService: IModelService, + @ISessionsWindowUsageService sessionsWindowUsageService: ISessionsWindowUsageService, ) { super(); - this._lifecycleTracker = new SessionsLifecycleTracker(this._storageService); + this._lifecycleTracker = new SessionsLifecycleTracker(this._storageService, sessionsWindowUsageService.windowOpenCount); // Registered after the lifecycle tracker is created but before it is // registered: disposing flushes buffered typing, which needs a live // lifecycle tracker to attribute it to. diff --git a/src/vs/sessions/contrib/sessions/browser/views/automationTemplates.ts b/src/vs/sessions/contrib/sessions/browser/views/automationTemplates.ts new file mode 100644 index 00000000000000..dd1f8508af8164 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/views/automationTemplates.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * 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 type { IAutomationSchedule } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; + +export interface IAutomationTemplate { + readonly id: string; + readonly name: string; + readonly prompt: string; + readonly schedule: IAutomationSchedule; +} + +export const AUTOMATION_TEMPLATES: readonly IAutomationTemplate[] = [ + { + id: 'issue-triage', + name: localize('automationTemplate.issueTriage.name', "Issue triage"), + prompt: localize('automationTemplate.issueTriage.prompt', "Review new issues, group duplicates, and suggest labels."), + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 0, scheduleDay: 0 }, + }, + { + id: 'pull-request-review', + name: localize('automationTemplate.pullRequestReview.name', "Pull request review"), + prompt: localize('automationTemplate.pullRequestReview.prompt', "Review recent changes for correctness, missing tests, and regressions."), + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 0, scheduleDay: 0 }, + }, + { + id: 'dependency-audit', + name: localize('automationTemplate.dependencyAudit.name', "Dependency audit"), + prompt: localize('automationTemplate.dependencyAudit.prompt', "Check dependencies and summarize recommended updates."), + schedule: { interval: 'weekly', scheduleHour: 9, scheduleMinute: 0, scheduleDay: 1 }, + }, + { + id: 'release-notes', + name: localize('automationTemplate.releaseNotes.name', "Release notes"), + prompt: localize('automationTemplate.releaseNotes.prompt', "Draft release notes from the week's merged changes."), + schedule: { interval: 'weekly', scheduleHour: 16, scheduleMinute: 0, scheduleDay: 5 }, + }, +]; diff --git a/src/vs/sessions/contrib/sessions/browser/views/automationsAccessibility.ts b/src/vs/sessions/contrib/sessions/browser/views/automationsAccessibility.ts index 120bb9eea6d1da..f0357de646d7c8 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/automationsAccessibility.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/automationsAccessibility.ts @@ -9,13 +9,14 @@ import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType import { AccessibleViewRegistry, IAccessibleViewImplementation } from '../../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { AccessibilityVerbositySettingId } from '../../../../../workbench/contrib/accessibility/browser/accessibilityConfiguration.js'; -import { IAutomationDescriptor, IAutomationRun } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; -import { IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { IAutomationDescriptor, IAutomationRun, IAutomationSchedule } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; +import { AutomationCatalogueState, IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { DAYS_OF_WEEK } from '../../../../../workbench/contrib/chat/common/automations/schedule.js'; import { Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; import { AutomationsCustomViewFocusContext } from '../../../../common/contextkeys.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { AUTOMATION_TEMPLATES } from './automationTemplates.js'; class AutomationsCustomViewAccessibilityHelp implements IAccessibleViewImplementation { readonly type = AccessibleViewType.Help; @@ -25,10 +26,15 @@ class AutomationsCustomViewAccessibilityHelp implements IAccessibleViewImplement getProvider(accessor: ServicesAccessor): AccessibleContentProvider { const layoutService = accessor.get(IAgentWorkbenchLayoutService); + const automationService = accessor.get(IAutomationService); const restoreFocus = createFocusRestorer(layoutService); + const templatesVisible = automationService.automations.get().length === 0; const content = [ - localize('automationsCustomView.help.overview', "You are in the Automations view. It contains automation cards followed by run history."), - localize('automationsCustomView.help.cards', "Tab to a card's Edit control and action buttons. Use Left Arrow and Right Arrow to move between Run now and Delete. Press Enter or Space to activate a control. Edit, or clicking anywhere else on the card, opens the automation dialog. Open a card's context menu{0} (for example Shift+F10). Duplicate opens a prefilled New automation dialog, Disable prevents scheduled runs, and Delete asks for confirmation. Run now starts a session immediately.", ''), + localize('automationsCustomView.help.overview', "You are in the Automations view. It contains available automation cards followed by run history. Loading, unavailable, and error messages indicate that the catalogue may be incomplete."), + ...(templatesVisible ? [ + localize('automationsCustomView.help.templates', "Starter templates are available. Tab to a template and press Enter or Space to open a New automation dialog with an editable name, prompt, and schedule."), + ] : []), + localize('automationsCustomView.help.cards', "For saved automations, Tab to a card's Edit control and action buttons. Use Left Arrow and Right Arrow to move between Run now and Delete. Press Enter or Space to activate a control. Edit, or clicking anywhere else on the card, opens the automation dialog. Open a card's context menu{0} (for example Shift+F10). Duplicate opens a prefilled New automation dialog, Disable prevents scheduled runs, and Delete asks for confirmation. Run now starts a session immediately.", ''), localize('automationsCustomView.help.history', "Run history is grouped by date. While a run is waiting for its session, a lightweight row shows the automation name with a Working... description. Once the session is available, use Up Arrow and Down Arrow to navigate the Sessions list, Enter to open, and Tab to reach Stop, the configured Archive or Mark as Done action, or Delete when available. Open a row's context menu, for example with Shift+F10, to rename it, change its active or read state, or delete it. Delete permanently deletes the session and removes it from run history after confirmation."), localize('automationsCustomView.help.read', "Completed and failed runs that have not been opened are announced as unread. Use Mark all as read to clear all available unread runs."), localize('automationsCustomView.help.accessibleView', "Use Open Accessible View to read the current automations and run history as text."), @@ -64,6 +70,7 @@ class AutomationsCustomViewAccessibleView implements IAccessibleViewImplementati || run.status === 'running' || (!!run.sessionResource && !!sessionsManagementService.getSession(run.sessionResource)) ), + automationService.catalogueState.get(), ), restoreFocus, AccessibilityVerbositySettingId.Automations, @@ -82,19 +89,39 @@ function createFocusRestorer(layoutService: IAgentWorkbenchLayoutService): () => }; } -export function buildAutomationsAccessibleContent(automations: readonly IAutomationDescriptor[], runs: readonly IAutomationRun[]): string { +export function buildAutomationsAccessibleContent(automations: readonly IAutomationDescriptor[], runs: readonly IAutomationRun[], catalogueState: AutomationCatalogueState): string { const lines = [localize('automationsAccessibleView.title', "Automations")]; - if (automations.length === 0) { - lines.push(localize('automationsAccessibleView.empty', "No automations.")); - } else { + if (automations.length > 0) { + if (catalogueState === 'loading') { + lines.push(localize('automationsAccessibleView.partialLoading', "Additional automations are loading.")); + } else if (catalogueState === 'unavailable') { + lines.push(localize('automationsAccessibleView.partialUnavailable', "Some automations are unavailable.")); + } else if (catalogueState === 'error') { + lines.push(localize('automationsAccessibleView.partialLoadError', "Some automations could not be loaded.")); + } for (const automation of automations) { lines.push(''); lines.push(automation.enabled ? localize('automationsAccessibleView.automation', "{0}, enabled", automation.name) : localize('automationsAccessibleView.automationDisabled', "{0}, disabled", automation.name)); - lines.push(localize('automationsAccessibleView.schedule', "Schedule: {0}", formatSchedule(automation))); + lines.push(localize('automationsAccessibleView.schedule', "Schedule: {0}", formatSchedule(automation.schedule))); lines.push(localize('automationsAccessibleView.prompt', "Prompt: {0}", automation.prompt)); } + } else if (catalogueState === 'loading') { + lines.push(localize('automationsAccessibleView.loading', "Loading automations.")); + } else if (catalogueState === 'unavailable') { + lines.push(localize('automationsAccessibleView.unavailable', "Some automations are unavailable. One or more providers are disconnected, disabled, or do not support automations.")); + } else if (catalogueState === 'error') { + lines.push(localize('automationsAccessibleView.loadError', "Unable to load automations.")); + } else { + lines.push(localize('automationsAccessibleView.empty', "No automations.")); + } + if (automations.length === 0) { + lines.push(''); + lines.push(localize('automationsAccessibleView.templates', "Available templates")); + for (const template of AUTOMATION_TEMPLATES) { + lines.push(localize('automationsAccessibleView.template', "{0}, {1}. {2}", template.name, formatSchedule(template.schedule), template.prompt)); + } } lines.push(''); @@ -119,8 +146,7 @@ export function buildAutomationsAccessibleContent(automations: readonly IAutomat return lines.join('\n'); } -function formatSchedule(automation: IAutomationDescriptor): string { - const schedule = automation.schedule; +function formatSchedule(schedule: IAutomationSchedule): string { switch (schedule.interval) { case 'manual': return localize('automationsAccessibleView.manual', "Manual"); diff --git a/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts index b0c4944b46abb1..0449d55de98b7b 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts @@ -16,15 +16,16 @@ import { combinedDisposable, Disposable, DisposableMap, DisposableStore, IDispos import { autorun, constObservable, IObservable, IReader, ISettableObservable, observableSignalFromEvent, observableValue, transaction } from '../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; import { localize, localize2 } from '../../../../../nls.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; -import type { IAutomationDescriptor, IAutomationRun, AutomationTarget } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; -import { IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import type { IAutomationDescriptor, IAutomationRun, IAutomationSchedule, AutomationTarget } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; +import { type AutomationCatalogueState, IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { CHAT_AUTOMATIONS_ENABLED_SETTING, ChatAutomationsEnabledContext } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { IAutomationRunner } from '../../../../../workbench/contrib/chat/common/automations/automationRunner.js'; -import { IAutomationDialogService } from '../../../../../workbench/contrib/chat/common/automations/automationDialogService.js'; +import { type AutomationDialogCreateInitialValues, IAutomationDialogService } from '../../../../../workbench/contrib/chat/common/automations/automationDialogService.js'; import { DAYS_OF_WEEK } from '../../../../../workbench/contrib/chat/common/automations/schedule.js'; import { AgentSessionApprovalModel } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { basename } from '../../../../../base/common/resources.js'; @@ -54,6 +55,7 @@ import { AutomationsCustomViewFocusContext, AutomationsHasItemsContext, SessionI import { SessionsFlatList, SessionItemStatusContext } from './sessionsList.js'; import { AUTOMATIONS_CUSTOM_VIEW_ID } from '../automationsConstants.js'; import { ARCHIVE_SESSION_COMMAND_ID, MARK_SESSION_READ_COMMAND_ID, MARK_SESSION_UNREAD_COMMAND_ID, RENAME_SESSION_COMMAND_ID, UNARCHIVE_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.js'; +import { AUTOMATION_TEMPLATES, IAutomationTemplate } from './automationTemplates.js'; const $ = DOM.$; const STOP_AUTOMATION_RUN_SESSION_COMMAND_ID = 'sessions.automations.stopRunSession'; @@ -132,12 +134,13 @@ export class AutomationsCardsWidget extends Disposable { this._register(toDisposable(() => focusContext.reset())); const scrollContent = DOM.append(this.element, $('.automations-cards-scroll-content')); - this.cardsSection = this._register(instantiationService.createInstance(AutomationCardsSection, scrollContent)); + this.cardsSection = this._register(instantiationService.createInstance(AutomationCardsSection, scrollContent, this.element)); this.historySection = this._register(instantiationService.createInstance(AutomationHistorySection, scrollContent, this.element, this.isMarkingAllRead)); this._register(autorun(reader => { + const catalogueState = this.automationService.catalogueState.read(reader); const items = this.automationService.automations.read(reader); - this.cardsSection.render(items); + this.cardsSection.render(items, catalogueState); })); const sessionDeleted = observableSignalFromEvent(this, this.sessionsManagementService.onDidDeleteSession); @@ -187,12 +190,33 @@ class AutomationCardsSection extends Disposable { private readonly container: HTMLElement; private readonly emptyContainer: HTMLElement; + private readonly loadingContainer: HTMLElement; + private readonly unavailableContainer: HTMLElement; + private readonly errorContainer: HTMLElement; + private readonly templatesContainer: HTMLElement; + private readonly partialStateContainer: HTMLElement; + private readonly partialLoadingIcon: HTMLElement; + private readonly partialErrorIcon: HTMLElement; + private readonly partialStateMessage: HTMLElement; private readonly persistentCards = new Map(); private readonly latestAutomations = new Map(); private readonly emptyStateDisposables = this._register(new DisposableStore()); + private readonly stateDisposables = this._register(new DisposableStore()); + private readonly templateAriaId = generateUuid(); + private emptyStateRendered = false; + private templatesRendered = false; + private emptyCreateButton: IButton | undefined; + private readonly loadingCreateButton: IButton; + private readonly unavailableCreateButton: IButton; + private readonly errorCreateButton: IButton; + private visibleContainer: HTMLElement | undefined; + private partialState: AutomationCatalogueState = 'ready'; + private pendingFocusAutomationId: string | undefined; + private focusRequestGeneration = 0; constructor( parent: HTMLElement, + private readonly focusRoot: HTMLElement, @IAutomationService private readonly automationService: IAutomationService, @IAutomationRunner private readonly automationRunner: IAutomationRunner, @IAutomationDialogService private readonly automationDialogService: IAutomationDialogService, @@ -204,11 +228,44 @@ class AutomationCardsSection extends Disposable { @IContextMenuService private readonly contextMenuService: IContextMenuService, ) { super(); + this.partialStateContainer = DOM.append(parent, $('.automations-cards-partial-state')); + this.partialStateContainer.setAttribute('role', 'status'); + this.partialStateContainer.setAttribute('aria-live', 'polite'); + this.partialStateContainer.style.display = 'none'; + this.partialLoadingIcon = DOM.append(this.partialStateContainer, $('span.automations-cards-partial-state-icon')); + this.partialLoadingIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.loading), 'codicon-modifier-spin'); + this.partialLoadingIcon.setAttribute('aria-hidden', 'true'); + this.partialErrorIcon = DOM.append(this.partialStateContainer, $('span.automations-cards-partial-state-icon')); + this.partialErrorIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.warning)); + this.partialErrorIcon.setAttribute('aria-hidden', 'true'); + this.partialStateMessage = DOM.append(this.partialStateContainer, $('span.automations-cards-partial-state-message')); this.container = DOM.append(parent, $('.automations-cards-grid')); + this.container.style.display = 'none'; this.emptyContainer = DOM.append(parent, $('.automations-cards-empty')); this.emptyContainer.style.display = 'none'; - this.renderEmptyState(); + this.loadingContainer = DOM.append(parent, $('.automations-cards-state.automations-cards-loading')); + this.loadingContainer.style.display = 'none'; + this.loadingCreateButton = this.renderLoadingState(); + this.unavailableContainer = DOM.append(parent, $('.automations-cards-state.automations-cards-unavailable')); + this.unavailableContainer.style.display = 'none'; + this.unavailableCreateButton = this.renderUnavailableState(); + this.errorContainer = DOM.append(parent, $('.automations-cards-state.automations-cards-error')); + this.errorContainer.style.display = 'none'; + this.errorCreateButton = this.renderErrorState(); + this.templatesContainer = DOM.append(parent, $('.automations-templates')); + this.templatesContainer.style.display = 'none'; + const focusTracker = this._register(DOM.trackFocus(this.focusRoot)); + this._register(focusTracker.onDidBlur(() => this.clearPendingFocus())); + this._register(DOM.addDisposableListener(this.focusRoot, DOM.EventType.FOCUS_OUT, (event: FocusEvent) => { + if (event.relatedTarget && (!DOM.isHTMLElement(event.relatedTarget) || !this.focusRoot.contains(event.relatedTarget))) { + this.clearPendingFocus(); + } + })); + this._register(DOM.addDisposableListener(DOM.getWindow(this.focusRoot), DOM.EventType.BLUR, () => this.clearPendingFocus())); + this._register(DOM.addDisposableListener(this.focusRoot, DOM.EventType.MOUSE_DOWN, () => this.clearPendingFocus())); + this._register(DOM.addDisposableListener(this.focusRoot, DOM.EventType.KEY_DOWN, () => this.clearPendingFocus())); this._register(toDisposable(() => { + this.clearPendingFocus(); for (const card of this.persistentCards.values()) { card.disposables.dispose(); card.element.remove(); @@ -218,7 +275,11 @@ class AutomationCardsSection extends Disposable { })); } - render(automations: readonly IAutomationDescriptor[]): void { + render(automations: readonly IAutomationDescriptor[], catalogueState: AutomationCatalogueState): void { + const activeElement = DOM.getActiveElement(); + const contentOwnedFocus = DOM.isHTMLElement(activeElement) && ( + this.visibleContainer?.contains(activeElement) || this.templatesContainer.contains(activeElement) + ); const activeAutomationIds = new Set(automations.map(automation => automation.id)); for (const [automationId, card] of this.persistentCards) { if (activeAutomationIds.has(automationId)) { @@ -251,15 +312,53 @@ class AutomationCardsSection extends Disposable { index++; } - if (automations.length === 0) { - this.container.style.display = 'none'; - this.emptyContainer.style.display = ''; - return; + const showTemplates = automations.length === 0; + if (showTemplates) { + if (!this.templatesRendered) { + this.renderTemplates(); + this.templatesRendered = true; + } + this.templatesContainer.style.display = ''; } + const nextContainer = showTemplates ? this.getEmptyStateContainer(catalogueState) : this.container; + const previousContainer = this.visibleContainer; + if (previousContainer !== nextContainer) { + nextContainer.style.display = ''; + this.visibleContainer = nextContainer; + } + this.renderPartialState(automations.length > 0 ? catalogueState : 'ready'); + + // Move focus before hiding its previous container. + if (!this.focusPendingAutomation() && contentOwnedFocus && DOM.isHTMLElement(activeElement)) { + if (!nextContainer.contains(activeElement) && !(showTemplates && this.templatesContainer.contains(activeElement))) { + this.focusVisibleState(automations, catalogueState); + } else if (!DOM.isActiveElement(activeElement)) { + activeElement.focus(); + } + } + if (previousContainer && previousContainer !== nextContainer) { + previousContainer.style.display = 'none'; + } + if (!showTemplates) { + this.templatesContainer.style.display = 'none'; + } + } - this.container.style.display = ''; - this.emptyContainer.style.display = 'none'; - + private getEmptyStateContainer(catalogueState: AutomationCatalogueState): HTMLElement { + switch (catalogueState) { + case 'loading': + return this.loadingContainer; + case 'unavailable': + return this.unavailableContainer; + case 'error': + return this.errorContainer; + case 'ready': + if (!this.emptyStateRendered) { + this.renderEmptyState(); + this.emptyStateRendered = true; + } + return this.emptyContainer; + } } private renderCard(automation: IAutomationDescriptor): IAutomationCardEntry { @@ -376,8 +475,8 @@ class AutomationCardsSection extends Disposable { card.deleteButton.enabled = this.automationService.canDeleteAutomation?.(automation.id) !== false; card.canDeleteContext.set(this.automationService.canDeleteAutomation?.(automation.id) !== false); card.canDisableContext.set(automation.enabled && this.automationService.canUpdateAutomation?.(automation.id) !== false); - const schedule = formatSchedule(automation); - const scheduleChanged = !previous || formatSchedule(previous) !== schedule; + const schedule = formatSchedule(automation.schedule); + const scheduleChanged = !previous || formatSchedule(previous.schedule) !== schedule; const nameChanged = !previous || previous.name !== automation.name; if (nameChanged || scheduleChanged) { card.card.setAttribute('aria-label', localize('automationCard', "{0} — {1}", automation.name, schedule)); @@ -458,24 +557,144 @@ class AutomationCardsSection extends Disposable { ...defaultButtonStyles, title: localize('createAutomation', "Create Automation"), })); + this.emptyCreateButton = createButton; createButton.label = localize('createAutomation', "Create Automation"); createButton.element.classList.add('automations-cards-create-button'); this.emptyStateDisposables.add(createButton.onDidClick(() => this.openCreateDialog())); } - private async openCreateDialog(): Promise { - if (!await this.ensureEnabled()) { + private renderTemplates(): void { + const templatesTitle = DOM.append(this.templatesContainer, $('h3.automations-templates-title')); + templatesTitle.id = 'automations-templates-title'; + templatesTitle.textContent = localize('automationTemplatesTitle', "Start with a template"); + const templatesDescription = DOM.append(this.templatesContainer, $('p.automations-templates-description')); + templatesDescription.textContent = localize('automationTemplatesDescription', "Choose a starting point, then review and customize it before creating."); + const templatesGrid = DOM.append(this.templatesContainer, $('.automations-templates-grid')); + templatesGrid.setAttribute('role', 'group'); + templatesGrid.setAttribute('aria-labelledby', templatesTitle.id); + for (const template of AUTOMATION_TEMPLATES) { + this.renderTemplateCard(templatesGrid, template); + } + } + + private renderLoadingState(): IButton { + this.loadingContainer.setAttribute('role', 'status'); + const icon = DOM.append(this.loadingContainer, $('span.automations-cards-state-icon')); + icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.loading), 'codicon-modifier-spin'); + icon.setAttribute('aria-hidden', 'true'); + const title = DOM.append(this.loadingContainer, $('h3.automations-cards-state-title')); + title.textContent = localize('loadingAutomations', "Loading automations..."); + const description = DOM.append(this.loadingContainer, $('p.automations-cards-state-description')); + description.textContent = localize('loadingAutomationsDescription', "You can create a new automation while existing automations load."); + return this.renderStateCreateButton(this.loadingContainer); + } + + private renderUnavailableState(): IButton { + const icon = DOM.append(this.unavailableContainer, $('span.automations-cards-state-icon')); + icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.debugDisconnect)); + icon.setAttribute('aria-hidden', 'true'); + const title = DOM.append(this.unavailableContainer, $('h3.automations-cards-state-title')); + title.textContent = localize('automationsUnavailable', "Some automations are unavailable"); + const description = DOM.append(this.unavailableContainer, $('p.automations-cards-state-description')); + description.textContent = localize('automationsUnavailableDescription', "One or more providers are disconnected, disabled, or do not support automations."); + return this.renderStateCreateButton(this.unavailableContainer); + } + + private renderErrorState(): IButton { + this.errorContainer.setAttribute('role', 'alert'); + const icon = DOM.append(this.errorContainer, $('span.automations-cards-state-icon')); + icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.error)); + icon.setAttribute('aria-hidden', 'true'); + const title = DOM.append(this.errorContainer, $('h3.automations-cards-state-title')); + title.textContent = localize('automationsLoadError', "Unable to load automations"); + const description = DOM.append(this.errorContainer, $('p.automations-cards-state-description')); + description.textContent = localize('automationsLoadErrorDescription', "The complete automation catalogue could not be read."); + return this.renderStateCreateButton(this.errorContainer); + } + + private renderStateCreateButton(container: HTMLElement): IButton { + const createButton = this.stateDisposables.add(new Button(container, { + ...defaultButtonStyles, + title: localize('createAutomation', "Create Automation"), + })); + createButton.label = localize('createAutomation', "Create Automation"); + createButton.element.classList.add('automations-cards-state-create-button'); + this.stateDisposables.add(createButton.onDidClick(() => this.openCreateDialog())); + return createButton; + } + + private renderPartialState(catalogueState: AutomationCatalogueState): void { + if (this.partialState === catalogueState) { return; } - const result = await this.automationDialogService.showAutomationDialog({}); - if (!result || result.kind !== 'create') { + this.partialState = catalogueState; + if (catalogueState === 'ready') { + this.partialStateContainer.style.display = 'none'; return; } + const isError = catalogueState === 'error'; + const isLoading = catalogueState === 'loading'; + this.partialStateContainer.style.display = ''; + this.partialStateContainer.classList.toggle('automations-cards-partial-state-error', isError); + this.partialLoadingIcon.style.display = isLoading ? '' : 'none'; + this.partialErrorIcon.style.display = isLoading ? 'none' : ''; + this.partialStateMessage.textContent = isError + ? localize('automationsPartialLoadError', "Some automations could not be loaded.") + : catalogueState === 'unavailable' + ? localize('automationsPartialUnavailable', "Some automations are unavailable.") + : localize('automationsPartialLoading', "Loading additional automations..."); + } + + private renderTemplateCard(container: HTMLElement, template: IAutomationTemplate): void { + const card = DOM.append(container, $('button.automations-template-card', { type: 'button' })); + const schedule = formatSchedule(template.schedule); + card.setAttribute('aria-label', localize('useAutomationTemplate', "Use template: {0}, {1}", template.name, schedule)); + + const nameRow = DOM.append(card, $('.automations-template-card-name')); + const name = DOM.append(nameRow, $('span.automations-template-card-name-text')); + name.textContent = template.name; + this.emptyStateDisposables.add(this.hoverService.setupDelayedHover(name, { content: template.name })); + const badge = DOM.append(nameRow, $('span.automations-template-card-badge')); + badge.textContent = localize('automationTemplateBadge', "Template"); + badge.setAttribute('aria-hidden', 'true'); + + const scheduleElement = DOM.append(card, $('span.automations-template-card-schedule')); + scheduleElement.textContent = schedule; + const prompt = DOM.append(card, $('span.automations-template-card-prompt')); + prompt.id = `automations-template-${this.templateAriaId}-${template.id}-description`; + prompt.textContent = template.prompt; + this.emptyStateDisposables.add(this.hoverService.setupDelayedHover(prompt, { content: template.prompt })); + card.setAttribute('aria-describedby', prompt.id); + + this.emptyStateDisposables.add(DOM.addDisposableListener(card, DOM.EventType.CLICK, () => { + void this.openCreateDialog({ + name: template.name, + prompt: template.prompt, + schedule: template.schedule, + }); + })); + } + + private async openCreateDialog(initialValues?: AutomationDialogCreateInitialValues): Promise { + this.clearPendingFocus(); if (!await this.ensureEnabled()) { return; } try { + const result = await this.automationDialogService.showAutomationDialog(initialValues ? { initialValues } : {}); + if (!result || result.kind !== 'create' || this._store.isDisposed) { + return; + } + const restoreFocus = DOM.isAncestorOfActiveElement(this.focusRoot); + const focusRequestGeneration = this.focusRequestGeneration; + if (!await this.ensureEnabled()) { + return; + } const created = await this.automationService.createAutomation(result.value, () => this.throwIfDisabled()); + if (restoreFocus && focusRequestGeneration === this.focusRequestGeneration && !this._store.isDisposed && DOM.isAncestorOfActiveElement(this.focusRoot)) { + this.pendingFocusAutomationId = created.id; + this.focusPendingAutomation(); + } status(localize('automationCreatedStatus', "Created automation {0}", created.name)); } catch (err) { this.logService.error('[AutomationsCards] Failed to create automation', err); @@ -486,6 +705,48 @@ class AutomationCardsSection extends Disposable { } } + private clearPendingFocus(): void { + this.pendingFocusAutomationId = undefined; + this.focusRequestGeneration++; + } + + private focusPendingAutomation(): boolean { + if (!this.pendingFocusAutomationId) { + return false; + } + if (this._store.isDisposed || !DOM.isAncestorOfActiveElement(this.focusRoot)) { + this.clearPendingFocus(); + return false; + } + const card = this.persistentCards.get(this.pendingFocusAutomationId); + if (!card) { + return false; + } + this.pendingFocusAutomationId = undefined; + card.main.focus(); + return true; + } + + private focusVisibleState(automations: readonly IAutomationDescriptor[], catalogueState: AutomationCatalogueState): void { + if (automations.length > 0) { + (this.persistentCards.get(automations[0].id)?.main ?? this.focusRoot).focus(); + return; + } + switch (catalogueState) { + case 'loading': + this.loadingCreateButton.focus(); + return; + case 'unavailable': + this.unavailableCreateButton.focus(); + return; + case 'error': + this.errorCreateButton.focus(); + return; + case 'ready': + (this.emptyCreateButton?.element ?? this.focusRoot).focus(); + } + } + private async openEditDialog(automation: IAutomationDescriptor): Promise { if (!await this.ensureEnabled()) { return; @@ -963,14 +1224,14 @@ function isTemporaryAutomationRun(run: IAutomationRun): boolean { return run.status === 'pending' || run.status === 'running'; } -function formatSchedule(automation: IAutomationDescriptor): string { - const { interval, scheduleHour, scheduleMinute } = automation.schedule; +function formatSchedule(schedule: IAutomationSchedule): string { + const { interval, scheduleHour, scheduleMinute } = schedule; const time = formatHourMinute(scheduleHour, scheduleMinute); switch (interval) { case 'hourly': return localize('scheduleHourly', "Hourly"); case 'daily': return localize('scheduleDailyAt', "Daily at {0}", time); case 'weekly': { - const day = DAYS_OF_WEEK[((automation.schedule.scheduleDay % 7) + 7) % 7]; + const day = DAYS_OF_WEEK[((schedule.scheduleDay % 7) + 7) % 7]; return localize('scheduleWeeklyAt', "{0} at {1}", day, time); } case 'manual': return localize('scheduleManual', "Manual"); @@ -1358,9 +1619,13 @@ registerAction2(class DuplicateAutomationAction extends Action2 { prompt: automation.prompt, schedule: automation.schedule, target: automation.target, - modelId: automation.modelId, - mode: automation.mode, - permissionLevel: automation.permissionLevel, + ...(automation.sessionTemplate + ? { sessionTemplate: automation.sessionTemplate } + : { + modelId: automation.modelId, + mode: automation.mode, + permissionLevel: automation.permissionLevel, + }), enabled: automation.enabled, }, }); diff --git a/src/vs/sessions/contrib/sessions/electron-browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/electron-browser/sessions.contribution.ts index 09549eeb07ccc9..216186fa99efc2 100644 --- a/src/vs/sessions/contrib/sessions/electron-browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/electron-browser/sessions.contribution.ts @@ -5,10 +5,9 @@ import { localize } from '../../../../nls.js'; import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; -import product from '../../../../platform/product/common/product.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { SESSIONS_APPLICATION_BADGE_SETTING, SessionsApplicationBadge } from './sessionsApplicationBadge.js'; +import { SESSIONS_APPLICATION_BADGE_DEFAULT, SESSIONS_APPLICATION_BADGE_SETTING, SessionsApplicationBadge } from './sessionsApplicationBadge.js'; Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ id: 'sessions', @@ -17,7 +16,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis type: 'boolean', tags: ['preview'], description: localize('sessions.showApplicationBadge', "Controls whether the application icon shows a badge with the number of unarchived sessions that are unread and no longer in progress, need input, or are no longer in progress and have failing CI checks on an open, non-draft pull request. The badge appears on the dock icon on macOS, on the launcher icon on Linux and over the taskbar icon on Windows."), - default: product.quality !== 'stable', + default: SESSIONS_APPLICATION_BADGE_DEFAULT, experiment: { mode: 'auto' } }, }, diff --git a/src/vs/sessions/contrib/sessions/electron-browser/sessionsApplicationBadge.ts b/src/vs/sessions/contrib/sessions/electron-browser/sessionsApplicationBadge.ts index 8b08c5631acc32..31ee5d66c2c380 100644 --- a/src/vs/sessions/contrib/sessions/electron-browser/sessionsApplicationBadge.ts +++ b/src/vs/sessions/contrib/sessions/electron-browser/sessionsApplicationBadge.ts @@ -13,6 +13,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IApplicationBadge, INativeHostService } from '../../../../platform/native/common/native.js'; import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; +import product from '../../../../platform/product/common/product.js'; import { IColorTheme, IThemeService } from '../../../../platform/theme/common/themeService.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; import { ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND } from '../../../../workbench/common/theme.js'; @@ -21,6 +22,7 @@ import { ISessionsManagementService } from '../../../services/sessions/common/se import { BlockedSessions } from '../../blockedSessions/browser/blockedSessions.js'; export const SESSIONS_APPLICATION_BADGE_SETTING = 'sessions.showApplicationBadge'; +export const SESSIONS_APPLICATION_BADGE_DEFAULT = product.quality !== 'stable'; /** * Renders the number of unarchived sessions that need the user's attention: @@ -56,7 +58,7 @@ export class SessionsApplicationBadge extends Disposable implements IWorkbenchCo ) { super(); - this._enabled = observableConfigValue(SESSIONS_APPLICATION_BADGE_SETTING, false, this._configurationService); + this._enabled = observableConfigValue(SESSIONS_APPLICATION_BADGE_SETTING, SESSIONS_APPLICATION_BADGE_DEFAULT, this._configurationService); this._blockedSessions = this._register(instantiationService.createInstance(BlockedSessions)); this._sessions = observableFromEvent(this, this._sessionsManagementService.onDidChangeSessions, () => this._sessionsManagementService.getSessions()); diff --git a/src/vs/sessions/contrib/sessions/test/browser/automationsNewBadge.test.ts b/src/vs/sessions/contrib/sessions/test/browser/automationsNewBadge.test.ts index a95823f476db64..2046a48bc132c8 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/automationsNewBadge.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/automationsNewBadge.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DeferredPromise } from '../../../../../base/common/async.js'; import { Emitter } from '../../../../../base/common/event.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; @@ -13,10 +14,12 @@ import { TestConfigurationService } from '../../../../../platform/configuration/ import { NullLogService } from '../../../../../platform/log/common/log.js'; import { InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import type { IAutomationDescriptor, IAutomationRun } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; -import { IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { type AutomationCatalogueState, IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { IWorkbenchAssignmentService } from '../../../../../workbench/services/assignment/common/assignmentService.js'; +import { ILifecycleService, LifecyclePhase } from '../../../../../workbench/services/lifecycle/common/lifecycle.js'; import type { ICustomViewDescriptor } from '../../../../services/customView/browser/customView.js'; import { ICustomViewService } from '../../../../services/customView/browser/customViewService.js'; +import { ISessionsWindowUsageService } from '../../../../services/sessions/browser/sessionsWindowUsageService.js'; import { AUTOMATIONS_CUSTOM_VIEW_ID } from '../../browser/automationsConstants.js'; import { AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, AUTOMATIONS_NEW_BADGE_STYLE_SETTING, AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT, AutomationsNewBadgeState, type AutomationsNewBadgeStyle } from '../../browser/automationsNewBadge.js'; @@ -50,6 +53,9 @@ suite('AutomationsNewBadgeState', () => { readonly runs?: readonly IAutomationRun[]; readonly activeView?: ICustomViewDescriptor; readonly seen?: boolean; + readonly hadPriorWindowOpen?: boolean; + readonly catalogueState?: AutomationCatalogueState; + readonly eventuallyReady?: boolean; readonly style?: AutomationsNewBadgeStyle; readonly configuredStyle?: AutomationsNewBadgeStyle; readonly treatmentError?: Error; @@ -61,9 +67,11 @@ suite('AutomationsNewBadgeState', () => { const automations = observableValue(disposables, options.automations ?? []); const runs = observableValue(disposables, options.runs ?? []); const activeView = observableValue(disposables, options.activeView); + const catalogueState = observableValue(disposables, options.catalogueState ?? 'ready'); const automationService = new class extends mock() { override readonly automations = automations; override readonly runs = runs; + override readonly catalogueState = catalogueState; }; const customViewService = new class extends mock() { override readonly activeCustomView = activeView; @@ -74,6 +82,20 @@ suite('AutomationsNewBadgeState', () => { if (options.configuredStyle) { void configurationService.setUserConfiguration(AUTOMATIONS_NEW_BADGE_STYLE_SETTING, options.configuredStyle); } + const sessionsWindowUsageService = new class extends mock() { + override readonly hadPriorWindowOpen = options.hadPriorWindowOpen ?? true; + override readonly windowOpenCount = this.hadPriorWindowOpen ? 2 : 1; + }; + const eventually = new DeferredPromise(); + const lifecycleService = new class extends mock() { + override when(phase: LifecyclePhase): Promise { + assert.strictEqual(phase, LifecyclePhase.Eventually); + return eventually.p; + } + }; + if (options.eventuallyReady !== false) { + void eventually.complete(); + } const state = disposables.add(new AutomationsNewBadgeState( automationService, customViewService, @@ -81,38 +103,42 @@ suite('AutomationsNewBadgeState', () => { assignmentService, configurationService, new NullLogService(), + sessionsWindowUsageService, + lifecycleService, )); - return { state, storageService, automations, runs, activeView, assignmentService, configurationService, refetchAssignments }; + return { + state, + storageService, + automations, + runs, + activeView, + assignmentService, + configurationService, + refetchAssignments, + catalogueState, + completeEventually: () => eventually.complete(), + }; } - test('keeps the resolved style stable until Automations is activated', async () => { - const { state, storageService, automations, runs, activeView } = createState(); - - await state.initialize(); - automations.set([upcastPartial({ id: 'late-automation' })], undefined); - runs.set([upcastPartial({ id: 'late-run' })], undefined); - const beforeActivation = { - showNewBadge: state.showNewBadge.get(), - style: state.presentation.get(), - stored: storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), - }; + test('stays hidden on the first Agents window open without reading the treatment', async () => { + const fixture = createState({ hadPriorWindowOpen: false, style: 'accent' }); - activeView.set(upcastPartial({ id: AUTOMATIONS_CUSTOM_VIEW_ID }), undefined); - const afterActivation = { - showNewBadge: state.showNewBadge.get(), - stored: storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), - }; + await fixture.state.initialize(); + fixture.refetchAssignments.fire(); + await Promise.resolve(); - assert.deepStrictEqual({ beforeActivation, afterActivation }, { - beforeActivation: { showNewBadge: true, style: 'outline', stored: undefined }, - afterActivation: { - showNewBadge: false, - stored: 'true', - }, + assert.deepStrictEqual({ + showNewBadge: fixture.state.showNewBadge.get(), + stored: fixture.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + treatments: fixture.assignmentService.treatments, + }, { + showNewBadge: false, + stored: undefined, + treatments: [], }); }); - test('resolves accent, soft, and outline from the hidden treatment', async () => { + test('resolves accent, soft, and outline for eligible returning users', async () => { const snapshots = []; for (const style of ['accent', 'soft', 'outline'] as const) { const fixture = createState({ style }); @@ -130,20 +156,116 @@ suite('AutomationsNewBadgeState', () => { ]); }); - test('falls back to outline when treatment resolution fails', async () => { - const fixture = createState({ treatmentError: new Error('Unavailable') }); + test('never reveals after initial catalogue discovery is suppressed', async () => { + const snapshots = []; + for (const initialState of ['loading', 'unavailable', 'error'] as const) { + const fixture = createState({ catalogueState: initialState, style: 'accent' }); + await fixture.state.initialize(); + fixture.catalogueState.set('ready', undefined); + await fixture.configurationService.setUserConfiguration(AUTOMATIONS_NEW_BADGE_STYLE_SETTING, 'soft'); + fixture.configurationService.onDidChangeConfigurationEmitter.fire(upcastPartial({ + affectsConfiguration: key => key === AUTOMATIONS_NEW_BADGE_STYLE_SETTING, + })); + fixture.refetchAssignments.fire(); + await Promise.resolve(); + snapshots.push({ + initialState, + showNewBadge: fixture.state.showNewBadge.get(), + stored: fixture.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + treatments: fixture.assignmentService.treatments, + }); + } + + assert.deepStrictEqual(snapshots, [ + { initialState: 'loading', showNewBadge: false, stored: undefined, treatments: [] }, + { initialState: 'unavailable', showNewBadge: false, stored: undefined, treatments: [] }, + { initialState: 'error', showNewBadge: false, stored: undefined, treatments: [] }, + ]); + }); + + test('waits for the bounded startup phase before deciding eligibility', async () => { + const fixture = createState({ eventuallyReady: false, style: 'accent' }); + const initialization = fixture.state.initialize(); + await Promise.resolve(); + const beforeEventually = fixture.state.presentation.get(); + + await fixture.completeEventually(); + await initialization; + + assert.deepStrictEqual({ + beforeEventually, + afterEventually: fixture.state.presentation.get(), + }, { + beforeEventually: undefined, + afterEventually: 'accent', + }); + }); + + test('retires the badge when Automation evidence appears after presentation', async () => { + const fixture = createState(); await fixture.state.initialize(); + const beforeEvidence = fixture.state.presentation.get(); + + fixture.automations.set([upcastPartial({ id: 'late-automation' })], undefined); + fixture.automations.set([], undefined); assert.deepStrictEqual({ - style: fixture.state.presentation.get(), - treatments: fixture.assignmentService.treatments, + beforeEvidence, + afterEvidence: fixture.state.presentation.get(), + stored: fixture.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), }, { - style: 'outline', - treatments: [AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT], + beforeEvidence: 'outline', + afterEvidence: undefined, + stored: 'true', + }); + }); + + test('suppresses for the window when the aggregate catalogue starts loading after presentation', async () => { + const fixture = createState(); + await fixture.state.initialize(); + const beforeDiscoveryChange = fixture.state.presentation.get(); + + fixture.catalogueState.set('loading', undefined); + fixture.catalogueState.set('ready', undefined); + fixture.refetchAssignments.fire(); + await Promise.resolve(); + + assert.deepStrictEqual({ + beforeDiscoveryChange, + afterDiscoveryChange: fixture.state.presentation.get(), + stored: fixture.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + }, { + beforeDiscoveryChange: 'outline', + afterDiscoveryChange: undefined, + stored: undefined, }); }); + test('suppresses for the window when the aggregate catalogue becomes unavailable or errors', async () => { + const snapshots = []; + for (const catalogueState of ['unavailable', 'error'] as const) { + const fixture = createState(); + await fixture.state.initialize(); + const beforeDiscoveryChange = fixture.state.presentation.get(); + + fixture.catalogueState.set(catalogueState, undefined); + fixture.catalogueState.set('ready', undefined); + + snapshots.push({ + catalogueState, + beforeDiscoveryChange, + afterDiscoveryChange: fixture.state.presentation.get(), + stored: fixture.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + }); + } + + assert.deepStrictEqual(snapshots, [ + { catalogueState: 'unavailable', beforeDiscoveryChange: 'outline', afterDiscoveryChange: undefined, stored: undefined }, + { catalogueState: 'error', beforeDiscoveryChange: 'outline', afterDiscoveryChange: undefined, stored: undefined }, + ]); + }); + test('lets the hidden setting override and live-update the treatment', async () => { const fixture = createState({ style: 'outline', configuredStyle: 'soft' }); await fixture.state.initialize(); @@ -151,7 +273,7 @@ suite('AutomationsNewBadgeState', () => { await fixture.configurationService.setUserConfiguration(AUTOMATIONS_NEW_BADGE_STYLE_SETTING, 'accent'); fixture.configurationService.onDidChangeConfigurationEmitter.fire(upcastPartial({ - affectsConfiguration: (key: string) => key === AUTOMATIONS_NEW_BADGE_STYLE_SETTING, + affectsConfiguration: key => key === AUTOMATIONS_NEW_BADGE_STYLE_SETTING, })); assert.deepStrictEqual({ @@ -165,49 +287,51 @@ suite('AutomationsNewBadgeState', () => { }); }); - test('resets seen state for development even when prior Automation evidence exists', async () => { - const fixture = createState({ - automations: [upcastPartial({ id: 'existing-automation' })], - style: 'accent', - }); - await fixture.state.initialize(); + test('falls back to outline when treatment resolution fails', async () => { + const fixture = createState({ treatmentError: new Error('Unavailable') }); - await fixture.state.reset(); + await fixture.state.initialize(); assert.deepStrictEqual({ - showNewBadge: fixture.state.showNewBadge.get(), style: fixture.state.presentation.get(), - stored: fixture.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + treatments: fixture.assignmentService.treatments, }, { - showNewBadge: true, - style: 'accent', - stored: undefined, + style: 'outline', + treatments: [AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT], }); }); - test('suppresses the badge when synchronous Automation evidence exists', async () => { - const definition = createState({ + test('force preview bypasses first-use and Automation evidence until activation', async () => { + const fixture = createState({ + hadPriorWindowOpen: false, automations: [upcastPartial({ id: 'existing-automation' })], + style: 'accent', }); - const run = createState({ - runs: [upcastPartial({ id: 'existing-run' })], - }); + await fixture.state.initialize(); - await definition.state.initialize(); - await run.state.initialize(); + await fixture.state.reset(); + fixture.runs.set([upcastPartial({ id: 'running' })], undefined); + const preview = { + style: fixture.state.presentation.get(), + stored: fixture.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + }; + fixture.activeView.set(upcastPartial({ id: AUTOMATIONS_CUSTOM_VIEW_ID }), undefined); assert.deepStrictEqual({ - definition: { - showNewBadge: definition.state.showNewBadge.get(), - stored: definition.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), - }, - run: { - showNewBadge: run.state.showNewBadge.get(), - stored: run.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + preview, + afterActivation: { + showNewBadge: fixture.state.showNewBadge.get(), + stored: fixture.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), }, }, { - definition: { showNewBadge: false, stored: 'true' }, - run: { showNewBadge: false, stored: 'true' }, + preview: { + style: 'accent', + stored: undefined, + }, + afterActivation: { + showNewBadge: false, + stored: 'true', + }, }); }); diff --git a/src/vs/sessions/contrib/sessions/test/browser/automationsView.fixture.ts b/src/vs/sessions/contrib/sessions/test/browser/automationsView.fixture.ts index 34ba4bae456ee7..2d8c233d401df3 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/automationsView.fixture.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/automationsView.fixture.ts @@ -28,7 +28,7 @@ import { IAutomationDescriptor, IAutomationRun } from '../../../../../workbench/ import { IAutomationDialogService } from '../../../../../workbench/contrib/chat/common/automations/automationDialogService.js'; import { ChatAutomationsEnabledContext } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { IAutomationRunner } from '../../../../../workbench/contrib/chat/common/automations/automationRunner.js'; -import { IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { AutomationCatalogueState, IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IVoicePlaybackService } from '../../../../../workbench/contrib/chat/common/voicePlaybackService.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; @@ -79,11 +79,13 @@ class FixtureAutomationService extends mock() { override readonly automations: IObservable; override readonly runs: IObservable; + override readonly catalogueState: IObservable; - constructor(automations: readonly IAutomationDescriptor[], runs: readonly IAutomationRun[]) { + constructor(automations: readonly IAutomationDescriptor[], runs: readonly IAutomationRun[], catalogueState: AutomationCatalogueState) { super(); this.automations = constObservable(automations); this.runs = constObservable(runs); + this.catalogueState = constObservable(catalogueState); } override async deleteRun(): Promise { } @@ -161,6 +163,7 @@ interface IAutomationsFixtureOptions { readonly width: number; readonly height: number; readonly populated: boolean; + readonly catalogueState?: AutomationCatalogueState; } export default defineThemedFixtureGroup({ path: 'sessions/automations/' }, { @@ -170,8 +173,44 @@ export default defineThemedFixtureGroup({ path: 'sessions/automations/' }, { }), Empty: defineComponentFixture({ labels: { kind: 'screenshot' }, + additionalThemes: ['darkHighContrast'], render: ctx => renderAutomations(ctx, { width: 1000, height: 520, populated: false }), }), + NarrowEmpty: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAutomations(ctx, { width: 520, height: 620, populated: false }), + }), + ShortEmpty: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAutomations(ctx, { width: 1000, height: 360, populated: false }), + }), + Loading: defineComponentFixture({ + render: ctx => renderAutomations(ctx, { width: 1000, height: 620, populated: false, catalogueState: 'loading' }), + }), + Unavailable: defineComponentFixture({ + labels: { kind: 'screenshot' }, + additionalThemes: ['darkHighContrast'], + render: ctx => renderAutomations(ctx, { width: 1000, height: 620, populated: false, catalogueState: 'unavailable' }), + }), + NarrowUnavailable: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAutomations(ctx, { width: 520, height: 620, populated: false, catalogueState: 'unavailable' }), + }), + PartialLoading: defineComponentFixture({ + render: ctx => renderAutomations(ctx, { width: 1000, height: 720, populated: true, catalogueState: 'loading' }), + }), + PartialUnavailable: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAutomations(ctx, { width: 1000, height: 720, populated: true, catalogueState: 'unavailable' }), + }), + PartialError: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAutomations(ctx, { width: 1000, height: 720, populated: true, catalogueState: 'error' }), + }), + Error: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAutomations(ctx, { width: 1000, height: 620, populated: false, catalogueState: 'error' }), + }), Narrow: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderAutomations(ctx, { width: 520, height: 720, populated: true }), @@ -186,7 +225,7 @@ function renderAutomations(ctx: ComponentFixtureContext, options: IAutomationsFi const contextKeyService = new ContextKeyService(configurationService); const actionViewItemService = new FixtureActionViewItemService(); const customViewService = ctx.disposableStore.add(new CustomViewService(new NullLogService(), ctx.disposableStore.add(new InMemoryStorageService()))); - const automationService = new FixtureAutomationService(data.automations, data.runs); + const automationService = new FixtureAutomationService(data.automations, data.runs, options.catalogueState ?? 'ready'); const sessionsManagementService = new FixtureSessionsManagementService(data.runs); ChatAutomationsEnabledContext.bindTo(contextKeyService).set(true); diff --git a/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts b/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts index 58d477c0ce981e..75a22b91b76cdc 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { IContextMenuDelegate } from '../../../../../base/browser/contextmenu.js'; import { ModifierKeyEmitter } from '../../../../../base/browser/dom.js'; import { GestureEvent, EventType as TouchEventType } from '../../../../../base/browser/touch.js'; +import type { IDelayedHoverOptions } from '../../../../../base/browser/ui/hover/hover.js'; import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; @@ -17,6 +18,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { AccessibleViewRegistry } from '../../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { TestAccessibilityService } from '../../../../../platform/accessibility/test/common/testAccessibilityService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -37,8 +39,9 @@ import { IAutomationDescriptor, IAutomationRun, IAutomationSchedule, AutomationR import { IAutomationDialogResult, IAutomationDialogService, IShowAutomationDialogOptions } from '../../../../../workbench/contrib/chat/common/automations/automationDialogService.js'; import { ChatAutomationsEnabledContext } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { IAutomationRunDispatch, IAutomationRunner, IAutomationRunOperation } from '../../../../../workbench/contrib/chat/common/automations/automationRunner.js'; -import { AutomationMutationGuard, IAutomationRunClaim, IAutomationService, ICreateAutomationOptions, IGuardedAutomationUpdateResult, IUpdateAutomationOptions, IUpdateAutomationRunOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { AutomationCatalogueState, AutomationMutationGuard, IAutomationRunClaim, IAutomationService, ICreateAutomationOptions, IGuardedAutomationUpdateResult, IUpdateAutomationOptions, IUpdateAutomationRunOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { ICustomViewDescriptor } from '../../../../services/customView/browser/customView.js'; +import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; import { IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; @@ -46,6 +49,7 @@ import { IActionViewItemService } from '../../../../../platform/actions/browser/ import { ICustomViewService } from '../../../../services/customView/browser/customViewService.js'; import { AutomationsHasItemsContext } from '../../../../common/contextkeys.js'; import { buildAutomationsAccessibleContent } from '../../browser/views/automationsAccessibility.js'; +import { AUTOMATION_TEMPLATES } from '../../browser/views/automationTemplates.js'; import { AutomationsCardsWidget, AutomationsCustomViewContribution } from '../../browser/views/automationsView.js'; import { workbenchInstantiationService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; import { ISessionsListModelService } from '../../../../services/sessions/browser/sessionsListModelService.js'; @@ -108,6 +112,14 @@ function dispatchKeydown(element: HTMLElement, init: KeyboardEventInit & { keyCo element.dispatchEvent(event); } +function moveFocus(from: HTMLElement, to: HTMLElement): void { + to.focus(); + // The hidden Electron runner updates activeElement without emitting native focus events. + if (!to.ownerDocument.hasFocus()) { + from.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: to })); + } +} + async function waitForSessionActions(): Promise { await timeout(100); } @@ -115,8 +127,10 @@ async function waitForSessionActions(): Promise { class FakeAutomationService extends mock() { private readonly automationValue = observableValue(this, []); private readonly runValue = observableValue(this, []); + private readonly catalogueStateValue = observableValue(this, 'loading'); override readonly automations: IObservable = this.automationValue; override readonly runs: IObservable = this.runValue; + override readonly catalogueState: IObservable = this.catalogueStateValue; updateResult: IGuardedAutomationUpdateResult | undefined; updateCalls = 0; deleteRunCalls = 0; @@ -124,6 +138,9 @@ class FakeAutomationService extends mock() { deleteError: Error | undefined; canDelete = true; canUpdate = true; + publishCreates = true; + createdAutomation: IAutomationDescriptor | undefined; + beforeCreate: (() => Promise) | undefined; readonly createCalls: ICreateAutomationOptions[] = []; readonly deleteCalls: string[] = []; readonly guardedUpdateCalls: { id: string; patch: IUpdateAutomationOptions; expected: IAutomationDescriptor }[] = []; @@ -137,6 +154,10 @@ class FakeAutomationService extends mock() { this.runValue.set(value, undefined); } + setCatalogueState(value: AutomationCatalogueState): void { + this.catalogueStateValue.set(value, undefined); + } + override getAutomation(id: string): IAutomationDescriptor | undefined { return this.automationValue.get().find(item => item.id === id); } @@ -148,6 +169,7 @@ class FakeAutomationService extends mock() { override async createAutomation(options: ICreateAutomationOptions, mutationGuard?: AutomationMutationGuard): Promise { mutationGuard?.(); this.createCalls.push(options); + await this.beforeCreate?.(); if (this.createError) { throw this.createError; } @@ -157,12 +179,16 @@ class FakeAutomationService extends mock() { prompt: options.prompt, schedule: options.schedule, target: options.target, + sessionTemplate: options.sessionTemplate, modelId: options.modelId ?? undefined, mode: options.mode ?? undefined, permissionLevel: options.permissionLevel ?? undefined, enabled: options.enabled ?? true, }); - this.setAutomations([created, ...this.automationValue.get()]); + this.createdAutomation = created; + if (this.publishCreates) { + this.setAutomations([created, ...this.automationValue.get()]); + } return created; } @@ -543,7 +569,7 @@ suite('AutomationsCardsWidget', () => { return !!button && button.style.display !== 'none'; } - function setup(archiveWording: 'archive' | 'done' = 'archive') { + function setup(archiveWording: 'archive' | 'done' = 'archive', hoverService: IHoverService = NullHoverService) { const automationService = new FakeAutomationService(); const automationDialogService = new FakeAutomationDialogService(); const contextMenuService = new TestContextMenuService(); @@ -575,7 +601,7 @@ suite('AutomationsCardsWidget', () => { ChatAutomationsEnabledContext.bindTo(contextKeyService).set(true); instantiationService.stub(IContextKeyService, contextKeyService); instantiationService.stub(IKeybindingService, keybindingService); - instantiationService.stub(IHoverService, NullHoverService); + instantiationService.stub(IHoverService, hoverService); instantiationService.stub(ILogService, logService); instantiationService.stub(ISessionsListModelService, new class extends mock() { override readonly onDidChange = Event.None; @@ -817,6 +843,7 @@ suite('AutomationsCardsWidget', () => { test('empty state is rendered once across repeated empty updates', () => { const { automationService, widget } = setup(); + automationService.setCatalogueState('ready'); automationService.setAutomations([]); automationService.setAutomations([]); @@ -824,10 +851,410 @@ suite('AutomationsCardsWidget', () => { titles: widget.element.querySelectorAll('.automations-cards-empty-title').length, descriptions: widget.element.querySelectorAll('.automations-cards-empty-description').length, buttons: widget.element.querySelectorAll('.automations-cards-create-button').length, + templateSections: widget.element.querySelectorAll('.automations-templates').length, + templateCards: widget.element.querySelectorAll('.automations-template-card').length, }, { titles: 1, descriptions: 1, buttons: 1, + templateSections: 1, + templateCards: 4, + }); + }); + + test('keeps templates available while distinguishing incomplete catalogues from confirmed empty', () => { + const { automationService, widget } = setup(); + + const loadingState = { + loading: widget.element.querySelector('.automations-cards-loading')?.style.display, + error: widget.element.querySelector('.automations-cards-error')?.style.display, + createButton: widget.element.querySelector('.automations-cards-loading .automations-cards-state-create-button')?.textContent, + templates: widget.element.querySelectorAll('.automations-template-card').length, + }; + automationService.setCatalogueState('error'); + const errorState = { + loading: widget.element.querySelector('.automations-cards-loading')?.style.display, + error: widget.element.querySelector('.automations-cards-error')?.style.display, + createButton: widget.element.querySelector('.automations-cards-error .automations-cards-state-create-button')?.textContent, + description: widget.element.querySelector('.automations-cards-error .automations-cards-state-description')?.textContent, + templates: widget.element.querySelectorAll('.automations-template-card').length, + }; + automationService.setCatalogueState('unavailable'); + const unavailableState = { + loading: widget.element.querySelector('.automations-cards-loading')?.style.display, + unavailable: widget.element.querySelector('.automations-cards-unavailable')?.style.display, + error: widget.element.querySelector('.automations-cards-error')?.style.display, + createButton: widget.element.querySelector('.automations-cards-unavailable .automations-cards-state-create-button')?.textContent, + templates: widget.element.querySelectorAll('.automations-template-card').length, + }; + automationService.setCatalogueState('ready'); + const readyState = { + loading: widget.element.querySelector('.automations-cards-loading')?.style.display, + error: widget.element.querySelector('.automations-cards-error')?.style.display, + templates: widget.element.querySelectorAll('.automations-template-card').length, + }; + + assert.deepStrictEqual({ loadingState, errorState, unavailableState, readyState }, { + loadingState: { loading: '', error: 'none', createButton: 'Create Automation', templates: 4 }, + errorState: { loading: 'none', error: '', createButton: 'Create Automation', description: 'The complete automation catalogue could not be read.', templates: 4 }, + unavailableState: { loading: 'none', unavailable: '', error: 'none', createButton: 'Create Automation', templates: 4 }, + readyState: { loading: 'none', error: 'none', templates: 4 }, + }); + }); + + test('surfaces partial catalogue states with saved automations', () => { + const { automationService, widget } = setup(); + automationService.setAutomations([automation()]); + const loadingMessage = widget.element.querySelector('.automations-cards-partial-state')?.textContent; + automationService.setCatalogueState('unavailable'); + const unavailableMessage = widget.element.querySelector('.automations-cards-partial-state')?.textContent; + automationService.setCatalogueState('error'); + + assert.deepStrictEqual({ + loadingMessage, + unavailableMessage, + errorMessage: widget.element.querySelector('.automations-cards-partial-state')?.textContent, + savedCards: widget.element.querySelectorAll('.automations-card').length, + templatesDisplay: widget.element.querySelector('.automations-templates')?.style.display, + }, { + loadingMessage: 'Loading additional automations...', + unavailableMessage: 'Some automations are unavailable.', + errorMessage: 'Some automations could not be loaded.', + savedCards: 1, + templatesDisplay: 'none', + }); + }); + + test('create remains available after a catalogue error', async () => { + const { automationDialogService, automationService, widget } = setup(); + automationService.setCatalogueState('error'); + + widget.element.querySelector('.automations-cards-error .automations-cards-state-create-button')?.click(); + await Promise.resolve(); + + assert.strictEqual(automationDialogService.showCalls, 1); + }); + + test('hides templates when saved automations become available', () => { + const { automationService, widget } = setup(); + + automationService.setAutomations([automation()]); + automationService.setCatalogueState('ready'); + + assert.deepStrictEqual({ + savedCards: widget.element.querySelectorAll('.automations-card').length, + templatesDisplay: widget.element.querySelector('.automations-templates')?.style.display, + }, { + savedCards: 1, + templatesDisplay: 'none', + }); + }); + + test('template opens create dialog with target-less initial values', async () => { + const { automationDialogService, automationService, widget } = setup(); + automationService.setCatalogueState('ready'); + + const templateCard = widget.element.querySelector('.automations-template-card'); + const describedBy = templateCard?.getAttribute('aria-describedby'); + templateCard?.click(); + await Promise.resolve(); + + assert.deepStrictEqual({ + dialogOptions: automationDialogService.lastOptions, + accessibleDescription: describedBy ? widget.element.querySelector(`#${describedBy}`)?.textContent : undefined, + }, { + dialogOptions: { + initialValues: { + name: 'Issue triage', + prompt: 'Review new issues, group duplicates, and suggest labels.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 0, scheduleDay: 0 }, + }, + }, + accessibleDescription: 'Review new issues, group duplicates, and suggest labels.', + }); + }); + + test('template hovers expose full text once and are disposed with the widget', () => { + const hovers: { target: HTMLElement; content: IDelayedHoverOptions['content']; disposed: boolean }[] = []; + const hoverService: IHoverService = { + ...NullHoverService, + setupDelayedHover: (target, options) => { + const entry = { target, content: (typeof options === 'function' ? options() : options).content, disposed: false }; + hovers.push(entry); + return toDisposable(() => entry.disposed = true); + }, + }; + const { automationService, widget } = setup('archive', hoverService); + const beforeReady = hovers.length; + automationService.setCatalogueState('ready'); + automationService.setAutomations([]); + automationService.setCatalogueState('error'); + automationService.setCatalogueState('ready'); + const contents = hovers.map(hover => ({ target: hover.target.className, content: hover.content })); + widget.dispose(); + + assert.deepStrictEqual({ + beforeReady, + contents, + allDisposed: hovers.every(hover => hover.disposed), + }, { + beforeReady: 8, + contents: AUTOMATION_TEMPLATES.flatMap(template => [ + { target: 'automations-template-card-name-text', content: template.name }, + { target: 'automations-template-card-prompt', content: template.prompt }, + ]), + allDisposed: true, + }); + }); + + test('template creation focuses the newly created automation card', async () => { + const { automationDialogService, automationService, widget } = setup(); + const submitted: ICreateAutomationOptions = { + name: 'Customized issue triage', + prompt: 'Review issues assigned to this project.', + schedule: { interval: 'weekly', scheduleHour: 10, scheduleMinute: 30, scheduleDay: 2 }, + target: { kind: 'quickChat', providerId: 'provider', sessionTypeId: 'agent' }, + enabled: true, + }; + automationDialogService.result = { kind: 'create', value: submitted }; + automationService.setCatalogueState('ready'); + + const templateCard = widget.element.querySelector('.automations-template-card'); + templateCard?.focus(); + templateCard?.click(); + await timeout(0); + + assert.deepStrictEqual({ + createCalls: automationService.createCalls, + activeElementLabel: document.activeElement?.getAttribute('aria-label'), + templateVisible: widget.element.querySelector('.automations-templates')?.style.display, + }, { + createCalls: [submitted], + activeElementLabel: 'Edit automation Customized issue triage', + templateVisible: 'none', + }); + }); + + for (const catalogueState of ['loading', 'unavailable', 'error'] as const) { + test(`creates from a template without requiring a complete ${catalogueState} catalogue`, async () => { + const { automationDialogService, automationService, widget } = setup(); + automationService.setCatalogueState(catalogueState); + const submitted: ICreateAutomationOptions = { + name: 'Local review', + prompt: 'Review the local workspace.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, + }; + automationDialogService.result = { kind: 'create', value: submitted }; + const templates = widget.element.querySelector('.automations-templates'); + const template = widget.element.querySelector('.automations-template-card'); + assert.ok(templates && template); + const beforeCreate = { + templatesVisible: templates.style.display === '', + emptyClaimVisible: widget.element.querySelector('.automations-cards-empty')?.style.display === '', + catalogueStatusVisible: widget.element.querySelector(`.automations-cards-${catalogueState}`)?.style.display === '', + }; + template.focus(); + template.click(); + await timeout(0); + + assert.deepStrictEqual({ + beforeCreate, + createCalls: automationService.createCalls, + catalogueState: automationService.catalogueState.get(), + templateDisplay: templates.style.display, + cardLabel: widget.element.querySelector('.automations-card-main')?.getAttribute('aria-label'), + warningVisible: widget.element.querySelector('.automations-cards-partial-state')?.style.display === '', + }, { + beforeCreate: { templatesVisible: true, emptyClaimVisible: false, catalogueStatusVisible: true }, + createCalls: [submitted], + catalogueState, + templateDisplay: 'none', + cardLabel: 'Edit automation Local review', + warningVisible: true, + }); + }); + } + + test('catalogue status changes preserve the focused template', () => { + const { automationService, widget } = setup(); + const template = widget.element.querySelector('.automations-template-card'); + assert.ok(template); + template.focus(); + const states: readonly AutomationCatalogueState[] = ['unavailable', 'error', 'ready', 'loading']; + const focusedStates = states.map(state => { + automationService.setCatalogueState(state); + return document.activeElement === template; + }); + + assert.deepStrictEqual(focusedStates, [true, true, true, true]); + }); + + test('state transitions preserve focus within the Automations view', () => { + const { automationService, widget } = setup(); + const loadingCreate = widget.element.querySelector('.automations-cards-loading .automations-cards-state-create-button'); + assert.ok(loadingCreate); + loadingCreate.focus(); + + const stateFocus = (['error', 'unavailable', 'ready'] as const).map(state => { + automationService.setCatalogueState(state); + const selector = state === 'ready' ? '.automations-cards-empty .automations-cards-create-button' : `.automations-cards-${state} .automations-cards-state-create-button`; + return widget.element.querySelector(selector) === document.activeElement; + }); + const template = widget.element.querySelector('.automations-template-card'); + assert.ok(template); + template.focus(); + automationService.setAutomations([automation()]); + const afterPopulated = document.activeElement?.getAttribute('aria-label'); + automationService.setCatalogueState('unavailable'); + automationService.setAutomations([]); + + assert.deepStrictEqual({ + stateFocus, + afterPopulated, + afterRemoval: widget.element.querySelector('.automations-cards-unavailable .automations-cards-state-create-button') === document.activeElement, + }, { + stateFocus: [true, true, true], + afterPopulated: 'Edit automation Daily review', + afterRemoval: true, + }); + }); + + test('repeated empty updates retain the focused template', () => { + const { automationService, widget } = setup(); + automationService.setCatalogueState('ready'); + const template = widget.element.querySelector('.automations-template-card'); + assert.ok(template); + template.focus(); + automationService.setAutomations([]); + automationService.setAutomations([]); + + assert.strictEqual(document.activeElement, template); + }); + + test('state changes do not move focus from another view', () => { + const { automationService, widget } = setup(); + const outside = document.createElement('button'); + document.body.append(outside); + disposables.add(toDisposable(() => outside.remove())); + outside.focus(); + automationService.setCatalogueState('unavailable'); + automationService.setCatalogueState('error'); + automationService.setCatalogueState('ready'); + automationService.setAutomations([automation()]); + + assert.deepStrictEqual({ + focusUnchanged: document.activeElement === outside, + cards: widget.element.querySelectorAll('.automations-card-main').length, + }, { focusUnchanged: true, cards: 1 }); + }); + + for (const focusAction of ['stay', 'leave-and-return', 'navigate'] as const) { + test(`delayed creation respects focus ownership: ${focusAction}`, async () => { + const { automationDialogService, automationService, widget } = setup(); + automationService.publishCreates = false; + automationDialogService.result = { + kind: 'create', + value: { + name: 'Delayed automation', + prompt: 'Publish later.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'provider', sessionTypeId: 'agent' }, + }, + }; + automationService.setCatalogueState('ready'); + const template = widget.element.querySelector('.automations-template-card'); + assert.ok(template); + template.focus(); + template.click(); + await timeout(0); + + if (focusAction === 'leave-and-return') { + const outside = document.createElement('button'); + document.body.append(outside); + disposables.add(toDisposable(() => outside.remove())); + moveFocus(template, outside); + await timeout(0); + widget.focus(); + } else if (focusAction === 'navigate') { + dispatchKeydown(template, { key: 'Tab', code: 'Tab', keyCode: 9 }); + widget.focus(); + } + const created = automationService.createdAutomation; + assert.ok(created); + automationService.setAutomations([automation(), created]); + + assert.strictEqual( + document.activeElement, + focusAction === 'stay' + ? widget.element.querySelector('[aria-label="Edit automation Delayed automation"]') + : widget.element, + ); + }); + } + + test('a pending create cannot re-arm focus after the user leaves and returns', async () => { + const { automationDialogService, automationService, widget } = setup(); + const createStarted = new DeferredPromise(); + const completeCreate = new DeferredPromise(); + automationService.beforeCreate = async () => { + await createStarted.complete(); + await completeCreate.p; + }; + automationService.publishCreates = false; + automationDialogService.result = { + kind: 'create', + value: { + name: 'Slow create', + prompt: 'Finish later.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'provider', sessionTypeId: 'agent' }, + }, + }; + automationService.setCatalogueState('ready'); + const template = widget.element.querySelector('.automations-template-card'); + assert.ok(template); + template.focus(); + template.click(); + await createStarted.p; + const outside = document.createElement('button'); + document.body.append(outside); + disposables.add(toDisposable(() => outside.remove())); + moveFocus(template, outside); + await timeout(0); + widget.focus(); + await completeCreate.complete(); + await timeout(0); + const created = automationService.createdAutomation; + assert.ok(created); + automationService.setAutomations([created]); + + assert.strictEqual(document.activeElement, widget.element); + }); + + test('template creation honors automations being disabled while the dialog is open', async () => { + const { automationDialogService, automationService, configurationService, dialogService, widget } = setup(); + automationDialogService.result = { + kind: 'create', + value: { + name: 'Issue triage', + prompt: 'Review issues.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'provider', sessionTypeId: 'agent' }, + }, + }; + automationDialogService.beforeReturn = () => configurationService.setUserConfiguration('chat.automations.enabled', false); + automationService.setCatalogueState('ready'); + + widget.element.querySelector('.automations-template-card')?.click(); + await dialogService.infoCalled.p; + + assert.deepStrictEqual({ + info: dialogService.infos, + createCalls: automationService.createCalls, + }, { + info: ['Automations are disabled.'], + createCalls: [], }); }); @@ -861,9 +1288,15 @@ suite('AutomationsCardsWidget', () => { prompt: 'Review all open issues', schedule: { interval: 'weekly', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 1 }, target: { kind: 'quickChat', providerId: 'provider', sessionTypeId: 'agent' }, - modelId: 'model', - mode: 'agent', - permissionLevel: 'autopilot', + sessionTemplate: { + modelId: 'model', + agent: { uri: 'file:///agents/reviewer.agent.md' }, + config: { + mode: 'agent', + autoApprove: 'autopilot', + providerOption: true, + }, + }, enabled: false, }); automationService.setAutomations([source]); @@ -902,9 +1335,7 @@ suite('AutomationsCardsWidget', () => { prompt: 'Review all open issues', schedule: source.schedule, target: source.target, - modelId: 'model', - mode: 'agent', - permissionLevel: 'autopilot', + sessionTemplate: source.sessionTemplate, enabled: false, }, }, @@ -946,6 +1377,33 @@ suite('AutomationsCardsWidget', () => { }); }); + test('duplicate preserves legacy flat configuration when no session template exists', async () => { + const { automationDialogService, automationService, instantiationService } = setup(); + const source = automation({ + name: 'Legacy review', + sessionTemplate: undefined, + modelId: 'legacy-model', + mode: 'ask', + permissionLevel: 'autopilot', + }); + automationService.setAutomations([source]); + const command = CommandsRegistry.getCommand('sessions.automations.duplicate'); + assert.ok(command); + + await instantiationService.invokeFunction(accessor => command.handler(accessor, source)); + + assert.deepStrictEqual(automationDialogService.lastOptions?.initialValues, { + name: 'Legacy review Copy', + prompt: source.prompt, + schedule: source.schedule, + target: source.target, + modelId: 'legacy-model', + mode: 'ask', + permissionLevel: 'autopilot', + enabled: source.enabled, + }); + }); + test('duplicate dialog failures are logged and reported to the user', async () => { const { automationDialogService, automationService, dialogService, instantiationService, logService } = setup(); const source = automation(); @@ -1754,11 +2212,88 @@ suite('AutomationsCardsWidget', () => { test('accessible view includes automation and run content', () => { assert.strictEqual( - buildAutomationsAccessibleContent([automation()], [run({ status: 'failed', errorMessage: 'boom' })]).includes('Daily review, Failed'), + buildAutomationsAccessibleContent([automation()], [run({ status: 'failed', errorMessage: 'boom' })], 'ready').includes('Daily review, Failed'), true, ); }); + test('accessible view includes templates when there are no automations', () => { + assert.strictEqual( + buildAutomationsAccessibleContent([], [], 'ready').includes('Issue triage, Daily at 9:00 AM. Review new issues, group duplicates, and suggest labels.'), + true, + ); + }); + + test('accessibility help describes visible templates independently of catalogue completeness', () => { + const { automationService, instantiationService } = setup(); + instantiationService.stub(IAgentWorkbenchLayoutService, new class extends mock() { }); + const help = AccessibleViewRegistry.getImplementations().find(implementation => implementation.name === 'sessions-automations-help'); + assert.ok(help); + const describesTemplates = () => { + const provider = instantiationService.invokeFunction(accessor => help.getProvider(accessor)); + assert.ok(provider); + disposables.add(provider); + return provider.provideContent().includes('Tab to a template'); + }; + const states: readonly AutomationCatalogueState[] = ['loading', 'unavailable', 'error', 'ready']; + const emptyCatalogueHelp = states.map(state => { + automationService.setCatalogueState(state); + return describesTemplates(); + }); + automationService.setAutomations([automation()]); + + assert.deepStrictEqual({ + emptyCatalogueHelp, + populatedCatalogueHelp: describesTemplates(), + }, { + emptyCatalogueHelp: [true, true, true, true], + populatedCatalogueHelp: false, + }); + }); + + test('accessible view distinguishes loading, unavailable, and error from confirmed empty', () => { + assert.deepStrictEqual({ + loading: buildAutomationsAccessibleContent([], [], 'loading').split('\n').slice(0, 2), + unavailable: buildAutomationsAccessibleContent([], [], 'unavailable').split('\n').slice(0, 2), + error: buildAutomationsAccessibleContent([], [], 'error').split('\n').slice(0, 2), + }, { + loading: ['Automations', 'Loading automations.'], + unavailable: ['Automations', 'Some automations are unavailable. One or more providers are disconnected, disabled, or do not support automations.'], + error: ['Automations', 'Unable to load automations.'], + }); + }); + + test('accessible view offers templates without claiming an incomplete catalogue is empty', () => { + const states: readonly AutomationCatalogueState[] = ['loading', 'unavailable', 'error', 'ready']; + const contents = states.map(state => { + const content = buildAutomationsAccessibleContent([], [], state); + return { + state, + claimsEmpty: content.includes('No automations.'), + templatesIncluded: AUTOMATION_TEMPLATES.every(template => content.includes(template.name)), + }; + }); + + assert.deepStrictEqual(contents, [ + { state: 'loading', claimsEmpty: false, templatesIncluded: true }, + { state: 'unavailable', claimsEmpty: false, templatesIncluded: true }, + { state: 'error', claimsEmpty: false, templatesIncluded: true }, + { state: 'ready', claimsEmpty: true, templatesIncluded: true }, + ]); + }); + + test('accessible view reports partial catalogue state with saved automations', () => { + assert.deepStrictEqual({ + loading: buildAutomationsAccessibleContent([automation()], [], 'loading').split('\n').slice(0, 2), + unavailable: buildAutomationsAccessibleContent([automation()], [], 'unavailable').split('\n').slice(0, 2), + error: buildAutomationsAccessibleContent([automation()], [], 'error').split('\n').slice(0, 2), + }, { + loading: ['Automations', 'Additional automations are loading.'], + unavailable: ['Automations', 'Some automations are unavailable.'], + error: ['Automations', 'Some automations could not be loaded.'], + }); + }); + test('running run shows needs-input indicator when session status transitions to NeedsInput', async () => { const { automationService, sessionsManagementService, widget } = setup(); sessionsManagementService.sessionStatus.set(SessionStatus.InProgress, undefined); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsLifecycleTracker.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsLifecycleTracker.test.ts index 95005e25a49172..c33384b044fe5c 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsLifecycleTracker.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsLifecycleTracker.test.ts @@ -10,6 +10,7 @@ import { constObservable, IObservable, observableValue } from '../../../../../ba import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { SessionsWindowUsageService } from '../../../../services/sessions/browser/sessionsWindowUsageService.js'; import { IChat, IGitHubInfo, IGitHubPullRequestRef, ISession, ISessionChangesSummary, ISessionFileChange, ISessionFolder, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; import { computePullRequestIcon, GitHubPullRequestState } from '../../../github/common/types.js'; import { MAX_TRACKED_SESSIONS, MAX_TYPED_FILES_PER_SESSION, SESSIONS_KEY, SessionsLifecycleTracker } from '../../browser/sessionsLifecycleTracker.js'; @@ -99,9 +100,14 @@ suite('SessionsLifecycleTracker', () => { let storage: InMemoryStorageService; let tracker: SessionsLifecycleTracker; + function createTracker(): SessionsLifecycleTracker { + const usage = new SessionsWindowUsageService(storage); + return disposables.add(new SessionsLifecycleTracker(storage, usage.windowOpenCount)); + } + setup(() => { storage = disposables.add(new InMemoryStorageService()); - tracker = disposables.add(new SessionsLifecycleTracker(storage)); + tracker = createTracker(); }); test('starts untracked until a user interaction is recorded', () => { @@ -144,7 +150,7 @@ suite('SessionsLifecycleTracker', () => { tracker.recordNewChatRequestSent(session); tracker.bumpCounter(session, 'feedbackAdded'); - const secondTracker = disposables.add(new SessionsLifecycleTracker(storage)); + const secondTracker = createTracker(); assert.strictEqual(secondTracker.isTracked(session.sessionId), true); const summary = secondTracker.finalize(session.sessionId, 'archived', session); @@ -265,7 +271,7 @@ suite('SessionsLifecycleTracker', () => { stored[session.sessionId].typedFileHashes = [123, 456]; storage.store(SESSIONS_KEY, JSON.stringify(stored), StorageScope.APPLICATION, StorageTarget.MACHINE); - const reloaded = disposables.add(new SessionsLifecycleTracker(storage)); + const reloaded = createTracker(); reloaded.addTypedCharacters(session.sessionId, URI.parse('file:///repo/a.ts'), 2); const summary = reloaded.finalize(session.sessionId, 'archived', session); @@ -566,7 +572,7 @@ suite('SessionsLifecycleTracker', () => { tracker.recordNewChatRequestSent(session); tracker.recordFirstRequestTaskInfo(session, { hasWorktreeCreatedTask: false, configuredTasksCount: 2 }); - const secondTracker = disposables.add(new SessionsLifecycleTracker(storage)); + const secondTracker = createTracker(); const summary = secondTracker.finalize(session.sessionId, 'archived', session); assert.ok(summary); @@ -660,7 +666,7 @@ suite('SessionsLifecycleTracker', () => { tracker.incrementAndGetUserRequestCounters(session); tracker.incrementAndGetUserRequestCounters(session); - const secondTracker = disposables.add(new SessionsLifecycleTracker(storage)); + const secondTracker = createTracker(); assert.deepStrictEqual(secondTracker.incrementAndGetUserRequestCounters(session), { userSessionsTotal: 3, userSessionsInWorkspace: 3, userSessionsForProvider: 3 }); }); @@ -708,7 +714,7 @@ suite('SessionsLifecycleTracker', () => { test('tracker treats corrupted storage as empty', () => { storage.store(SESSIONS_KEY, '{not valid json', StorageScope.APPLICATION, StorageTarget.MACHINE); - const recoveredTracker = disposables.add(new SessionsLifecycleTracker(storage)); + const recoveredTracker = createTracker(); assert.deepStrictEqual(recoveredTracker.getTrackedIds(), []); }); @@ -745,7 +751,7 @@ suite('SessionsLifecycleTracker', () => { } storage.store(SESSIONS_KEY, JSON.stringify(stored), StorageScope.APPLICATION, StorageTarget.MACHINE); - const capTracker = disposables.add(new SessionsLifecycleTracker(storage)); + const capTracker = createTracker(); assert.strictEqual(capTracker.getTrackedIds().length, MAX_TRACKED_SESSIONS); const newSession = createSession('brand-new'); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index d7c5604b9a7962..2cdab9bae8e41d 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -15,7 +15,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { TestAccessibilityService } from '../../../../../platform/accessibility/test/common/testAccessibilityService.js'; import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; -import { IMenuService } from '../../../../../platform/actions/common/actions.js'; +import { IMenu, IMenuService, MenuId, MenuItemAction } from '../../../../../platform/actions/common/actions.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'; @@ -43,7 +43,7 @@ import { ChatInteractivity, ChatOriginKind, IChat, ISession, SessionStatus } fro import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; -import { computeReorderSortChanges, groupByDate, groupByWorkspace, groupSessionsForList, ISessionSection, limitSessionsForList, SessionSectionRenderer, SessionsFlatList, SessionsList, SessionsListFocusedChatItemContext, sortSessions, SessionsGrouping, SessionsSorting } from '../../browser/views/sessionsList.js'; +import { computeReorderSortChanges, groupByDate, groupByWorkspace, groupSessionsForList, ISessionSection, limitSessionsForList, SessionItemToolbarMenuId, SessionSectionRenderer, SessionsFlatList, SessionsList, SessionsListFocusedChatItemContext, sortSessions, SessionsGrouping, SessionsSorting } from '../../browser/views/sessionsList.js'; import { AgentSessionApprovalKind, AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { getSessionSummaryHoverData } from '../../browser/sessionHoverContent.js'; import { createListHarness, createTestSession, ISortChangeRecord } from './sessionsListTestUtils.js'; @@ -275,7 +275,7 @@ suite('Sessions - SessionsList', () => { }); }); - test('updates the Automations row accessible label when the new badge is dismissed', () => { + test('updates the Automations row accessible label when the new badge is dismissed', async () => { const activeCustomView = observableValue(disposables, undefined); const harness = createListHarness(disposables, [], instantiationService => { ChatAutomationsEnabledContext.bindTo(instantiationService.get(IContextKeyService)).set(true); @@ -283,6 +283,7 @@ suite('Sessions - SessionsList', () => { instantiationService.stub(IAutomationService, new class extends mock() { override readonly automations = constObservable([]); override readonly runs = constObservable([]); + override readonly catalogueState = constObservable('ready' as const); }); instantiationService.stub(ICustomViewService, new class extends mock() { override readonly activeCustomView = activeCustomView; @@ -295,6 +296,7 @@ suite('Sessions - SessionsList', () => { onSessionOpen: () => { }, })); list.layout(300, 400); + await list.resetAutomationsNewBadge(); const row = container.querySelector('.monaco-list-row'); const before = row?.getAttribute('aria-label'); @@ -1362,6 +1364,84 @@ suite('Sessions - SessionsList', () => { }); }); + suite('session toolbar keyboard focus', () => { + for (const pinned of [false, true]) { + test(`tabs into the selected ${pinned ? 'pinned' : 'unpinned'} session toolbar without hover`, () => { + const session = createTestSession('Session').session; + const harness = createListHarness(disposables, [session], { + pinnedSessionIds: new Set(pinned ? [session.sessionId] : []), + }); + harness.instantiationService.stub(IContextKeyService, harness.store.add(new ContextKeyService(new TestConfigurationService()))); + const action = new class extends mock() { + override readonly id = 'sessions.test.action'; + override readonly label = 'Session Action'; + override readonly enabled = true; + override async run(): Promise { } + }(); + harness.instantiationService.stub(IMenuService, new class extends mock() { + override createMenu(menuId: MenuId): IMenu { + return { + onDidChange: Event.None, + getActions: () => menuId === SessionItemToolbarMenuId ? [['navigation', [action]]] : [], + dispose: () => { }, + }; + } + }()); + const container = harness.createContainer(); + const list = harness.store.add(harness.instantiationService.createInstance(SessionsList, container, { + grouping: () => SessionsGrouping.Date, + sorting: () => SessionsSorting.Created, + onSessionOpen: () => { }, + })); + list.layout(300, 400); + assert.ok(list.reveal(session.resource)); + + const tree = container.querySelector('.monaco-list'); + const row = container.querySelector('.session-item')?.closest('.monaco-list-row'); + const toolbar = row?.querySelector('.session-title-toolbar'); + const button = toolbar?.querySelector('[tabindex="0"]'); + assert.ok(tree && row && toolbar && button); + + const outside = mainWindow.document.createElement('button'); + container.appendChild(outside); + outside.focus(); + const unfocusedDisplay = mainWindow.getComputedStyle(toolbar).display; + list.focus(); + const focusedDisplay = mainWindow.getComputedStyle(toolbar).display; + tree.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', keyCode: 9, bubbles: true, cancelable: true })); + const toolbarFocused = mainWindow.document.activeElement === button; + const toolbarFocusedDisplay = mainWindow.getComputedStyle(toolbar).display; + const tabFromAction = new KeyboardEvent('keydown', { key: 'Tab', keyCode: 9, bubbles: true, cancelable: true }); + button.dispatchEvent(tabFromAction); + outside.focus(); + const blurredDisplay = mainWindow.getComputedStyle(toolbar).display; + list.focus(); + const reverseTab = new KeyboardEvent('keydown', { key: 'Tab', keyCode: 9, shiftKey: true, bubbles: true, cancelable: true }); + tree.dispatchEvent(reverseTab); + + assert.deepStrictEqual({ + selected: row.classList.contains('selected'), + unfocusedDisplay, + focusedDisplay, + toolbarFocused, + toolbarFocusedDisplay, + tabFromActionPrevented: tabFromAction.defaultPrevented, + blurredDisplay, + reverseTabPrevented: reverseTab.defaultPrevented, + }, { + selected: true, + unfocusedDisplay: 'none', + focusedDisplay: 'block', + toolbarFocused: true, + toolbarFocusedDisplay: 'block', + tabFromActionPrevented: false, + blurredDisplay: 'none', + reverseTabPrevented: false, + }); + }); + } + }); + suite('session chat rows', () => { function createChat(title: string, origin?: ChatOriginKind, interactivity = ChatInteractivity.Full, status = SessionStatus.Completed): IChat { diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts index dee26dab895c5d..6d6e799682aa43 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts @@ -24,6 +24,7 @@ import { ISessionsListModelService, SessionSortMode } from '../../../../services import { ISessionSectionOrderService } from '../../../../services/sessions/browser/sessionSectionOrderService.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { ISessionsWindowUsageService } from '../../../../services/sessions/browser/sessionsWindowUsageService.js'; import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { IChat, ISession, ISessionCapabilities, ISessionChangesSummary, SessionStatus } from '../../../../services/sessions/common/session.js'; import { IDeleteChatOptions } from '../../../../services/sessions/common/sessionsProvider.js'; @@ -245,6 +246,10 @@ export function createListHarness(disposables: Pick, ses override getProviders() { return []; } override getProvider() { return undefined; } }); + instantiationService.stub(ISessionsWindowUsageService, new class extends mock() { + override readonly hadPriorWindowOpen = true; + override readonly windowOpenCount = 2; + }); instantiationService.stub(IVoicePlaybackService, new class extends mock() { override readonly pendingResponseVersion = constObservable(0); override hasPendingResponse() { return false; } diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsTelemetry.contribution.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsTelemetry.contribution.test.ts index 2a028c51bc1afb..7cabddb04aadc7 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsTelemetry.contribution.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsTelemetry.contribution.test.ts @@ -30,6 +30,7 @@ import { IActiveSession, ISendRequestSentEvent, ISessionsManagementService } fro import { ISessionsPartService } from '../../../../services/sessions/browser/sessionsPartService.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { ISessionsWindowUsageService } from '../../../../services/sessions/browser/sessionsWindowUsageService.js'; import { SessionsTelemetryContribution } from '../../browser/sessionsTelemetry.contribution.js'; interface IRequestSentTelemetry { @@ -213,6 +214,10 @@ suite('SessionsTelemetryContribution', () => { providersService, tasksService, modelService, + new class extends mock() { + override readonly hadPriorWindowOpen = false; + override readonly windowOpenCount = 1; + }(), )); return { telemetryService, storageService, onDidSendRequest, onDidArchiveSession, onModelAdded }; diff --git a/src/vs/sessions/contrib/sessions/test/electron-browser/sessionsApplicationBadge.test.ts b/src/vs/sessions/contrib/sessions/test/electron-browser/sessionsApplicationBadge.test.ts index a32721d8dc75ca..e0010e612c72c0 100644 --- a/src/vs/sessions/contrib/sessions/test/electron-browser/sessionsApplicationBadge.test.ts +++ b/src/vs/sessions/contrib/sessions/test/electron-browser/sessionsApplicationBadge.test.ts @@ -15,6 +15,7 @@ import { IConfigurationChangeEvent } from '../../../../../platform/configuration import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IApplicationBadge, INativeHostService } from '../../../../../platform/native/common/native.js'; +import product from '../../../../../platform/product/common/product.js'; import { TestThemeService } from '../../../../../platform/theme/test/common/testThemeService.js'; import { ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; import { ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; @@ -81,12 +82,11 @@ function createSession(id: string, state: { status?: SessionStatus; isRead?: boo suite('SessionsApplicationBadge', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - function createBadge(sessions: ISession[], enabled = true) { + function createBadgeWithConfiguration(sessions: ISession[], configuration: TestConfigurationService) { const management = store.add(new TestSessionsManagementService()); management.sessions.push(...sessions); const nativeHost = new TestNativeHostService(); - const configuration = new TestConfigurationService({ [SESSIONS_APPLICATION_BADGE_SETTING]: enabled }); const blockedSessions = new TestBlockedSessions(); blockedSessions.setSessions(sessions.filter(session => !session.isArchived.get() && session.status.get() === SessionStatus.NeedsInput)); const instantiationService = store.add(new TestInstantiationService()); @@ -97,6 +97,10 @@ suite('SessionsApplicationBadge', () => { return { management, nativeHost, configuration, blockedSessions }; } + function createBadge(sessions: ISession[], enabled = true) { + return createBadgeWithConfiguration(sessions, new TestConfigurationService({ [SESSIONS_APPLICATION_BADGE_SETTING]: enabled })); + } + function badgeCounts(nativeHost: TestNativeHostService): (number | undefined)[] { return nativeHost.badges.map(badge => badge?.count); } @@ -133,6 +137,15 @@ suite('SessionsApplicationBadge', () => { ]); }); + test('uses the product-quality default when the setting value is unavailable', () => { + const { nativeHost } = createBadgeWithConfiguration( + [createSession('unread', { isRead: false }).session], + new TestConfigurationService() + ); + + assert.deepStrictEqual(badgeCounts(nativeHost), product.quality !== 'stable' ? [1] : []); + }); + test('is off until enabled', () => { const { nativeHost, configuration } = createBadge([createSession('unread', { isRead: false }).session], false); diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 1c0de89b12b0bb..564d88d45b5bb9 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -24,7 +24,7 @@ import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uri import { getSessionReferenceResource } from './sessionReference.js'; import { ICreateNewChatInSessionOptions, ICreateNewSessionOptions, IDeferredNewSessionRequestOptions, IProviderSessionType, ISendRequestOptions, ISendRequestSentEvent, ISessionsChangeEvent, ISessionsManagementService, NewSessionRequestOptions, WorkspaceNotTrustedError } from '../common/sessionsManagement.js'; import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from './sessionsProvidersService.js'; -import { IDeleteChatOptions, IPreparedNewSession, ISessionChangeEvent, ISessionsProvider, type SessionResourceResolveReason } from '../common/sessionsProvider.js'; +import { IDeleteChatOptions, IPreparedNewSession, ISessionChangeEvent, ISessionsProvider, type ISessionsProviderCreateSessionOptions, type SessionResourceResolveReason } from '../common/sessionsProvider.js'; import { ChatModelSource, IChat, ISession, ISessionWorkspace, ISideChatSelection, SessionStatus, ISessionType } from '../common/session.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -495,7 +495,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa const { provider, sessionTypeId } = this._resolveProviderForNewSession(folderUri, options); const previousNewSession = this._newSession.get(); - const session = provider.createNewSession(folderUri, sessionTypeId, { metadata: options?.metadata }); + const session = provider.createNewSession(folderUri, sessionTypeId, this._providerCreateSessionOptions(provider, options)); // Providers no longer dispose the previous new session implicitly, so // dispose the one this composer just replaced. Use its own provider @@ -514,7 +514,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa createAutomationSession(folderUri: URI, options?: ICreateNewSessionOptions): ISession { const { provider, sessionTypeId } = this._resolveProviderForNewSession(folderUri, options); const previousAutomationSession = this._automationSession.get(); - const session = provider.createNewSession(folderUri, sessionTypeId); + const session = provider.createNewSession(folderUri, sessionTypeId, this._providerCreateSessionOptions(provider, options)); if (previousAutomationSession && previousAutomationSession.sessionId !== session.sessionId) { this._getProvider(previousAutomationSession)?.deleteNewSession(previousAutomationSession.sessionId); } @@ -582,7 +582,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa const { provider, sessionTypeId } = this._resolveProviderForQuickChat(options); const previousNewSession = this._newSession.get(); - const session = provider.createQuickChat(sessionTypeId); + const session = provider.createQuickChat(sessionTypeId, this._providerCreateSessionOptions(provider, options)); this._newSession.set(session, undefined); this.storageService.store(LAST_USED_QUICK_CHAT_SESSION_TYPE_STORAGE_KEY, sessionTypeId, StorageScope.PROFILE, StorageTarget.USER); @@ -598,7 +598,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa createAutomationQuickChat(options?: ICreateNewSessionOptions): ISession { const { provider, sessionTypeId } = this._resolveProviderForQuickChat(options); const previousAutomationSession = this._automationSession.get(); - const session = provider.createQuickChat(sessionTypeId); + const session = provider.createQuickChat(sessionTypeId, this._providerCreateSessionOptions(provider, options)); if (previousAutomationSession && previousAutomationSession.sessionId !== session.sessionId) { this._getProvider(previousAutomationSession)?.deleteNewSession(previousAutomationSession.sessionId); } @@ -606,6 +606,35 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return session; } + private _providerCreateSessionOptions(provider: ISessionsProvider, options: ICreateNewSessionOptions | undefined): ISessionsProviderCreateSessionOptions { + const sessionTemplate = options?.sessionTemplate ?? options?.automationConfiguration?.sessionTemplate; + if (sessionTemplate && provider.supportsAutomationSessionConfiguration !== true) { + throw new Error(`Sessions provider '${provider.id}' does not support Automation session templates.`); + } + const automationConfiguration = sessionTemplate + ? { sessionTemplate } + : options?.automationConfiguration; + return { + metadata: options?.metadata, + ...(automationConfiguration ? { automationConfiguration } : {}), + }; + } + + async getAutomationSessionConfiguration(session: ISession) { + const provider = this._getProvider(session); + return provider?.supportsAutomationSessionConfiguration === true && provider.getAutomationSessionConfiguration + ? provider.getAutomationSessionConfiguration(session.sessionId) + : null; + } + + supportsAutomationSessionConfiguration(session: ISession): boolean { + return this._getProvider(session)?.supportsAutomationSessionConfiguration === true; + } + + usesCombinedNewSessionConfigPicker(session: ISession): boolean { + return this._getProvider(session)?.usesCombinedNewSessionConfigPicker === true; + } + async createNewChatInSession(session: ISession, options?: ICreateNewChatInSessionOptions): Promise { const provider = this._getProvider(session); if (!provider) { @@ -850,7 +879,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa throw new WorkspaceNotTrustedError(); } } - const session = provider.createNewSession(folderUri, sessionTypeId, { metadata: createOptions?.metadata }); + const session = provider.createNewSession(folderUri, sessionTypeId, this._providerCreateSessionOptions(provider, createOptions)); this._unlistedNewSessions.set(session.resource, session); const requestActivity = new MutableDisposable(); try { @@ -874,7 +903,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa async createAndSendQuickChatRequest(options: ISendRequestOptions, createOptions?: ICreateNewSessionOptions, token: CancellationToken = CancellationToken.None): Promise { const { provider, sessionTypeId } = this._resolveProviderForQuickChat(createOptions); - const session = provider.createQuickChat(sessionTypeId); + const session = provider.createQuickChat(sessionTypeId, this._providerCreateSessionOptions(provider, createOptions)); return this._configureAndSendNewSession(provider, session, options, createOptions, false, token); } diff --git a/src/vs/sessions/services/sessions/browser/sessionsWindowUsageService.ts b/src/vs/sessions/services/sessions/browser/sessionsWindowUsageService.ts new file mode 100644 index 00000000000000..9949f79d05af9f --- /dev/null +++ b/src/vs/sessions/services/sessions/browser/sessionsWindowUsageService.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 { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; + +const AGENTS_WINDOW_OPEN_COUNT_KEY = 'agentSessions.telemetry.summary.appLaunchCount'; + +export const ISessionsWindowUsageService = createDecorator('sessionsWindowUsageService'); + +export interface ISessionsWindowUsageService { + readonly _serviceBrand: undefined; + readonly hadPriorWindowOpen: boolean; + readonly windowOpenCount: number; +} + +export class SessionsWindowUsageService implements ISessionsWindowUsageService { + + declare readonly _serviceBrand: undefined; + + readonly hadPriorWindowOpen: boolean; + readonly windowOpenCount: number; + + constructor(@IStorageService storageService: IStorageService) { + const previousWindowOpenCount = storageService.getNumber(AGENTS_WINDOW_OPEN_COUNT_KEY, StorageScope.APPLICATION, 0); + this.hadPriorWindowOpen = previousWindowOpenCount > 0; + this.windowOpenCount = previousWindowOpenCount + 1; + storageService.store(AGENTS_WINDOW_OPEN_COUNT_KEY, this.windowOpenCount, StorageScope.APPLICATION, StorageTarget.MACHINE); + } +} + +registerSingleton(ISessionsWindowUsageService, SessionsWindowUsageService, InstantiationType.Eager); diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index 3488b625c13ea8..516487685bde2d 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -8,8 +8,9 @@ import { IObservable } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IAutomationSessionTemplate } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { IChat, ISession, ISessionType, ISessionWorkspace, ISideChatSelection } from './session.js'; -import { IDeleteChatOptions, ISendRequestOptions as ISessionsProviderSendRequestOptions, type SessionResourceResolveReason } from './sessionsProvider.js'; +import { IAutomationSessionConfiguration, IDeleteChatOptions, ISendRequestOptions as ISessionsProviderSendRequestOptions, type SessionResourceResolveReason } from './sessionsProvider.js'; /** Raised when unattended session creation targets a workspace that requires trust. */ export class WorkspaceNotTrustedError extends Error { @@ -93,6 +94,10 @@ export interface ICreateNewSessionOptions { * does not implement the setter. */ readonly permissionLevel?: string; + /** Provider-owned session values restored into an Automation draft. */ + readonly sessionTemplate?: IAutomationSessionTemplate; + /** Complete provider-owned Automation draft state. */ + readonly automationConfiguration?: IAutomationSessionConfiguration; /** * Optional worktree isolation mode (`worktree` or `workspace`) to apply * via {@link ISessionsProvider.setIsolationMode}. Skipped if the @@ -392,6 +397,18 @@ export interface ISessionsManagementService { */ discardAutomationSession(session?: ISession): void; + /** + * Capture the provider-owned values currently selected on an Automation draft. + * `null` means the provider does not support capture; `undefined` means the draft was replaced. + */ + getAutomationSessionConfiguration(session: ISession): Promise; + + /** Whether the session's provider can restore and capture Automation configuration. */ + supportsAutomationSessionConfiguration(session: ISession): boolean; + + /** Whether the session's provider combines Mode and Model controls on phone layouts. */ + usesCombinedNewSessionConfigPicker(session: ISession): boolean; + /** * Create a new session for the given folder. * diff --git a/src/vs/sessions/services/sessions/common/sessionsProvider.ts b/src/vs/sessions/services/sessions/common/sessionsProvider.ts index 401ec2844879d2..91314ebd1ea24d 100644 --- a/src/vs/sessions/services/sessions/common/sessionsProvider.ts +++ b/src/vs/sessions/services/sessions/common/sessionsProvider.ts @@ -9,9 +9,9 @@ import { IDisposable } from '../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; -import { ILanguageModelChatMetadataAndIdentifier } from '../../../../workbench/contrib/chat/common/languageModels.js'; +import { ILanguageModelChatMetadataAndIdentifier, type IModelConfigurationAccess } from '../../../../workbench/contrib/chat/common/languageModels.js'; import { ModelIdentifierResolution } from '../../../../workbench/contrib/chat/common/modelSelection.js'; -import { IAutomationDescriptor, IAutomationRun } from '../../../../workbench/contrib/chat/common/automations/automation.js'; +import { IAutomationDescriptor, IAutomationRun, IAutomationSessionTemplate } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { IAutomationStore } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; import { ChatModelSource, IChat, ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, ISideChatSelection } from './session.js'; @@ -51,6 +51,16 @@ export interface ISendRequestOptions { export interface ISessionsProviderCreateSessionOptions { /** Initial provider metadata to associate with the session. */ readonly metadata?: Record; + /** Complete Automation state for providers that also own compatibility projections. */ + readonly automationConfiguration?: IAutomationSessionConfiguration; +} + +/** Provider-owned Automation draft state plus temporary compatibility projections. */ +export interface IAutomationSessionConfiguration { + readonly sessionTemplate?: IAutomationSessionTemplate; + readonly modelId?: string; + readonly mode?: string; + readonly permissionLevel?: string; } /** Programmatic worktree settings applied together before a new session starts. */ @@ -241,6 +251,11 @@ export interface ISessionsProvider { */ readonly supportsQuickChats?: boolean; + /** Whether phone layouts replace separate Mode and Model controls with one picker. */ + readonly usesCombinedNewSessionConfigPicker?: boolean; + /** Whether Automation configuration can be restored at draft creation and captured through `getAutomationSessionConfiguration`. */ + readonly supportsAutomationSessionConfiguration?: boolean; + /** * Optional. Fires when a capability flag that consumers gate UI on (e.g. * {@link supportsQuickChats}) changes at runtime, so they can re-evaluate. @@ -296,7 +311,7 @@ export interface ISessionsProvider { * support quick chats must throw. * @param sessionTypeId The ID of the session type to create. */ - createQuickChat(sessionTypeId: string): ISession; + createQuickChat(sessionTypeId: string, options?: ISessionsProviderCreateSessionOptions): ISession; /** * Delete a new (untitled, not-yet-sent) session previously created via @@ -307,6 +322,12 @@ export interface ISessionsProvider { */ deleteNewSession(sessionId: string): void; + /** Capture Automation draft values; implementing this also declares support for restoring `automationConfiguration` at draft creation. */ + getAutomationSessionConfiguration?(sessionId: string): Promise; + + /** Model preferences scoped to an Automation draft rather than the ordinary New Session defaults. */ + getAutomationModelConfiguration?(sessionId: string): IModelConfigurationAccess | undefined; + /** * Get the session types supported for a given workspace URI. * @param workspaceUri The URI of the workspace to get session types for. diff --git a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts index 8e7c0ea22be2eb..4c27736b266fd7 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts @@ -216,6 +216,9 @@ class MockSessionStore implements ISessionsManagementService { createNewSession(_folderUri: URI, _options?: ICreateNewSessionOptions): ISession { throw new Error('not implemented'); } createAutomationSession(_folderUri: URI, _options?: ICreateNewSessionOptions): ISession { throw new Error('not implemented'); } createAutomationQuickChat(_options?: ICreateNewSessionOptions): ISession { throw new Error('not implemented'); } + getAutomationSessionConfiguration(): Promise { return Promise.resolve(undefined); } + supportsAutomationSessionConfiguration(): boolean { return false; } + usesCombinedNewSessionConfigPicker(): boolean { return false; } createQuickChat(_options?: ICreateNewSessionOptions): ISession { throw new Error('not implemented'); } createNewChatInSession(_session: ISession): Promise { throw new Error('not implemented'); } forkChatInSession(_session: ISession, _sourceChat: URI, _turnId: string): Promise { throw new Error('not implemented'); } diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 1ee026a2fe4dba..de90014ff2fd69 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -36,6 +36,7 @@ import { PreferredGroup } from '../../../../../workbench/services/editor/common/ import { nullExtensionDescription } from '../../../../../workbench/services/extensions/common/extensions.js'; import { SessionTypeAuthRequirement, ChatInteractivity, ChatOriginKind, IChat, ISession, ISessionType, ISessionWorkspace, ISideChatSelection, SessionStatus } from '../../common/session.js'; import { ILanguageModelChatMetadataAndIdentifier } from '../../../../../workbench/contrib/chat/common/languageModels.js'; +import { IAutomationSessionTemplate } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; import { ISessionChangeEvent, ISendRequestOptions, ISessionModelsSnapshot, ISessionModelPickerOptions, ISessionsProvider, ISessionsProviderCreateSessionOptions, ISessionWorktreeConfiguration } from '../../common/sessionsProvider.js'; import { SessionsManagementService } from '../../browser/sessionsManagementService.js'; import { ISessionsManagementService, ICreateNewSessionOptions, inheritableSessionTarget, ISendRequestSentEvent, WorkspaceNotTrustedError } from '../../common/sessionsManagement.js'; @@ -1842,6 +1843,73 @@ suite('SessionsManagementService', () => { assert.strictEqual(view.activeSession.get(), undefined); }); + test('createAndSendNewChatRequest restores Automation configuration during draft creation', async () => { + const session = stubSession({ + sessionId: 's1', + providerId: 'test', + }); + let providerOptions: ISessionsProviderCreateSessionOptions | undefined; + const provider = new class extends TestSessionsProvider { + override readonly supportsAutomationSessionConfiguration = true; + override resolveWorkspace(): ISessionWorkspace { return { folderUri: URI.parse('test:///folder') } as unknown as ISessionWorkspace; } + override createNewSession(_folderUri?: URI, _sessionTypeId?: string, options?: ISessionsProviderCreateSessionOptions): ISession { + providerOptions = options; + return session; + } + override async getAutomationSessionConfiguration() { return automationConfiguration; } + }(session); + const { service } = createSessionsManagementService(session, disposables, provider); + const sessionTemplate = { + modelId: 'model', + config: { mode: 'plan', autoApprove: 'assisted' }, + }; + const automationConfiguration = { + sessionTemplate, + modelId: 'model', + mode: 'plan', + permissionLevel: 'assisted', + }; + + await service.createAndSendNewChatRequest(URI.parse('test:///folder'), { query: 'hi' }, { + sessionTemplate, + automationConfiguration, + }); + + assert.deepStrictEqual(providerOptions, { + metadata: undefined, + automationConfiguration: { sessionTemplate }, + }); + }); + + test('createAndSendNewChatRequest rejects canonical Automation templates for providers without restoration support', async () => { + const session = stubSession({ + sessionId: 's1', + providerId: 'test', + }); + let createCount = 0; + const provider = new class extends TestSessionsProvider { + override resolveWorkspace(): ISessionWorkspace { return { folderUri: URI.parse('test:///folder') } as unknown as ISessionWorkspace; } + override createNewSession(): ISession { + createCount++; + return session; + } + }(session); + const { service } = createSessionsManagementService(session, disposables, provider); + const sessionTemplate = { modelId: 'model', config: { mode: 'plan' } }; + + await Promise.all([ + assert.rejects( + service.createAndSendNewChatRequest(URI.parse('test:///folder'), { query: 'hi' }, { sessionTemplate }), + /does not support Automation session templates/, + ), + assert.rejects( + service.createAndSendNewChatRequest(URI.parse('test:///folder'), { query: 'hi' }, { automationConfiguration: { sessionTemplate } }), + /does not support Automation session templates/, + ), + ]); + assert.strictEqual(createCount, 0); + }); + test('createAndSendNewChatRequest prepares request options while configuring the provisional session', async () => { const session = stubSession({ sessionId: 's1', @@ -2925,7 +2993,7 @@ suite('SessionsManagementService', () => { }); }); - test('automation draft lifecycle is isolated from the new-session draft', () => { + test('automation draft lifecycle and session template are isolated from the new-session draft', async () => { const drafts = [ stubSession({ sessionId: 'automation-workspace', providerId: 'test' }), stubSession({ sessionId: 'new-session', providerId: 'test' }), @@ -2933,9 +3001,16 @@ suite('SessionsManagementService', () => { stubSession({ sessionId: 'automation-replacement', providerId: 'test' }), ]; const deleted: string[] = []; + const createOptions: Array = []; + const sessionTemplate: IAutomationSessionTemplate = { + modelId: 'model', + agent: { uri: 'file:///agent.md' }, + config: { mode: 'plan' }, + }; let createIndex = 0; const provider = new class extends TestSessionsProvider { override readonly supportsQuickChats = true; + override readonly supportsAutomationSessionConfiguration = true; override resolveWorkspace(folderUri: URI): ISessionWorkspace { return { uri: folderUri, @@ -2946,16 +3021,24 @@ suite('SessionsManagementService', () => { isVirtualWorkspace: false, }; } - override createNewSession(): ISession { return drafts[createIndex++]; } - override createQuickChat(): ISession { return drafts[createIndex++]; } + override createNewSession(_folderUri: URI, _sessionTypeId: string, options?: ISessionsProviderCreateSessionOptions): ISession { + createOptions.push(options); + return drafts[createIndex++]; + } + override createQuickChat(_sessionTypeId: string, options?: ISessionsProviderCreateSessionOptions): ISession { + createOptions.push(options); + return drafts[createIndex++]; + } + override async getAutomationSessionConfiguration() { return { sessionTemplate }; } override deleteNewSession(sessionId: string): void { deleted.push(sessionId); } }(drafts[0]); const { service } = createSessionsManagementService(drafts[0], disposables, provider); const folderUri = URI.parse('test:///folder'); - const firstAutomationSession = service.createAutomationSession(folderUri); + const firstAutomationSession = service.createAutomationSession(folderUri, { sessionTemplate }); + const capturedConfiguration = await service.getAutomationSessionConfiguration(firstAutomationSession); service.createNewSession(folderUri); - service.createAutomationQuickChat(); + service.createAutomationQuickChat({ sessionTemplate }); service.discardAutomationSession(firstAutomationSession); service.createAutomationSession(folderUri); service.discardAutomationSession(); @@ -2963,10 +3046,19 @@ suite('SessionsManagementService', () => { assert.deepStrictEqual({ newSession: service.newSession.get()?.sessionId, automationSession: service.automationSession.get()?.sessionId, + capturedConfiguration, + createOptions, deleted, }, { newSession: 'new-session', automationSession: undefined, + capturedConfiguration: { sessionTemplate }, + createOptions: [ + { metadata: undefined, automationConfiguration: { sessionTemplate } }, + { metadata: undefined }, + { metadata: undefined, automationConfiguration: { sessionTemplate } }, + { metadata: undefined }, + ], deleted: ['automation-workspace', 'automation-quick-chat', 'automation-replacement'], }); }); diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsWindowUsageService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsWindowUsageService.test.ts new file mode 100644 index 00000000000000..0836681a0d785c --- /dev/null +++ b/src/vs/sessions/services/sessions/test/browser/sessionsWindowUsageService.test.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { SessionsWindowUsageService } from '../../browser/sessionsWindowUsageService.js'; + +suite('SessionsWindowUsageService', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('snapshots prior use before recording each window open', () => { + const storageService = disposables.add(new InMemoryStorageService()); + const firstWindow = new SessionsWindowUsageService(storageService); + const secondWindow = new SessionsWindowUsageService(storageService); + + assert.deepStrictEqual({ + firstWindow: { + hadPriorWindowOpen: firstWindow.hadPriorWindowOpen, + windowOpenCount: firstWindow.windowOpenCount, + }, + secondWindow: { + hadPriorWindowOpen: secondWindow.hadPriorWindowOpen, + windowOpenCount: secondWindow.windowOpenCount, + }, + storedCount: storageService.getNumber('agentSessions.telemetry.summary.appLaunchCount', StorageScope.APPLICATION), + machineKeys: storageService.keys(StorageScope.APPLICATION, StorageTarget.MACHINE), + }, { + firstWindow: { + hadPriorWindowOpen: false, + windowOpenCount: 1, + }, + secondWindow: { + hadPriorWindowOpen: true, + windowOpenCount: 2, + }, + storedCount: 2, + machineKeys: ['agentSessions.telemetry.summary.appLaunchCount'], + }); + }); + + test('recognizes launches recorded before the badge without session history', () => { + const storageService = disposables.add(new InMemoryStorageService()); + storageService.store('agentSessions.telemetry.summary.appLaunchCount', 7, StorageScope.APPLICATION, StorageTarget.MACHINE); + + const usage = new SessionsWindowUsageService(storageService); + + assert.deepStrictEqual({ + hadPriorWindowOpen: usage.hadPriorWindowOpen, + windowOpenCount: usage.windowOpenCount, + storedCount: storageService.getNumber('agentSessions.telemetry.summary.appLaunchCount', StorageScope.APPLICATION), + }, { + hadPriorWindowOpen: true, + windowOpenCount: 8, + storedCount: 8, + }); + }); +}); diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index d7d5a085f33222..a958637e752924 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -456,6 +456,7 @@ import './browser/paneCompositePartService.js'; import './browser/parts/editorParts.js'; import './browser/parts/sessionsParts.js'; import './browser/parts/customViewGridParts.js'; +import './services/sessions/browser/sessionsWindowUsageService.js'; import './services/sessions/browser/sessionsService.js'; import './services/workspaceFolderLabel/browser/workspaceFolderLabelService.js'; import './services/customView/browser/customViewService.js'; diff --git a/src/vs/workbench/browser/contextkeys.ts b/src/vs/workbench/browser/contextkeys.ts index 11fb7cce74a242..fe55713cedc506 100644 --- a/src/vs/workbench/browser/contextkeys.ts +++ b/src/vs/workbench/browser/contextkeys.ts @@ -5,7 +5,7 @@ import { Disposable } from '../../base/common/lifecycle.js'; import { IContextKeyService, IContextKey, setConstant as setConstantContextKey } from '../../platform/contextkey/common/contextkey.js'; -import { IsMacContext, IsLinuxContext, IsWindowsContext, IsWebContext, IsMacNativeContext, IsDevelopmentContext, IsIOSContext, ProductQualityContext, IsMobileContext } from '../../platform/contextkey/common/contextkeys.js'; +import { IsMacContext, IsLinuxContext, IsWindowsContext, IsWebContext, IsMacNativeContext, IsDevelopmentContext, IsIOSContext, ProductQualityContext, IsMobileContext, IsChromeOSContext } from '../../platform/contextkey/common/contextkeys.js'; import { SplitEditorsVertically, InEditorZenModeContext, AuxiliaryBarVisibleContext, SecondarySideBarVisibleContext, SideBarVisibleContext, PanelAlignmentContext, PanelMaximizedContext, PanelVisibleContext, EmbedderIdentifierContext, EditorTabsVisibleContext, IsMainEditorCenteredLayoutContext, MainEditorAreaVisibleContext, DirtyWorkingCopiesContext, EmptyWorkspaceSupportContext, EnterMultiRootWorkspaceSupportContext, HasWebFileSystemAccess, IsMainWindowFullscreenContext, OpenFolderWorkspaceSupportContext, RemoteNameContext, VirtualWorkspaceContext, WorkbenchStateContext, WorkspaceFolderCountContext, PanelPositionContext, TemporaryWorkspaceContext, TitleBarVisibleContext, TitleBarStyleContext, IsAuxiliaryWindowFocusedContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupIndexContext, ActiveEditorGroupLastContext, ActiveEditorGroupLockedContext, MultipleEditorGroupsContext, EditorsVisibleContext, AuxiliaryBarMaximizedContext, InAutomationContext, IsSessionsWindowContext } from '../common/contextkeys.js'; import { preferredSideBySideGroupDirection, GroupDirection, IEditorGroupsService } from '../services/editor/common/editorGroupsService.js'; import { IConfigurationService } from '../../platform/configuration/common/configuration.js'; @@ -83,6 +83,7 @@ export class WorkbenchContextKeysHandler extends Disposable { IsMacContext.bindTo(this.contextKeyService); IsLinuxContext.bindTo(this.contextKeyService); IsWindowsContext.bindTo(this.contextKeyService); + IsChromeOSContext.bindTo(this.contextKeyService); IsWebContext.bindTo(this.contextKeyService); IsMacNativeContext.bindTo(this.contextKeyService); diff --git a/src/vs/workbench/browser/parts/editor/editorTabsControl.ts b/src/vs/workbench/browser/parts/editor/editorTabsControl.ts index b5ae9531268552..e475eff20153bc 100644 --- a/src/vs/workbench/browser/parts/editor/editorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/editorTabsControl.ts @@ -160,7 +160,7 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC @IQuickInputService protected quickInputService: IQuickInputService, @IThemeService themeService: IThemeService, @IEditorResolverService private readonly editorResolverService: IEditorResolverService, - @IHostService private readonly hostService: IHostService, + @IHostService protected readonly hostService: IHostService, @IMenuService protected readonly menuService: IMenuService, ) { super(themeService); diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index 9b68bee9ebf947..ed5f02ab8d6aa3 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -59,8 +59,6 @@ import { IHostService } from '../../../services/host/browser/host.js'; import { BugIndicatingError } from '../../../../base/common/errors.js'; import { applyDragImage } from '../../../../base/browser/ui/dnd/dnd.js'; -const modifierKeyEmitter = ModifierKeyEmitter.getInstance(); - interface IEditorInputLabel { readonly editor: EditorInput; @@ -123,6 +121,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { // Alt-hold alternative to a tab's close action (JetBrains-style), applied only // to the currently hovered tab; see updateTabActionForHoveredTab(). private hoveredTabIndex: number | undefined; + private isAltPressed = false; private readonly tabResourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); private tabLabels: IEditorInputLabel[] = []; @@ -178,7 +177,32 @@ export class MultiEditorTabsControl extends EditorTabsControl { // React to Alt being held/released to swap in the "Close Others" tab action // for the currently hovered tab only (if any). - this._register(modifierKeyEmitter.event(() => this.updateTabActionForHoveredTab())); + this._register(ModifierKeyEmitter.getInstance().event(e => this.setAltPressed(e.altKey))); + + // Alt can get stuck when focus leaves the window while it is held (e.g. Alt+Tab + // to another application) because the matching `keyup` is never received. Give + // up on the swap entirely in that case so that a tab that is still hovered does + // not silently keep the "Close Others" action (https://github.com/microsoft/vscode/issues/331979) + this._register(this.hostService.onDidChangeFocus(hasFocus => { + if (!hasFocus) { + this.setAltPressed(false); + this.setHoveredTab(undefined); + } + })); + } + + // Tracks whether Alt is currently held to swap a tab's close action (see + // redrawTabAction()). Besides the modifier key events this is also re-validated + // from mouse events over the tabs because those always carry the actual state, + // even when a `keyup` went to another application. + private setAltPressed(isAltPressed: boolean): void { + if (this.isAltPressed === isAltPressed) { + return; + } + + this.isAltPressed = isAltPressed; + + this.updateTabActionForHoveredTab(); } private updateTabActionForHoveredTab(): void { @@ -206,7 +230,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { const previousHoveredTabIndex = this.hoveredTabIndex; this.hoveredTabIndex = tabIndex; - if (!modifierKeyEmitter.keyStatus.altKey) { + if (!this.isAltPressed) { return; // Alt is not held, no action swap in effect to redraw } @@ -426,6 +450,16 @@ export class MultiEditorTabsControl extends EditorTabsControl { this.setHoveredTab(undefined); })); + // Re-validate the Alt state from mouse events over the tabs: the `keyup` for + // Alt may have been missed while another application had focus, in which case + // the tab action would otherwise remain swapped to "Close Others". Listen in + // the capture phase because the tab action stops the mouse down event. + for (const eventType of [EventType.MOUSE_MOVE, EventType.MOUSE_DOWN]) { + this._register(addDisposableListener(tabsContainer, eventType, (e: MouseEvent) => { + this.setAltPressed(e.altKey); + }, true)); + } + // Prevent auto-pasting (https://github.com/microsoft/vscode/issues/201696) if (isLinux) { this._register(addDisposableListener(tabsContainer, EventType.MOUSE_UP, e => { @@ -1670,7 +1704,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { // Alt swaps a visible Close action to Close Others, but only for the // currently hovered tab; Unpin is unaffected. - const wantsCloseOthersAction = hasCloseAction && modifierKeyEmitter.keyStatus.altKey && tabIndex === this.hoveredTabIndex; + const wantsCloseOthersAction = hasCloseAction && this.isAltPressed && tabIndex === this.hoveredTabIndex; this.closeOtherEditorTabsInGroupAction.enabled = this.groupView.count > 1; let tabAction; diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts index 9d2e8a85719467..bff652596ca48d 100644 --- a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts +++ b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts @@ -18,7 +18,7 @@ import { IThemeService } from '../../../../platform/theme/common/themeService.js import { TITLE_BAR_ACTIVE_BACKGROUND, TITLE_BAR_ACTIVE_FOREGROUND, TITLE_BAR_INACTIVE_FOREGROUND, TITLE_BAR_INACTIVE_BACKGROUND, TITLE_BAR_BORDER, WORKBENCH_BACKGROUND } from '../../../common/theme.js'; import { isMacintosh, isWindows, isLinux, isWeb, isNative, platformLocale } from '../../../../base/common/platform.js'; import { Color } from '../../../../base/common/color.js'; -import { EventType, EventHelper, Dimension, append, $, addDisposableListener, prepend, reset, getWindow, getWindowId, isAncestor, getActiveDocument, isHTMLElement } from '../../../../base/browser/dom.js'; +import { EventType, EventHelper, Dimension, append, $, addDisposableListener, prepend, reset, getWindow, getWindowId, isAncestor, getActiveDocument, isHTMLElement, AnimationFrameScheduler } from '../../../../base/browser/dom.js'; import { CustomMenubarControl } from './menubarControl.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; @@ -280,6 +280,7 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { private centerAdjacentToolBarElement: HTMLElement | undefined; private readonly updateToolBarDisposable = this._register(new DisposableStore()); private updateToolBarElement: HTMLElement | undefined; + private titleBarToolBarOverflowScheduler!: AnimationFrameScheduler; private globalToolbarMenu: IMenu | undefined; private layoutToolbarMenu: IMenu | undefined; @@ -474,6 +475,8 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { protected override createContentArea(parent: HTMLElement): HTMLElement { this.element = parent; this.rootContainer = append(parent, $('.titlebar-container')); + // Measure in the render phase so DOM reads do not force layout during startup. + this.titleBarToolBarOverflowScheduler = this._register(new AnimationFrameScheduler(this.rootContainer, () => this.updateTitleBarToolBarOverflow())); this.leftContent = append(this.rootContainer, $('.titlebar-left')); this.centerContent = append(this.rootContainer, $('.titlebar-center')); @@ -516,7 +519,7 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { })); // Re-evaluate fit when items change, see #303222. - this.centerAdjacentToolBarDisposable.add(centerAdjacentToolBar.onDidChangeMenuItems(() => this.updateTitleBarToolBarOverflow())); + this.centerAdjacentToolBarDisposable.add(centerAdjacentToolBar.onDidChangeMenuItems(() => this.titleBarToolBarOverflowScheduler.schedule())); } // Update Toolbar (before the right-aligned toolbar actions) @@ -533,7 +536,7 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { hoverDelegate: this.hoverDelegate })); - this.updateToolBarDisposable.add(updateToolBar.onDidChangeMenuItems(() => this.updateTitleBarToolBarOverflow())); + this.updateToolBarDisposable.add(updateToolBar.onDidChangeMenuItems(() => this.titleBarToolBarOverflowScheduler.schedule())); } // Create Toolbar Actions @@ -939,8 +942,7 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { super.layoutContents(width, height); - // Run after `layoutContents` so the title bar reflects its new width when measuring overflow. - this.updateTitleBarToolBarOverflow(); + this.titleBarToolBarOverflowScheduler.schedule(); } /** diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 1882af7904d22a..6d7000716e0a92 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -441,6 +441,7 @@ export class OpenPermissionPickerAction extends Action2 { ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), ChatContextKeys.chatModeKind.notEqualsTo(ChatModeKind.Ask), ChatContextKeys.inQuickChat.negate(), + ChatContextKeys.inAutomationsDialog.negate(), ContextKeyExpr.or( ChatContextKeys.lockedToCodingAgent.negate(), ChatContextKeys.lockedCodingAgentId.isEqualTo(AgentSessionProviders.Background), diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts index 61245e4ee3ab8f..4d4691978947b4 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts @@ -18,7 +18,7 @@ import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agen import { resolveChangesetUriTemplate, selectDefaultChangeset, type DefaultChangesetKind } from '../../../../../../platform/agentHost/common/changesetUri.js'; import { ISessionArtifact, isGitHubArtifactLink, readSessionArtifacts, SessionArtifactType } from '../../../../../../platform/agentHost/common/sessionArtifacts.js'; import { observableFromSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; -import { Changeset, ChangesetState, ChangesetStatus, ChatOriginKind, DEFAULT_CHAT_ID, getSessionChatResource, getSessionRelatedPullRequestUrls, parseChatUri, readSessionGitHubState, SessionState, SessionSummaryMeta, StateComponents } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { Changeset, ChangesetState, ChangesetStatus, ChatOriginKind, DEFAULT_CHAT_ID, getSessionChatResource, getSessionRelatedPullRequestUrls, isSubagentChatUri, parseChatUri, readSessionGitHubState, SessionState, SessionSummaryMeta, StateComponents } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IClipboardService } from '../../../../../../platform/clipboard/common/clipboardService.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -132,6 +132,15 @@ export function resolveAgentHostSessionChangeset( return changeset && resource ? { changeset, resource } : undefined; } +/** Resolves the chat channel URI a workbench chat resource addresses. */ +export function getAgentHostSessionChatResource(sessionResource: URI, state: Pick | undefined): URI | undefined { + const explicitChatResource = new URLSearchParams(sessionResource.query).get(CHAT_SUBAGENT_RESOURCE_QUERY_PARAM); + if (explicitChatResource) { + return parseUri(explicitChatResource); + } + return state ? parseUri(getSessionChatResource(state, sessionResource.fragment || DEFAULT_CHAT_ID)?.toString()) : undefined; +} + /** Returns the workbench chat resources whose browsers belong in the current chat's pill. */ export function getAgentHostSessionBrowserOwnerIds(sessionResource: URI, state: Pick | undefined): ReadonlySet { const ownerIds = new Set([sessionResource.toString()]); @@ -139,8 +148,7 @@ export function getAgentHostSessionBrowserOwnerIds(sessionResource: URI, state: return ownerIds; } - const explicitChatResource = new URLSearchParams(sessionResource.query).get(CHAT_SUBAGENT_RESOURCE_QUERY_PARAM); - const currentChatResource = parseUri(explicitChatResource ?? getSessionChatResource(state, sessionResource.fragment || DEFAULT_CHAT_ID)?.toString()); + const currentChatResource = getAgentHostSessionChatResource(sessionResource, state); if (!currentChatResource) { return ownerIds; } @@ -237,7 +245,6 @@ export class AgentHostSessionInputPills extends Disposable { ) { super(); - const pillsEnabled = constObservable(true); const sessionResource = observableFromEvent(this, this._widget.onDidChangeViewModel, () => this._widget.viewModel?.sessionResource); const sessionResolutionChanged = observableSignalFromEvent(this, connectionsService.onDidChangeSessionResolution); const resolution = derivedOpts({ owner: this, equalsFn: resolutionEquals }, reader => { @@ -254,6 +261,14 @@ export class AgentHostSessionInputPills extends Disposable { return observableFromSubscription(this, subscription.object); }); const sessionState = derived(this, reader => sessionStateSource.read(reader).read(reader)); + // A subagent (worker) chat inherits the session-wide pills, where they read + // as the subagent's own work, so the row stays hidden there. + const subagentChat = derived(this, reader => { + const resource = sessionResource.read(reader); + const chatResource = resource ? getAgentHostSessionChatResource(resource, sessionState.read(reader)) : undefined; + return !!chatResource && isSubagentChatUri(chatResource); + }); + const pillsVisible = derived(this, reader => !subagentChat.read(reader)); const changesetTarget = derivedOpts({ owner: this, equalsFn: changesetTargetEquals }, reader => { const currentResolution = resolution.read(reader); return currentResolution @@ -364,7 +379,7 @@ export class AgentHostSessionInputPills extends Disposable { debugName: 'AgentHostSessionInputPills.content', compact, targetWindow: getWindow(this._widget.inputPart.persistentContentContainerElement), - enabled: pillsEnabled, + enabled: pillsVisible, sources: constObservable(sources.sources), offeredKinds: offeredPillKinds, ariaLabel: localize('agentHostSessionPills.ariaLabel', "Session status"), 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 2367f4d32e192c..073309ea455116 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -387,6 +387,14 @@ function getSubagentAgentName(tc: ToolCallState): string | undefined { return v && v.length > 0 ? v : undefined; } +/** Surfaces `_meta.progressMessage` of a running tool call as the invocation's progress step. */ +function applyToolCallProgress(invocation: ChatToolInvocation, tc: ToolCallState): void { + const progressMessage = tc.status === ToolCallStatus.Running ? readToolCallMeta(tc).progressMessage : undefined; + if (progressMessage !== undefined) { + invocation.acceptProgress({ message: progressMessage }); + } +} + /** The subagent chat resource for a subagent-spawning tool call: prefer the host-stamped `_meta.subagentChatUri`, then a discovery block, then a derived fallback. */ function getSubagentChatResource(tc: ToolCallState, subagentContent: ToolResultSubagentContent | undefined, sessionResource: URI): string { return readToolCallMeta(tc).subagentChatUri ?? subagentContent?.resource ?? buildSubagentChatUri(sessionResource.toString(), tc.toolCallId); @@ -2410,6 +2418,7 @@ export function toolCallStateToInvocation(tc: ToolCallState, subAgentInvocationI if (tc.status === ToolCallStatus.AuthRequired) { invocation.setAuthenticationRequired(toolCallAuthenticationServer(tc, mcpServerAuthority)); } + applyToolCallProgress(invocation, tc); // Tools that render a bespoke, client-authored invocation message override // the server text here. Add new per-tool cases alongside this branch. @@ -2591,7 +2600,7 @@ export function updateRunningToolSpecificData(existing: ChatToolInvocation, tc: if (isAddCommentTool(tc.toolName)) { existing.invocationMessage = addCommentReference(tc, resourceUris) ?? existing.invocationMessage; } - + applyToolCallProgress(existing, tc); const subagentContent = getToolSubagentContent(tc); if (subagentContent) { @@ -2820,6 +2829,8 @@ export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: ToolC const result: IToolResult | undefined = isFailure || resultDetails ? { content: [], toolResultError: isFailure ? errorString : undefined, toolResultDetails: resultDetails } : undefined; + // Clear transient progress so didExecuteTool does not promote it to the past-tense message. + invocation.acceptProgress({ message: undefined }); const cancelledFromStreaming = isCancelled && invocation.cancelFromStreaming( tc.reason === ToolCallCancellationReason.Skipped ? ToolConfirmKind.Skipped : ToolConfirmKind.Denied, tc.reasonMessage ? stringOrMarkdownToString(tc.reasonMessage, connectionAuthority) : undefined, diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index aaac9757e0a8f6..d51ea36e44d841 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -73,7 +73,6 @@ import { WorkbenchList } from '../../../../../../platform/list/browser/listServi import { canLog, ILogService, LogLevel } from '../../../../../../platform/log/common/log.js'; import { ObservableMemento, observableMemento } from '../../../../../../platform/observable/common/observableMemento.js'; import { bindContextKey } from '../../../../../../platform/observable/common/platformObservableUtils.js'; -import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { IVoiceModeOnboardingService } from '../../../../agentsVoice/browser/voiceModeOnboarding.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { IThemeService } from '../../../../../../platform/theme/common/themeService.js'; @@ -168,7 +167,7 @@ import { ChatPetAchievementIds, didExplicitlySwitchChatPetModel } from '../../ch import { IChatPetService } from '../../chatPetService.js'; import { DelegationSessionPickerActionItem } from './delegationSessionPickerActionItem.js'; import { ModelPickerActionItem, IModelPickerDelegate, IModelPickerPresentationOptions } from './modelPicker/modelPickerActionItem.js'; -import { IModePickerDelegate, isModeConsideredBuiltIn, ModePickerActionItem } from './modePickerActionItem.js'; +import { IModePickerDelegate, ModePickerActionItem } from './modePickerActionItem.js'; import { IPermissionPickerDelegate, PermissionPickerActionItem } from './permissionPickerActionItem.js'; import { SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js'; import { WorkspacePickerActionItem } from './workspacePickerActionItem.js'; @@ -275,6 +274,7 @@ export interface IChatInputPartOptions { menus: { executeToolbar: MenuId; telemetrySource: string; + inputToolbar?: MenuId; inputSideToolbar?: MenuId; }; editorOverflowWidgetsDomNode?: HTMLElement; @@ -310,15 +310,6 @@ export interface IChatInputPartOptions { * Returns true when the action was handled. */ secondaryToolbarOverflowActionHandler?: (actionId: string, anchor: HTMLElement) => boolean; - /** - * When true, the mode picker hides custom agents and only offers the - * built-in modes (Agent / Ask / Edit / Plan, gated by their normal - * visibility rules). Custom-agent discovery is workspace-scoped and - * doesn't follow the dialog's folder selection, so surfacing custom - * agents tied to the workbench's open folders would mislead the user - * when scheduling against a different folder. - */ - hideCustomChatModes?: boolean; /** * When true, suppress the autorun that switches the current language * model to a mode's declared preferred model (`IChatMode.model`). @@ -916,7 +907,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge @IChatAttachmentWidgetRegistry private readonly _chatAttachmentWidgetRegistry: IChatAttachmentWidgetRegistry, @IChatInputNotificationService private readonly chatInputNotificationService: IChatInputNotificationService, @IChatPhoneInputPresenter private readonly chatPhoneInputPresenter: IChatPhoneInputPresenter, - @IProductService private readonly productService: IProductService, @IVoiceModeOnboardingService private readonly voiceModeOnboardingService: IVoiceModeOnboardingService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @IVoiceSessionController private readonly voiceSessionController: IVoiceSessionController, @@ -1449,35 +1439,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } private _createModePickerDelegate(): IModePickerDelegate { - // When `hideCustomChatModes` is set (e.g. the automations dialog), - // strip genuinely user-defined custom agents from the picker - // while preserving extension-contributed modes (Plan / new-Ask / - // new-Edit) that the picker categorises as built-in via - // `isModeConsideredBuiltIn`. Those live in `IChatModes.custom` but - // are part of the built-in product surface, not the - // folder-scoped agent files we want to hide. The underlying - // observable is untouched so mode validation, model picking and - // persistence continue to see the real list. - const productService = this.productService; - const currentChatModes: IObservable = this.options.hideCustomChatModes - ? derived(reader => { - const inner = this._currentChatModesObservable.read(reader); - const filteredCustom = inner.custom.filter(m => isModeConsideredBuiltIn(m, productService)); - const wrapped: IChatModes = { - onDidChange: inner.onDidChange, - builtin: inner.builtin, - custom: filteredCustom, - findModeById: (id: string) => inner.builtin.find(m => m.id === id) ?? filteredCustom.find(m => m.id === id), - findModeByName: (name: string) => inner.builtin.find(m => m.name.read(undefined) === name) ?? filteredCustom.find(m => m.name.read(undefined) === name), - waitForPendingUpdates: () => inner.waitForPendingUpdates(), - }; - return wrapped; - }) - : this._currentChatModesObservable; - return { currentMode: this._currentModeObservable, - currentChatModes, + currentChatModes: this._currentChatModesObservable, sessionResource: () => this._widget?.viewModel?.sessionResource, // Direct setter for hosts that embed `ChatInputPart` without // registering an `IChatWidget` (e.g. the automations dialog). @@ -3545,7 +3509,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._register(dom.addStandardDisposableListener(toolbarsContainer, dom.EventType.CLICK, e => this.inputEditor.focus())); this._register(dom.addStandardDisposableListener(this.attachmentsContainer, dom.EventType.CLICK, e => this.inputEditor.focus())); - this.inputActionsToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, this.options.renderInputToolbarBelowInput ? this.attachmentsContainer : toolbarsContainer, MenuId.ChatInput, { + const inputToolbarMenu = this.options.menus.inputToolbar ?? MenuId.ChatInput; + this.inputActionsToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, this.options.renderInputToolbarBelowInput ? this.attachmentsContainer : toolbarsContainer, inputToolbarMenu, { telemetrySource: this.options.menus.telemetrySource, menuOptions: { shouldForwardArgs: true }, hiddenItemStrategy: HiddenItemStrategy.NoHide, @@ -3557,7 +3522,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge actionMinWidth: 48, getActionMinWidth: getInputActionMinWidth, allowOverflow: () => this._inputPickerResponsiveLayout?.areAllItemsCompact() === true, - getOverflowAction: (action, getAnchor) => getOverflowAction(action, MenuId.ChatInput, inputOverflowPickerHandlers, getAnchor, toolbarsContainer), + getOverflowAction: (action, getAnchor) => getOverflowAction(action, inputToolbarMenu, inputOverflowPickerHandlers, getAnchor, toolbarsContainer), }, actionViewItemProvider: (action, options) => { // Phone-layout branch: when an agents-window phone presenter diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerModelConfig.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerModelConfig.ts index 038547158b8067..35d6dbb4d245cf 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerModelConfig.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerModelConfig.ts @@ -3,29 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IAction } from '../../../../../../../base/common/actions.js'; -import { IStringDictionary } from '../../../../../../../base/common/collections.js'; -import { Event } from '../../../../../../../base/common/event.js'; import { formatTokenCount } from '../../../../../../../base/common/numbers.js'; -import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelConfigurationSchema } from '../../../../common/languageModels.js'; +import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelConfigurationSchema, type IModelConfigurationAccess } from '../../../../common/languageModels.js'; -/** - * Read/write access to a model's configuration (e.g. context size, thinking - * effort). Implemented either by the global `ILanguageModelsService` or by - * a per-editor override layer so that one editor's changes do not sync to other - * already-open editors. Structurally satisfied by `ILanguageModelsService`. - */ -export interface IModelConfigurationAccess { - getModelConfiguration(modelId: string): IStringDictionary | undefined; - setModelConfiguration(modelId: string, values: IStringDictionary): Promise; - getModelConfigurationActions(modelId: string): IAction[]; - /** - * Fires when this access layer's configuration changes (e.g. user picks a - * new context size). Implementations that always read the global value can - * omit this and rely on `ILanguageModelsService.onDidChangeLanguageModels`. - */ - readonly onDidChange?: Event; -} +export type { IModelConfigurationAccess } from '../../../../common/languageModels.js'; /** The thinking effort group, or the routing tier for the Auto model. */ export const MODEL_CONFIG_GROUP_EFFORT = 'navigation'; diff --git a/src/vs/workbench/contrib/chat/common/automations/automation.ts b/src/vs/workbench/contrib/chat/common/automations/automation.ts index e042db5a32cb82..d7741e05d9196e 100644 --- a/src/vs/workbench/contrib/chat/common/automations/automation.ts +++ b/src/vs/workbench/contrib/chat/common/automations/automation.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../../base/common/uri.js'; +import type { JsonPrimitive } from '../../../../../platform/agentHost/common/state/protocol/state.js'; /** * How often an automation runs. `hourly` fires every hour from creation/update; @@ -52,6 +53,39 @@ export type AutomationTarget = readonly sessionTypeId: string; }; +/** Provider-owned values used to create each Automation run session. */ +export interface IAutomationSessionTemplate { + /** Optional language model identifier. */ + readonly modelId?: string; + /** Model-specific preferences, independent of provider session configuration. */ + readonly modelConfiguration?: Readonly>; + /** Optional custom agent selection. */ + readonly agent?: { readonly uri: string }; + /** Provider-owned session configuration values. */ + readonly config?: Readonly>; +} + +export function isAutomationModelConfiguration(value: unknown): value is NonNullable { + return !!value && typeof value === 'object' && !Array.isArray(value) + && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) + && Object.values(value).every(entry => entry === null + || typeof entry === 'string' + || typeof entry === 'boolean' + || (typeof entry === 'number' && Number.isFinite(entry))); +} + +export function assertAutomationSessionTemplate(template: IAutomationSessionTemplate | undefined): void { + if (template?.modelConfiguration === undefined) { + return; + } + if (typeof template.modelId !== 'string' || !template.modelId.trim()) { + throw new Error('Automation model configuration requires a model identifier.'); + } + if (!isAutomationModelConfiguration(template.modelConfiguration)) { + throw new Error('Automation model configuration must contain only JSON primitive values.'); + } +} + /** * A single scheduled automation. Identity is the immutable `id`; everything * else may be edited by the user. @@ -65,13 +99,16 @@ export interface IAutomationDescriptor { /** Explicit workspace-backed or workspace-less execution target. */ readonly target: AutomationTarget; - /** Optional language model identifier to seed the new session with. */ + /** Complete provider-owned session template. */ + readonly sessionTemplate?: IAutomationSessionTemplate; + + /** @deprecated Legacy decode alias. New Automations store this in {@link sessionTemplate}. */ readonly modelId?: string; - /** Optional chat mode (`agent`/`ask`/`edit`). Defaults to provider's default; custom modes unsupported. */ + /** @deprecated Legacy decode alias. New Automations store this in {@link sessionTemplate}. */ readonly mode?: string; - /** Optional permission level (`default`/`autoApprove`/`autopilot`). Overrides only for scheduled runs; defaults to provider's default. */ + /** @deprecated Legacy decode alias. New Automations store this in {@link sessionTemplate}. */ readonly permissionLevel?: string; readonly enabled: boolean; diff --git a/src/vs/workbench/contrib/chat/common/automations/automationDialogService.ts b/src/vs/workbench/contrib/chat/common/automations/automationDialogService.ts index a16fe115767740..02eb48ab6df112 100644 --- a/src/vs/workbench/contrib/chat/common/automations/automationDialogService.ts +++ b/src/vs/workbench/contrib/chat/common/automations/automationDialogService.ts @@ -4,12 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; -import { IAutomationDescriptor } from './automation.js'; +import { AutomationTarget, IAutomationDescriptor } from './automation.js'; import { ICreateAutomationOptions, IUpdateAutomationOptions } from './automationService.js'; +export type AutomationDialogCreateInitialValues = Omit & { readonly target?: AutomationTarget }; + export type IShowAutomationDialogOptions = | { readonly existing: IAutomationDescriptor; readonly initialValues?: never } - | { readonly existing?: never; readonly initialValues?: ICreateAutomationOptions }; + | { readonly existing?: never; readonly initialValues?: AutomationDialogCreateInitialValues }; export type IAutomationDialogResult = | { readonly kind: 'create'; readonly value: ICreateAutomationOptions } diff --git a/src/vs/workbench/contrib/chat/common/automations/automationService.ts b/src/vs/workbench/contrib/chat/common/automations/automationService.ts index f92f21f536e57c..100474cb9b9f76 100644 --- a/src/vs/workbench/contrib/chat/common/automations/automationService.ts +++ b/src/vs/workbench/contrib/chat/common/automations/automationService.ts @@ -4,14 +4,30 @@ *--------------------------------------------------------------------------------------------*/ import { IObservable } from '../../../../../base/common/observable.js'; +import { stableStringify } from '../../../../../base/common/objects.js'; import { URI } from '../../../../../base/common/uri.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { ChatPermissionLevel } from '../constants.js'; -import { IAutomationDescriptor, IAutomationRun, AutomationRunTrigger, IAutomationSchedule, AutomationTarget } from './automation.js'; +import { IAutomationDescriptor, IAutomationRun, AutomationRunTrigger, IAutomationSchedule, IAutomationSessionTemplate, AutomationTarget } from './automation.js'; export const IAutomationService = createDecorator('automationService'); export const ConfigureAutomationToolReferenceName = 'configureAutomation'; +export type AutomationCatalogueState = 'loading' | 'ready' | 'unavailable' | 'error'; + +export function combineAutomationCatalogueStates(states: readonly AutomationCatalogueState[]): AutomationCatalogueState { + if (states.includes('error')) { + return 'error'; + } + if (states.includes('loading')) { + return 'loading'; + } + if (states.includes('unavailable')) { + return 'unavailable'; + } + return 'ready'; +} + /** Invoked immediately before each storage CAS attempt; throwing aborts before that attempt. */ export type AutomationMutationGuard = () => void; @@ -30,6 +46,22 @@ export function isAutomationActiveRunError(error: unknown): boolean { || (error instanceof AggregateError && error.errors.length > 0 && error.errors.every(isAutomationActiveRunError)); } +/** Signals that deprecated configuration aliases cannot modify an explicit provider template. */ +export class AutomationSessionTemplateAuthorityError extends Error { + constructor() { + super('A canonical Automation session template cannot be updated through legacy configuration aliases.'); + } +} + +export function assertAutomationSessionTemplateAuthority(current: IAutomationDescriptor, patch: IUpdateAutomationOptions): void { + const targetAuthorityChanged = patch.target !== undefined + && (patch.target.providerId !== current.target.providerId || patch.target.sessionTypeId !== current.target.sessionTypeId); + const legacyConfigurationPatched = patch.modelId !== undefined || patch.mode !== undefined || patch.permissionLevel !== undefined; + if (current.sessionTemplate && patch.sessionTemplate === undefined && !targetAuthorityChanged && legacyConfigurationPatched) { + throw new AutomationSessionTemplateAuthorityError(); + } +} + /** * Input for `createAutomation`. The service fills in `id`, timestamps, and * `nextRunAt`. @@ -39,8 +71,12 @@ export interface ICreateAutomationOptions { readonly prompt: string; readonly schedule: IAutomationSchedule; readonly target: AutomationTarget; + readonly sessionTemplate?: IAutomationSessionTemplate; + /** @deprecated Compatibility input translated into {@link sessionTemplate}. */ readonly modelId?: string; + /** @deprecated Compatibility input translated into {@link sessionTemplate}. */ readonly mode?: string; + /** @deprecated Compatibility input translated into {@link sessionTemplate}. */ readonly permissionLevel?: string; readonly enabled?: boolean; } @@ -54,8 +90,12 @@ export interface IUpdateAutomationOptions { readonly prompt?: string; readonly schedule?: IAutomationSchedule; readonly target?: AutomationTarget; + readonly sessionTemplate?: IAutomationSessionTemplate | null; + /** @deprecated Compatibility input translated into {@link sessionTemplate}. */ readonly modelId?: string | null; + /** @deprecated Compatibility input translated into {@link sessionTemplate}. */ readonly mode?: string | null; + /** @deprecated Compatibility input translated into {@link sessionTemplate}. */ readonly permissionLevel?: string | null; readonly enabled?: boolean; } @@ -89,7 +129,7 @@ export function serializeAutomationEditableState(automation: IAutomationDescript ? { kind: automation.target.isolation.kind, branch: automation.target.isolation.branch } : { kind: automation.target.isolation.kind }, }; - return JSON.stringify({ + return stableStringify({ name: automation.name, prompt: automation.prompt, schedule: { @@ -99,6 +139,7 @@ export function serializeAutomationEditableState(automation: IAutomationDescript scheduleDay: automation.schedule.scheduleDay, }, target, + sessionTemplate: automation.sessionTemplate, modelId: automation.modelId, mode: automation.mode, permissionLevel: automation.permissionLevel ?? ChatPermissionLevel.Default, @@ -134,6 +175,9 @@ export interface IAutomationRunClaim { * cross-window propagation, persistence, and observables consistent. */ export interface IAutomationStore { + /** Completeness of the Automation catalogue, independent of individual providers' operation availability. */ + readonly catalogueState: IObservable; + /** All defined automations, newest first. */ readonly automations: IObservable; diff --git a/src/vs/workbench/contrib/chat/common/automations/automationTelemetry.ts b/src/vs/workbench/contrib/chat/common/automations/automationTelemetry.ts index 60e2508221d682..381801a4c64082 100644 --- a/src/vs/workbench/contrib/chat/common/automations/automationTelemetry.ts +++ b/src/vs/workbench/contrib/chat/common/automations/automationTelemetry.ts @@ -24,7 +24,7 @@ type AutomationCreateEvent = { type AutomationCreateClassification = { intervalKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Cadence the user picked (manual/hourly/daily/weekly).' }; - permissionLevel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Permission level chosen (default/autoApprove/autopilot).' }; + permissionLevel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Legacy permission-level alias when available (default/assisted/autoApprove/autopilot).' }; isolationMode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode chosen (workspace/worktree).' }; enabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the automation was created in the enabled state.' }; owner: 'benvillalobos'; @@ -98,7 +98,7 @@ type AutomationRunClassification = { intervalKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Cadence of the automation that ran.' }; success: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the run completed without error.' }; durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Wall-clock duration of the run kickoff (recordRunStart through completed/failed).' }; - permissionLevel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Permission level applied to the run (default/autoApprove/autopilot).' }; + permissionLevel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Legacy permission-level alias applied to the run when available (default/assisted/autoApprove/autopilot).' }; isolationMode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode applied to the run (workspace/worktree).' }; owner: 'benvillalobos'; comment: 'Tracks Automations run outcomes and timing.'; diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index b675ac35e43742..b37777bc2b6d6b 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -545,6 +545,15 @@ export interface ILanguageModelsGroup { }; } +/** Read/write access to model-specific configuration, globally or within one conversation. */ +export interface IModelConfigurationAccess { + getModelConfiguration(modelId: string): IStringDictionary | undefined; + setModelConfiguration(modelId: string, values: IStringDictionary): Promise; + getModelConfigurationActions(modelId: string): IAction[]; + /** Configuration changes within this scope; global access uses `onDidChangeLanguageModels`. */ + readonly onDidChange?: Event; +} + export interface ILanguageModelsService { readonly _serviceBrand: undefined; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts index 38846533a203fc..c8361279657426 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts @@ -23,6 +23,7 @@ import { BrowserEditorInput } from '../../../../browserView/common/browserEditor import { IBrowserViewModel, IBrowserViewWorkbenchService } from '../../../../browserView/common/browserView.js'; import { IEditorService } from '../../../../../services/editor/common/editorService.js'; import { CHAT_SUBAGENT_RESOURCE_QUERY_PARAM } from '../../../common/constants.js'; +import { type IChatWidgetViewModelChangeEvent } from '../../../browser/chat.js'; import { AgentHostSessionInputPills, getAgentHostSessionBrowserOwnerIds, getAgentHostSessionPillMetadata, resolveAgentHostSessionChangeset } from '../../../browser/agentSessions/agentHost/agentHostSessionInputPills.js'; import { ISessionChatPillVisibilityService, SessionChatPillKind } from '../../../common/sessionChatPills.js'; import { chatPersistentContentVisibleClass, ChatWidget } from '../../../browser/widget/chatWidget.js'; @@ -608,4 +609,106 @@ suite('AgentHostSessionInputPills', () => { empty: false, }); }); + + test('hides the session pills in a subagent chat', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const sessionResource = URI.parse('agent-host-copilot:/session'); + const backendSession = URI.parse('copilot:/session'); + const defaultChat = buildDefaultChatUri(backendSession); + const subagentChat = buildSubagentChatUri(backendSession, 'tool-1'); + const connection = new StaticAgentConnection(new Map([ + [StateComponents.Session, { + defaultChat, + chats: [{ + resource: subagentChat, + origin: { kind: ChatOriginKind.Tool, chat: defaultChat, toolCallId: 'tool-1' }, + }], + _meta: withSessionArtifacts(undefined, [{ + id: 'preview', + type: SessionArtifactType.Website, + label: 'Preview', + link: 'https://example.com/preview', + isArtifact: true, + }]), + } as unknown as SessionState], + ])); + const connectionsService = upcastPartial({ + onDidChangeConnections: Event.None, + onDidChangeSessionResolution: Event.None, + connections: [], + resolveSessionResource: () => ({ connection, connectionAuthority: 'local', backendSession }), + }); + const browserViewService = upcastPartial({ + onDidChangeBrowserViews: Event.None, + getKnownBrowserViews: () => new Map(), + }); + const visibility = upcastPartial({ + readHiddenKinds: () => new Set(), + isVisible: () => true, + hide: () => { }, + toggle: () => { }, + }); + instantiationService.stub(ISessionChatPillVisibilityService, visibility); + const [clipboardService, configurationService, editorService, openerService] = instantiationService.invokeFunction(accessor => [ + accessor.get(IClipboardService), + accessor.get(IConfigurationService), + accessor.get(IEditorService), + accessor.get(IOpenerService), + ] as const); + const persistentContent = document.createElement('div'); + document.body.appendChild(persistentContent); + store.add(toDisposable(() => persistentContent.remove())); + let persistentContentHeight: number | undefined; + // The chat editor keeps one pills instance while its widget navigates + // between the session and one of its subagent chats. + let viewModel = upcastPartial({ sessionResource }); + const viewModelChanged = store.add(new Emitter()); + const widget = upcastPartial({ + inputPart: upcastPartial({ + persistentContentContainerElement: persistentContent, + registerChatPetHorizontalPlatformProvider: () => Disposable.None, + }), + onDidChangeViewModel: viewModelChanged.event, + get viewModel() { return viewModel; }, + setPersistentContentHeight: height => persistentContentHeight = height, + }); + store.add(new AgentHostSessionInputPills( + widget, + false, + connectionsService, + browserViewService, + clipboardService, + configurationService, + editorService, + instantiationService, + openerService, + visibility, + )); + const showChat = (resource: URI) => { + const previousSessionResource = viewModel.sessionResource; + viewModel = upcastPartial({ sessionResource: resource }); + viewModelChanged.fire({ previousSessionResource, currentSessionResource: resource }); + return { + pills: Array.from(persistentContent.querySelectorAll('.chat-pill-label')).map(label => label.textContent), + hidden: persistentContent.querySelector('.agent-host-session-input-pills')?.classList.contains('hidden'), + persistentContentHeight, + }; + }; + const explicitQuery = new URLSearchParams(); + explicitQuery.set(CHAT_SUBAGENT_RESOURCE_QUERY_PARAM, subagentChat); + + assert.deepStrictEqual({ + session: showChat(sessionResource), + // The subagent editor addresses its chat by query parameter, and by + // fragment alone once the session state resolves the chat id. + explicitSubagent: showChat(sessionResource.with({ fragment: 'subagent/tool-1', query: explicitQuery.toString() })), + canonicalSubagent: showChat(sessionResource.with({ fragment: 'subagent/tool-1' })), + backToSession: showChat(sessionResource), + }, { + session: { pills: ['1 Artifact'], hidden: false, persistentContentHeight: 28 }, + explicitSubagent: { pills: [], hidden: true, persistentContentHeight: undefined }, + canonicalSubagent: { pills: [], hidden: true, persistentContentHeight: undefined }, + backToSession: { pills: ['1 Artifact'], hidden: false, persistentContentHeight: 28 }, + }); + }); }); 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 ba552893d72364..05cf007f81d14e 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 @@ -113,7 +113,7 @@ import { ChatQuestionCarouselData } from '../../../common/model/chatProgressType import { ChatPlanReviewData } from '../../../common/model/chatProgressTypes/chatPlanReviewData.js'; import { ChatElicitationRequestPart } from '../../../common/model/chatProgressTypes/chatElicitationRequestPart.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; -import type { IChatModel, IChatModelInputState, IChatPendingRequest, IChatRequestModel, IInputModel } from '../../../common/model/chatModel.js'; +import { reviveSerializableInputState, type IChatModel, type IChatModelInputState, type IChatPendingRequest, type IChatRequestModel, type IInputModel } from '../../../common/model/chatModel.js'; import { convertBufferToScreenshotVariable } from '../../../browser/attachments/chatScreenshotContext.js'; import { AgentHostCompletionReferenceKind, ChatPasteAttachmentMetadata, createChatReferenceVariableEntry, isChatReferenceVariableEntry, toAgentHostCompletionVariableEntry, type IChatRequestVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { messageAttachmentsToVariableData } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; @@ -2093,10 +2093,14 @@ suite('AgentHostChatContribution', () => { }); test('creates an empty draft from the last request selection when none exists', async () => { + const modelConfiguration = { thinkingLevel: 'low', contextSize: 272_000 }; const languageModels = new Map([ ['agent-host-copilot:opus-4.7', upcastPartial({ id: 'opus-4.7', name: 'Opus 4.7' })], ]); - const { sessionHandler, agentHostService } = createContribution(disposables, { languageModels }); + const { sessionHandler, agentHostService } = createContribution(disposables, { + languageModels, + languageModelsServiceOverride: { getModelConfiguration: () => ({ thinkingLevel: 'high' }) }, + }); const backendSession = AgentSession.uri('copilot', 'last-selection-session'); const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/last-selection-session' }); @@ -2116,8 +2120,8 @@ suite('AgentHostChatContribution', () => { id: 'turn-1', message: { text: 'previous request', - origin: { kind: MessageKind.User }, - model: { id: 'opus-4.7' }, + origin: { kind: MessageKind.Automation }, + model: { id: 'opus-4.7', config: modelConfiguration }, agent: { uri: 'agent://reviewer' }, }, responseParts: [], @@ -2128,22 +2132,26 @@ suite('AgentHostChatContribution', () => { const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); disposables.add(toDisposable(() => chatSession.dispose())); + const serializedState = chatSession.transferredState?.inputState; + const restoredState = serializedState ? reviveSerializableInputState(serializedState) : undefined; assert.deepStrictEqual({ inputText: chatSession.transferredState?.inputState?.inputText, model: chatSession.transferredState?.inputState?.selectedModel?.identifier, mode: chatSession.transferredState?.inputState?.mode, + modelConfiguration: restoredState?.modelConfiguration, draftAction: agentHostService.dispatchedActions.find(d => d.action.type === ActionType.ChatDraftChanged)?.action, }, { inputText: '', model: 'agent-host-copilot:opus-4.7', mode: { id: 'agent://reviewer', kind: ChatModeKind.Agent }, + modelConfiguration, draftAction: { type: ActionType.ChatDraftChanged, draft: { text: '', origin: { kind: MessageKind.User }, - model: { id: 'opus-4.7' }, + model: { id: 'opus-4.7', config: modelConfiguration }, agent: { uri: 'agent://reviewer' }, }, }, 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 eb4c3d7fc36027..6c24ea33366422 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 @@ -123,6 +123,12 @@ function updateRunningToolSpecificData(existing: Parameters[0]): string | IMarkdownString | undefined { + const state = invocation.state.get(); + return state.type === IChatToolInvocation.StateKind.Executing ? state.progress.get().message : undefined; +} + function assertInputOutputDetails(details: unknown): asserts details is IToolResultInputOutputDetails { assert.ok(isToolResultInputOutputDetails(details)); } @@ -2246,6 +2252,16 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(value, 'Read [](vscode-agent-host://ssh__macbook-air/path/to/foo.ts?_ah%3DeyJzY2hlbWUiOiJmaWxlIn0)'); }); + test('does not promote transient progress to the past-tense message', () => { + const withPastTense = toolCallStateToInvocation(createToolCallState()); + updateRunningToolSpecificData(withPastTense, createToolCallState({ _meta: { progressMessage: 'Searching' } })); + finalizeToolInvocation(withPastTense, createCompletedToolCall({ pastTenseMessage: 'Called test tool' })); + const withoutPastTense = toolCallStateToInvocation(createToolCallState()); + updateRunningToolSpecificData(withoutPastTense, createToolCallState({ _meta: { progressMessage: 'Searching' } })); + finalizeToolInvocation(withoutPastTense, createCompletedToolCall({ pastTenseMessage: undefined })); + assert.deepStrictEqual({ withPastTense: withPastTense.pastTenseMessage, withoutPastTense: withoutPastTense.pastTenseMessage }, { withPastTense: 'Called test tool', withoutPastTense: undefined }); + }); + test('finalizes pty terminal tool with compatibility output and exit code', () => { const tc = createToolCallState({ toolInput: 'echo hi', @@ -3578,6 +3594,30 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(termData.terminalCommandId, 'cmd-id-from-revive'); assert.strictEqual(termData.terminalCommandOutput?.text, 'hi\r\n'); }); + + test('applies _meta.progressMessage to the executing invocation progress', () => { + const updated = toolCallStateToInvocation(createToolCallState()); + updateRunningToolSpecificData(updated, createToolCallState({ _meta: { progressMessage: 'Searching' } })); + const seeded = toolCallStateToInvocation(createToolCallState({ _meta: { progressMessage: 'Ranking' } })); + assert.deepStrictEqual({ updated: executingProgressMessage(updated), seeded: executingProgressMessage(seeded) }, { updated: 'Searching', seeded: 'Ranking' }); + }); + + test('an empty _meta.progressMessage clears the message already shown', () => { + const invocation = toolCallStateToInvocation(createToolCallState()); + updateRunningToolSpecificData(invocation, createToolCallState({ _meta: { progressMessage: 'Searching' } })); + updateRunningToolSpecificData(invocation, createToolCallState({ _meta: { progressMessage: '' } })); + const seeded = toolCallStateToInvocation(createToolCallState({ _meta: { progressMessage: '' } })); + assert.deepStrictEqual({ cleared: executingProgressMessage(invocation), seeded: executingProgressMessage(seeded) }, { cleared: '', seeded: '' }); + }); + + test('keeps the current progress when _meta.progressMessage is missing or not a string', () => { + const invocation = toolCallStateToInvocation(createToolCallState()); + updateRunningToolSpecificData(invocation, createToolCallState({ _meta: { progressMessage: 'Searching' } })); + updateRunningToolSpecificData(invocation, createToolCallState({ _meta: { progressMessage: 42 } })); + updateRunningToolSpecificData(invocation, createToolCallState()); + const untouched = toolCallStateToInvocation(createToolCallState({ _meta: { progressMessage: 42 } })); + assert.deepStrictEqual({ kept: executingProgressMessage(invocation), untouched: executingProgressMessage(untouched) }, { kept: 'Searching', untouched: undefined }); + }); }); suite('usageInfoToQuotas', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts index 8aa755efa60993..23d7ffcdca2eb0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts @@ -270,6 +270,25 @@ suite('ChatMarkdownContentPart', () => { ); }); + test('preserves absolute Windows targets for newly created file links', () => { + disposables.add(chatSessionsService.registerChatSessionContentProvider('chat-session', { + provideChatSessionContent: async () => { throw new Error('Unexpected session resolution'); }, + resolveChatResponseUri: (_resource, href) => rewriteAgentHostLinkTarget(href, 'windows-host'), + })); + + const files = [ + 'C:/Repos/3/vscode/simple.txt', + 'C:/my project/simple.txt', + '//server/share/simple.txt', + ]; + const part = createMarkdownPart(files.map(path => `Created [simple.txt](<${path}>).`).join('\n')); + + assert.deepStrictEqual( + Array.from(part.domNode.querySelectorAll('a'), link => ({ text: link.textContent, href: link.dataset.href })), + files.map(path => ({ text: 'simple.txt', href: toAgentHostUri(URI.file(path), 'windows-host').toString() })), + ); + }); + test('renders plain markdown without code blocks', () => { const part = createMarkdownPart('Hello, world!'); diff --git a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts index 9e1f0e696f3c4e..e5c48220407969 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts @@ -130,7 +130,9 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa await this.updateResultCount(); } this.updateButtons(this._foundMatch); - this.focusFindBox(); + if (!options.checkImeCompletionState || !e.fromCompositionEnd) { + this.focusFindBox(); + } this._delayedUpdateHistory(); } })); @@ -412,6 +414,9 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa } protected focusFindBox() { + if (this._findInput.isImeSessionInProgress) { + return; + } // Focus back onto the find box, which // requires focusing onto the next button first this.nextBtn.focus(); diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts index b06c588bd0ecd7..0548c2e8f79ea6 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts @@ -55,7 +55,7 @@ import { IEditorService } from '../../../services/editor/common/editorService.js import { EnablementState, IExtensionManagementServerService, IPublisherInfo, IWorkbenchExtensionEnablementService, IWorkbenchExtensionManagementService } from '../../../services/extensionManagement/common/extensionManagement.js'; import { IExtensionIgnoredRecommendationsService, IExtensionRecommendationsService } from '../../../services/extensionRecommendations/common/extensionRecommendations.js'; import { IWorkspaceExtensionsConfigService } from '../../../services/extensionRecommendations/common/workspaceExtensionsConfig.js'; -import { EXTENSIONS_SUPPORT_AGENTS_WINDOW } from '../../../services/extensions/common/extensionManifestPropertiesService.js'; +import { EXTENSIONS_ENABLE_AGENTS_WINDOW_CAPABILITY, EXTENSIONS_SUPPORT_AGENTS_WINDOW } from '../../../services/extensions/common/extensionManifestPropertiesService.js'; import { IHostService } from '../../../services/host/browser/host.js'; import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; import { IPreferencesService } from '../../../services/preferences/common/preferences.js'; @@ -266,6 +266,15 @@ Registry.as(ConfigurationExtensions.Configuration) } }] }, + [EXTENSIONS_ENABLE_AGENTS_WINDOW_CAPABILITY]: { + type: 'boolean', + scope: ConfigurationScope.APPLICATION, + description: localize('extensions.experimental.enableAgentsWindowCapability', "When enabled, extensions can declare whether they support running in the Agents window."), + default: false, + tags: ['experimental'], + experiment: { mode: 'startup' }, + agentsWindow: { default: false } + }, 'extensions.experimental.affinity': { type: 'object', markdownDescription: localize('extensions.affinity', "Configure an extension to execute in a different extension host process."), diff --git a/src/vs/workbench/contrib/interactive/browser/interactive.contribution.ts b/src/vs/workbench/contrib/interactive/browser/interactive.contribution.ts index c6dd6f89ab4d2f..b9eb768618f214 100644 --- a/src/vs/workbench/contrib/interactive/browser/interactive.contribution.ts +++ b/src/vs/workbench/contrib/interactive/browser/interactive.contribution.ts @@ -51,7 +51,7 @@ import { INotebookEditorService } from '../../notebook/browser/services/notebook import { CellEditType, CellKind, CellUri, INTERACTIVE_WINDOW_EDITOR_ID, NotebookSetting, NotebookWorkingCopyTypeIdentifier } from '../../notebook/common/notebookCommon.js'; import { InteractiveWindowOpen, IS_COMPOSITE_NOTEBOOK, NOTEBOOK_EDITOR_FOCUSED } from '../../notebook/common/notebookContextKeys.js'; import { INotebookKernelService } from '../../notebook/common/notebookKernelService.js'; -import { INotebookService } from '../../notebook/common/notebookService.js'; +import { registerBuiltinNotebookType } from '../../notebook/common/notebookTypeRegistry.js'; import { columnToEditorGroup } from '../../../services/editor/common/editorGroupColumn.js'; import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; import { IEditorResolverService, RegisteredEditorPriority } from '../../../services/editor/common/editorResolverService.js'; @@ -76,30 +76,24 @@ Registry.as(EditorExtensions.EditorPane).registerEditorPane ] ); +registerBuiltinNotebookType('interactive', { + providerDisplayName: 'Interactive Notebook', + displayName: 'Interactive', + filenamePattern: ['*.interactive'], + priority: RegisteredEditorPriority.builtin +}); + export class InteractiveDocumentContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.interactiveDocument'; constructor( - @INotebookService notebookService: INotebookService, @IEditorResolverService editorResolverService: IEditorResolverService, @IEditorService editorService: IEditorService, @IInstantiationService private readonly instantiationService: IInstantiationService ) { super(); - const info = notebookService.getContributedNotebookType('interactive'); - - // We need to contribute a notebook type for the Interactive Window to provide notebook models. - if (!info) { - this._register(notebookService.registerContributedNotebookType('interactive', { - providerDisplayName: 'Interactive Notebook', - displayName: 'Interactive', - filenamePattern: ['*.interactive'], - priority: RegisteredEditorPriority.builtin - })); - } - editorResolverService.registerEditor( `${Schemas.vscodeInteractiveInput}:/**`, { diff --git a/src/vs/workbench/contrib/markers/browser/markersTable.ts b/src/vs/workbench/contrib/markers/browser/markersTable.ts index 59ed734a3ed97d..d0be59eed89372 100644 --- a/src/vs/workbench/contrib/markers/browser/markersTable.ts +++ b/src/vs/workbench/contrib/markers/browser/markersTable.ts @@ -297,7 +297,7 @@ export class MarkersTable extends Disposable implements IProblemsWidget { ) { super(); - this.table = this.instantiationService.createInstance(WorkbenchTable, + this.table = this._register(this.instantiationService.createInstance(WorkbenchTable, 'Markers', this.container, new MarkersTableVirtualDelegate(), @@ -352,7 +352,7 @@ export class MarkersTable extends Disposable implements IProblemsWidget { this.instantiationService.createInstance(MarkerSourceColumnRenderer), ], options - ) as WorkbenchTable; + ) as WorkbenchTable); // eslint-disable-next-line no-restricted-syntax const list = this.table.domNode.querySelector('.monaco-list-rows')! as HTMLElement; diff --git a/src/vs/workbench/contrib/notebook/browser/services/notebookServiceImpl.ts b/src/vs/workbench/contrib/notebook/browser/services/notebookServiceImpl.ts index 7be2de7e36ac78..b0ef57932be391 100644 --- a/src/vs/workbench/contrib/notebook/browser/services/notebookServiceImpl.ts +++ b/src/vs/workbench/contrib/notebook/browser/services/notebookServiceImpl.ts @@ -34,6 +34,7 @@ import { INotebookEditorModelResolverService } from '../../common/notebookEditor import { NotebookOutputRendererInfo, NotebookStaticPreloadInfo } from '../../common/notebookOutputRenderer.js'; import { NotebookEditorDescriptor, NotebookProviderInfo } from '../../common/notebookProvider.js'; import { INotebookSerializer, INotebookService, SimpleNotebookProviderInfo } from '../../common/notebookService.js'; +import { getBuiltinNotebookTypes, IBuiltinNotebookType } from '../../common/notebookTypeRegistry.js'; import { DiffEditorInputFactoryFunction, EditorInputFactoryFunction, EditorInputFactoryObject, IEditorResolverService, IEditorType, RegisteredEditorPriority, RegisteredEditorRegistrationInfo, UntitledEditorInputFactoryFunction, type MergeEditorInputFactoryFunction } from '../../../../services/editor/common/editorResolverService.js'; import { IExtensionService, isProposedApiEnabled } from '../../../../services/extensions/common/extensions.js'; import { IExtensionPointUser } from '../../../../services/extensions/common/extensionsRegistry.js'; @@ -50,6 +51,19 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { CancellationError } from '../../../../../base/common/errors.js'; import { ICellRange } from '../../common/notebookRange.js'; +function createNotebookProviderInfo(viewType: string, data: INotebookContributionData): NotebookProviderInfo { + const info = new NotebookProviderInfo({ + extension: data.extension, + id: viewType, + displayName: data.displayName, + providerDisplayName: data.providerDisplayName, + priority: data.priority || RegisteredEditorPriority.default, + selectors: [] + }); + info.update({ selectors: data.filenamePattern }); + return info; +} + interface NotebookProviderInfoStoreMemento { editors: NotebookProviderInfo[]; } @@ -81,10 +95,16 @@ export class NotebookProviderInfoStore extends Disposable { this._memento = new Memento(NotebookProviderInfoStore.CUSTOM_EDITORS_STORAGE_ID, storageService); const mementoObject = this._memento.getMemento(StorageScope.PROFILE, StorageTarget.MACHINE); + for (const notebookType of getBuiltinNotebookTypes()) { + this._addBuiltinNotebookType(notebookType); + } + // Process the notebook contributions but buffer changes from the resolver this._editorResolverService.bufferChangeEvents(() => { for (const info of (mementoObject[NotebookProviderInfoStore.CUSTOM_EDITORS_ENTRY_ID] || []) as NotebookEditorDescriptor[]) { - this.add(new NotebookProviderInfo(info), false); + if (!this._contributedEditors.has(info.id)) { + this.add(new NotebookProviderInfo(info), false); + } } }); @@ -98,7 +118,13 @@ export class NotebookProviderInfoStore extends Disposable { } })); - notebooksExtensionPoint.setHandler(extensions => this._setupHandler(extensions)); + this._register(notebooksExtensionPoint.setHandler(extensions => this._setupHandler(extensions))); + } + + private _addBuiltinNotebookType({ viewType, data }: IBuiltinNotebookType): void { + if (!this._contributedEditors.has(viewType)) { + this.add(createNotebookProviderInfo(viewType, data), false); + } } override dispose(): void { @@ -727,18 +753,7 @@ export class NotebookService extends Disposable implements INotebookService { } registerContributedNotebookType(viewType: string, data: INotebookContributionData): IDisposable { - - const info = new NotebookProviderInfo({ - extension: data.extension, - id: viewType, - displayName: data.displayName, - providerDisplayName: data.providerDisplayName, - priority: data.priority || RegisteredEditorPriority.default, - selectors: [] - }); - - info.update({ selectors: data.filenamePattern }); - + const info = createNotebookProviderInfo(viewType, data); const reg = this.notebookProviderInfoStore.add(info); this._onDidChangeEditorTypes.fire(); diff --git a/src/vs/workbench/contrib/notebook/common/notebookTypeRegistry.ts b/src/vs/workbench/contrib/notebook/common/notebookTypeRegistry.ts new file mode 100644 index 00000000000000..f9e8e984f08dc1 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/common/notebookTypeRegistry.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { INotebookContributionData } from './notebookCommon.js'; + +export interface IBuiltinNotebookType { + readonly viewType: string; + readonly data: INotebookContributionData; +} + +interface IBuiltinNotebookTypeRegistry { + register(viewType: string, data: INotebookContributionData): IDisposable; + getAll(): Iterable; +} + +const notebookTypeRegistryId = 'workbench.contributions.builtinNotebookTypes'; + +class BuiltinNotebookTypeRegistry implements IBuiltinNotebookTypeRegistry { + private readonly notebookTypes = new Map(); + + register(viewType: string, data: INotebookContributionData): IDisposable { + if (this.notebookTypes.has(viewType)) { + throw new Error(`Built-in notebook type '${viewType}' is already registered`); + } + + this.notebookTypes.set(viewType, { viewType, data }); + return toDisposable(() => this.notebookTypes.delete(viewType)); + } + + getAll(): Iterable { + return this.notebookTypes.values(); + } +} + +Registry.add(notebookTypeRegistryId, new BuiltinNotebookTypeRegistry()); + +export function registerBuiltinNotebookType(viewType: string, data: INotebookContributionData): IDisposable { + return Registry.as(notebookTypeRegistryId).register(viewType, data); +} + +export function getBuiltinNotebookTypes(): Iterable { + return Registry.as(notebookTypeRegistryId).getAll(); +} diff --git a/src/vs/workbench/contrib/notebook/test/browser/notebookServiceImpl.test.ts b/src/vs/workbench/contrib/notebook/test/browser/notebookServiceImpl.test.ts index 1099d6beeae479..22f06b63d487c3 100644 --- a/src/vs/workbench/contrib/notebook/test/browser/notebookServiceImpl.test.ts +++ b/src/vs/workbench/contrib/notebook/test/browser/notebookServiceImpl.test.ts @@ -17,6 +17,7 @@ import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/ import { NotebookProviderInfoStore } from '../../browser/services/notebookServiceImpl.js'; import { INotebookEditorModelResolverService } from '../../common/notebookEditorModelResolverService.js'; import { NotebookProviderInfo } from '../../common/notebookProvider.js'; +import { registerBuiltinNotebookType } from '../../common/notebookTypeRegistry.js'; import { EditorResolverService } from '../../../../services/editor/browser/editorResolverService.js'; import { RegisteredEditorPriority } from '../../../../services/editor/common/editorResolverService.js'; import { IExtensionService, nullExtensionDescription } from '../../../../services/extensions/common/extensions.js'; @@ -25,13 +26,13 @@ import { workbenchInstantiationService } from '../../../../test/browser/workbenc suite('NotebookProviderInfoStore', function () { const disposables = ensureNoDisposablesAreLeakedInTestSuite() as Pick; - test('Can\'t open untitled notebooks in test #119363', function () { + function createStore(memento: { editors?: NotebookProviderInfo[] } = {}): NotebookProviderInfoStore { const instantiationService = workbenchInstantiationService(undefined, disposables); - const store = new NotebookProviderInfoStore( + return disposables.add(new NotebookProviderInfoStore( new class extends mock() { override get() { return ''; } override store() { } - override getObject() { return {}; } + override getObject() { return memento; } }, new class extends mock() { override onDidRegisterExtensions = Event.None; @@ -47,8 +48,11 @@ suite('NotebookProviderInfoStore', function () { }, new class extends mock() { }, new class extends mock() { } - ); - disposables.add(store); + )); + } + + test('Can\'t open untitled notebooks in test #119363', function () { + const store = createStore(); const fooInfo = new NotebookProviderInfo({ extension: nullExtensionDescription.identifier, @@ -92,4 +96,38 @@ suite('NotebookProviderInfoStore', function () { assert.strictEqual(providers[0] === barInfo, true); }); + test('declarative built-in notebook types take precedence over stale memento entries', function () { + disposables.add(registerBuiltinNotebookType('test.builtinNotebook', { + providerDisplayName: 'Built-in Notebook', + displayName: 'Built-in Notebook', + filenamePattern: ['*.builtinNotebook'], + priority: RegisteredEditorPriority.builtin + })); + + const store = createStore({ + editors: [new NotebookProviderInfo({ + id: 'test.builtinNotebook', + displayName: 'Stale Notebook', + providerDisplayName: 'Stale Notebook', + priority: RegisteredEditorPriority.default, + selectors: [{ filenamePattern: '*.staleNotebook' }] + })] + }); + const info = store.get('test.builtinNotebook'); + + assert.deepStrictEqual({ + id: info?.id, + displayName: info?.displayName, + providerDisplayName: info?.providerDisplayName, + priority: info?.priority, + selectors: info?.selectors + }, { + id: 'test.builtinNotebook', + displayName: 'Built-in Notebook', + providerDisplayName: 'Built-in Notebook', + priority: RegisteredEditorPriority.builtin, + selectors: ['*.builtinNotebook'] + }); + }); + }); diff --git a/src/vs/workbench/contrib/webview/test/browser/webviewFindWidget.test.ts b/src/vs/workbench/contrib/webview/test/browser/webviewFindWidget.test.ts new file mode 100644 index 00000000000000..4f1922dfebaca9 --- /dev/null +++ b/src/vs/workbench/contrib/webview/test/browser/webviewFindWidget.test.ts @@ -0,0 +1,140 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { timeout } 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 { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { WebviewFindDelegate, WebviewFindWidget } from '../../browser/webviewFindWidget.js'; + +suite('WebviewFindWidget', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + let input: HTMLInputElement; + let nextButton: HTMLElement; + let hasFindResult: Emitter; + let searches: string[]; + let inputFocusSpy: sinon.SinonSpy; + let nextButtonFocusSpy: sinon.SinonSpy; + + setup(async () => { + searches = []; + hasFindResult = store.add(new Emitter()); + const delegate: WebviewFindDelegate = { + hasFindResult: hasFindResult.event, + onDidStopFind: Event.None, + checkImeCompletionState: true, + find: () => { }, + updateFind: value => { searches.push(value); }, + stopFind: () => { }, + focus: () => { }, + }; + + const container = document.createElement('div'); + container.className = 'monaco-workbench'; + document.body.appendChild(container); + store.add(toDisposable(() => container.remove())); + + const instantiationService = workbenchInstantiationService(undefined, store); + const widget = store.add(instantiationService.createInstance(WebviewFindWidget, delegate)); + container.appendChild(widget.getDomNode()); + widget.show(); + await timeout(0); + + const inputElement = widget.getFindInputDomNode().querySelector('input'); + const nextButtonElement = widget.getDomNode().querySelector('.codicon-find-next-match'); + assert.ok(inputElement); + assert.ok(nextButtonElement); + input = inputElement; + nextButton = nextButtonElement; + input.focus(); + assert.strictEqual(document.activeElement, input); + + // An unfocused Electron test window does not reliably dispatch DOM focus events. + inputFocusSpy = sinon.spy(input, 'focus'); + nextButtonFocusSpy = sinon.spy(nextButton, 'focus'); + }); + + teardown(() => { + sinon.restore(); + }); + + test('compositionend updates search without moving focus', () => { + input.dispatchEvent(new CompositionEvent('compositionstart')); + input.value = 'ㅇ'; + input.dispatchEvent(new InputEvent('input', { isComposing: true })); + const searchesDuringComposition = [...searches]; + + // Chromium delivers the committed input before compositionend, while still composing. + input.value = '안'; + input.dispatchEvent(new InputEvent('input', { isComposing: true })); + input.dispatchEvent(new CompositionEvent('compositionend', { data: '안' })); + + assert.deepStrictEqual({ + searchesDuringComposition, + searches, + inputFocusCalls: inputFocusSpy.callCount, + nextButtonFocusCalls: nextButtonFocusSpy.callCount, + inputHasFocus: document.activeElement === input, + }, { + searchesDuringComposition: [], + searches: ['안'], + inputFocusCalls: 0, + nextButtonFocusCalls: 0, + inputHasFocus: true, + }); + }); + + test('find results do not move focus after a new composition starts', () => { + input.dispatchEvent(new CompositionEvent('compositionstart')); + input.value = '안'; + input.dispatchEvent(new CompositionEvent('compositionend', { data: '안' })); + + input.dispatchEvent(new CompositionEvent('compositionstart')); + input.value = '안ㄴ'; + input.dispatchEvent(new InputEvent('input', { isComposing: true })); + inputFocusSpy.resetHistory(); + nextButtonFocusSpy.resetHistory(); + + // Deliver the previous search result after the next syllable has started composing. + hasFindResult.fire(true); + + assert.deepStrictEqual({ + searches, + nextButtonEnabled: nextButton.getAttribute('aria-disabled') === 'false', + inputFocusCalls: inputFocusSpy.callCount, + nextButtonFocusCalls: nextButtonFocusSpy.callCount, + inputHasFocus: document.activeElement === input, + }, { + searches: ['안'], + nextButtonEnabled: true, + inputFocusCalls: 0, + nextButtonFocusCalls: 0, + inputHasFocus: true, + }); + }); + + test('normal input still updates search and restores focus', () => { + input.value = 'hello'; + input.dispatchEvent(new InputEvent('input', { data: 'hello' })); + + assert.deepStrictEqual({ + searches, + inputFocusCalls: inputFocusSpy.callCount, + nextButtonFocusCalls: nextButtonFocusSpy.callCount, + inputFocusedAfterNextButton: inputFocusSpy.calledAfter(nextButtonFocusSpy), + inputHasFocus: document.activeElement === input, + }, { + searches: ['hello'], + inputFocusCalls: 1, + nextButtonFocusCalls: 1, + inputFocusedAfterNextButton: true, + inputHasFocus: true, + }); + }); +}); diff --git a/src/vs/workbench/contrib/webviewView/browser/webviewViewPane.ts b/src/vs/workbench/contrib/webviewView/browser/webviewViewPane.ts index ec2ba39e773aa9..f0cac4216bdf9e 100644 --- a/src/vs/workbench/contrib/webviewView/browser/webviewViewPane.ts +++ b/src/vs/workbench/contrib/webviewView/browser/webviewViewPane.ts @@ -257,6 +257,8 @@ export class WebviewViewPane extends ViewPane { priority: 150 }; this.activity.value = this.activityService.showViewActivity(this.id, activity); + } else { + this.activity.clear(); } } diff --git a/src/vs/workbench/contrib/webviewView/test/browser/webviewViewPane.test.ts b/src/vs/workbench/contrib/webviewView/test/browser/webviewViewPane.test.ts new file mode 100644 index 00000000000000..22eb2eb0331b6f --- /dev/null +++ b/src/vs/workbench/contrib/webviewView/test/browser/webviewViewPane.test.ts @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IViewBadge, IViewDescriptorService, ViewContainerLocation } from '../../../../common/views.js'; +import { IActivityService } from '../../../../services/activity/common/activity.js'; +import { IViewsService } from '../../../../services/views/common/viewsService.js'; +import { TestViewsService, workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { IWebviewService } from '../../../webview/browser/webview.js'; +import { WebviewViewPane } from '../../browser/webviewViewPane.js'; +import { IWebviewViewService } from '../../browser/webviewViewService.js'; + +class TestViewDescriptorService extends mock() { + override readonly onDidChangeLocation = Event.None; + + override getViewLocationById(): ViewContainerLocation { + return ViewContainerLocation.Sidebar; + } +} + +class TestWebviewViewPane extends WebviewViewPane { + setBadge(badge: IViewBadge | undefined): void { + this.updateBadge(badge); + } +} + +suite('WebviewViewPane', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('clears view activity when badge is removed', () => { + const activityEvents: string[] = []; + const instantiationService = workbenchInstantiationService(undefined, store); + instantiationService.stub(IViewDescriptorService, new TestViewDescriptorService()); + instantiationService.stub(IActivityService, new class extends mock() { + override showViewActivity(viewId: string) { + activityEvents.push(`show:${viewId}`); + return toDisposable(() => activityEvents.push(`dispose:${viewId}`)); + } + }); + instantiationService.stub(IViewsService, new TestViewsService()); + instantiationService.stub(IWebviewService, new class extends mock() { }); + instantiationService.stub(IWebviewViewService, new class extends mock() { + override readonly onNewResolverRegistered = Event.None; + }); + + const pane = store.add(instantiationService.createInstance(TestWebviewViewPane, { + id: 'testWebviewView', + title: 'Test Webview View', + })); + + pane.setBadge({ value: 1, tooltip: 'Test badge' }); + pane.setBadge(undefined); + + assert.deepStrictEqual(activityEvents, [ + 'show:testWebviewView', + 'dispose:testWebviewView', + ]); + }); +}); diff --git a/src/vs/workbench/services/extensionManagement/test/browser/extensionEnablementService.test.ts b/src/vs/workbench/services/extensionManagement/test/browser/extensionEnablementService.test.ts index 58f7b7df4483e2..ecfc714e3d3d29 100644 --- a/src/vs/workbench/services/extensionManagement/test/browser/extensionEnablementService.test.ts +++ b/src/vs/workbench/services/extensionManagement/test/browser/extensionEnablementService.test.ts @@ -30,7 +30,7 @@ import { IHostService } from '../../../host/browser/host.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { IExtensionBisectService } from '../../browser/extensionBisect.js'; import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService, WorkspaceTrustRequestOptions } from '../../../../../platform/workspace/common/workspaceTrust.js'; -import { EXTENSIONS_SUPPORT_AGENTS_WINDOW, ExtensionManifestPropertiesService, IExtensionManifestPropertiesService } from '../../../extensions/common/extensionManifestPropertiesService.js'; +import { EXTENSIONS_ENABLE_AGENTS_WINDOW_CAPABILITY, EXTENSIONS_SUPPORT_AGENTS_WINDOW, ExtensionManifestPropertiesService, IExtensionManifestPropertiesService } from '../../../extensions/common/extensionManifestPropertiesService.js'; import { TestChatEntitlementService, TestContextService, TestProductService, TestWorkspaceTrustEnablementService, TestWorkspaceTrustManagementService } from '../../../../test/common/workbenchTestServices.js'; import { TestWorkspace } from '../../../../../platform/workspace/test/common/testWorkspace.js'; import { ExtensionManagementService } from '../../common/extensionManagementService.js'; @@ -1294,7 +1294,8 @@ suite('ExtensionEnablementService Test', () => { ]); }); - test('test extensions declaring agents window support are enabled in sessions window', () => { + test('test extensions declaring agents window support are enabled in sessions window', async () => { + await (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(EXTENSIONS_ENABLE_AGENTS_WINDOW_CAPABILITY, true); instantiationService.stub(IWorkbenchEnvironmentService, { isSessionsWindow: true }); testObject = disposableStore.add(new TestExtensionEnablementService(instantiationService)); diff --git a/src/vs/workbench/services/extensions/common/extensionManifestPropertiesService.ts b/src/vs/workbench/services/extensions/common/extensionManifestPropertiesService.ts index b448c855a89159..a1242f027b5075 100644 --- a/src/vs/workbench/services/extensions/common/extensionManifestPropertiesService.ts +++ b/src/vs/workbench/services/extensions/common/extensionManifestPropertiesService.ts @@ -23,6 +23,7 @@ import { isWeb } from '../../../../base/common/platform.js'; export const IExtensionManifestPropertiesService = createDecorator('extensionManifestPropertiesService'); export const EXTENSIONS_SUPPORT_AGENTS_WINDOW = 'extensions.supportAgentsWindow'; +export const EXTENSIONS_ENABLE_AGENTS_WINDOW_CAPABILITY = 'extensions.experimental.enableAgentsWindowCapability'; const SESSIONS_WINDOW_ALLOWED_CONTRIBUTION_POINTS: ReadonlySet = new Set([ 'themes', @@ -100,7 +101,7 @@ export class ExtensionManifestPropertiesService extends Disposable implements IE return configuredSessionsWindowSupport; } - if (manifest.enabledApiProposals?.includes('agentsWindowActivation')) { + if (this.configurationService.getValue(EXTENSIONS_ENABLE_AGENTS_WINDOW_CAPABILITY) && manifest.enabledApiProposals?.includes('agentsWindowActivation')) { const declaredSessionsWindowSupport = manifest.capabilities?.agentsWindow?.supported; if (declaredSessionsWindowSupport !== undefined) { return declaredSessionsWindowSupport; diff --git a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts index cf756954d70a45..d5220dbd0c5dd7 100644 --- a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts +++ b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts @@ -589,7 +589,7 @@ export const schema: IJSONSchema = { } }, agentsWindow: { - description: nls.localize('vscode.extension.capabilities.agentsWindow', "Declares whether the extension should be enabled in the Agents window. Requires the `agentsWindowActivation` API proposal."), + description: nls.localize('vscode.extension.capabilities.agentsWindow', "Declares whether the extension should be enabled in the Agents window. Requires the `agentsWindowActivation` API proposal and the experimental Agents window capability setting."), type: 'object', required: ['supported'], defaultSnippets: [ @@ -597,7 +597,7 @@ export const schema: IJSONSchema = { ], properties: { supported: { - markdownDescription: nls.localize('vscode.extension.capabilities.agentsWindow.supported', "Declares whether the extension supports running in the Agents window. The extension must enable the `agentsWindowActivation` API proposal for this property to take effect."), + markdownDescription: nls.localize('vscode.extension.capabilities.agentsWindow.supported', "Declares whether the extension supports running in the Agents window. The extension must enable the `agentsWindowActivation` API proposal and the `extensions.experimental.enableAgentsWindowCapability` setting for this property to take effect."), type: 'boolean' } } diff --git a/src/vs/workbench/services/extensions/test/common/extensionManifestPropertiesService.test.ts b/src/vs/workbench/services/extensions/test/common/extensionManifestPropertiesService.test.ts index 1a592ed3f55081..951c25cdf53dbc 100644 --- a/src/vs/workbench/services/extensions/test/common/extensionManifestPropertiesService.test.ts +++ b/src/vs/workbench/services/extensions/test/common/extensionManifestPropertiesService.test.ts @@ -14,7 +14,7 @@ import { TestInstantiationService } from '../../../../../platform/instantiation/ import { NullLogService } from '../../../../../platform/log/common/log.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { IWorkspaceTrustEnablementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; -import { EXTENSIONS_SUPPORT_AGENTS_WINDOW, ExtensionManifestPropertiesService } from '../../common/extensionManifestPropertiesService.js'; +import { EXTENSIONS_ENABLE_AGENTS_WINDOW_CAPABILITY, EXTENSIONS_SUPPORT_AGENTS_WINDOW, ExtensionManifestPropertiesService } from '../../common/extensionManifestPropertiesService.js'; import { TestProductService, TestWorkspaceTrustEnablementService } from '../../../../test/common/workbenchTestServices.js'; suite('ExtensionManifestPropertiesService - ExtensionKind', () => { @@ -155,7 +155,17 @@ suite('ExtensionManifestPropertiesService - SessionsWindowSupport', () => { ], [true, false]); }); - test('uses declared agents window support', () => { + test('ignores declared agents window support when the experimental feature is disabled', () => { + testObject = createTestObject(); + + assert.deepStrictEqual([ + testObject.canExecuteOnSessionsWindow(getExtensionManifest({ main: './out/extension.js', enabledApiProposals: ['agentsWindowActivation'], capabilities: { agentsWindow: { supported: true } }, contributes: { commands: [] } })), + testObject.canExecuteOnSessionsWindow(getExtensionManifest({ enabledApiProposals: ['agentsWindowActivation'], capabilities: { agentsWindow: { supported: false } }, contributes: { themes: [] } })), + ], [false, true]); + }); + + test('uses declared agents window support when the experimental feature is enabled', async () => { + await testConfigurationService.setUserConfiguration(EXTENSIONS_ENABLE_AGENTS_WINDOW_CAPABILITY, true); testObject = createTestObject(); assert.deepStrictEqual([ diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts index b3667b63df9f0c..1ea5f53a5e624d 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts @@ -482,7 +482,7 @@ export async function renderChatWidget(context: ComponentFixtureContext, options ? Math.max(0, Math.max(116, inputHeight) - inputHeight) : Math.max(0, height - inputHeight); listContainer.style.height = `${contentHeight}px`; - listContainer.dataset['expectedHeight'] = String(contentHeight); + listContainer.dataset.expectedHeight = String(contentHeight); listWidget.layout(contentHeight, width); } finally { layouting = false; @@ -807,7 +807,7 @@ async function renderResizeObserverLoopHarness(context: ComponentFixtureContext, if (event instanceof ErrorEvent && event.message.includes('ResizeObserver loop')) { warningCount++; warnings.textContent = `Warnings: ${warningCount}`; - warnings.dataset['observerContext'] = dom.getRecentDisposableResizeObserverContextForLoopError(event.message, targetWindow) ?? event.message; + warnings.dataset.observerContext = dom.getRecentDisposableResizeObserverContextForLoopError(event.message, targetWindow) ?? event.message; status.textContent = 'Captured ResizeObserver warning'; } })); @@ -908,11 +908,11 @@ async function renderDisabledPetResizeObserverProbe(context: ComponentFixtureCon const status = dom.append(context.container, dom.$('.disabled-pet-resize-observer-status')); status.role = 'status'; status.textContent = 'Running disabled pet observer probe'; - status.dataset['warningCount'] = '0'; + status.dataset.warningCount = '0'; context.disposableStore.add(dom.addDisposableListener(targetWindow, dom.EventType.ERROR, event => { if (event instanceof ErrorEvent && event.message.includes('ResizeObserver loop')) { - status.dataset['warningCount'] = String(Number(status.dataset['warningCount']) + 1); - status.dataset['observerContext'] = dom.getRecentDisposableResizeObserverContextForLoopError(event.message, targetWindow) ?? event.message; + status.dataset.warningCount = String(Number(status.dataset.warningCount) + 1); + status.dataset.observerContext = dom.getRecentDisposableResizeObserverContextForLoopError(event.message, targetWindow) ?? event.message; } })); diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/fullwidthCharacterWidth.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/fullwidthCharacterWidth.fixture.ts new file mode 100644 index 00000000000000..5f068f85963abd --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/editor/fullwidthCharacterWidth.fixture.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../base/common/uri.js'; +import { IEditorConstructionOptions } from '../../../../../editor/browser/config/editorConfiguration.js'; +import { CodeEditorWidget } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; + +/** Four full-width characters should line up with eight narrow ones. */ +const GRID = [ + '+--------+--------+', + '|漢字漢字|abcdefgh|', + '|ABCD|12345678|', + '|ひらがな|ijklmnop|', + '|カタカナ|qrstuvwx|', + '|、。「」|........|', + '+--------+--------+', +].join('\n'); + +/** Characters outside the shared classification retain their natural rendering. */ +const MIXED = [ + '|アイウエオカキク|halfwidth|', + '|Ünïcödé |accented |', + '|あ́い́う́ |combining|', + '|𠀋︀𡈽︀ |astralVS|', +].join('\n'); + +const CODE = [ + '// 全角文字はここで二つのセルを占めます', + 'const greeting = "こんにちは、世界";', + 'const width = "ab".length; // 二文字', +].join('\n'); + +let modelCounter = 0; + +function createEditor(context: ComponentFixtureContext, content: string, options: IEditorConstructionOptions = {}) { + const { container, disposableStore, theme } = context; + container.style.width = '420px'; + container.style.height = '150px'; + container.style.border = '1px solid var(--vscode-editorWidget-border)'; + + const instantiationService = createEditorServices(disposableStore, { colorTheme: theme }); + const model = disposableStore.add(createTextModel( + instantiationService, + content, + URI.parse(`inmemory://fullwidth/${modelCounter++}.txt`), + 'plaintext' + )); + const editor = disposableStore.add(instantiationService.createInstance( + CodeEditorWidget, + container, + { + automaticLayout: true, + fontFamily: 'Consolas, "Courier New", monospace', + fontSize: 14, + glyphMargin: false, + lineNumbers: 'off', + minimap: { enabled: false }, + renderLineHighlight: 'none', + scrollBeyondLastLine: false, + scrollbar: { horizontal: 'hidden', vertical: 'hidden' }, + wordWrap: 'off', + ...options, + }, + { contributions: [] } + )); + editor.setModel(model); + + return { editor, instantiationService }; +} + +function renderGrid(context: ComponentFixtureContext, fullwidthCharacterWidth: 'font' | 'twoCells'): void { + createEditor(context, GRID, { fullwidthCharacterWidth }); +} + +function renderMixed(context: ComponentFixtureContext, fullwidthCharacterWidth: 'font' | 'twoCells'): void { + createEditor(context, MIXED, { fullwidthCharacterWidth }); +} + +function renderCode(context: ComponentFixtureContext, fullwidthCharacterWidth: 'font' | 'twoCells'): void { + createEditor(context, CODE, { fullwidthCharacterWidth }); +} + +function renderSelection(context: ComponentFixtureContext, fullwidthCharacterWidth: 'font' | 'twoCells'): void { + const { editor } = createEditor(context, GRID, { fullwidthCharacterWidth }); + editor.setSelection(new Range(2, 2, 4, 6)); +} + +function renderProportionalFont(context: ComponentFixtureContext): void { + // The policy is computed away when the font is not monospace, because the surrounding + // narrow characters do not sit on a grid to line up with in the first place. + createEditor(context, GRID, { fullwidthCharacterWidth: 'twoCells', fontFamily: 'Georgia, serif' }); +} + +export default defineThemedFixtureGroup({ path: 'editor/' }, { + FullwidthCharacterWidthFont: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + expectedVisualDescriptions: ['An ASCII box drawing contains five rows. The left column holds four CJK characters per row and the right column holds eight narrow characters. The pipe separating the two columns is allowed to sit at a different horizontal position on the CJK rows than on the dashed border rows.'], + render: context => renderGrid(context, 'font'), + }), + FullwidthCharacterWidthTwoCells: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + expectedVisualDescriptions: ['An ASCII box drawing contains five rows. Every pipe forms one straight unbroken vertical line down the whole box, and the closing pipes line up with the plus signs of the dashed borders above and below. Each CJK character is horizontally centered over the two narrow cells it occupies, with even spacing on both sides.'], + render: context => renderGrid(context, 'twoCells'), + }), + FullwidthCharacterWidthClassification: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + expectedVisualDescriptions: ['Four rows of text each start and end with a pipe. Half-width katakana and accented Latin letters render narrow. Each hiragana base character is centered in a two-cell box independently of its following combining accent. The supplementary-plane CJK characters and their variation selectors retain their natural rendering.'], + render: context => renderMixed(context, 'twoCells'), + }), + FullwidthCharacterWidthCode: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + expectedVisualDescriptions: ['Three lines of source code mix Latin identifiers with Japanese text. The Latin code renders normally and the Japanese characters are evenly spaced, with the quotes and semicolons remaining attached to the text they belong to.'], + render: context => renderCode(context, 'twoCells'), + }), + FullwidthCharacterWidthSelection: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + expectedVisualDescriptions: ['A selection spans three rows of an ASCII box drawing. The highlighted region is a continuous block with straight vertical edges and no gaps or slivers between adjacent CJK characters. The selection starts and ends exactly on a character cell boundary rather than partway through a glyph.'], + render: context => renderSelection(context, 'twoCells'), + }), + FullwidthCharacterWidthProportionalFont: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + expectedVisualDescriptions: ['An ASCII box drawing is rendered in a proportional serif font. The columns do not line up, which is expected: the setting has no effect because the narrow characters are not on a fixed grid either.'], + render: renderProportionalFont, + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/multiDiffEditorScroll.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/multiDiffEditorScroll.fixture.ts index 7dd57baa92a262..46be783bf473b7 100644 --- a/src/vs/workbench/test/browser/componentFixtures/multiDiffEditorScroll.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/multiDiffEditorScroll.fixture.ts @@ -701,7 +701,7 @@ function parseSerializedFixtureState(text: string): ISerializedFixtureState { if (!isJsonObject(value)) { throw new Error('Fixture state must be a JSON object.'); } - const items = value['items']; + const items = value.items; if (!Array.isArray(items)) { throw new Error('Fixture state must contain an items array.'); } @@ -720,24 +720,24 @@ function parseSerializedFixtureItem(value: JsonValue, index: number): ISerialize if (!isJsonObject(value)) { throw new Error(`items[${index}] must be a JSON object.`); } - const bindingPhase = value['bindingPhase']; + const bindingPhase = value.bindingPhase; if (!isBindingPhase(bindingPhase)) { throw new Error(`items[${index}].bindingPhase must be unbound, binding, projecting, or active.`); } const path = `items[${index}]`; - const lineCount = value['lineCount'] === undefined + const lineCount = value.lineCount === undefined ? heightToLineCount(readNonNegativeNumber(value, 'fullHeight', true, path)) : readNonNegativeInteger(value, 'lineCount', path); - const geometryOscillationValue = value['geometryOscillation']; + const geometryOscillationValue = value.geometryOscillation; const geometryOscillation = geometryOscillationValue === undefined ? createDefaultGeometryOscillation() : parseSerializedGeometryOscillation(geometryOscillationValue, index, lineCount); - const topLineCountValue = value['topLineCount']; - const bottomLineCountValue = value['bottomLineCount']; - const legacyContentTopInset = value['contentTopInset']; - const mountLineCountsValue = value['mountLineCounts']; - const legacyMountHeightsValue = value['mountHeights']; - const unmountLineCountResetValue = value['unmountLineCountReset']; + const topLineCountValue = value.topLineCount; + const bottomLineCountValue = value.bottomLineCount; + const legacyContentTopInset = value.contentTopInset; + const mountLineCountsValue = value.mountLineCounts; + const legacyMountHeightsValue = value.mountHeights; + const unmountLineCountResetValue = value.unmountLineCountReset; return { label: readString(value, 'label', path), lineCount, @@ -787,12 +787,12 @@ function parseSerializedGeometryOscillation(value: JsonValue, index: number, lin if (!isJsonObject(value)) { throw new Error(`${path} must be a JSON object.`); } - const ordering = value['ordering']; + const ordering = value.ordering; if (!isGeometryChangeOrdering(ordering)) { throw new Error(`${path}.ordering must be atomic, lines-first, or offset-first.`); } const normalizedOrdering = ordering === 'height-first' ? 'lines-first' : ordering; - if (value['topLineCountA'] !== undefined) { + if (value.topLineCountA !== undefined) { return { enabled: readBoolean(value, 'enabled', path), topLineCountA: readNonNegativeInteger(value, 'topLineCountA', path), @@ -802,7 +802,7 @@ function parseSerializedGeometryOscillation(value: JsonValue, index: number, lin ordering: normalizedOrdering, }; } - const location = value['location']; + const location = value.location; if (location !== 'above' && location !== 'below') { throw new Error(`${path}.location must be above or below when importing legacy height geometry.`); } diff --git a/src/vs/workbench/test/browser/componentFixtures/resizeObserver.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/resizeObserver.fixture.ts index 0e7442bd188b97..5552e4fd2dce5a 100644 --- a/src/vs/workbench/test/browser/componentFixtures/resizeObserver.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/resizeObserver.fixture.ts @@ -15,7 +15,7 @@ function renderLoopContextProbe(context: ComponentFixtureContext): void { const status = dom.append(context.container, dom.$('.resize-observer-context-status')); status.textContent = 'Waiting for ResizeObserver loop warning'; - status.dataset['warningCount'] = '0'; + status.dataset.warningCount = '0'; const observation = context.disposableStore.add(new MutableDisposable()); context.disposableStore.add(dom.addDisposableListener(targetWindow, dom.EventType.ERROR, event => { @@ -23,8 +23,8 @@ function renderLoopContextProbe(context: ComponentFixtureContext): void { return; } - status.dataset['warningCount'] = '1'; - status.dataset['observerContext'] = dom.getRecentDisposableResizeObserverContextForLoopError(event.message, targetWindow) ?? ''; + status.dataset.warningCount = '1'; + status.dataset.observerContext = dom.getRecentDisposableResizeObserverContextForLoopError(event.message, targetWindow) ?? ''; status.textContent = 'Captured ResizeObserver loop warning'; observation.clear(); })); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts index a47526872711a0..ef4e63773ce8d5 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts @@ -41,6 +41,8 @@ import { ISessionsListModelService } from '../../../../../sessions/services/sess // eslint-disable-next-line local/code-import-patterns import { ISessionsProvidersService } from '../../../../../sessions/services/sessions/browser/sessionsProvidersService.js'; // eslint-disable-next-line local/code-import-patterns +import { ISessionsWindowUsageService } from '../../../../../sessions/services/sessions/browser/sessionsWindowUsageService.js'; +// eslint-disable-next-line local/code-import-patterns import { ISessionsService } from '../../../../../sessions/services/sessions/browser/sessionsService.js'; // eslint-disable-next-line local/code-import-patterns import { ICustomViewService } from '../../../../../sessions/services/customView/browser/customViewService.js'; @@ -72,6 +74,7 @@ import { IChatService } from '../../../../contrib/chat/common/chatService/chatSe import { IChatModel } from '../../../../contrib/chat/common/model/chatModel.js'; import { IVoicePlaybackService } from '../../../../contrib/chat/common/voicePlaybackService.js'; import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; +import { ILifecycleService, LifecyclePhase } from '../../../../services/lifecycle/common/lifecycle.js'; import { TestProductService } from '../../../common/workbenchTestServices.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; @@ -227,6 +230,7 @@ interface IRenderOptions { readonly newSessionButtonStyle?: NewSessionButtonStyle; readonly newSessionButtonTreatment?: NewSessionButtonStyle; readonly showFocusedToolbar?: boolean; + readonly focusSelectedSession?: boolean; } async function renderSessionsList(ctx: ComponentFixtureContext, options: IRenderOptions): Promise { @@ -257,7 +261,7 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender additionalServices: reg => { registerWorkbenchServices(reg); reg.defineInstance(IProductService, TestProductService); - if (options.showFocusedToolbar) { + if (options.showFocusedToolbar || options.focusSelectedSession) { const archiveAction = new class extends mock() { override readonly id = 'sessions.fixture.archive'; override readonly label = 'Archive'; @@ -350,6 +354,14 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender override getProviders() { return []; } override getProvider() { return undefined; } }()); + reg.defineInstance(ISessionsWindowUsageService, new class extends mock() { + override readonly hadPriorWindowOpen = true; + override readonly windowOpenCount = 2; + }()); + reg.defineInstance(ILifecycleService, new class extends mock() { + override phase = LifecyclePhase.Eventually; + override when(): Promise { return Promise.resolve(); } + }()); reg.defineInstance(IVoicePlaybackService, new class extends mock() { override readonly pendingResponseVersion: IObservable = constObservable(0); override hasPendingResponse() { return false; } @@ -357,6 +369,7 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender reg.defineInstance(IAutomationService, new class extends mock() { override readonly automations = constObservable([]); override readonly runs = automationRuns; + override readonly catalogueState = constObservable('ready' as const); }()); reg.defineInstance(IWorkbenchAssignmentService, new class extends mock() { override readonly onDidRefetchAssignments = Event.None; @@ -438,6 +451,9 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender })); list.layout(options.phone ? 260 : showHeader ? 180 : 220, width); + if (options.showAutomations) { + await list.resetAutomationsNewBadge(); + } if (options.automationRunStatus) { automationRuns.set([{ id: 'fixture-run', @@ -473,6 +489,13 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender throw new Error('Expected the rendered New Session action to react to the lightweight keybinding-background style setting.'); } + if (options.focusSelectedSession) { + if (!sessions[0] || !list.reveal(sessions[0].resource)) { + throw new Error('Expected a session to select for keyboard navigation.'); + } + list.focus(); + } + if (options.showFocusedToolbar) { return Promise.resolve().then(() => { const sessionRow = listHost.querySelector('.session-item')?.closest('.monaco-list-row'); @@ -528,6 +551,16 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { width: 260, }), }), + SessionsList_SelectedKeyboardFocus: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + additionalThemes: ['darkHighContrast'], + expectedVisualDescriptions: ['The selected session row has keyboard focus and a visible Archive action without hover; its long title truncates before the action.'], + render: ctx => renderSessionsList(ctx, { + sessions: [{ id: 'a', title: 'Fix keyboard navigation in the selected session', workspace: 'vscode', minutesAgo: 12 }], + focusSelectedSession: true, + width: 260, + }), + }), SessionsList_CustomGroup_InProgress: defineComponentFixture({ render: ctx => renderSessionsList(ctx, { sessions: [ diff --git a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts new file mode 100644 index 00000000000000..f441acd6f53471 --- /dev/null +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -0,0 +1,205 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { $, EventType, ModifierKeyEmitter } from '../../../../../base/browser/dom.js'; +import { mainWindow } from '../../../../../base/browser/window.js'; +import { Event } from '../../../../../base/common/event.js'; +import { DisposableStore, 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 { TreeViewsDnDService } from '../../../../../editor/common/services/treeViewsDnd.js'; +import { ITreeViewsDnDService } from '../../../../../editor/common/services/treeViewsDndService.js'; +import { DEFAULT_EDITOR_PART_OPTIONS, IEditorGroupsView, IEditorGroupView, IEditorPartsView } from '../../../../browser/parts/editor/editor.js'; +import { MultiEditorTabsControl } from '../../../../browser/parts/editor/multiEditorTabsControl.js'; +import { EditorsOrder } from '../../../../common/editor.js'; +import { EditorGroupModel } from '../../../../common/editor/editorGroupModel.js'; +import { EditorInput } from '../../../../common/editor/editorInput.js'; +import { IHostService } from '../../../../services/host/browser/host.js'; +import { INotebookDocumentService, NotebookDocumentWorkbenchService } from '../../../../services/notebook/common/notebookDocumentService.js'; +import { TestFileEditorInput, TestHostService, workbenchInstantiationService } from '../../workbenchTestServices.js'; + +suite('MultiEditorTabsControl', () => { + + let disposables: DisposableStore; + + let container: HTMLElement; + let hostService: TestHostService; + + setup(() => { + disposables = new DisposableStore(); + + // The tabs control resolves the shared modifier key emitter on creation, + // so dispose it again to keep each test independent of the Alt state that + // other suites may have left behind + disposables.add(toDisposable(() => ModifierKeyEmitter.disposeInstance())); + + const instantiationService = workbenchInstantiationService(undefined, disposables); + instantiationService.stub(ITreeViewsDnDService, new TreeViewsDnDService()); + instantiationService.stub(INotebookDocumentService, new NotebookDocumentWorkbenchService()); + + hostService = instantiationService.get(IHostService) as TestHostService; + + const model = disposables.add(instantiationService.createInstance(EditorGroupModel, undefined)); + for (let i = 0; i < 2; i++) { + const editor = disposables.add(new TestFileEditorInput(URI.file(`/path/file${i}.txt`), 'testEditorInput')); + model.openEditor(editor, { pinned: true, active: i === 0 }); + } + + const groupView = new class extends mock() { + override get id() { return model.id; } + override get count() { return model.count; } + override get stickyCount() { return model.stickyCount; } + override get activeEditor() { return model.activeEditor; } + override get activeEditorPane() { return undefined; } + override get selectedEditors() { return model.selectedEditors; } + override get ariaLabel() { return 'Editor Group 1'; } + override get groupsView(): IEditorGroupsView { return groupsView; } + override getEditorByIndex(index: number) { return model.getEditorByIndex(index); } + override getIndexOfEditor(editor: EditorInput) { return model.indexOf(editor); } + override getEditors(order: EditorsOrder, options?: { excludeSticky?: boolean }) { return model.getEditors(order, options); } + override isActive(editor: EditorInput) { return model.isActive(editor); } + override isPinned(editorOrIndex: EditorInput | number) { return model.isPinned(editorOrIndex); } + override isSticky(editorOrIndex: EditorInput | number) { return model.isSticky(editorOrIndex); } + override isSelected(editorOrIndex: EditorInput | number) { return model.isSelected(editorOrIndex); } + override createEditorActions() { return { actions: { primary: [], secondary: [] }, onDidChange: Event.None }; } + override relayout() { } + override readonly onDidActiveEditorChange = Event.None; + }; + + const groupsView = new class extends mock() { + override get partOptions() { return DEFAULT_EDITOR_PART_OPTIONS; } + override get activeGroup(): IEditorGroupView { return groupView; } + override get groups(): IEditorGroupView[] { return [groupView]; } + override readonly onDidChangeEditorPartOptions = Event.None; + override readonly onDidVisibilityChange = Event.None; + }; + + const editorPartsView = new class extends mock() { + override get count() { return 1; } + override getGroup() { return groupView; } + }; + + container = $('.title.tabs'); + mainWindow.document.body.appendChild(container); + + const control = disposables.add(instantiationService.createInstance(MultiEditorTabsControl, container, editorPartsView, groupsView, groupView, model, undefined, false)); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + }); + + teardown(() => { + container.remove(); + disposables.dispose(); + }); + + function tabActions(): string[] { + return Array.from(container.querySelectorAll('.tabs-container > .tab')).map(tab => { + const action = tab.querySelector('.tab-actions .action-label'); + if (action?.classList.contains('codicon-close-all')) { + return 'closeOthers'; + } + + return action?.classList.contains('codicon-close-small') ? 'close' : 'unknown'; + }); + } + + function hoverTab(tabIndex: number): void { + container.querySelectorAll('.tabs-container > .tab')[tabIndex].dispatchEvent(new MouseEvent(EventType.MOUSE_ENTER)); + } + + function moveMouseOverTabs(altKey: boolean): void { + container.querySelector('.tabs-container')!.dispatchEvent(new MouseEvent(EventType.MOUSE_MOVE, { altKey, bubbles: true })); + } + + function mouseDownOnTabAction(tabIndex: number, altKey: boolean): void { + container.querySelectorAll('.tabs-container > .tab')[tabIndex].querySelector('.tab-actions .action-label')!.dispatchEvent(new MouseEvent(EventType.MOUSE_DOWN, { altKey, bubbles: true, cancelable: true })); + } + + function alt(pressed: boolean): void { + mainWindow.dispatchEvent(new KeyboardEvent(pressed ? EventType.KEY_DOWN : EventType.KEY_UP, { key: 'Alt', altKey: pressed })); + } + + test('Alt swaps the close action of the hovered tab only', () => { + const actions = [tabActions()]; + + hoverTab(0); + alt(true); + actions.push(tabActions()); + + alt(false); + actions.push(tabActions()); + + assert.deepStrictEqual(actions, [ + ['close', 'close'], + ['closeOthers', 'close'], + ['close', 'close'] + ]); + }); + + test('Alt does not stay armed when the window loses focus (#331979)', () => { + hoverTab(0); + alt(true); + + const actions = [tabActions()]; + + // Alt+Tab to another application: the `keyup` for Alt is + // delivered to that application and never seen here + hostService.setFocus(false); + ModifierKeyEmitter.getInstance().resetKeyStatus(); + actions.push(tabActions()); + + // Alt being reported as pressed again when focus returns must not + // swap the action of a tab that is still hovered from before + hostService.setFocus(true); + alt(true); + actions.push(tabActions()); + + // Hovering a tab again arms the swap as usual + hoverTab(0); + actions.push(tabActions()); + + assert.deepStrictEqual(actions, [ + ['closeOthers', 'close'], + ['close', 'close'], + ['close', 'close'], + ['closeOthers', 'close'] + ]); + }); + + test('Alt is revalidated from mouse events over the tabs (#331979)', () => { + hoverTab(0); + alt(true); + + const actions = [tabActions()]; + + // The `keyup` for Alt went to another application, so only the + // next mouse event reveals that Alt is no longer pressed + moveMouseOverTabs(false); + actions.push(tabActions()); + + assert.deepStrictEqual(actions, [ + ['closeOthers', 'close'], + ['close', 'close'] + ]); + }); + + test('Alt is revalidated when pressing the tab action without moving the mouse (#331979)', () => { + hoverTab(0); + alt(true); + + const actions = [tabActions()]; + + mouseDownOnTabAction(0, false); + actions.push(tabActions()); + + assert.deepStrictEqual(actions, [ + ['closeOthers', 'close'], + ['close', 'close'] + ]); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); +}); diff --git a/src/vs/workbench/test/browser/parts/titlebar/titlebarPart.test.ts b/src/vs/workbench/test/browser/parts/titlebar/titlebarPart.test.ts new file mode 100644 index 00000000000000..2005d39e4409af --- /dev/null +++ b/src/vs/workbench/test/browser/parts/titlebar/titlebarPart.test.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { mainWindow } from '../../../../../base/browser/window.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { BrowserTitlebarPart } from '../../../../browser/parts/titlebar/titlebarPart.js'; + +suite('Workbench - Titlebar Part', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const updateTitleBarToolBarOverflow = Reflect.get(BrowserTitlebarPart.prototype, 'updateTitleBarToolBarOverflow') as (this: BrowserTitlebarPart) => void; + + test('hides optional toolbar groups in priority order', () => { + let rootClientWidth = 130; + const centerAdjacentToolBar = mainWindow.document.createElement('div'); + const updateToolBar = mainWindow.document.createElement('div'); + const root = createMeasuredElement( + () => rootClientWidth, + () => 100 + visibleWidth(centerAdjacentToolBar, 30) + visibleWidth(updateToolBar, 30) + ); + const titlebarPart = Object.create(BrowserTitlebarPart.prototype) as BrowserTitlebarPart; + Reflect.set(titlebarPart, 'rootContainer', root); + Reflect.set(titlebarPart, 'centerAdjacentToolBarElement', centerAdjacentToolBar); + Reflect.set(titlebarPart, 'updateToolBarElement', updateToolBar); + + updateTitleBarToolBarOverflow.call(titlebarPart); + const centerHiddenFirst = [centerAdjacentToolBar, updateToolBar].map(element => element.classList.contains('overflowing')); + + rootClientWidth = 100; + updateTitleBarToolBarOverflow.call(titlebarPart); + const bothHidden = [centerAdjacentToolBar, updateToolBar].map(element => element.classList.contains('overflowing')); + + rootClientWidth = 160; + updateTitleBarToolBarOverflow.call(titlebarPart); + const bothVisible = [centerAdjacentToolBar, updateToolBar].map(element => element.classList.contains('overflowing')); + + assert.deepStrictEqual({ centerHiddenFirst, bothHidden, bothVisible }, { + centerHiddenFirst: [true, false], + bothHidden: [true, true], + bothVisible: [false, false], + }); + }); +}); + +function createMeasuredElement(clientWidth: () => number, scrollWidth: () => number): HTMLElement { + const element = mainWindow.document.createElement('div'); + Object.defineProperties(element, { + clientWidth: { get: clientWidth }, + scrollWidth: { get: scrollWidth }, + }); + return element; +} + +function visibleWidth(element: HTMLElement, width: number): number { + return element.classList.contains('overflowing') || element.classList.contains('has-no-actions') ? 0 : width; +} diff --git a/test/automation/src/electron.ts b/test/automation/src/electron.ts index 89a1249b9d16e4..d1bb569ccedd8b 100644 --- a/test/automation/src/electron.ts +++ b/test/automation/src/electron.ts @@ -74,15 +74,15 @@ export async function resolveElectronConfiguration(options: LaunchOptions): Prom if (userDataDir) { const remoteDataDir = `${userDataDir}-server`; fs.mkdirSync(remoteDataDir, { recursive: true }); - env['TESTRESOLVER_DATA_FOLDER'] = remoteDataDir; + env.TESTRESOLVER_DATA_FOLDER = remoteDataDir; } - env['TESTRESOLVER_LOGS_FOLDER'] = join(logsPath, 'server'); + env.TESTRESOLVER_LOGS_FOLDER = join(logsPath, 'server'); // Exercise the remote server's exit diagnostics (see `installServerProcessExitDiagnostics` // in server-main.ts) so unexpected server exits are explained in the captured logs, // even when running smoke tests locally (where `isCI` is false). - env['VSCODE_SERVER_EXIT_DIAGNOSTICS'] = '1'; + env.VSCODE_SERVER_EXIT_DIAGNOSTICS = '1'; if (options.verbose) { - env['TESTRESOLVER_LOG_LEVEL'] = 'trace'; + env.TESTRESOLVER_LOG_LEVEL = 'trace'; } } diff --git a/test/automation/src/scm.ts b/test/automation/src/scm.ts index 3fdf89752dc226..7019e35b27fd89 100644 --- a/test/automation/src/scm.ts +++ b/test/automation/src/scm.ts @@ -27,7 +27,7 @@ function toChange(element: IElement): Change { const type = element.attributes['data-tooltip'] || ''; const actionElementList = findElements(element, e => /\baction-label\b/.test(e.className)); - const actions = actionElementList.map(e => e.attributes['title']); + const actions = actionElementList.map(e => e.attributes.title); return { name: name.textContent || '', diff --git a/test/automation/src/search.ts b/test/automation/src/search.ts index 8c8000c92dd1df..98440b4a86acea 100644 --- a/test/automation/src/search.ts +++ b/test/automation/src/search.ts @@ -49,8 +49,8 @@ export class Search extends Viewlet { } async getSearchTooltip(): Promise { - const icon = await this.code.waitForElement(`.activitybar .action-label.codicon.codicon-search-view-icon`, (el) => !!el?.attributes?.['title']); - return icon.attributes['title']; + const icon = await this.code.waitForElement(`.activitybar .action-label.codicon.codicon-search-view-icon`, (el) => !!el?.attributes?.title); + return icon.attributes.title; } async searchFor(text: string): Promise { diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 325959aee2be01..f6602cde73f9d1 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -162,6 +162,42 @@ #### editor/codeEditor/CodeEditor/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/89b2d50f5bd33feaa20b3c8f83f3d83548fda48b6f85cd7d784460aafaeeb596) +#### editor/fullwidthCharacterWidth/FullwidthCharacterWidthClassification/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/5e9fd5db76103ac883390a2f6be4b6884d38466fd725d4091b7e46adb8349726) + +#### editor/fullwidthCharacterWidth/FullwidthCharacterWidthClassification/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/55d5ee041108b8fd525642374564c62fb96f0c5a098412876615262a87e5f5c6) + +#### editor/fullwidthCharacterWidth/FullwidthCharacterWidthCode/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/d8120ea9b281f54a8a9aa86409b5959bf11f4b4432cf31c54e80bbd03df3ce11) + +#### editor/fullwidthCharacterWidth/FullwidthCharacterWidthCode/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/b84c6f6f05420ba33e71cec227c976481b166a2967e9f295b9723c0a32228969) + +#### editor/fullwidthCharacterWidth/FullwidthCharacterWidthFont/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/88302b9073ab85f969299413d70dbb4d855fda5dec449cd4ec38e8d4920a722a) + +#### editor/fullwidthCharacterWidth/FullwidthCharacterWidthFont/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/0028778f2f2a0859c2def1a544d03b53b791b7510a8264c2af47e4e0024f4ee5) + +#### editor/fullwidthCharacterWidth/FullwidthCharacterWidthProportionalFont/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/aab15ca1b8ba97370fcffa6e3e600f446d479e0fb5077e56cc358c0c2e970fbe) + +#### editor/fullwidthCharacterWidth/FullwidthCharacterWidthProportionalFont/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/467e6c59b70fbd0153e463f8a5bbdb778f9b96aa27117fa8c276e126004fe2ac) + +#### editor/fullwidthCharacterWidth/FullwidthCharacterWidthSelection/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/af71f253cba2881e336da3eebd2ff3df7f1b20b9de7c407bb18bb944e59e5125) + +#### editor/fullwidthCharacterWidth/FullwidthCharacterWidthSelection/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/d2f28d080bfa99f2f93566c860e11ca891eb8e38ef27a4b20286b7227d235d89) + +#### editor/fullwidthCharacterWidth/FullwidthCharacterWidthTwoCells/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/55870698ddf7da331e17668ad072981bb261f3edff4ec8577874f9cb5af2c435) + +#### editor/fullwidthCharacterWidth/FullwidthCharacterWidthTwoCells/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/07cc96d028f69fd9609b88fa23b1c087cde223393dd0fd9ad6341e480068db95) + #### editor/injectedTextDecorations/ColorDecorators/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/33584d1dce7a32853b0c3c269ea341c1346364d42f3acede0f39d055ec54ff03) @@ -344,3 +380,12 @@ #### sessions/sessionsList/SessionsList_PeerChatInProgress/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/4aceb7c295b1a4b1cb8675fbbfa3376d9951506a0d02e6f34eac1770dd148110) + +#### sessions/sessionsList/SessionsList_SelectedKeyboardFocus/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/d40fed5ccaf446183e91efce92563fe87fe4505a6f3ec92219de95d74ede5803) + +#### sessions/sessionsList/SessionsList_SelectedKeyboardFocus/DarkHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/54f41b5fe0f20683151eb5891abbe8ac449d2f16f7dde922e9a3a7104c638770) + +#### sessions/sessionsList/SessionsList_SelectedKeyboardFocus/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/6a869c24b606e74a74331f03f693b8459e7ab2a92f77269b4b2ca49b880a4ffa) diff --git a/test/componentFixtures/playwright/tests/chatResizeObserver.spec.ts b/test/componentFixtures/playwright/tests/chatResizeObserver.spec.ts index b90e06aa3d1e7f..ec92baaf8d8ee3 100644 --- a/test/componentFixtures/playwright/tests/chatResizeObserver.spec.ts +++ b/test/componentFixtures/playwright/tests/chatResizeObserver.spec.ts @@ -34,7 +34,7 @@ for (const scenario of scenarios) { const geometry = await page.locator('.interactive-list').evaluate(element => { const list = element.querySelector('.monaco-list'); return { - expectedHeight: Number((element as HTMLElement).dataset['expectedHeight']), + expectedHeight: Number((element as HTMLElement).dataset.expectedHeight), containerHeight: element.getBoundingClientRect().height, listHeight: list?.getBoundingClientRect().height, }; diff --git a/test/componentFixtures/playwright/tests/utils.ts b/test/componentFixtures/playwright/tests/utils.ts index 2fbbdb78dd0981..60d007d2c5b27a 100644 --- a/test/componentFixtures/playwright/tests/utils.ts +++ b/test/componentFixtures/playwright/tests/utils.ts @@ -6,7 +6,7 @@ import { Page } from '@playwright/test'; function getBaseURL(): string { - const port = process.env['COMPONENT_EXPLORER_PORT']; + const port = process.env.COMPONENT_EXPLORER_PORT; if (!port) { throw new Error('COMPONENT_EXPLORER_PORT is not set. Is the webServer running?'); } diff --git a/test/monaco/esm-check/esm-check.js b/test/monaco/esm-check/esm-check.js index 6b5fdf4fe93e70..6f634b0aa5e50f 100644 --- a/test/monaco/esm-check/esm-check.js +++ b/test/monaco/esm-check/esm-check.js @@ -23,7 +23,7 @@ async function run() { await extractSourcesWithoutCSS(); const server = await startServer(); - const browser = await playwright['chromium'].launch({ + const browser = await playwright.chromium.launch({ headless: !DEBUG_TESTS, devtools: DEBUG_TESTS // slowMo: DEBUG_TESTS ? 2000 : 0 diff --git a/test/sanity/src/context.ts b/test/sanity/src/context.ts index 7fb4ac6a8b0d71..3a0d092ff01394 100644 --- a/test/sanity/src/context.ts +++ b/test/sanity/src/context.ts @@ -896,9 +896,9 @@ export class TestContext { private getWindowsInstallDir(type: 'user' | 'system'): string { let parentDir: string; if (type === 'system') { - parentDir = process.env['ProgramW6432'] || process.env['PROGRAMFILES'] || ''; + parentDir = process.env.ProgramW6432 || process.env.PROGRAMFILES || ''; } else { - parentDir = path.join(process.env['LOCALAPPDATA'] || '', 'Programs'); + parentDir = path.join(process.env.LOCALAPPDATA || '', 'Programs'); } switch (this.options.quality) { @@ -1284,13 +1284,13 @@ export class TestContext { return await webkit.launch({ headless }); } case 'win32': { - const executablePath = process.env['PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH'] ?? 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe'; + const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ?? 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe'; this.log(`Using Chromium executable at: ${executablePath}`); return await chromium.launch({ headless, executablePath }); } case 'linux': default: { - const executablePath = process.env['PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH'] ?? '/usr/bin/chromium-browser'; + const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ?? '/usr/bin/chromium-browser'; this.log(`Using Chromium executable at: ${executablePath}`); return await chromium.launch({ headless, diff --git a/test/sanity/src/main.ts b/test/sanity/src/main.ts index 2b3822a019ebe6..bb6569479fed63 100644 --- a/test/sanity/src/main.ts +++ b/test/sanity/src/main.ts @@ -35,7 +35,7 @@ const context = new TestContext({ cleanup: options.cleanup, checkSigning: options['signing-check'], headlessBrowser: options.headless, - downloadOnly: !options['detection'], + downloadOnly: !options.detection, screenshotsDir: options['screenshots-dir'], crashDumpsDir: options['crash-dumps-dir'], artifactsDir: options['artifacts-dir'], diff --git a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts index cecf302d772ae6..5e8b7476de91b4 100644 --- a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts +++ b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts @@ -366,10 +366,10 @@ export function setup(logger: Logger) { // "from source" signal: parseQuality() also returns Quality.Dev for // a `--build` product when VSCODE_QUALITY is unset, which would // wrongly hard-fail a packaged build that legitimately lacks Codex. - const isFromSource = process.env['VSCODE_DEV'] === '1'; - const isPublishBuild = (process.env['VSCODE_PUBLISH'] ?? '').toLowerCase() === 'true'; + const isFromSource = process.env.VSCODE_DEV === '1'; + const isPublishBuild = (process.env.VSCODE_PUBLISH ?? '').toLowerCase() === 'true'; if (isFromSource || isPublishBuild) { - throw new Error(`[Agents Window/Codex] Codex session type unexpectedly unavailable (VSCODE_DEV=${process.env['VSCODE_DEV'] ?? ''}, VSCODE_PUBLISH=${process.env['VSCODE_PUBLISH'] ?? ''}) — the SDK should be resolvable from node_modules (from source) or product.agentSdks.codex (publish build)`); + throw new Error(`[Agents Window/Codex] Codex session type unexpectedly unavailable (VSCODE_DEV=${process.env.VSCODE_DEV ?? ''}, VSCODE_PUBLISH=${process.env.VSCODE_PUBLISH ?? ''}) — the SDK should be resolvable from node_modules (from source) or product.agentSdks.codex (publish build)`); } logger.log('[Agents Window/Codex] Codex session type not available in this built product (no product.agentSdks.codex); skipping'); this.skip(); @@ -380,7 +380,7 @@ export function setup(logger: Logger) { // optional dependency that npm silently skips when its install fails. // A stale `node_modules` cache can thus have the shim but no binary, so // fail fast here (from source) instead of timing out at spawn time. - if (process.env['VSCODE_DEV'] === '1') { + if (process.env.VSCODE_DEV === '1') { const repoRoot = path.resolve(process.cwd(), '..', '..'); const platformPkgDir = path.join(repoRoot, 'node_modules', `@openai/codex-${process.platform}-${process.arch}`); const binaryName = process.platform === 'win32' ? 'codex.exe' : 'codex'; diff --git a/test/smoke/src/areas/preferences/preferences.test.ts b/test/smoke/src/areas/preferences/preferences.test.ts index a2088a2771743b..7ef9a85d5ba515 100644 --- a/test/smoke/src/areas/preferences/preferences.test.ts +++ b/test/smoke/src/areas/preferences/preferences.test.ts @@ -74,7 +74,7 @@ export function setup(logger: Logger) { await app.code.waitAndClick('.settings-editor .monaco-list-rows .setting-item-control select', 2, 2); await app.code.waitAndClick('.context-view .monaco-list-row:nth-child(1) .option-text', 2, 2); await app.workbench.settingsEditor.searchSettingsUI('test'); - await app.code.waitForElements('.settings-editor .settings-toc-container', false, elements => elements.length === 1 && elements[0].attributes['style'].includes('width: 0px')); + await app.code.waitForElements('.settings-editor .settings-toc-container', false, elements => elements.length === 1 && elements[0].attributes.style.includes('width: 0px')); await app.code.waitForElements('.settings-editor .settings-body .monaco-sash', false, elements => elements.length === 1 && elements[0].className.includes('disabled')); // Show ToC when searching @@ -82,7 +82,7 @@ export function setup(logger: Logger) { await app.code.waitAndClick('.settings-editor .monaco-list-rows .setting-item-control select', 2, 2); await app.code.waitAndClick('.context-view .monaco-list-row:nth-child(2) .option-text', 2, 2); await app.workbench.settingsEditor.searchSettingsUI('test'); - await app.code.waitForElements('.settings-editor .settings-toc-container', false, elements => elements.length === 1 && !elements[0].attributes['style'].includes('width: 0px')); + await app.code.waitForElements('.settings-editor .settings-toc-container', false, elements => elements.length === 1 && !elements[0].attributes.style.includes('width: 0px')); await app.code.waitForElements('.settings-editor .settings-body .monaco-sash', false, elements => elements.length === 1 && !elements[0].className.includes('disabled')); }); }); diff --git a/test/smoke/test/index.js b/test/smoke/test/index.js index d9115a3b2b478c..77a9f5fd3ab9bb 100644 --- a/test/smoke/test/index.js +++ b/test/smoke/test/index.js @@ -16,13 +16,13 @@ const opts = minimist(args, { string: ['f', 'g'] }); -const suite = opts['web'] ? 'Browser Smoke Tests' : 'Desktop Smoke Tests'; +const suite = opts.web ? 'Browser Smoke Tests' : 'Desktop Smoke Tests'; const options = { color: true, timeout: 2 * 60 * 1000, slow: 30 * 1000, - grep: opts['f'] || opts['g'], + grep: opts.f || opts.g, failZero: opts['fail-zero'] }; diff --git a/test/unit/electron/index.js b/test/unit/electron/index.js index 10678564d5c196..f2d9b7f3fdfb10 100644 --- a/test/unit/electron/index.js +++ b/test/unit/electron/index.js @@ -124,7 +124,7 @@ if (crashReporterDirectory) { crashReporter.start({ companyName: 'Microsoft', - productName: process.env['VSCODE_DEV'] ? `${product.nameShort} Dev` : product.nameShort, + productName: process.env.VSCODE_DEV ? `${product.nameShort} Dev` : product.nameShort, uploadToServer: false, compress: true }); diff --git a/test/unit/electron/preload.js b/test/unit/electron/preload.js index 04439d66ea15f3..265feef98ee375 100644 --- a/test/unit/electron/preload.js +++ b/test/unit/electron/preload.js @@ -65,7 +65,7 @@ get execPath() { return process.execPath; }, cwd() { - return process.env['VSCODE_CWD'] || process.execPath.substr(0, process.execPath.lastIndexOf(process.platform === 'win32' ? '\\' : '/')); + return process.env.VSCODE_CWD || process.execPath.substr(0, process.execPath.lastIndexOf(process.platform === 'win32' ? '\\' : '/')); }, getProcessMemoryInfo() { diff --git a/test/unit/electron/renderer.js b/test/unit/electron/renderer.js index ee563e715b51a5..9ca33031dd71d1 100644 --- a/test/unit/electron/renderer.js +++ b/test/unit/electron/renderer.js @@ -69,8 +69,8 @@ const coverage = require('../coverage'); const { pathToFileURL } = require('url'); // Disabled custom inspect. See #38847 -if (util.inspect && util.inspect['defaultOptions']) { - util.inspect['defaultOptions'].customInspect = false; +if (util.inspect && util.inspect.defaultOptions) { + util.inspect.defaultOptions.customInspect = false; } // VSCODE_GLOBALS: package/product.json